commit: b13d8f7e6339e877a38a28008630dc8ec64abcdf
parent 51d3d8d255de221f7ac99e41f2f8e56c7d6a21a9
Author: Shpuld Shpludson <shp@cock.li>
Date: Sun, 9 Jan 2022 18:37:01 +0000
Merge branch 'develop' into 'master'
Update MASTER for 2.4.2
See merge request pleroma/pleroma-fe!1421
Diffstat:
100 files changed, 4692 insertions(+), 1121 deletions(-)
diff --git a/.babelrc b/.babelrc
@@ -1,5 +1,5 @@
{
- "presets": ["@babel/preset-env"],
- "plugins": ["@babel/plugin-transform-runtime", "lodash", "@vue/babel-plugin-transform-vue-jsx"],
+ "presets": ["@babel/preset-env", "@vue/babel-preset-jsx"],
+ "plugins": ["@babel/plugin-transform-runtime", "lodash"],
"comments": false
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -3,6 +3,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
+## [2.4.2] - 2022-01-09
+### Added
+- Added Apply and Reset buttons to the bottom of theme tab to minimize UI travel
+- Implemented user option to always show floating New Post button (normally mobile-only)
+- Display reasons for instance specific policies
+- Added functionality to cancel follow request
+
+### Fixed
+- Fixed link to external profile not working on user profiles
+- Fixed mobile shoutbox display
+- Fixed favicon badge not working in Chrome
+- Escape html more properly in subject/display name
+
+
## [2.4.0] - 2021-08-08
### Added
- Added a quick settings to timeline header for easier access
diff --git a/package.json b/package.json
@@ -47,8 +47,8 @@
"@babel/preset-env": "^7.7.6",
"@babel/register": "^7.7.4",
"@ungap/event-target": "^0.1.0",
- "@vue/babel-helper-vue-jsx-merge-props": "^1.0.0",
- "@vue/babel-plugin-transform-vue-jsx": "^1.1.2",
+ "@vue/babel-helper-vue-jsx-merge-props": "^1.2.1",
+ "@vue/babel-preset-jsx": "^1.2.4",
"@vue/test-utils": "^1.0.0-beta.26",
"autoprefixer": "^6.4.0",
"babel-eslint": "^7.0.0",
diff --git a/src/App.js b/src/App.js
@@ -73,6 +73,9 @@ export default {
this.$store.state.instance.instanceSpecificPanelContent
},
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
+ shoutboxPosition () {
+ return this.$store.getters.mergedConfig.showNewPostButton || false
+ },
hideShoutbox () {
return this.$store.getters.mergedConfig.hideShoutbox
},
diff --git a/src/App.scss b/src/App.scss
@@ -88,6 +88,10 @@ a {
font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif);
+ &.-sublime {
+ background: transparent;
+ }
+
i[class*=icon-],
.svg-inline--fa {
color: $fallback--text;
diff --git a/src/App.vue b/src/App.vue
@@ -53,6 +53,7 @@
v-if="currentUser && shout && !hideShoutbox"
:floating="true"
class="floating-shout mobile-hidden"
+ :class="{ 'left': shoutboxPosition }"
/>
<MobilePostStatusButton />
<UserReportingModal />
diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js
@@ -1,5 +1,6 @@
import UserCard from '../user_card/user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const BasicUserCard = {
@@ -13,7 +14,8 @@ const BasicUserCard = {
},
components: {
UserCard,
- UserAvatar
+ UserAvatar,
+ RichContent
},
methods: {
toggleUserExpanded () {
diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue
@@ -25,17 +25,11 @@
:title="user.name"
class="basic-user-card-user-name"
>
- <!-- eslint-disable vue/no-v-html -->
- <span
- v-if="user.name_html"
+ <RichContent
class="basic-user-card-user-name-value"
- v-html="user.name_html"
+ :html="user.name"
+ :emoji="user.emoji"
/>
- <!-- eslint-enable vue/no-v-html -->
- <span
- v-else
- class="basic-user-card-user-name-value"
- >{{ user.name }}</span>
</div>
<div>
<router-link
diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js
@@ -1,5 +1,5 @@
import { mapState } from 'vuex'
-import StatusContent from '../status_content/status_content.vue'
+import StatusBody from '../status_content/status_content.vue'
import fileType from 'src/services/file_type/file_type.service'
import UserAvatar from '../user_avatar/user_avatar.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
@@ -16,7 +16,7 @@ const ChatListItem = {
AvatarList,
Timeago,
ChatTitle,
- StatusContent
+ StatusBody
},
computed: {
...mapState({
@@ -38,12 +38,14 @@ const ChatListItem = {
},
messageForStatusContent () {
const message = this.chat.lastMessage
+ const messageEmojis = message ? message.emojis : []
const isYou = message && message.account_id === this.currentUser.id
const content = message ? (this.attachmentInfo || message.content) : ''
const messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content
return {
summary: '',
- statusnet_html: messagePreview,
+ emojis: messageEmojis,
+ raw_html: messagePreview,
text: messagePreview,
attachments: []
}
diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss
@@ -77,18 +77,15 @@
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
}
- .StatusContent {
- img.emoji {
- width: 1.4em;
- height: 1.4em;
- }
+ .chat-preview-body {
+ --emoji-size: 1.4em;
}
.time-wrapper {
line-height: 1.4em;
}
- .single-line {
+ .chat-preview-body {
padding-right: 1em;
}
}
diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue
@@ -29,7 +29,8 @@
</div>
</div>
<div class="chat-preview">
- <StatusContent
+ <StatusBody
+ class="chat-preview-body"
:status="messageForStatusContent"
:single-line="true"
/>
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
@@ -57,8 +57,9 @@ const ChatMessage = {
messageForStatusContent () {
return {
summary: '',
- statusnet_html: this.message.content,
- text: this.message.content,
+ emojis: this.message.emojis,
+ raw_html: this.message.content || '',
+ text: this.message.content || '',
attachments: this.message.attachments
}
},
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
@@ -89,8 +89,9 @@
}
.without-attachment {
- .status-content {
- &::after {
+ .message-content {
+ // TODO figure out how to do it properly
+ .RichContent::after {
margin-right: 5.4em;
content: " ";
display: inline-block;
@@ -162,6 +163,7 @@
.visible {
opacity: 1;
}
+
}
.chat-message-date-separator {
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
@@ -71,6 +71,7 @@
</Popover>
</div>
<StatusContent
+ class="message-content"
:status="messageForStatusContent"
:full-content="true"
>
diff --git a/src/components/follow_button/follow_button.js b/src/components/follow_button/follow_button.js
@@ -14,7 +14,7 @@ export default {
if (this.inProgress || this.relationship.following) {
return this.$t('user_card.follow_unfollow')
} else if (this.relationship.requested) {
- return this.$t('user_card.follow_again')
+ return this.$t('user_card.follow_cancel')
} else {
return this.$t('user_card.follow')
}
@@ -33,7 +33,7 @@ export default {
},
methods: {
onClick () {
- this.relationship.following ? this.unfollow() : this.follow()
+ this.relationship.following || this.relationship.requested ? this.unfollow() : this.follow()
},
follow () {
this.inProgress = true
diff --git a/src/components/hashtag_link/hashtag_link.js b/src/components/hashtag_link/hashtag_link.js
@@ -0,0 +1,36 @@
+import { extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
+
+const HashtagLink = {
+ name: 'HashtagLink',
+ props: {
+ url: {
+ required: true,
+ type: String
+ },
+ content: {
+ required: true,
+ type: String
+ },
+ tag: {
+ required: false,
+ type: String,
+ default: ''
+ }
+ },
+ methods: {
+ onClick () {
+ const tag = this.tag || extractTagFromUrl(this.url)
+ if (tag) {
+ const link = this.generateTagLink(tag)
+ this.$router.push(link)
+ } else {
+ window.open(this.url, '_blank')
+ }
+ },
+ generateTagLink (tag) {
+ return `/tag/${tag}`
+ }
+ }
+}
+
+export default HashtagLink
diff --git a/src/components/hashtag_link/hashtag_link.scss b/src/components/hashtag_link/hashtag_link.scss
@@ -0,0 +1,6 @@
+.HashtagLink {
+ position: relative;
+ white-space: normal;
+ display: inline-block;
+ color: var(--link);
+}
diff --git a/src/components/hashtag_link/hashtag_link.vue b/src/components/hashtag_link/hashtag_link.vue
@@ -0,0 +1,19 @@
+<template>
+ <span
+ class="HashtagLink"
+ >
+ <!-- eslint-disable vue/no-v-html -->
+ <a
+ :href="url"
+ class="original"
+ target="_blank"
+ @click.prevent="onClick"
+ v-html="content"
+ />
+ <!-- eslint-enable vue/no-v-html -->
+ </span>
+</template>
+
+<script src="./hashtag_link.js"/>
+
+<style lang="scss" src="./hashtag_link.scss"/>
diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
@@ -0,0 +1,95 @@
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { mapGetters, mapState } from 'vuex'
+import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faAt
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faAt
+)
+
+const MentionLink = {
+ name: 'MentionLink',
+ props: {
+ url: {
+ required: true,
+ type: String
+ },
+ content: {
+ required: true,
+ type: String
+ },
+ userId: {
+ required: false,
+ type: String
+ },
+ userScreenName: {
+ required: false,
+ type: String
+ }
+ },
+ methods: {
+ onClick () {
+ const link = generateProfileLink(
+ this.userId || this.user.id,
+ this.userScreenName || this.user.screen_name
+ )
+ this.$router.push(link)
+ }
+ },
+ computed: {
+ user () {
+ return this.url && this.$store && this.$store.getters.findUserByUrl(this.url)
+ },
+ isYou () {
+ // FIXME why user !== currentUser???
+ return this.user && this.user.id === this.currentUser.id
+ },
+ userName () {
+ return this.user && this.userNameFullUi.split('@')[0]
+ },
+ userNameFull () {
+ return this.user && this.user.screen_name
+ },
+ userNameFullUi () {
+ return this.user && this.user.screen_name_ui
+ },
+ highlight () {
+ return this.user && this.mergedConfig.highlight[this.user.screen_name]
+ },
+ highlightType () {
+ return this.highlight && ('-' + this.highlight.type)
+ },
+ highlightClass () {
+ if (this.highlight) return highlightClass(this.user)
+ },
+ style () {
+ if (this.highlight) {
+ const {
+ backgroundColor,
+ backgroundPosition,
+ backgroundImage,
+ ...rest
+ } = highlightStyle(this.highlight)
+ return rest
+ }
+ },
+ classnames () {
+ return [
+ {
+ '-you': this.isYou,
+ '-highlighted': this.highlight
+ },
+ this.highlightType
+ ]
+ },
+ ...mapGetters(['mergedConfig']),
+ ...mapState({
+ currentUser: state => state.users.currentUser
+ })
+ }
+}
+
+export default MentionLink
diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
@@ -0,0 +1,91 @@
+.MentionLink {
+ position: relative;
+ white-space: normal;
+ display: inline-block;
+ color: var(--link);
+
+ & .new,
+ & .original {
+ display: inline-block;
+ border-radius: 2px;
+ }
+
+ .full {
+ position: absolute;
+ display: inline-block;
+ pointer-events: none;
+ opacity: 0;
+ top: 100%;
+ left: 0;
+ height: 100%;
+ word-wrap: normal;
+ white-space: nowrap;
+ transition: opacity 0.2s ease;
+ z-index: 1;
+ margin-top: 0.25em;
+ padding: 0.5em;
+ user-select: all;
+ }
+
+ .short {
+ user-select: none;
+ }
+
+ & .short,
+ & .full {
+ white-space: nowrap;
+ }
+
+ .new {
+ &.-you {
+ & .shortName,
+ & .full {
+ font-weight: 600;
+ }
+ }
+
+ .at {
+ color: var(--link);
+ opacity: 0.8;
+ display: inline-block;
+ height: 50%;
+ line-height: 1;
+ padding: 0 0.1em;
+ vertical-align: -25%;
+ margin: 0;
+ }
+
+ &.-striped {
+ & .userName,
+ & .full {
+ background-image:
+ repeating-linear-gradient(
+ 135deg,
+ var(--____highlight-tintColor),
+ var(--____highlight-tintColor) 5px,
+ var(--____highlight-tintColor2) 5px,
+ var(--____highlight-tintColor2) 10px
+ );
+ }
+ }
+
+ &.-solid {
+ & .userName,
+ & .full {
+ background-image: linear-gradient(var(--____highlight-tintColor2), var(--____highlight-tintColor2));
+ }
+ }
+
+ &.-side {
+ & .userName,
+ & .userNameFull {
+ box-shadow: 0 -5px 3px -4px inset var(--____highlight-solidColor);
+ }
+ }
+ }
+
+ &:hover .new .full {
+ opacity: 1;
+ pointer-events: initial;
+ }
+}
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
@@ -0,0 +1,56 @@
+<template>
+ <span
+ class="MentionLink"
+ >
+ <!-- eslint-disable vue/no-v-html -->
+ <a
+ v-if="!user"
+ :href="url"
+ class="original"
+ target="_blank"
+ v-html="content"
+ />
+ <!-- eslint-enable vue/no-v-html -->
+ <span
+ v-if="user"
+ class="new"
+ :style="style"
+ :class="classnames"
+ >
+ <a
+ class="short button-unstyled"
+ :href="url"
+ @click.prevent="onClick"
+ >
+ <!-- eslint-disable vue/no-v-html -->
+ <FAIcon
+ size="sm"
+ icon="at"
+ class="at"
+ /><span class="shortName"><span
+ class="userName"
+ v-html="userName"
+ /></span>
+ <span
+ v-if="isYou"
+ class="you"
+ >{{ $t('status.you') }}</span>
+ <!-- eslint-enable vue/no-v-html -->
+ </a>
+ <span
+ v-if="userName !== userNameFull"
+ class="full popover-default"
+ :class="[highlightType]"
+ >
+ <span
+ class="userNameFull"
+ v-text="'@' + userNameFull"
+ />
+ </span>
+ </span>
+ </span>
+</template>
+
+<script src="./mention_link.js"/>
+
+<style lang="scss" src="./mention_link.scss"/>
diff --git a/src/components/mentions_line/mentions_line.js b/src/components/mentions_line/mentions_line.js
@@ -0,0 +1,37 @@
+import MentionLink from 'src/components/mention_link/mention_link.vue'
+import { mapGetters } from 'vuex'
+
+export const MENTIONS_LIMIT = 5
+
+const MentionsLine = {
+ name: 'MentionsLine',
+ props: {
+ mentions: {
+ required: true,
+ type: Array
+ }
+ },
+ data: () => ({ expanded: false }),
+ components: {
+ MentionLink
+ },
+ computed: {
+ mentionsComputed () {
+ return this.mentions.slice(0, MENTIONS_LIMIT)
+ },
+ extraMentions () {
+ return this.mentions.slice(MENTIONS_LIMIT)
+ },
+ manyMentions () {
+ return this.extraMentions.length > 0
+ },
+ ...mapGetters(['mergedConfig'])
+ },
+ methods: {
+ toggleShowMore () {
+ this.expanded = !this.expanded
+ }
+ }
+}
+
+export default MentionsLine
diff --git a/src/components/mentions_line/mentions_line.scss b/src/components/mentions_line/mentions_line.scss
@@ -0,0 +1,11 @@
+.MentionsLine {
+ .showMoreLess {
+ white-space: normal;
+ color: var(--link);
+ }
+
+ .fullExtraMentions,
+ .mention-link:not(:last-child) {
+ margin-right: 0.25em;
+ }
+}
diff --git a/src/components/mentions_line/mentions_line.vue b/src/components/mentions_line/mentions_line.vue
@@ -0,0 +1,43 @@
+<template>
+ <span class="MentionsLine">
+ <MentionLink
+ v-for="mention in mentionsComputed"
+ :key="mention.index"
+ class="mention-link"
+ :content="mention.content"
+ :url="mention.url"
+ :first-mention="false"
+ /><span
+ v-if="manyMentions"
+ class="extraMentions"
+ >
+ <span
+ v-if="expanded"
+ class="fullExtraMentions"
+ >
+ <MentionLink
+ v-for="mention in extraMentions"
+ :key="mention.index"
+ class="mention-link"
+ :content="mention.content"
+ :url="mention.url"
+ :first-mention="false"
+ />
+ </span><button
+ v-if="!expanded"
+ class="button-unstyled showMoreLess"
+ @click="toggleShowMore"
+ >
+ {{ $t('status.plus_more', { number: extraMentions.length }) }}
+ </button><button
+ v-if="expanded"
+ class="button-unstyled showMoreLess"
+ @click="toggleShowMore"
+ >
+ {{ $t('general.show_less') }}
+ </button>
+ </span>
+ </span>
+</template>
+<script src="./mentions_line.js" ></script>
+<style lang="scss" src="./mentions_line.scss" />
diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.js b/src/components/mobile_post_status_button/mobile_post_status_button.js
@@ -44,6 +44,9 @@ const MobilePostStatusButton = {
return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
},
+ isPersistent () {
+ return !!this.$store.getters.mergedConfig.showNewPostButton
+ },
autohideFloatingPostButton () {
return !!this.$store.getters.mergedConfig.autohideFloatingPostButton
}
diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.vue b/src/components/mobile_post_status_button/mobile_post_status_button.vue
@@ -2,7 +2,7 @@
<div v-if="isLoggedIn">
<button
class="button-default new-status-button"
- :class="{ 'hidden': isHidden }"
+ :class="{ 'hidden': isHidden, 'always-show': isPersistent }"
@click="openPostForm"
>
<FAIcon icon="pen" />
@@ -47,7 +47,7 @@
}
@media all and (min-width: 801px) {
- .new-status-button {
+ .new-status-button:not(.always-show) {
display: none;
}
}
diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.js b/src/components/mrf_transparency_panel/mrf_transparency_panel.js
@@ -1,17 +1,56 @@
import { mapState } from 'vuex'
import { get } from 'lodash'
+/**
+ * This is for backwards compatibility. We originally didn't recieve
+ * extra info like a reason why an instance was rejected/quarantined/etc.
+ * Because we didn't want to break backwards compatibility it was decided
+ * to add an extra "info" key.
+ */
+const toInstanceReasonObject = (instances, info, key) => {
+ return instances.map(instance => {
+ if (info[key] && info[key][instance] && info[key][instance]['reason']) {
+ return { instance: instance, reason: info[key][instance]['reason'] }
+ }
+ return { instance: instance, reason: '' }
+ })
+}
+
const MRFTransparencyPanel = {
computed: {
...mapState({
federationPolicy: state => get(state, 'instance.federationPolicy'),
mrfPolicies: state => get(state, 'instance.federationPolicy.mrf_policies', []),
- quarantineInstances: state => get(state, 'instance.federationPolicy.quarantined_instances', []),
- acceptInstances: state => get(state, 'instance.federationPolicy.mrf_simple.accept', []),
- rejectInstances: state => get(state, 'instance.federationPolicy.mrf_simple.reject', []),
- ftlRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.federated_timeline_removal', []),
- mediaNsfwInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_nsfw', []),
- mediaRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_removal', []),
+ quarantineInstances: state => toInstanceReasonObject(
+ get(state, 'instance.federationPolicy.quarantined_instances', []),
+ get(state, 'instance.federationPolicy.quarantined_instances_info', []),
+ 'quarantined_instances'
+ ),
+ acceptInstances: state => toInstanceReasonObject(
+ get(state, 'instance.federationPolicy.mrf_simple.accept', []),
+ get(state, 'instance.federationPolicy.mrf_simple_info', []),
+ 'accept'
+ ),
+ rejectInstances: state => toInstanceReasonObject(
+ get(state, 'instance.federationPolicy.mrf_simple.reject', []),
+ get(state, 'instance.federationPolicy.mrf_simple_info', []),
+ 'reject'
+ ),
+ ftlRemovalInstances: state => toInstanceReasonObject(
+ get(state, 'instance.federationPolicy.mrf_simple.federated_timeline_removal', []),
+ get(state, 'instance.federationPolicy.mrf_simple_info', []),
+ 'federated_timeline_removal'
+ ),
+ mediaNsfwInstances: state => toInstanceReasonObject(
+ get(state, 'instance.federationPolicy.mrf_simple.media_nsfw', []),
+ get(state, 'instance.federationPolicy.mrf_simple_info', []),
+ 'media_nsfw'
+ ),
+ mediaRemovalInstances: state => toInstanceReasonObject(
+ get(state, 'instance.federationPolicy.mrf_simple.media_removal', []),
+ get(state, 'instance.federationPolicy.mrf_simple_info', []),
+ 'media_removal'
+ ),
keywordsFtlRemoval: state => get(state, 'instance.federationPolicy.mrf_keyword.federated_timeline_removal', []),
keywordsReject: state => get(state, 'instance.federationPolicy.mrf_keyword.reject', []),
keywordsReplace: state => get(state, 'instance.federationPolicy.mrf_keyword.replace', [])
diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.scss b/src/components/mrf_transparency_panel/mrf_transparency_panel.scss
@@ -0,0 +1,21 @@
+.mrf-section {
+ margin: 1em;
+
+ table {
+ width:100%;
+ text-align: left;
+ padding-left:10px;
+ padding-bottom:20px;
+
+ th, td {
+ width: 180px;
+ max-width: 360px;
+ overflow: hidden;
+ vertical-align: text-top;
+ }
+
+ th+th, td+td {
+ width: auto;
+ }
+ }
+}
diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.vue b/src/components/mrf_transparency_panel/mrf_transparency_panel.vue
@@ -31,13 +31,24 @@
<p>{{ $t("about.mrf.simple.accept_desc") }}</p>
- <ul>
- <li
- v-for="instance in acceptInstances"
- :key="instance"
- v-text="instance"
- />
- </ul>
+ <table>
+ <tr>
+ <th>{{ $t("about.mrf.simple.instance") }}</th>
+ <th>{{ $t("about.mrf.simple.reason") }}</th>
+ </tr>
+ <tr
+ v-for="entry in acceptInstances"
+ :key="entry.instance + '_accept'"
+ >
+ <td>{{ entry.instance }}</td>
+ <td v-if="entry.reason === ''">
+ {{ $t("about.mrf.simple.not_applicable") }}
+ </td>
+ <td v-else>
+ {{ entry.reason }}
+ </td>
+ </tr>
+ </table>
</div>
<div v-if="rejectInstances.length">
@@ -45,13 +56,24 @@
<p>{{ $t("about.mrf.simple.reject_desc") }}</p>
- <ul>
- <li
- v-for="instance in rejectInstances"
- :key="instance"
- v-text="instance"
- />
- </ul>
+ <table>
+ <tr>
+ <th>{{ $t("about.mrf.simple.instance") }}</th>
+ <th>{{ $t("about.mrf.simple.reason") }}</th>
+ </tr>
+ <tr
+ v-for="entry in rejectInstances"
+ :key="entry.instance + '_reject'"
+ >
+ <td>{{ entry.instance }}</td>
+ <td v-if="entry.reason === ''">
+ {{ $t("about.mrf.simple.not_applicable") }}
+ </td>
+ <td v-else>
+ {{ entry.reason }}
+ </td>
+ </tr>
+ </table>
</div>
<div v-if="quarantineInstances.length">
@@ -59,13 +81,24 @@
<p>{{ $t("about.mrf.simple.quarantine_desc") }}</p>
- <ul>
- <li
- v-for="instance in quarantineInstances"
- :key="instance"
- v-text="instance"
- />
- </ul>
+ <table>
+ <tr>
+ <th>{{ $t("about.mrf.simple.instance") }}</th>
+ <th>{{ $t("about.mrf.simple.reason") }}</th>
+ </tr>
+ <tr
+ v-for="entry in quarantineInstances"
+ :key="entry.instance + '_quarantine'"
+ >
+ <td>{{ entry.instance }}</td>
+ <td v-if="entry.reason === ''">
+ {{ $t("about.mrf.simple.not_applicable") }}
+ </td>
+ <td v-else>
+ {{ entry.reason }}
+ </td>
+ </tr>
+ </table>
</div>
<div v-if="ftlRemovalInstances.length">
@@ -73,13 +106,24 @@
<p>{{ $t("about.mrf.simple.ftl_removal_desc") }}</p>
- <ul>
- <li
- v-for="instance in ftlRemovalInstances"
- :key="instance"
- v-text="instance"
- />
- </ul>
+ <table>
+ <tr>
+ <th>{{ $t("about.mrf.simple.instance") }}</th>
+ <th>{{ $t("about.mrf.simple.reason") }}</th>
+ </tr>
+ <tr
+ v-for="entry in ftlRemovalInstances"
+ :key="entry.instance + '_ftl_removal'"
+ >
+ <td>{{ entry.instance }}</td>
+ <td v-if="entry.reason === ''">
+ {{ $t("about.mrf.simple.not_applicable") }}
+ </td>
+ <td v-else>
+ {{ entry.reason }}
+ </td>
+ </tr>
+ </table>
</div>
<div v-if="mediaNsfwInstances.length">
@@ -87,13 +131,24 @@
<p>{{ $t("about.mrf.simple.media_nsfw_desc") }}</p>
- <ul>
- <li
- v-for="instance in mediaNsfwInstances"
- :key="instance"
- v-text="instance"
- />
- </ul>
+ <table>
+ <tr>
+ <th>{{ $t("about.mrf.simple.instance") }}</th>
+ <th>{{ $t("about.mrf.simple.reason") }}</th>
+ </tr>
+ <tr
+ v-for="entry in mediaNsfwInstances"
+ :key="entry.instance + '_media_nsfw'"
+ >
+ <td>{{ entry.instance }}</td>
+ <td v-if="entry.reason === ''">
+ {{ $t("about.mrf.simple.not_applicable") }}
+ </td>
+ <td v-else>
+ {{ entry.reason }}
+ </td>
+ </tr>
+ </table>
</div>
<div v-if="mediaRemovalInstances.length">
@@ -101,13 +156,24 @@
<p>{{ $t("about.mrf.simple.media_removal_desc") }}</p>
- <ul>
- <li
- v-for="instance in mediaRemovalInstances"
- :key="instance"
- v-text="instance"
- />
- </ul>
+ <table>
+ <tr>
+ <th>{{ $t("about.mrf.simple.instance") }}</th>
+ <th>{{ $t("about.mrf.simple.reason") }}</th>
+ </tr>
+ <tr
+ v-for="entry in mediaRemovalInstances"
+ :key="entry.instance + '_media_removal'"
+ >
+ <td>{{ entry.instance }}</td>
+ <td v-if="entry.reason === ''">
+ {{ $t("about.mrf.simple.not_applicable") }}
+ </td>
+ <td v-else>
+ {{ entry.reason }}
+ </td>
+ </tr>
+ </table>
</div>
<h2 v-if="hasKeywordPolicies">
@@ -161,7 +227,6 @@
<script src="./mrf_transparency_panel.js"></script>
<style lang="scss">
-.mrf-section {
- margin: 1em;
-}
+@import '../../_variables.scss';
+@import './mrf_transparency_panel.scss';
</style>
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
@@ -4,6 +4,7 @@ import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@@ -44,7 +45,8 @@ const Notification = {
UserAvatar,
UserCard,
Timeago,
- Status
+ Status,
+ RichContent
},
methods: {
toggleUserExpanded () {
diff --git a/src/components/notification/notification.scss b/src/components/notification/notification.scss
@@ -2,6 +2,8 @@
// TODO Copypaste from Status, should unify it somehow
.Notification {
+ --emoji-size: 14px;
+
&.-muted {
padding: 0.25em 0.6em;
height: 1.2em;
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
@@ -51,12 +51,14 @@
<span class="notification-details">
<div class="name-and-action">
<!-- eslint-disable vue/no-v-html -->
- <bdi
- v-if="!!notification.from_profile.name_html"
- class="username"
- :title="'@'+notification.from_profile.screen_name_ui"
- v-html="notification.from_profile.name_html"
- />
+ <bdi v-if="!!notification.from_profile.name_html">
+ <RichContent
+ class="username"
+ :title="'@'+notification.from_profile.screen_name_ui"
+ :html="notification.from_profile.name_html"
+ :emoji="notification.from_profile.emoji"
+ />
+ </bdi>
<!-- eslint-enable vue/no-v-html -->
<span
v-else
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
@@ -148,13 +148,6 @@
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
-
- img {
- width: 14px;
- height: 14px;
- vertical-align: middle;
- object-fit: contain
- }
}
.timeago {
diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js
@@ -1,10 +1,14 @@
-import Timeago from '../timeago/timeago.vue'
+import Timeago from 'components/timeago/timeago.vue'
+import RichContent from 'components/rich_content/rich_content.jsx'
import { forEach, map } from 'lodash'
export default {
name: 'Poll',
- props: ['basePoll'],
- components: { Timeago },
+ props: ['basePoll', 'emoji'],
+ components: {
+ Timeago,
+ RichContent
+ },
data () {
return {
loading: false,
diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue
@@ -17,8 +17,11 @@
<span class="result-percentage">
{{ percentageForOption(option.votes_count) }}%
</span>
- <!-- eslint-disable-next-line vue/no-v-html -->
- <span v-html="option.title_html" />
+ <RichContent
+ :html="option.title_html"
+ :handle-links="false"
+ :emoji="emoji"
+ />
</div>
<div
class="result-fill"
@@ -42,8 +45,11 @@
:value="index"
>
<label class="option-vote">
- <!-- eslint-disable-next-line vue/no-v-html -->
- <div v-html="option.title_html" />
+ <RichContent
+ :html="option.title_html"
+ :handle-links="false"
+ :emoji="emoji"
+ />
</label>
</div>
</div>
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
@@ -0,0 +1,327 @@
+import Vue from 'vue'
+import { unescape, flattenDeep } from 'lodash'
+import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
+import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
+import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
+import StillImage from 'src/components/still-image/still-image.vue'
+import MentionsLine, { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.vue'
+import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
+
+import './rich_content.scss'
+
+/**
+ * RichContent, The Über-powered component for rendering Post HTML.
+ *
+ * This takes post HTML and does multiple things to it:
+ * - Groups all mentions into <MentionsLine>, this affects all mentions regardles
+ * of where they are (beginning/middle/end), even single mentions are converted
+ * to a <MentionsLine> containing single <MentionLink>.
+ * - Replaces emoji shortcodes with <StillImage>'d images.
+ *
+ * There are two problems with this component's architecture:
+ * 1. Parsing HTML and rendering are inseparable. Attempts to separate the two
+ * proven to be a massive overcomplication due to amount of things done here.
+ * 2. We need to output both render and some extra data, which seems to be imp-
+ * possible in vue. Current solution is to emit 'parseReady' event when parsing
+ * is done within render() function.
+ *
+ * Apart from that one small hiccup with emit in render this _should_ be vue3-ready
+ */
+export default Vue.component('RichContent', {
+ name: 'RichContent',
+ props: {
+ // Original html content
+ html: {
+ required: true,
+ type: String
+ },
+ attentions: {
+ required: false,
+ default: () => []
+ },
+ // Emoji object, as in status.emojis, note the "s" at the end...
+ emoji: {
+ required: true,
+ type: Array
+ },
+ // Whether to handle links or not (posts: yes, everything else: no)
+ handleLinks: {
+ required: false,
+ type: Boolean,
+ default: false
+ },
+ // Meme arrows
+ greentext: {
+ required: false,
+ type: Boolean,
+ default: false
+ }
+ },
+ // NEVER EVER TOUCH DATA INSIDE RENDER
+ render (h) {
+ // Pre-process HTML
+ const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
+ let currentMentions = null // Current chain of mentions, we group all mentions together
+ // This is used to recover spacing removed when parsing mentions
+ let lastSpacing = ''
+
+ const lastTags = [] // Tags that appear at the end of post body
+ const writtenMentions = [] // All mentions that appear in post body
+ const invisibleMentions = [] // All mentions that go beyond the limiter (see MentionsLine)
+ // to collapse too many mentions in a row
+ const writtenTags = [] // All tags that appear in post body
+ // unique index for vue "tag" property
+ let mentionIndex = 0
+ let tagsIndex = 0
+
+ const renderImage = (tag) => {
+ return <StillImage
+ {...{ attrs: getAttrs(tag) }}
+ class="img"
+ />
+ }
+
+ const renderHashtag = (attrs, children, encounteredTextReverse) => {
+ const linkData = getLinkData(attrs, children, tagsIndex++)
+ writtenTags.push(linkData)
+ if (!encounteredTextReverse) {
+ lastTags.push(linkData)
+ }
+ return <HashtagLink {...{ props: linkData }}/>
+ }
+
+ const renderMention = (attrs, children) => {
+ const linkData = getLinkData(attrs, children, mentionIndex++)
+ linkData.notifying = this.attentions.some(a => a.statusnet_profile_url === linkData.url)
+ writtenMentions.push(linkData)
+ if (currentMentions === null) {
+ currentMentions = []
+ }
+ currentMentions.push(linkData)
+ if (currentMentions.length > MENTIONS_LIMIT) {
+ invisibleMentions.push(linkData)
+ }
+ if (currentMentions.length === 1) {
+ return <MentionsLine mentions={ currentMentions } />
+ } else {
+ return ''
+ }
+ }
+
+ // Processor to use with html_tree_converter
+ const processItem = (item, index, array, what) => {
+ // Handle text nodes - just add emoji
+ if (typeof item === 'string') {
+ const emptyText = item.trim() === ''
+ if (item.includes('\n')) {
+ currentMentions = null
+ }
+ if (emptyText) {
+ // don't include spaces when processing mentions - we'll include them
+ // in MentionsLine
+ lastSpacing = item
+ return currentMentions !== null ? item.trim() : item
+ }
+
+ currentMentions = null
+ if (item.includes(':')) {
+ item = ['', processTextForEmoji(
+ item,
+ this.emoji,
+ ({ shortcode, url }) => {
+ return <StillImage
+ class="emoji img"
+ src={url}
+ title={`:${shortcode}:`}
+ alt={`:${shortcode}:`}
+ />
+ }
+ )]
+ }
+ return item
+ }
+
+ // Handle tag nodes
+ if (Array.isArray(item)) {
+ const [opener, children, closer] = item
+ const Tag = getTagName(opener)
+ const attrs = getAttrs(opener)
+ const previouslyMentions = currentMentions !== null
+ /* During grouping of mentions we trim all the empty text elements
+ * This padding is added to recover last space removed in case
+ * we have a tag right next to mentions
+ */
+ const mentionsLinePadding =
+ // Padding is only needed if we just finished parsing mentions
+ previouslyMentions &&
+ // Don't add padding if content is string and has padding already
+ !(children && typeof children[0] === 'string' && children[0].match(/^\s/))
+ ? lastSpacing
+ : ''
+ switch (Tag) {
+ case 'br':
+ currentMentions = null
+ break
+ case 'img': // replace images with StillImage
+ return ['', [mentionsLinePadding, renderImage(opener)], '']
+ case 'a': // replace mentions with MentionLink
+ if (!this.handleLinks) break
+ if (attrs['class'] && attrs['class'].includes('mention')) {
+ // Handling mentions here
+ return renderMention(attrs, children)
+ } else {
+ currentMentions = null
+ break
+ }
+ case 'span':
+ if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) {
+ return ['', children.map(processItem), '']
+ }
+ }
+
+ if (children !== undefined) {
+ return [
+ '',
+ [
+ mentionsLinePadding,
+ [opener, children.map(processItem), closer]
+ ],
+ ''
+ ]
+ } else {
+ return ['', [mentionsLinePadding, item], '']
+ }
+ }
+ }
+
+ // Processor for back direction (for finding "last" stuff, just easier this way)
+ let encounteredTextReverse = false
+ const processItemReverse = (item, index, array, what) => {
+ // Handle text nodes - just add emoji
+ if (typeof item === 'string') {
+ const emptyText = item.trim() === ''
+ if (emptyText) return item
+ if (!encounteredTextReverse) encounteredTextReverse = true
+ return unescape(item)
+ } else if (Array.isArray(item)) {
+ // Handle tag nodes
+ const [opener, children] = item
+ const Tag = opener === '' ? '' : getTagName(opener)
+ switch (Tag) {
+ case 'a': // replace mentions with MentionLink
+ if (!this.handleLinks) break
+ const attrs = getAttrs(opener)
+ // should only be this
+ if (
+ (attrs['class'] && attrs['class'].includes('hashtag')) || // Pleroma style
+ (attrs['rel'] === 'tag') // Mastodon style
+ ) {
+ return renderHashtag(attrs, children, encounteredTextReverse)
+ } else {
+ attrs.target = '_blank'
+ const newChildren = [...children].reverse().map(processItemReverse).reverse()
+
+ return <a {...{ attrs }}>
+ { newChildren }
+ </a>
+ }
+ case '':
+ return [...children].reverse().map(processItemReverse).reverse()
+ }
+
+ // Render tag as is
+ if (children !== undefined) {
+ const newChildren = Array.isArray(children)
+ ? [...children].reverse().map(processItemReverse).reverse()
+ : children
+ return <Tag {...{ attrs: getAttrs(opener) }}>
+ { newChildren }
+ </Tag>
+ } else {
+ return <Tag/>
+ }
+ }
+ return item
+ }
+
+ const pass1 = convertHtmlToTree(html).map(processItem)
+ const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
+ // DO NOT USE SLOTS they cause a re-render feedback loop here.
+ // slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
+ // at least until vue3?
+ const result = <span class="RichContent">
+ { pass2 }
+ </span>
+
+ const event = {
+ lastTags,
+ writtenMentions,
+ writtenTags,
+ invisibleMentions
+ }
+
+ // DO NOT MOVE TO UPDATE. BAD IDEA.
+ this.$emit('parseReady', event)
+
+ return result
+ }
+})
+
+const getLinkData = (attrs, children, index) => {
+ const stripTags = (item) => {
+ if (typeof item === 'string') {
+ return item
+ } else {
+ return item[1].map(stripTags).join('')
+ }
+ }
+ const textContent = children.map(stripTags).join('')
+ return {
+ index,
+ url: attrs.href,
+ tag: attrs['data-tag'],
+ content: flattenDeep(children).join(''),
+ textContent
+ }
+}
+
+/** Pre-processing HTML
+ *
+ * Currently this does one thing:
+ * - add green/cyantexting
+ *
+ * @param {String} html - raw HTML to process
+ * @param {Boolean} greentext - whether to enable greentexting or not
+ */
+export const preProcessPerLine = (html, greentext) => {
+ const greentextHandle = new Set(['p', 'div'])
+
+ const lines = convertHtmlToLines(html)
+ const newHtml = lines.reverse().map((item, index, array) => {
+ if (!item.text) return item
+ const string = item.text
+
+ // Greentext stuff
+ if (
+ // Only if greentext is engaged
+ greentext &&
+ // Only handle p's and divs. Don't want to affect blockquotes, code etc
+ item.level.every(l => greentextHandle.has(l)) &&
+ // Only if line begins with '>' or '<'
+ (string.includes('>') || string.includes('<'))
+ ) {
+ const cleanedString = string.replace(/<[^>]+?>/gi, '') // remove all tags
+ .replace(/@\w+/gi, '') // remove mentions (even failed ones)
+ .trim()
+ if (cleanedString.startsWith('>')) {
+ return `<span class='greentext'>${string}</span>`
+ } else if (cleanedString.startsWith('<')) {
+ return `<span class='cyantext'>${string}</span>`
+ }
+ }
+
+ return string
+ }).reverse().join('')
+
+ return { newHtml }
+}
diff --git a/src/components/rich_content/rich_content.scss b/src/components/rich_content/rich_content.scss
@@ -0,0 +1,64 @@
+.RichContent {
+ blockquote {
+ margin: 0.2em 0 0.2em 2em;
+ font-style: italic;
+ }
+
+ pre {
+ overflow: auto;
+ }
+
+ code,
+ samp,
+ kbd,
+ var,
+ pre {
+ font-family: var(--postCodeFont, monospace);
+ }
+
+ p {
+ margin: 0 0 1em 0;
+ }
+
+ p:last-child {
+ margin: 0 0 0 0;
+ }
+
+ h1 {
+ font-size: 1.1em;
+ line-height: 1.2em;
+ margin: 1.4em 0;
+ }
+
+ h2 {
+ font-size: 1.1em;
+ margin: 1em 0;
+ }
+
+ h3 {
+ font-size: 1em;
+ margin: 1.2em 0;
+ }
+
+ h4 {
+ margin: 1.1em 0;
+ }
+
+ .img {
+ display: inline-block;
+ }
+
+ .emoji {
+ display: inline-block;
+ width: var(--emoji-size, 32px);
+ height: var(--emoji-size, 32px);
+ }
+
+ .img,
+ video {
+ max-width: 100%;
+ max-height: 400px;
+ vertical-align: middle;
+ object-fit: contain;
+ }
+}
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
@@ -123,6 +123,11 @@
</BooleanSetting>
</li>
<li>
+ <BooleanSetting path="alwaysShowNewPostButton">
+ {{ $t('settings.always_show_post_button') }}
+ </BooleanSetting>
+ </li>
+ <li>
<BooleanSetting path="autohideFloatingPostButton">
{{ $t('settings.autohide_floating_post_button') }}
</BooleanSetting>
diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
@@ -24,7 +24,7 @@ library.add(
const ProfileTab = {
data () {
return {
- newName: this.$store.state.users.currentUser.name,
+ newName: this.$store.state.users.currentUser.name_unescaped,
newBio: unescape(this.$store.state.users.currentUser.description),
newLocked: this.$store.state.users.currentUser.locked,
newNoRichText: this.$store.state.users.currentUser.no_rich_text,
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -475,7 +475,7 @@ export default {
this.loadThemeFromLocalStorage(false, true)
break
case 'file':
- console.err('Forcing snapshout from file is not supported yet')
+ console.error('Forcing snapshot from file is not supported yet')
break
}
this.dismissWarning()
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
@@ -270,6 +270,9 @@
.apply-container {
justify-content: center;
+ position: absolute;
+ bottom: 8px;
+ right: 5px;
}
.radius-item,
diff --git a/src/components/shout_panel/shout_panel.vue b/src/components/shout_panel/shout_panel.vue
@@ -79,12 +79,19 @@
.floating-shout {
position: fixed;
- right: 0px;
bottom: 0px;
z-index: 1000;
max-width: 25em;
}
+.floating-shout.left {
+ left: 0px;
+}
+
+.floating-shout:not(.left) {
+ right: 0px;
+}
+
.shout-panel {
.shout-heading {
cursor: pointer;
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
@@ -49,6 +49,7 @@ const SideDrawer = {
currentUser () {
return this.$store.state.users.currentUser
},
+ shout () { return this.$store.state.shout.channel.state === 'joined' },
unseenNotifications () {
return unseenNotificationsFromStore(this.$store)
},
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
@@ -106,10 +106,10 @@
</router-link>
</li>
<li
- v-if="chat"
+ v-if="shout"
@click="toggleDrawer"
>
- <router-link :to="{ name: 'chat-panel' }">
+ <router-link :to="{ name: 'shout-panel' }">
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
diff --git a/src/components/status/status.js b/src/components/status/status.js
@@ -9,9 +9,12 @@ import UserAvatar from '../user_avatar/user_avatar.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
import StatusContent from '../status_content/status_content.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
import StatusPopover from '../status_popover/status_popover.vue'
import UserListPopover from '../user_list_popover/user_list_popover.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
+import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
+import MentionLink from 'src/components/mention_link/mention_link.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import { muteWordHits } from '../../services/status_parser/status_parser.js'
@@ -68,7 +71,10 @@ const Status = {
StatusPopover,
UserListPopover,
EmojiReactions,
- StatusContent
+ StatusContent,
+ RichContent,
+ MentionLink,
+ MentionsLine
},
props: [
'statusoid',
@@ -92,7 +98,8 @@ const Status = {
userExpanded: false,
mediaPlaying: [],
suspendable: true,
- error: null
+ error: null,
+ headTailLinks: null
}
},
computed: {
@@ -132,12 +139,15 @@ const Status = {
},
replyProfileLink () {
if (this.isReply) {
- return this.generateUserProfileLink(this.status.in_reply_to_user_id, this.replyToName)
+ const user = this.$store.getters.findUser(this.status.in_reply_to_user_id)
+ // FIXME Why user not found sometimes???
+ return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
retweet () { return !!this.statusoid.retweeted_status },
+ retweeterUser () { return this.statusoid.user },
retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name_ui },
- retweeterHtml () { return this.statusoid.user.name_html },
+ retweeterHtml () { return this.statusoid.user.name },
retweeterProfileLink () { return this.generateUserProfileLink(this.statusoid.user.id, this.statusoid.user.screen_name) },
status () {
if (this.retweet) {
@@ -156,6 +166,25 @@ const Status = {
muteWordHits () {
return muteWordHits(this.status, this.muteWords)
},
+ mentionsLine () {
+ if (!this.headTailLinks) return []
+ const writtenSet = new Set(this.headTailLinks.writtenMentions.map(_ => _.url))
+ return this.status.attentions.filter(attn => {
+ // no reply user
+ return attn.id !== this.status.in_reply_to_user_id &&
+ // no self-replies
+ attn.statusnet_profile_url !== this.status.user.statusnet_profile_url &&
+ // don't include if mentions is written
+ !writtenSet.has(attn.statusnet_profile_url)
+ }).map(attn => ({
+ url: attn.statusnet_profile_url,
+ content: attn.screen_name,
+ userId: attn.id
+ }))
+ },
+ hasMentionsLine () {
+ return this.mentionsLine.length > 0
+ },
muted () {
if (this.statusoid.user.id === this.currentUser.id) return false
const { status } = this
@@ -303,6 +332,9 @@ const Status = {
},
removeMediaPlaying (id) {
this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id)
+ },
+ setHeadTailLinks (headTailLinks) {
+ this.headTailLinks = headTailLinks
}
},
watch: {
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
@@ -1,10 +1,10 @@
-
@import '../../_variables.scss';
$status-margin: 0.75em;
.Status {
min-width: 0;
+ white-space: normal;
&:hover {
--_still-image-img-visibility: visible;
@@ -93,12 +93,8 @@ $status-margin: 0.75em;
margin-right: 0.4em;
text-overflow: ellipsis;
- .emoji {
- width: 14px;
- height: 14px;
- vertical-align: middle;
- object-fit: contain;
- }
+ --_still_image-label-scale: 0.25;
+ --emoji-size: 14px;
}
.status-favicon {
@@ -155,35 +151,24 @@ $status-margin: 0.75em;
}
}
+ .glued-label {
+ display: inline-flex;
+ white-space: nowrap;
+ }
+
.timeago {
margin-right: 0.2em;
}
- .heading-reply-row {
+ & .heading-reply-row {
position: relative;
align-content: baseline;
font-size: 12px;
- line-height: 18px;
+ line-height: 160%;
max-width: 100%;
- display: flex;
- flex-wrap: wrap;
align-items: stretch;
}
- .reply-to-and-accountname {
- display: flex;
- height: 18px;
- margin-right: 0.5em;
- max-width: 100%;
-
- .reply-to-link {
- white-space: nowrap;
- word-break: break-word;
- text-overflow: ellipsis;
- overflow-x: hidden;
- }
- }
-
& .reply-to-popover,
& .reply-to-no-popover {
min-width: 0;
@@ -220,21 +205,27 @@ $status-margin: 0.75em;
}
}
- .reply-to {
+ & .mentions,
+ & .reply-to {
+ white-space: nowrap;
position: relative;
+ padding-right: 0.25em;
}
- .reply-to-text {
+ & .mentions-text,
+ & .reply-to-text {
+ color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
- .replies-separator {
- margin-left: 0.4em;
+ .mentions-line {
+ display: inline;
}
.replies {
+ margin-top: 0.25em;
line-height: 18px;
font-size: 12px;
display: flex;
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
@@ -1,5 +1,4 @@
<template>
- <!-- eslint-disable vue/no-v-html -->
<div
v-if="!hideStatus"
class="Status"
@@ -89,8 +88,12 @@
<router-link
v-if="retweeterHtml"
:to="retweeterProfileLink"
- v-html="retweeterHtml"
- />
+ >
+ <RichContent
+ :html="retweeterHtml"
+ :emoji="retweeterUser.emoji"
+ />
+ </router-link>
<router-link
v-else
:to="retweeterProfileLink"
@@ -145,8 +148,12 @@
v-if="status.user.name_html"
class="status-username"
:title="status.user.name"
- v-html="status.user.name_html"
- />
+ >
+ <RichContent
+ :html="status.user.name"
+ :emoji="status.user.emoji"
+ />
+ </h4>
<h4
v-else
class="status-username"
@@ -214,11 +221,13 @@
</button>
</span>
</div>
-
- <div class="heading-reply-row">
- <div
+ <div
+ v-if="isReply || hasMentionsLine"
+ class="heading-reply-row"
+ >
+ <span
v-if="isReply"
- class="reply-to-and-accountname"
+ class="glued-label"
>
<StatusPopover
v-if="!isPreview"
@@ -238,7 +247,7 @@
flip="horizontal"
/>
<span
- class="faint-link reply-to-text"
+ class="reply-to-text"
>
{{ $t('status.reply_to') }}
</span>
@@ -251,50 +260,76 @@
>
<span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span>
- <router-link
- class="reply-to-link"
- :title="replyToName"
- :to="replyProfileLink"
- >
- {{ replyToName }}
- </router-link>
- <span
- v-if="replies && replies.length"
- class="faint replies-separator"
- >
- -
- </span>
- </div>
- <div
- v-if="inConversation && !isPreview && replies && replies.length"
- class="replies"
+ <MentionLink
+ :content="replyToName"
+ :url="replyProfileLink"
+ :user-id="status.in_reply_to_user_id"
+ :user-screen-name="status.in_reply_to_screen_name"
+ :first-mention="false"
+ />
+ </span>
+
+ <!-- This little wrapper is made for sole purpose of "gluing" -->
+ <!-- "Mentions" label to the first mention -->
+ <span
+ v-if="hasMentionsLine"
+ class="glued-label"
>
- <span class="faint">{{ $t('status.replies_list') }}</span>
- <StatusPopover
- v-for="reply in replies"
- :key="reply.id"
- :status-id="reply.id"
+ <span
+ class="mentions"
+ :aria-label="$t('tool_tip.mentions')"
+ @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
- <button
- class="button-unstyled -link reply-link"
- @click.prevent="gotoOriginal(reply.id)"
+ <span
+ class="mentions-text"
>
- {{ reply.name }}
- </button>
- </StatusPopover>
- </div>
+ {{ $t('status.mentions') }}
+ </span>
+ </span>
+ <MentionsLine
+ v-if="hasMentionsLine"
+ :mentions="mentionsLine.slice(0, 1)"
+ class="mentions-line-first"
+ />
+ </span>
+ <MentionsLine
+ v-if="hasMentionsLine"
+ :mentions="mentionsLine.slice(1)"
+ class="mentions-line"
+ />
</div>
</div>
<StatusContent
+ ref="content"
:status="status"
:no-heading="noHeading"
:highlight="highlight"
:focused="isFocused"
@mediaplay="addMediaPlaying($event)"
@mediapause="removeMediaPlaying($event)"
+ @parseReady="setHeadTailLinks"
/>
+ <div
+ v-if="inConversation && !isPreview && replies && replies.length"
+ class="replies"
+ >
+ <span class="faint">{{ $t('status.replies_list') }}</span>
+ <StatusPopover
+ v-for="reply in replies"
+ :key="reply.id"
+ :status-id="reply.id"
+ >
+ <button
+ class="button-unstyled -link reply-link"
+ @click.prevent="gotoOriginal(reply.id)"
+ >
+ {{ reply.name }}
+ </button>
+ </StatusPopover>
+ </div>
+
<transition name="fade">
<div
v-if="!hidePostStats && isFocused && combinedFavsAndRepeatsUsers.length > 0"
@@ -402,7 +437,6 @@
</div>
</template>
</div>
-<!-- eslint-enable vue/no-v-html -->
</template>
<script src="./status.js" ></script>
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
@@ -0,0 +1,127 @@
+import fileType from 'src/services/file_type/file_type.service'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
+import { mapGetters } from 'vuex'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faFile,
+ faMusic,
+ faImage,
+ faLink,
+ faPollH
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faFile,
+ faMusic,
+ faImage,
+ faLink,
+ faPollH
+)
+
+const StatusContent = {
+ name: 'StatusContent',
+ props: [
+ 'status',
+ 'focused',
+ 'noHeading',
+ 'fullContent',
+ 'singleLine'
+ ],
+ data () {
+ return {
+ showingTall: this.fullContent || (this.inConversation && this.focused),
+ showingLongSubject: false,
+ // not as computed because it sets the initial state which will be changed later
+ expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject,
+ postLength: this.status.text.length,
+ parseReadyDone: false
+ }
+ },
+ computed: {
+ localCollapseSubjectDefault () {
+ return this.mergedConfig.collapseMessageWithSubject
+ },
+ // This is a bit hacky, but we want to approximate post height before rendering
+ // so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them)
+ // as well as approximate line count by counting characters and approximating ~80
+ // per line.
+ //
+ // Using max-height + overflow: auto for status components resulted in false positives
+ // very often with japanese characters, and it was very annoying.
+ tallStatus () {
+ const lengthScore = this.status.raw_html.split(/<p|<br/).length + this.postLength / 80
+ return lengthScore > 20
+ },
+ longSubject () {
+ return this.status.summary.length > 240
+ },
+ // When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
+ mightHideBecauseSubject () {
+ return !!this.status.summary && this.localCollapseSubjectDefault
+ },
+ mightHideBecauseTall () {
+ return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault)
+ },
+ hideSubjectStatus () {
+ return this.mightHideBecauseSubject && !this.expandingSubject
+ },
+ hideTallStatus () {
+ return this.mightHideBecauseTall && !this.showingTall
+ },
+ showingMore () {
+ return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject)
+ },
+ attachmentTypes () {
+ return this.status.attachments.map(file => fileType.fileType(file.mimetype))
+ },
+ ...mapGetters(['mergedConfig'])
+ },
+ components: {
+ RichContent
+ },
+ mounted () {
+ this.status.attentions && this.status.attentions.forEach(attn => {
+ const { id } = attn
+ this.$store.dispatch('fetchUserIfMissing', id)
+ })
+ },
+ methods: {
+ onParseReady (event) {
+ if (this.parseReadyDone) return
+ this.parseReadyDone = true
+ this.$emit('parseReady', event)
+ const { writtenMentions, invisibleMentions } = event
+ writtenMentions
+ .filter(mention => !mention.notifying)
+ .forEach(mention => {
+ const { content, url } = mention
+ const cleanedString = content.replace(/<[^>]+?>/gi, '') // remove all tags
+ if (!cleanedString.startsWith('@')) return
+ const handle = cleanedString.slice(1)
+ const host = url.replace(/^https?:\/\//, '').replace(/\/.+?$/, '')
+ this.$store.dispatch('fetchUserIfMissing', `${handle}@${host}`)
+ })
+ /* This is a bit of a hack to make current tall status detector work
+ * with rich mentions. Invisible mentions are detected at RichContent level
+ * and also we generate plaintext version of mentions by stripping tags
+ * so here we subtract from post length by each mention that became invisible
+ * via MentionsLine
+ */
+ this.postLength = invisibleMentions.reduce((acc, mention) => {
+ return acc - mention.textContent.length - 1
+ }, this.postLength)
+ },
+ toggleShowMore () {
+ if (this.mightHideBecauseTall) {
+ this.showingTall = !this.showingTall
+ } else if (this.mightHideBecauseSubject) {
+ this.expandingSubject = !this.expandingSubject
+ }
+ },
+ generateTagLink (tag) {
+ return `/tag/${tag}`
+ }
+ }
+}
+
+export default StatusContent
diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss
@@ -0,0 +1,118 @@
+@import '../../_variables.scss';
+
+.StatusBody {
+
+ .emoji {
+ --_still_image-label-scale: 0.5;
+ }
+
+ & .text,
+ & .summary {
+ font-family: var(--postFont, sans-serif);
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ word-break: break-word;
+ line-height: 1.4em;
+ }
+
+ .summary {
+ display: block;
+ font-style: italic;
+ padding-bottom: 0.5em;
+ }
+
+ .text {
+ &.-single-line {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ height: 1.4em;
+ }
+ }
+
+ .summary-wrapper {
+ margin-bottom: 0.5em;
+ border-style: solid;
+ border-width: 0 0 1px 0;
+ border-color: var(--border, $fallback--border);
+ flex-grow: 0;
+
+ &.-tall {
+ position: relative;
+
+ .summary {
+ max-height: 2em;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+ }
+
+ .text-wrapper {
+ display: flex;
+ flex-direction: column;
+ flex-wrap: nowrap;
+
+ &.-tall-status {
+ position: relative;
+ height: 220px;
+ overflow-x: hidden;
+ overflow-y: hidden;
+ z-index: 1;
+
+ .media-body {
+ min-height: 0;
+ mask:
+ linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
+ linear-gradient(to top, white, white);
+
+ /* Autoprefixed seem to ignore this one, and also syntax is different */
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ }
+ }
+ }
+
+ & .tall-status-hider,
+ & .tall-subject-hider,
+ & .status-unhider,
+ & .cw-status-hider {
+ display: inline-block;
+ word-break: break-all;
+ width: 100%;
+ text-align: center;
+ }
+
+ .tall-status-hider {
+ position: absolute;
+ height: 70px;
+ margin-top: 150px;
+ line-height: 110px;
+ z-index: 2;
+ }
+
+ .tall-subject-hider {
+ // position: absolute;
+ padding-bottom: 0.5em;
+ }
+
+ & .status-unhider,
+ & .cw-status-hider {
+ word-break: break-all;
+
+ svg {
+ color: inherit;
+ }
+ }
+
+ .greentext {
+ color: $fallback--cGreen;
+ color: var(--postGreentext, $fallback--cGreen);
+ }
+
+ .cyantext {
+ color: var(--postCyantext, $fallback--cBlue);
+ }
+}
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
@@ -0,0 +1,97 @@
+<template>
+ <div class="StatusBody">
+ <div class="body">
+ <div
+ v-if="status.summary_raw_html"
+ class="summary-wrapper"
+ :class="{ '-tall': (longSubject && !showingLongSubject) }"
+ >
+ <RichContent
+ class="media-body summary"
+ :html="status.summary_raw_html"
+ :emoji="status.emojis"
+ />
+ <button
+ v-if="longSubject && showingLongSubject"
+ class="button-unstyled -link tall-subject-hider"
+ @click.prevent="showingLongSubject=false"
+ >
+ {{ $t("status.hide_full_subject") }}
+ </button>
+ <button
+ v-else-if="longSubject"
+ class="button-unstyled -link tall-subject-hider"
+ @click.prevent="showingLongSubject=true"
+ >
+ {{ $t("status.show_full_subject") }}
+ </button>
+ </div>
+ <div
+ :class="{'-tall-status': hideTallStatus}"
+ class="text-wrapper"
+ >
+ <button
+ v-if="hideTallStatus"
+ class="button-unstyled -link tall-status-hider"
+ :class="{ '-focused': focused }"
+ @click.prevent="toggleShowMore"
+ >
+ {{ $t("general.show_more") }}
+ </button>
+ <RichContent
+ v-if="!hideSubjectStatus && !(singleLine && status.summary_raw_html)"
+ :class="{ '-single-line': singleLine }"
+ class="text media-body"
+ :html="status.raw_html"
+ :emoji="status.emojis"
+ :handle-links="true"
+ :greentext="mergedConfig.greentext"
+ :attentions="status.attentions"
+ @parseReady="onParseReady"
+ />
+
+ <button
+ v-if="hideSubjectStatus"
+ class="button-unstyled -link cw-status-hider"
+ @click.prevent="toggleShowMore"
+ >
+ {{ $t("status.show_content") }}
+ <FAIcon
+ v-if="attachmentTypes.includes('image')"
+ icon="image"
+ />
+ <FAIcon
+ v-if="attachmentTypes.includes('video')"
+ icon="video"
+ />
+ <FAIcon
+ v-if="attachmentTypes.includes('audio')"
+ icon="music"
+ />
+ <FAIcon
+ v-if="attachmentTypes.includes('unknown')"
+ icon="file"
+ />
+ <FAIcon
+ v-if="status.poll && status.poll.options"
+ icon="poll-h"
+ />
+ <FAIcon
+ v-if="status.card"
+ icon="link"
+ />
+ </button>
+ <button
+ v-if="showingMore && !fullContent"
+ class="button-unstyled -link status-unhider"
+ @click.prevent="toggleShowMore"
+ >
+ {{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }}
+ </button>
+ </div>
+ </div>
+ <slot v-if="!hideSubjectStatus" />
+ </div>
+</template>
+<script src="./status_body.js" ></script>
+<style lang="scss" src="./status_body.scss" />
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
@@ -1,11 +1,9 @@
import Attachment from '../attachment/attachment.vue'
import Poll from '../poll/poll.vue'
import Gallery from '../gallery/gallery.vue'
+import StatusBody from 'src/components/status_body/status_body.vue'
import LinkPreview from '../link-preview/link-preview.vue'
-import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import fileType from 'src/services/file_type/file_type.service'
-import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
-import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
import { mapGetters, mapState } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
@@ -35,52 +33,11 @@ const StatusContent = {
'fullContent',
'singleLine'
],
- data () {
- return {
- showingTall: this.fullContent || (this.inConversation && this.focused),
- showingLongSubject: false,
- // not as computed because it sets the initial state which will be changed later
- expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
- }
- },
computed: {
- localCollapseSubjectDefault () {
- return this.mergedConfig.collapseMessageWithSubject
- },
hideAttachments () {
return (this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation)
},
- // This is a bit hacky, but we want to approximate post height before rendering
- // so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them)
- // as well as approximate line count by counting characters and approximating ~80
- // per line.
- //
- // Using max-height + overflow: auto for status components resulted in false positives
- // very often with japanese characters, and it was very annoying.
- tallStatus () {
- const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80
- return lengthScore > 20
- },
- longSubject () {
- return this.status.summary.length > 240
- },
- // When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
- mightHideBecauseSubject () {
- return !!this.status.summary && this.localCollapseSubjectDefault
- },
- mightHideBecauseTall () {
- return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault)
- },
- hideSubjectStatus () {
- return this.mightHideBecauseSubject && !this.expandingSubject
- },
- hideTallStatus () {
- return this.mightHideBecauseTall && !this.showingTall
- },
- showingMore () {
- return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject)
- },
nsfwClickthrough () {
if (!this.status.nsfw) {
return false
@@ -118,45 +75,11 @@ const StatusContent = {
file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
)
},
- attachmentTypes () {
- return this.status.attachments.map(file => fileType.fileType(file.mimetype))
- },
maxThumbnails () {
return this.mergedConfig.maxThumbnails
},
- postBodyHtml () {
- const html = this.status.statusnet_html
-
- if (this.mergedConfig.greentext) {
- try {
- if (html.includes('>')) {
- // This checks if post has '>' at the beginning, excluding mentions so that @mention >impying works
- return processHtml(html, (string) => {
- if (string.includes('>') &&
- string
- .replace(/<[^>]+?>/gi, '') // remove all tags
- .replace(/@\w+/gi, '') // remove mentions (even failed ones)
- .trim()
- .startsWith('>')) {
- return `<span class='greentext'>${string}</span>`
- } else {
- return string
- }
- })
- } else {
- return html
- }
- } catch (e) {
- console.err('Failed to process status html', e)
- return html
- }
- } else {
- return html
- }
- },
...mapGetters(['mergedConfig']),
...mapState({
- betterShadow: state => state.interface.browserSupport.cssFilter,
currentUser: state => state.users.currentUser
})
},
@@ -164,48 +87,10 @@ const StatusContent = {
Attachment,
Poll,
Gallery,
- LinkPreview
+ LinkPreview,
+ StatusBody
},
methods: {
- linkClicked (event) {
- const target = event.target.closest('.status-content a')
- if (target) {
- if (target.className.match(/mention/)) {
- const href = target.href
- const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href))
- if (attn) {
- event.stopPropagation()
- event.preventDefault()
- const link = this.generateUserProfileLink(attn.id, attn.screen_name)
- this.$router.push(link)
- return
- }
- }
- if (target.rel.match(/(?:^|\s)tag(?:$|\s)/) || target.className.match(/hashtag/)) {
- // Extract tag name from dataset or link url
- const tag = target.dataset.tag || extractTagFromUrl(target.href)
- if (tag) {
- const link = this.generateTagLink(tag)
- this.$router.push(link)
- return
- }
- }
- window.open(target.href, '_blank')
- }
- },
- toggleShowMore () {
- if (this.mightHideBecauseTall) {
- this.showingTall = !this.showingTall
- } else if (this.mightHideBecauseSubject) {
- this.expandingSubject = !this.expandingSubject
- }
- },
- generateUserProfileLink (id, name) {
- return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
- },
- generateTagLink (tag) {
- return `/tag/${tag}`
- },
setMedia () {
const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
return () => this.$store.dispatch('setMedia', attachments)
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
@@ -1,133 +1,55 @@
<template>
- <!-- eslint-disable vue/no-v-html -->
<div class="StatusContent">
<slot name="header" />
- <div
- v-if="status.summary_html"
- class="summary-wrapper"
- :class="{ 'tall-subject': (longSubject && !showingLongSubject) }"
+ <StatusBody
+ :status="status"
+ :single-line="singleLine"
+ @parseReady="$emit('parseReady', $event)"
>
+ <div v-if="status.poll && status.poll.options">
+ <Poll
+ :base-poll="status.poll"
+ :emoji="status.emojis"
+ />
+ </div>
+
<div
- class="media-body summary"
- @click.prevent="linkClicked"
- v-html="status.summary_html"
- />
- <button
- v-if="longSubject && showingLongSubject"
- class="button-unstyled -link tall-subject-hider"
- @click.prevent="showingLongSubject=false"
- >
- {{ $t("status.hide_full_subject") }}
- </button>
- <button
- v-else-if="longSubject"
- class="button-unstyled -link tall-subject-hider"
- :class="{ 'tall-subject-hider_focused': focused }"
- @click.prevent="showingLongSubject=true"
- >
- {{ $t("status.show_full_subject") }}
- </button>
- </div>
- <div
- :class="{'tall-status': hideTallStatus}"
- class="status-content-wrapper"
- >
- <button
- v-if="hideTallStatus"
- class="button-unstyled -link tall-status-hider"
- :class="{ 'tall-status-hider_focused': focused }"
- @click.prevent="toggleShowMore"
- >
- {{ $t("general.show_more") }}
- </button>
- <div
- v-if="!hideSubjectStatus"
- :class="{ 'single-line': singleLine }"
- class="status-content media-body"
- @click.prevent="linkClicked"
- v-html="postBodyHtml"
- />
- <button
- v-if="hideSubjectStatus"
- class="button-unstyled -link cw-status-hider"
- @click.prevent="toggleShowMore"
+ v-if="status.attachments.length !== 0"
+ class="attachments media-body"
>
- {{ $t("status.show_content") }}
- <FAIcon
- v-if="attachmentTypes.includes('image')"
- icon="image"
- />
- <FAIcon
- v-if="attachmentTypes.includes('video')"
- icon="video"
- />
- <FAIcon
- v-if="attachmentTypes.includes('audio')"
- icon="music"
- />
- <FAIcon
- v-if="attachmentTypes.includes('unknown')"
- icon="file"
- />
- <FAIcon
- v-if="status.poll && status.poll.options"
- icon="poll-h"
+ <attachment
+ v-for="attachment in nonGalleryAttachments"
+ :key="attachment.id"
+ class="non-gallery"
+ :size="attachmentSize"
+ :nsfw="nsfwClickthrough"
+ :attachment="attachment"
+ :allow-play="true"
+ :set-media="setMedia()"
+ @play="$emit('mediaplay', attachment.id)"
+ @pause="$emit('mediapause', attachment.id)"
/>
- <FAIcon
- v-if="status.card"
- icon="link"
+ <gallery
+ v-if="galleryAttachments.length > 0"
+ :nsfw="nsfwClickthrough"
+ :attachments="galleryAttachments"
+ :set-media="setMedia()"
/>
- </button>
- <button
- v-if="showingMore && !fullContent"
- class="button-unstyled -link status-unhider"
- @click.prevent="toggleShowMore"
- >
- {{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }}
- </button>
- </div>
-
- <div v-if="status.poll && status.poll.options && !hideSubjectStatus">
- <poll :base-poll="status.poll" />
- </div>
-
- <div
- v-if="status.attachments.length !== 0 && (!hideSubjectStatus || showingLongSubject)"
- class="attachments media-body"
- >
- <attachment
- v-for="attachment in nonGalleryAttachments"
- :key="attachment.id"
- class="non-gallery"
- :size="attachmentSize"
- :nsfw="nsfwClickthrough"
- :attachment="attachment"
- :allow-play="true"
- :set-media="setMedia()"
- @play="$emit('mediaplay', attachment.id)"
- @pause="$emit('mediapause', attachment.id)"
- />
- <gallery
- v-if="galleryAttachments.length > 0"
- :nsfw="nsfwClickthrough"
- :attachments="galleryAttachments"
- :set-media="setMedia()"
- />
- </div>
+ </div>
- <div
- v-if="status.card && !hideSubjectStatus && !noHeading"
- class="link-preview media-body"
- >
- <link-preview
- :card="status.card"
- :size="attachmentSize"
- :nsfw="nsfwClickthrough"
- />
- </div>
+ <div
+ v-if="status.card && !noHeading"
+ class="link-preview media-body"
+ >
+ <link-preview
+ :card="status.card"
+ :size="attachmentSize"
+ :nsfw="nsfwClickthrough"
+ />
+ </div>
+ </StatusBody>
<slot name="footer" />
</div>
- <!-- eslint-enable vue/no-v-html -->
</template>
<script src="./status_content.js" ></script>
@@ -139,156 +61,5 @@ $status-margin: 0.75em;
.StatusContent {
flex: 1;
min-width: 0;
-
- .status-content-wrapper {
- display: flex;
- flex-direction: column;
- flex-wrap: nowrap;
- }
-
- .tall-status {
- position: relative;
- height: 220px;
- overflow-x: hidden;
- overflow-y: hidden;
- z-index: 1;
- .status-content {
- min-height: 0;
- mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
- linear-gradient(to top, white, white);
- /* Autoprefixed seem to ignore this one, and also syntax is different */
- -webkit-mask-composite: xor;
- mask-composite: exclude;
- }
- }
-
- .tall-status-hider {
- display: inline-block;
- word-break: break-all;
- position: absolute;
- height: 70px;
- margin-top: 150px;
- width: 100%;
- text-align: center;
- line-height: 110px;
- z-index: 2;
- }
-
- .status-unhider, .cw-status-hider {
- width: 100%;
- text-align: center;
- display: inline-block;
- word-break: break-all;
-
- svg {
- color: inherit;
- }
- }
-
- img, video {
- max-width: 100%;
- max-height: 400px;
- vertical-align: middle;
- object-fit: contain;
-
- &.emoji {
- width: 32px;
- height: 32px;
- }
- }
-
- .summary-wrapper {
- margin-bottom: 0.5em;
- border-style: solid;
- border-width: 0 0 1px 0;
- border-color: var(--border, $fallback--border);
- flex-grow: 0;
- }
-
- .summary {
- font-style: italic;
- padding-bottom: 0.5em;
- }
-
- .tall-subject {
- position: relative;
- .summary {
- max-height: 2em;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- }
-
- .tall-subject-hider {
- display: inline-block;
- word-break: break-all;
- // position: absolute;
- width: 100%;
- text-align: center;
- padding-bottom: 0.5em;
- }
-
- .status-content {
- font-family: var(--postFont, sans-serif);
- line-height: 1.4em;
- white-space: pre-wrap;
- overflow-wrap: break-word;
- word-wrap: break-word;
- word-break: break-word;
-
- blockquote {
- margin: 0.2em 0 0.2em 2em;
- font-style: italic;
- }
-
- pre {
- overflow: auto;
- }
-
- code, samp, kbd, var, pre {
- font-family: var(--postCodeFont, monospace);
- }
-
- p {
- margin: 0 0 1em 0;
- }
-
- p:last-child {
- margin: 0 0 0 0;
- }
-
- h1 {
- font-size: 1.1em;
- line-height: 1.2em;
- margin: 1.4em 0;
- }
-
- h2 {
- font-size: 1.1em;
- margin: 1.0em 0;
- }
-
- h3 {
- font-size: 1em;
- margin: 1.2em 0;
- }
-
- h4 {
- margin: 1.1em 0;
- }
-
- &.single-line {
- white-space: nowrap;
- text-overflow: ellipsis;
- overflow: hidden;
- height: 1.4em;
- }
- }
-}
-
-.greentext {
- color: $fallback--cGreen;
- color: var(--postGreentext, $fallback--cGreen);
}
</style>
diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue
@@ -30,7 +30,7 @@
position: relative;
line-height: 0;
overflow: hidden;
- display: flex;
+ display: inline-flex;
align-items: center;
canvas {
@@ -47,12 +47,13 @@
img {
width: 100%;
- min-height: 100%;
+ height: 100%;
object-fit: contain;
}
&.animated {
&::before {
+ zoom: var(--_still_image-label-scale, 1);
content: 'gif';
position: absolute;
line-height: 10px;
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
@@ -5,6 +5,7 @@ import FollowButton from '../follow_button/follow_button.vue'
import ModerationTools from '../moderation_tools/moderation_tools.vue'
import AccountActions from '../account_actions/account_actions.vue'
import Select from '../select/select.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
@@ -120,7 +121,8 @@ export default {
AccountActions,
ProgressButton,
FollowButton,
- Select
+ Select,
+ RichContent
},
methods: {
muteUser () {
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
@@ -38,21 +38,12 @@
</router-link>
<div class="user-summary">
<div class="top-line">
- <!-- eslint-disable vue/no-v-html -->
- <div
- v-if="user.name_html"
+ <RichContent
:title="user.name"
class="user-name"
- v-html="user.name_html"
+ :html="user.name"
+ :emoji="user.emoji"
/>
- <!-- eslint-enable vue/no-v-html -->
- <div
- v-else
- :title="user.name"
- class="user-name"
- >
- {{ user.name }}
- </div>
<button
v-if="!isOtherUser && user.is_local"
class="button-unstyled edit-profile-button"
@@ -65,7 +56,7 @@
:title="$t('user_card.edit_profile')"
/>
</button>
- <button
+ <a
v-if="isOtherUser && !user.is_local"
:href="user.statusnet_profile_url"
target="_blank"
@@ -75,7 +66,7 @@
class="icon"
icon="external-link-alt"
/>
- </button>
+ </a>
<AccountActions
v-if="isOtherUser && loggedIn"
:user="user"
@@ -267,20 +258,12 @@
<span>{{ hideFollowersCount ? $t('user_card.hidden') : user.followers_count }}</span>
</div>
</div>
- <!-- eslint-disable vue/no-v-html -->
- <p
- v-if="!hideBio && user.description_html"
+ <RichContent
+ v-if="!hideBio"
class="user-card-bio"
- @click.prevent="linkClicked"
- v-html="user.description_html"
+ :html="user.description_html"
+ :emoji="user.emoji"
/>
- <!-- eslint-enable vue/no-v-html -->
- <p
- v-else-if="!hideBio"
- class="user-card-bio"
- >
- {{ user.description }}
- </p>
</div>
</div>
</template>
@@ -293,9 +276,10 @@
.user-card {
position: relative;
- &:hover .Avatar {
+ &:hover {
--_still-image-img-visibility: visible;
--_still-image-canvas-visibility: hidden;
+ --_still-image-label-visibility: hidden;
}
.panel-heading {
@@ -339,12 +323,12 @@
}
}
- p {
- margin-bottom: 0;
- }
-
&-bio {
text-align: center;
+ display: block;
+ line-height: 18px;
+ padding: 1em;
+ margin: 0;
a {
color: $fallback--link;
@@ -356,11 +340,6 @@
vertical-align: middle;
max-width: 100%;
max-height: 400px;
-
- &.emoji {
- width: 32px;
- height: 32px;
- }
}
}
@@ -462,13 +441,6 @@
// big one
z-index: 1;
- img {
- width: 26px;
- height: 26px;
- vertical-align: middle;
- object-fit: contain
- }
-
.top-line {
display: flex;
}
@@ -481,12 +453,7 @@
margin-right: 1em;
font-size: 15px;
- img {
- object-fit: contain;
- height: 16px;
- width: 16px;
- vertical-align: middle;
- }
+ --emoji-size: 14px;
}
.bottom-line {
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
@@ -4,6 +4,7 @@ import FollowCard from '../follow_card/follow_card.vue'
import Timeline from '../timeline/timeline.vue'
import Conversation from '../conversation/conversation.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
import List from '../list/list.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
import { library } from '@fortawesome/fontawesome-svg-core'
@@ -164,7 +165,8 @@ const UserProfile = {
FriendList,
FollowCard,
TabSwitcher,
- Conversation
+ Conversation,
+ RichContent
}
}
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
@@ -20,20 +20,24 @@
:key="index"
class="user-profile-field"
>
- <!-- eslint-disable vue/no-v-html -->
<dt
:title="user.fields_text[index].name"
class="user-profile-field-name"
- @click.prevent="linkClicked"
- v-html="field.name"
- />
+ >
+ <RichContent
+ :html="field.name"
+ :emoji="user.emoji"
+ />
+ </dt>
<dd
:title="user.fields_text[index].value"
class="user-profile-field-value"
- @click.prevent="linkClicked"
- v-html="field.value"
- />
- <!-- eslint-enable vue/no-v-html -->
+ >
+ <RichContent
+ :html="field.value"
+ :emoji="user.emoji"
+ />
+ </dd>
</dl>
</div>
<tab-switcher
diff --git a/src/i18n/ca.json b/src/i18n/ca.json
@@ -10,11 +10,12 @@
"text_limit": "Límit de text",
"title": "Funcionalitats",
"who_to_follow": "A qui seguir",
- "pleroma_chat_messages": "Xat de Pleroma"
+ "pleroma_chat_messages": "Xat de Pleroma",
+ "upload_limit": "Límit de càrrega"
},
"finder": {
"error_fetching_user": "No s'ha pogut carregar l'usuari/a",
- "find_user": "Find user"
+ "find_user": "Trobar usuari"
},
"general": {
"apply": "Aplica",
@@ -32,7 +33,16 @@
"error_retry": "Si us plau, prova de nou",
"generic_error": "Hi ha hagut un error",
"loading": "Carregant…",
- "more": "Més"
+ "more": "Més",
+ "flash_content": "Fes clic per mostrar el contingut Flash utilitzant Ruffle (experimental, pot no funcionar).",
+ "flash_security": "Tingues en compte que això pot ser potencialment perillós, ja que el contingut Flash encara és un codi arbitrari.",
+ "flash_fail": "No s'ha pogut carregar el contingut del flaix, consulta la consola per als detalls.",
+ "role": {
+ "moderator": "Moderador/a",
+ "admin": "Administrador/a"
+ },
+ "dismiss": "Descartar",
+ "peek": "Donar un cop d'ull"
},
"login": {
"login": "Inicia sessió",
@@ -45,15 +55,20 @@
"enter_recovery_code": "Posa un codi de recuperació",
"authentication_code": "Codi d'autenticació",
"hint": "Entra per participar a la conversa",
- "description": "Entra amb OAuth"
+ "description": "Entra amb OAuth",
+ "heading": {
+ "totp": "Autenticació de dos factors",
+ "recovery": "Recuperació de dos factors"
+ },
+ "enter_two_factor_code": "Introdueix un codi de dos factors"
},
"nav": {
"chat": "Xat local públic",
- "friend_requests": "Soŀlicituds de connexió",
+ "friend_requests": "Sol·licituds de seguiment",
"mentions": "Mencions",
- "public_tl": "Flux públic del node",
+ "public_tl": "Línia temporal pública",
"timeline": "Flux personal",
- "twkn": "Flux de la xarxa coneguda",
+ "twkn": "Xarxa coneguda",
"chats": "Xats",
"timelines": "Línies de temps",
"preferences": "Preferències",
@@ -62,19 +77,25 @@
"dms": "Missatges directes",
"interactions": "Interaccions",
"back": "Enrere",
- "administration": "Administració"
+ "administration": "Administració",
+ "about": "Quant a",
+ "bookmarks": "Marcadors",
+ "user_search": "Cerca d'usuaris",
+ "home_timeline": "Línea temporal personal"
},
"notifications": {
- "broken_favorite": "No es coneix aquest estat. S'està cercant.",
+ "broken_favorite": "Publicació desconeguda, s'està cercant…",
"favorited_you": "ha marcat un estat teu",
"followed_you": "ha començat a seguir-te",
"load_older": "Carrega més notificacions",
"notifications": "Notificacions",
- "read": "Read!",
+ "read": "Llegit!",
"repeated_you": "ha repetit el teu estat",
"migrated_to": "migrat a",
"no_more_notifications": "No més notificacions",
- "follow_request": "et vol seguir"
+ "follow_request": "et vol seguir",
+ "reacted_with": "ha reaccionat amb {0}",
+ "error": "Error obtenint notificacions: {0}"
},
"post_status": {
"account_not_locked_warning": "El teu compte no està {0}. Qualsevol persona pot seguir-te per llegir les teves entrades reservades només a seguidores.",
@@ -83,24 +104,33 @@
"content_type": {
"text/plain": "Text pla",
"text/markdown": "Markdown",
- "text/html": "HTML"
+ "text/html": "HTML",
+ "text/bbcode": "BBCode"
},
"content_warning": "Assumpte (opcional)",
- "default": "Em sento…",
+ "default": "Acabe d'aterrar a L.A.",
"direct_warning": "Aquesta entrada només serà visible per les usuràries que etiquetis",
"posting": "Publicació",
"scope": {
- "direct": "Directa - Publica només per les usuàries etiquetades",
- "private": "Només seguidors/es - Publica només per comptes que et segueixin",
- "public": "Pública - Publica als fluxos públics",
- "unlisted": "Silenciosa - No la mostris en fluxos públics"
+ "direct": "Directa - publica només per als usuaris etiquetats",
+ "private": "Només seguidors/es - publica només per comptes que et segueixin",
+ "public": "Pública - publica als fluxos públics",
+ "unlisted": "Silenciosa - no la mostris en fluxos públics"
},
"scope_notice": {
"private": "Aquesta entrada serà visible només per a qui et segueixi",
- "public": "Aquesta entrada serà visible per a tothom"
+ "public": "Aquesta entrada serà visible per a tothom",
+ "unlisted": "Aquesta entrada no es veurà ni a la Línia de temps local ni a la Línia de temps federada"
},
"preview_empty": "Buida",
- "preview": "Vista prèvia"
+ "preview": "Vista prèvia",
+ "direct_warning_to_first_only": "Aquesta publicació només serà visible per als usuaris mencionats al principi del missatge.",
+ "empty_status_error": "No es pot publicar un estat buit sense fitxers adjunts",
+ "media_description": "Descripció multimèdia",
+ "direct_warning_to_all": "Aquesta publicació serà visible per a tots els usuaris mencionats.",
+ "new_status": "Publicar un nou estat",
+ "post": "Publicació",
+ "media_description_error": "Ha fallat la pujada del contingut. Prova de nou"
},
"registration": {
"bio": "Presentació",
@@ -118,13 +148,19 @@
"username_required": "no es pot deixar en blanc"
},
"fullname_placeholder": "p. ex. Lain Iwakura",
- "username_placeholder": "p. ex. lain"
+ "username_placeholder": "p. ex. lain",
+ "captcha": "CAPTCHA",
+ "register": "Registrar-se",
+ "reason": "Raó per a registrar-se",
+ "bio_placeholder": "p.e.\nHola, sóc la Lain.\nSóc una noia anime que viu a un suburbi de Japó. Potser em coneixes per Wired.",
+ "reason_placeholder": "Aquesta instància aprova els registres manualment.\nExplica a l'administració per què vols registrar-te.",
+ "new_captcha": "Clica a la imatge per obtenir un nou captcha"
},
"settings": {
"attachmentRadius": "Adjunts",
"attachments": "Adjunts",
"avatar": "Avatar",
- "avatarAltRadius": "Avatars en les notificacions",
+ "avatarAltRadius": "Avatars (notificacions)",
"avatarRadius": "Avatars",
"background": "Fons de pantalla",
"bio": "Presentació",
@@ -134,8 +170,8 @@
"cOrange": "Taronja (marca com a preferit)",
"cRed": "Vermell (canceŀla)",
"change_password": "Canvia la contrasenya",
- "change_password_error": "No s'ha pogut canviar la contrasenya",
- "changed_password": "S'ha canviat la contrasenya",
+ "change_password_error": "No s'ha pogut canviar la contrasenya.",
+ "changed_password": "S'ha canviat la contrasenya correctament!",
"collapse_subject": "Replega les entrades amb títol",
"confirm_new_password": "Confirma la nova contrasenya",
"current_avatar": "L'avatar actual",
@@ -176,7 +212,7 @@
"new_password": "Contrasenya nova",
"notification_visibility": "Notifica'm quan algú",
"notification_visibility_follows": "Comença a seguir-me",
- "notification_visibility_likes": "Marca com a preferida una entrada meva",
+ "notification_visibility_likes": "Favorits",
"notification_visibility_mentions": "Em menciona",
"notification_visibility_repeats": "Republica una entrada meva",
"no_rich_text_description": "Neteja el formatat de text de totes les entrades",
@@ -193,7 +229,7 @@
"profile_banner": "Fons de perfil",
"profile_tab": "Perfil",
"radii_help": "Configura l'arrodoniment de les vores (en píxels)",
- "replies_in_timeline": "Replies in timeline",
+ "replies_in_timeline": "Respostes al flux",
"reply_visibility_all": "Mostra totes les respostes",
"reply_visibility_following": "Mostra només les respostes a entrades meves o d'usuàries que jo segueixo",
"reply_visibility_self": "Mostra només les respostes a entrades meves",
@@ -216,7 +252,7 @@
"true": "sí"
},
"show_moderator_badge": "Mostra una insígnia de Moderació en el meu perfil",
- "show_admin_badge": "Mostra una insígnia d'Administració en el meu perfil",
+ "show_admin_badge": "Mostra una insígnia \"d'Administració\" en el meu perfil",
"hide_followers_description": "No mostris qui m'està seguint",
"hide_follows_description": "No mostris a qui segueixo",
"notification_visibility_emoji_reactions": "Reaccions",
@@ -254,25 +290,257 @@
"allow_following_move": "Permet el seguiment automàtic quan un compte a qui seguim es mou",
"mfa": {
"scan": {
- "secret_code": "Clau"
+ "secret_code": "Clau",
+ "title": "Escanejar",
+ "desc": "S'està usant l'aplicació two-factor, escaneja aquest codi QR o introdueix la clau de text:"
},
"authentication_methods": "Mètodes d'autenticació",
"waiting_a_recovery_codes": "Rebent còpies de seguretat dels codis…",
"recovery_codes": "Codis de recuperació.",
"warning_of_generate_new_codes": "Quan generes nous codis de recuperació, els antics ja no funcionaran més.",
- "generate_new_recovery_codes": "Genera nous codis de recuperació"
+ "generate_new_recovery_codes": "Genera nous codis de recuperació",
+ "otp": "OTP",
+ "confirm_and_enable": "Confirmar i habilitar OTP",
+ "recovery_codes_warning": "Anote els codis o guarda'ls en un lloc segur, o no els veuràs una altra volta. Si perds l'accés a la teua aplicació 2FA i els codis de recuperació, no podràs accedir al compte.",
+ "title": "Autenticació de dos factors",
+ "setup_otp": "Configurar OTP",
+ "wait_pre_setup_otp": "preconfiguració OTP",
+ "verify": {
+ "desc": "Per habilitar l'autenticació two-factor, introdueix el codi des de la teva aplicació two-factor:"
+ }
},
"enter_current_password_to_confirm": "Posar la contrasenya actual per confirmar la teva identitat",
"security": "Seguretat",
- "app_name": "Nom de l'aplicació"
+ "app_name": "Nom de l'aplicació",
+ "subject_line_mastodon": "Com a mastodon: copiar com és",
+ "mute_export_button": "Exportar silenciats a un fitxer csv",
+ "mute_import_error": "Error al importar silenciats",
+ "mutes_imported": "Silenciats importats! Processar-los portarà una estona.",
+ "import_mutes_from_a_csv_file": "Importar silenciats des d'un fitxer csv",
+ "word_filter": "Filtre de paraules",
+ "hide_media_previews": "Ocultar les vistes prèvies multimèdia",
+ "hide_filtered_statuses": "Amagar estats filtrats",
+ "play_videos_in_modal": "Reproduir vídeos en un marc emergent",
+ "file_export_import": {
+ "errors": {
+ "invalid_file": "El fitxer seleccionat no és vàlid com a còpia de seguretat de la configuració. No s'ha realitzat cap canvi."
+ },
+ "backup_settings": "Còpia de seguretat de la configuració a un fitxer",
+ "backup_settings_theme": "Còpia de seguretat de la configuració i tema a un fitxer",
+ "restore_settings": "Restaurar configuració des d'un fitxer",
+ "backup_restore": "Còpia de seguretat de la configuració"
+ },
+ "user_mutes": "Usuaris",
+ "subject_line_email": "Com a l'email: \"re: tema\"",
+ "search_user_to_block": "Busca a qui vols bloquejar",
+ "save": "Guardar els canvis",
+ "use_contain_fit": "No retallar els adjunts en miniatures",
+ "reset_profile_background": "Restablir fons del perfil",
+ "reset_profile_banner": "Restablir banner del perfil",
+ "emoji_reactions_on_timeline": "Mostrar reaccions emoji al flux",
+ "max_thumbnails": "Quantitat màxima de miniatures per publicació",
+ "hide_user_stats": "Amagar les estadístiques de l'usuari (p. ex. el nombre de seguidors)",
+ "reset_banner_confirm": "Realment vols restablir el banner?",
+ "reset_background_confirm": "Realment vols restablir el fons del perfil?",
+ "subject_input_always_show": "Sempre mostrar el camp del tema",
+ "subject_line_noop": "No copiar",
+ "subject_line_behavior": "Copiar el tema a les respostes",
+ "search_user_to_mute": "Busca a qui vols silenciar",
+ "mute_export": "Exportar silenciats",
+ "scope_copy": "Copiar visibilitat quan contestes (En els missatges directes sempre es copia)",
+ "reset_avatar": "Restablir avatar",
+ "right_sidebar": "Mostrar barra lateral a la dreta",
+ "no_blocks": "No hi han bloquejats",
+ "no_mutes": "No hi han silenciats",
+ "hide_follows_count_description": "No mostrar el nombre de comptes que segueixo",
+ "mute_import": "Importar silenciats",
+ "hide_all_muted_posts": "Ocultar publicacions silenciades",
+ "hide_wallpaper": "Amagar el fons de la instància",
+ "notification_visibility_moves": "Usuari Migrat",
+ "reply_visibility_following_short": "Mostrar respostes als meus seguidors",
+ "reply_visibility_self_short": "Mostrar respostes només a un mateix",
+ "autohide_floating_post_button": "Ocultar automàticament el botó 'Nova Publicació' (mòbil)",
+ "minimal_scopes_mode": "Minimitzar les opcions de visibilitat de la publicació",
+ "sensitive_by_default": "Marcar publicacions com a sensibles per defecte",
+ "useStreamingApi": "Rebre publicacions i notificacions en temps real",
+ "hide_isp": "Ocultar el panell especific de la instància",
+ "preload_images": "Precarregar les imatges",
+ "setting_changed": "La configuració és diferent a la predeterminada",
+ "hide_followers_count_description": "No mostrar el nombre de seguidors",
+ "reset_avatar_confirm": "Realment vols restablir l'avatar?",
+ "accent": "Accent",
+ "useStreamingApiWarning": "(No recomanat, experimental, pot ometre publicacions)",
+ "style": {
+ "fonts": {
+ "family": "Nom de la font",
+ "size": "Mida (en píxels)",
+ "custom": "Personalitza",
+ "_tab_label": "Fonts",
+ "help": "Selecciona la font per als elements de la interfície. Per a \"personalitzat\" deus escriure el nom de la font exactament com apareix al sistema.",
+ "components": {
+ "post": "Text de les publicacions",
+ "postCode": "Text monoespai en publicació (text enriquit)",
+ "input": "Camps d'entrada",
+ "interface": "Interfície"
+ }
+ },
+ "preview": {
+ "input": "Acabo d'aterrar a Los Angeles.",
+ "button": "Botó",
+ "mono": "contingut",
+ "content": "Contingut",
+ "header": "Previsualització",
+ "header_faint": "Això està bé",
+ "error": "Exemple d'error",
+ "faint_link": "Manual d'ajuda",
+ "checkbox": "He llegit els termes i condicions",
+ "link": "un bonic enllaç"
+ },
+ "shadows": {
+ "spread": "Difon",
+ "filter_hint": {
+ "drop_shadow_syntax": "{0} no suporta el paràmetre {1} i la paraula clau {2}.",
+ "avatar_inset": "Tingues en compte que combinar ombres interiors i no interiors als avatars podria donar resultats inesperats amb avatars transparents.",
+ "inset_classic": "Les ombres interiors estaran usant {0}",
+ "always_drop_shadow": "Advertència, aquesta ombra sempre utilitza {0} quan el navegador ho suporta.",
+ "spread_zero": "Ombres amb propagació > 0 apareixeran com si estigueren posades a zero"
+ },
+ "components": {
+ "popup": "Texts i finestres emergents (popups & tooltips)",
+ "panel": "Panell",
+ "panelHeader": "Capçalera del panell",
+ "avatar": "Avatar de l'usuari (en vista de perfil)",
+ "input": "Camp d'entrada",
+ "buttonHover": "Botó (surant)",
+ "buttonPressed": "Botó (pressionat)",
+ "topBar": "Barra superior",
+ "buttonPressedHover": "Botó (surant i pressionat)",
+ "avatarStatus": "Avatar de l'usuari (en vista de publicació)",
+ "button": "Botó"
+ },
+ "hintV3": "per a les ombres també pots usar la notació {0} per a utilitzar un altre espai de color.",
+ "blur": "Difuminat",
+ "component": "Component",
+ "override": "Sobreescriure",
+ "shadow_id": "Ombra #{value}",
+ "_tab_label": "Ombra i il·luminació",
+ "inset": "Ombra interior"
+ },
+ "switcher": {
+ "use_snapshot": "Versió antiga",
+ "help": {
+ "future_version_imported": "El fitxer importat es va crear per a una versió del front-end més recent.",
+ "migration_snapshot_ok": "Per a estar segurs, s'ha carregat la instantània del tema. Pots intentar carregar les dades del tema.",
+ "migration_napshot_gone": "Per alguna raó, faltava la instantània, algunes coses podrien veure's diferents del que recordes.",
+ "snapshot_source_mismatch": "Conflicte de versions: probablement el front-end s'ha revertit i actualitzat una altra volta, si has canviat el tema en una versió anterior, segurament vols utilitzar la versió antiga; d'altra banda utilitza la nova versió.",
+ "v2_imported": "El fitxer que has importat va ser creat per a un front-end més antic. Intentem maximitzar la compatibilitat, però podrien haver inconsistències.",
+ "fe_upgraded": "El motor de temes de PleromaFE es va actualitzar després de l'actualització de la versió.",
+ "snapshot_missing": "No hi havia cap instantània del tema al fitxer, per tant podria veure's diferent del previst originalment.",
+ "upgraded_from_v2": "PleromaFE s'ha actualitzat, el tema pot veure's un poc diferent de com recordes.",
+ "fe_downgraded": "Versió de PleromaFE revertida.",
+ "older_version_imported": "El fitxer que has importat va ser creat en una versió del front-end més antiga."
+ },
+ "keep_as_is": "Mantindre com està",
+ "save_load_hint": "Les opcions \"Mantindre\" conserven les opcions configurades actualment al seleccionar o carregar temes, també emmagatzema aquestes opcions quan s'exporta un tema. Quan es desactiven totes les caselles de verificació, el tema exportat ho guardarà tot.",
+ "keep_color": "Mantindre colors",
+ "keep_opacity": "Mantindre opacitat",
+ "keep_shadows": "Mantindre ombres",
+ "keep_fonts": "Mantindre fonts",
+ "keep_roundness": "Mantindre rodoneses",
+ "clear_all": "Netejar tot",
+ "reset": "Reinciar",
+ "load_theme": "Carregar tema",
+ "use_source": "Nova versió",
+ "clear_opacity": "Netejar opacitat"
+ },
+ "common": {
+ "contrast": {
+ "hint": "El ràtio de contrast és {ratio}. {level} {context}",
+ "level": {
+ "bad": "no compleix amb cap pauta d'accecibilitat",
+ "aaa": "Compleix amb el nivell AA (recomanat)",
+ "aa": "Compleix amb el nivell AA (mínim)"
+ },
+ "context": {
+ "18pt": "per a textos grans (+18pt)",
+ "text": "per a textos"
+ }
+ },
+ "opacity": "Opacitat",
+ "color": "Color"
+ },
+ "advanced_colors": {
+ "badge": "Fons de insígnies",
+ "inputs": "Camps d'entrada",
+ "wallpaper": "Fons de pantalla",
+ "pressed": "Pressionat",
+ "chat": {
+ "outgoing": "Eixint",
+ "border": "Borde",
+ "incoming": "Entrants"
+ },
+ "borders": "Bordes",
+ "panel_header": "Capçalera del panell",
+ "buttons": "Botons",
+ "faint_text": "Text esvaït",
+ "poll": "Gràfica de l'enquesta",
+ "toggled": "Commutat",
+ "alert": "Fons d'alertes",
+ "alert_error": "Error",
+ "alert_warning": "Precaució",
+ "post": "Publicacions/Biografies d'usuaris",
+ "badge_notification": "Notificacions",
+ "selectedMenu": "Element del menú seleccionat",
+ "tabs": "Pestanyes",
+ "_tab_label": "Avançat",
+ "alert_neutral": "Neutral",
+ "popover": "Suggeriments, menús, superposicions",
+ "top_bar": "Barra superior",
+ "highlight": "Elements destacats",
+ "disabled": "Deshabilitat",
+ "icons": "Icones",
+ "selectedPost": "Publicació seleccionada",
+ "underlay": "Subratllat"
+ },
+ "common_colors": {
+ "main": "Colors comuns",
+ "rgbo": "Icones, accents, insígnies",
+ "foreground_hint": "mira la pestanya \"Avançat\" per a un control més detallat",
+ "_tab_label": "Comú"
+ },
+ "radii": {
+ "_tab_label": "Rodonesa"
+ }
+ },
+ "version": {
+ "frontend_version": "Versió \"Frontend\"",
+ "backend_version": "Versió \"backend\"",
+ "title": "Versió"
+ },
+ "theme_help_v2_1": "També pots anular alguns components de color i opacitat activant la casella. Usa el botó \"Esborrar tot\" per esborrar totes les anulacions.",
+ "type_domains_to_mute": "Buscar dominis per a silenciar",
+ "greentext": "Text verd (meme arrows)",
+ "fun": "Divertit",
+ "notification_setting_filters": "Filtres",
+ "virtual_scrolling": "Optimitzar la representació del flux",
+ "notification_setting_block_from_strangers": "Bloqueja les notificacions dels usuaris que no segueixes",
+ "enable_web_push_notifications": "Habilitar notificacions del navegador",
+ "notification_blocks": "Bloquejar a un usuari para totes les notificacions i també les cancel·la.",
+ "more_settings": "Més opcions",
+ "notification_setting_privacy": "Privacitat",
+ "upload_a_photo": "Pujar una foto",
+ "notification_setting_hide_notification_contents": "Amagar el remitent i els continguts de les notificacions push",
+ "notifications": "Notificacions",
+ "notification_mutes": "Per a deixar de rebre notificacions d'un usuari en concret, silencia'l-ho.",
+ "theme_help_v2_2": "Les icones per baix d'algunes entrades són indicadors del contrast del fons/text, desplaça el ratolí per a més informació. Tingues en compte que quan s'utilitzen indicadors de contrast de transparència es mostra el pitjor cas possible."
},
"time": {
"day": "{0} dia",
"days": "{0} dies",
"day_short": "{0} dia",
"days_short": "{0} dies",
- "hour": "{0} hour",
- "hours": "{0} hours",
+ "hour": "{0} hora",
+ "hours": "{0} hores",
"hour_short": "{0}h",
"hours_short": "{0}h",
"in_future": "in {0}",
@@ -287,12 +555,12 @@
"months_short": "{0} mesos",
"now": "ara mateix",
"now_short": "ara mateix",
- "second": "{0} second",
- "seconds": "{0} seconds",
+ "second": "{0} segon",
+ "seconds": "{0} segons",
"second_short": "{0}s",
"seconds_short": "{0}s",
- "week": "{0} setm.",
- "weeks": "{0} setm.",
+ "week": "{0} setmana",
+ "weeks": "{0} setmanes",
"week_short": "{0} setm.",
"weeks_short": "{0} setm.",
"year": "{0} any",
@@ -308,7 +576,13 @@
"no_retweet_hint": "L'entrada és només per a seguidores o és \"directa\", i per tant no es pot republicar",
"repeated": "republicat",
"show_new": "Mostra els nous",
- "up_to_date": "Actualitzat"
+ "up_to_date": "Actualitzat",
+ "socket_reconnected": "Connexió a temps real establerta",
+ "socket_broke": "Connexió a temps real perduda: codi CloseEvent {0}",
+ "error": "Error de càrrega de la línia de temps: {0}",
+ "no_statuses": "No hi ha entrades",
+ "reload": "Recarrega",
+ "no_more_statuses": "No hi ha més entrades"
},
"user_card": {
"approve": "Aprova",
@@ -324,13 +598,59 @@
"muted": "Silenciat",
"per_day": "per dia",
"remote_follow": "Seguiment remot",
- "statuses": "Estats"
+ "statuses": "Estats",
+ "unblock_progress": "Desbloquejant…",
+ "unmute": "Deixa de silenciar",
+ "follow_progress": "Sol·licitant…",
+ "admin_menu": {
+ "force_nsfw": "Marca totes les entrades amb \"No segur per a entorns laborals\"",
+ "strip_media": "Esborra els audiovisuals de les entrades",
+ "disable_any_subscription": "Deshabilita completament seguir algú",
+ "quarantine": "Deshabilita la federació a les entrades de les usuàries",
+ "moderation": "Moderació",
+ "delete_user_confirmation": "Estàs completament segur/a? Aquesta acció no es pot desfer.",
+ "revoke_admin": "Revoca l'Admin",
+ "activate_account": "Activa el compte",
+ "deactivate_account": "Desactiva el compte",
+ "revoke_moderator": "Revoca Moderació",
+ "delete_account": "Esborra el compte",
+ "disable_remote_subscription": "Deshabilita seguir algú des d'una instància remota",
+ "delete_user": "Esborra la usuària",
+ "grant_admin": "Concedir permisos d'Administració",
+ "grant_moderator": "Concedir permisos de Moderació"
+ },
+ "edit_profile": "Edita el perfil",
+ "hidden": "Amagat",
+ "follow_sent": "Petició enviada!",
+ "unmute_progress": "Deixant de silenciar…",
+ "bot": "Bot",
+ "mute_progress": "Silenciant…",
+ "favorites": "Favorits",
+ "mention": "Menció",
+ "follow_unfollow": "Deixa de seguir",
+ "subscribe": "Subscriu-te",
+ "show_repeats": "Mostra les repeticions",
+ "report": "Report",
+ "its_you": "Ets tu!",
+ "unblock": "Desbloqueja",
+ "block_progress": "Bloquejant…",
+ "message": "Missatge",
+ "unsubscribe": "Anul·la la subscripció",
+ "hide_repeats": "Amaga les repeticions",
+ "highlight": {
+ "disabled": "Sense ressaltat",
+ "solid": "Fons sòlid",
+ "striped": "Fons a ratlles",
+ "side": "Ratlla lateral"
+ }
},
"user_profile": {
- "timeline_title": "Flux personal"
+ "timeline_title": "Flux personal",
+ "profile_loading_error": "Disculpes, hi ha hagut un error carregant aquest perfil.",
+ "profile_does_not_exist": "Disculpes, aquest perfil no existeix."
},
"who_to_follow": {
- "more": "More",
+ "more": "Més",
"who_to_follow": "A qui seguir"
},
"selectable_list": {
@@ -342,10 +662,19 @@
},
"interactions": {
"load_older": "Carrega antigues interaccions",
- "favs_repeats": "Repeticions i favorits"
+ "favs_repeats": "Repeticions i favorits",
+ "follows": "Nous seguidors"
},
"emoji": {
- "stickers": "Adhesius"
+ "stickers": "Adhesius",
+ "keep_open": "Mantindre el selector obert",
+ "custom": "Emojis personalitzats",
+ "unicode": "Emojis unicode",
+ "load_all_hint": "Carregat el primer emoji {saneAmount}, carregar tots els emoji pot causar problemes de rendiment.",
+ "emoji": "Emoji",
+ "search_emoji": "Buscar un emoji",
+ "add_emoji": "Inserir un emoji",
+ "load_all": "Carregant tots els {emojiAmount} emoji"
},
"polls": {
"expired": "L'enquesta va acabar fa {0}",
@@ -357,7 +686,11 @@
"votes": "vots",
"option": "Opció",
"add_option": "Afegeix opció",
- "add_poll": "Afegeix enquesta"
+ "add_poll": "Afegeix enquesta",
+ "expiry": "Temps de vida de l'enquesta",
+ "people_voted_count": "{count} persona ha votat | {count} persones han votat",
+ "votes_count": "{count} vot | {count} vots",
+ "not_enough_options": "L'enquesta no té suficients opcions úniques"
},
"media_modal": {
"next": "Següent",
@@ -365,7 +698,8 @@
},
"importer": {
"error": "Ha succeït un error mentre s'importava aquest arxiu.",
- "success": "Importat amb èxit."
+ "success": "Importat amb èxit.",
+ "submit": "Enviar"
},
"image_cropper": {
"cancel": "Cancel·la",
@@ -379,7 +713,9 @@
},
"domain_mute_card": {
"mute_progress": "Silenciant…",
- "mute": "Silencia"
+ "mute": "Silencia",
+ "unmute": "Deixar de silenciar",
+ "unmute_progress": "Deixant de silenciar…"
},
"about": {
"staff": "Equip responsable",
@@ -391,16 +727,132 @@
"reject": "Rebutja",
"accept_desc": "Aquesta instància només accepta missatges de les següents instàncies:",
"accept": "Accepta",
- "simple_policies": "Polítiques específiques de la instància"
+ "simple_policies": "Polítiques específiques de la instància",
+ "ftl_removal_desc": "Aquesta instància elimina les següents instàncies del flux de la xarxa coneguda:",
+ "ftl_removal": "Eliminació de la línia de temps coneguda",
+ "media_nsfw_desc": "Aquesta instància obliga el contingut multimèdia a establir-se com a sensible dins de les publicacions en les següents instàncies:",
+ "media_removal": "Eliminació de la multimèdia",
+ "media_removal_desc": "Aquesta instància elimina els suports multimèdia de les publicacions en les següents instàncies:",
+ "media_nsfw": "Forçar contingut multimèdia com a sensible"
},
"mrf_policies_desc": "Les polítiques MRF controlen el comportament federat de la instància. Les següents polítiques estan habilitades:",
"mrf_policies": "Polítiques MRF habilitades",
"keyword": {
"replace": "Reemplaça",
"reject": "Rebutja",
- "keyword_policies": "Polítiques de paraules clau"
+ "keyword_policies": "Filtratge per paraules clau",
+ "is_replaced_by": "→",
+ "ftl_removal": "Eliminació de la línia de temps federada"
},
"federation": "Federació"
}
+ },
+ "shoutbox": {
+ "title": "Gàbia de Grills"
+ },
+ "status": {
+ "delete": "Esborra l'entrada",
+ "delete_confirm": "Segur que vols esborrar aquesta entrada?",
+ "thread_muted_and_words": ", té les paraules:",
+ "show_full_subject": "Mostra tot el tema",
+ "show_content": "Mostra el contingut",
+ "repeats": "Repeticions",
+ "bookmark": "Marcadors",
+ "status_unavailable": "Entrada no disponible",
+ "expand": "Expandeix",
+ "copy_link": "Copia l'enllaç a l'entrada",
+ "hide_full_subject": "Amaga tot el tema",
+ "favorites": "Favorits",
+ "replies_list": "Contestacions:",
+ "mute_conversation": "Silencia la conversa",
+ "thread_muted": "Fil silenciat",
+ "hide_content": "Amaga el contingut",
+ "status_deleted": "S'ha esborrat aquesta entrada",
+ "nsfw": "No segur per a entorns laborals",
+ "unbookmark": "Desmarca",
+ "external_source": "Font externa",
+ "unpin": "Deixa de destacar al perfil",
+ "pinned": "Destacat",
+ "reply_to": "Contesta a",
+ "pin": "Destaca al perfil",
+ "unmute_conversation": "Deixa de silenciar la conversa"
+ },
+ "user_reporting": {
+ "additional_comments": "Comentaris addicionals",
+ "forward_description": "Aquest compte és d'un altre servidor. Vols enviar una còpia del report allà també?",
+ "forward_to": "Endavant a {0}",
+ "generic_error": "Hi ha hagut un error mentre s'estava processant la teva sol·licitud.",
+ "title": "Reportant {0}",
+ "add_comment_description": "Aquest report serà enviat a la moderació a la instància. Pots donar una explicació de per què estàs reportant aquest compte:",
+ "submit": "Envia"
+ },
+ "tool_tip": {
+ "add_reaction": "Afegeix una Reacció",
+ "accept_follow_request": "Accepta la sol·licitud de seguir",
+ "repeat": "Repeteix",
+ "reply": "Respon",
+ "favorite": "Favorit",
+ "user_settings": "Configuració d'usuària",
+ "reject_follow_request": "Rebutja la sol·licitud de seguir",
+ "bookmark": "Marcador",
+ "media_upload": "Pujar multimèdia"
+ },
+ "search": {
+ "no_results": "No hi ha resultats",
+ "people": "Persones",
+ "hashtags": "Etiquetes",
+ "people_talking": "{count} persones parlant"
+ },
+ "upload": {
+ "file_size_units": {
+ "B": "B",
+ "KiB": "KiB",
+ "GiB": "GiB",
+ "TiB": "TiB",
+ "MiB": "MiB"
+ },
+ "error": {
+ "base": "La pujada ha fallat.",
+ "file_too_big": "Fitxer massa gran [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
+ "default": "Prova de nou d'aquí una estona",
+ "message": "La pujada ha fallat: {0}"
+ }
+ },
+ "errors": {
+ "storage_unavailable": "Pleroma no ha pogut accedir a l'emmagatzematge del navegador. El teu inici de sessió o configuració no es desaran i et pots trobar algun altre problema. Prova a habilitar les galetes."
+ },
+ "password_reset": {
+ "password_reset": "Reinicia la contrasenya",
+ "forgot_password": "Has oblidat la contrasenya?",
+ "too_many_requests": "Has arribat al límit d'intents. Prova de nou d'aquí una estona.",
+ "password_reset_required_but_mailer_is_disabled": "Has de reiniciar la teva contrasenya però el reinici de la contrasenya està deshabilitat. Si us plau, contacta l'administració de la teva instància.",
+ "placeholder": "El teu correu electrònic o nom d'usuària",
+ "instruction": "Introdueix la teva adreça de correu electrònic o nom d'usuària. T'enviarem un enllaç per reiniciar la teva contrasenya.",
+ "return_home": "Torna a la pàgina principal",
+ "password_reset_required": "Has de reiniciar la teva contrasenya per iniciar la sessió.",
+ "password_reset_disabled": "El reinici de la contrasenya està deshabilitat. Si us plau, contacta l'administració de la teva instància.",
+ "check_email": "Comprova que has rebut al correu electrònic un enllaç per reiniciar la teva contrasenya."
+ },
+ "file_type": {
+ "image": "Imatge",
+ "file": "Fitxer",
+ "video": "Vídeo",
+ "audio": "Àudio"
+ },
+ "chats": {
+ "chats": "Xats",
+ "new": "Nou xat",
+ "delete_confirm": "Realment vols esborrar aquest missatge?",
+ "error_sending_message": "Alguna cosa ha fallat quan s'enviava el missatge.",
+ "more": "Més",
+ "delete": "Esborra",
+ "empty_message_error": "No es pot publicar un missatge buit",
+ "you": "Tu:",
+ "message_user": "Missatge {nickname}",
+ "error_loading_chat": "Alguna cosa ha fallat quan es carregava el xat.",
+ "empty_chat_list_placeholder": "Encara no tens cap xat. Crea un nou xat!"
+ },
+ "display_date": {
+ "today": "Avui"
}
}
diff --git a/src/i18n/cs.json b/src/i18n/cs.json
@@ -407,7 +407,6 @@
"follow": "Sledovat",
"follow_sent": "Požadavek odeslán!",
"follow_progress": "Odeslílám požadavek…",
- "follow_again": "Odeslat požadavek znovu?",
"follow_unfollow": "Přestat sledovat",
"followees": "Sledovaní",
"followers": "Sledující",
diff --git a/src/i18n/de.json b/src/i18n/de.json
@@ -9,7 +9,7 @@
"scope_options": "Reichweitenoptionen",
"text_limit": "Zeichenlimit",
"title": "Funktionen",
- "who_to_follow": "Wem folgen?",
+ "who_to_follow": "Vorschläge",
"upload_limit": "Maximale Upload Größe",
"pleroma_chat_messages": "Pleroma Chat"
},
@@ -39,7 +39,10 @@
"close": "Schliessen",
"retry": "Versuche es erneut",
"error_retry": "Bitte versuche es erneut",
- "loading": "Lade…"
+ "loading": "Lade…",
+ "flash_content": "Klicken, um den Flash-Inhalt mit Ruffle anzuzeigen (Die Funktion ist experimentell und funktioniert daher möglicherweise nicht).",
+ "flash_security": "Diese Funktion stellt möglicherweise eine Risiko dar, weil Flash-Inhalte weiterhin potentiell gefährlich sind.",
+ "flash_fail": "Falsh-Inhalt konnte nicht geladen werden, Details werden in der Konsole angezeigt."
},
"login": {
"login": "Anmelden",
@@ -538,7 +541,9 @@
"reset_background_confirm": "Hintergrund wirklich zurücksetzen?",
"reset_banner_confirm": "Banner wirklich zurücksetzen?",
"reset_avatar_confirm": "Avatar wirklich zurücksetzen?",
- "reset_profile_banner": "Profilbanner zurücksetzen"
+ "reset_profile_banner": "Profilbanner zurücksetzen",
+ "hide_shoutbox": "Shoutbox der Instanz verbergen",
+ "right_sidebar": "Seitenleiste rechts anzeigen"
},
"timeline": {
"collapse": "Einklappen",
@@ -564,7 +569,6 @@
"follow": "Folgen",
"follow_sent": "Anfrage gesendet!",
"follow_progress": "Anfragen…",
- "follow_again": "Anfrage erneut senden?",
"follow_unfollow": "Folgen beenden",
"followees": "Folgt",
"followers": "Folgende",
@@ -779,7 +783,7 @@
"error_sending_message": "Beim Senden der Nachricht ist ein Fehler aufgetreten.",
"error_loading_chat": "Beim Laden des Chats ist ein Fehler aufgetreten.",
"delete_confirm": "Soll diese Nachricht wirklich gelöscht werden?",
- "empty_message_error": "Die Nachricht darf nicht leer sein.",
+ "empty_message_error": "Die Nachricht darf nicht leer sein",
"delete": "Löschen",
"message_user": "Nachricht an {nickname} senden",
"empty_chat_list_placeholder": "Es sind noch keine Chats vorhanden. Jetzt einen Chat starten!",
diff --git a/src/i18n/en.json b/src/i18n/en.json
@@ -13,6 +13,9 @@
"mrf_policies_desc": "MRF policies manipulate the federation behaviour of the instance. The following policies are enabled:",
"simple": {
"simple_policies": "Instance-specific policies",
+ "instance": "Instance",
+ "reason": "Reason",
+ "not_applicable": "N/A",
"accept": "Accept",
"accept_desc": "This instance only accepts messages from the following instances:",
"reject": "Reject",
@@ -259,6 +262,8 @@
"security": "Security",
"setting_changed": "Setting is different from default",
"enter_current_password_to_confirm": "Enter your current password to confirm your identity",
+ "mentions_new_style": "Fancier mention links",
+ "mentions_new_place": "Put mentions on a separate line",
"mfa": {
"otp": "OTP",
"setup_otp": "Setup OTP",
@@ -350,6 +355,7 @@
"hide_isp": "Hide instance-specific panel",
"hide_shoutbox": "Hide instance shoutbox",
"right_sidebar": "Show sidebar on the right side",
+ "always_show_post_button": "Always show floating New Post button",
"hide_wallpaper": "Hide instance wallpaper",
"preload_images": "Preload images",
"use_one_click_nsfw": "Open NSFW attachments with just one click",
@@ -698,6 +704,7 @@
"unbookmark": "Unbookmark",
"delete_confirm": "Do you really want to delete this status?",
"reply_to": "Reply to",
+ "mentions": "Mentions",
"replies_list": "Replies:",
"mute_conversation": "Mute conversation",
"unmute_conversation": "Unmute conversation",
@@ -712,7 +719,9 @@
"hide_content": "Hide content",
"status_deleted": "This post was deleted",
"nsfw": "NSFW",
- "expand": "Expand"
+ "expand": "Expand",
+ "you": "(You)",
+ "plus_more": "+{number} more"
},
"user_card": {
"approve": "Approve",
@@ -722,9 +731,9 @@
"edit_profile": "Edit profile",
"favorites": "Favorites",
"follow": "Follow",
+ "follow_cancel": "Cancel request",
"follow_sent": "Request sent!",
"follow_progress": "Requesting…",
- "follow_again": "Send request again?",
"follow_unfollow": "Unfollow",
"followees": "Following",
"followers": "Followers",
diff --git a/src/i18n/eo.json b/src/i18n/eo.json
@@ -39,7 +39,10 @@
"role": {
"moderator": "Reguligisto",
"admin": "Administranto"
- }
+ },
+ "flash_content": "Klaku por montri enhavon de Flash per Ruffle. (Eksperimente, eble ne funkcios.)",
+ "flash_security": "Sciu, ke tio povas esti danĝera, ĉar la enhavo de Flash ja estas arbitra programo.",
+ "flash_fail": "Malsukcesis enlegi enhavon de Flash; vidu detalojn en konzolo."
},
"image_cropper": {
"crop_picture": "Tondi bildon",
@@ -87,7 +90,8 @@
"interactions": "Interagoj",
"administration": "Administrado",
"bookmarks": "Legosignoj",
- "timelines": "Historioj"
+ "timelines": "Historioj",
+ "home_timeline": "Hejma historio"
},
"notifications": {
"broken_favorite": "Nekonata stato, serĉante ĝin…",
@@ -119,10 +123,10 @@
"direct_warning": "Ĉi tiu afiŝo estos videbla nur por ĉiuj menciitaj uzantoj.",
"posting": "Afiŝante",
"scope": {
- "direct": "Rekta – Afiŝi nur al menciitaj uzantoj",
- "private": "Nur abonantoj – Afiŝi nur al abonantoj",
- "public": "Publika – Afiŝi al publikaj historioj",
- "unlisted": "Nelistigita – Ne afiŝi al publikaj historioj"
+ "direct": "Rekta – afiŝi nur al menciitaj uzantoj",
+ "private": "Nur abonantoj – afiŝi nur al abonantoj",
+ "public": "Publika – afiŝi al publikaj historioj",
+ "unlisted": "Nelistigita – ne afiŝi al publikaj historioj"
},
"scope_notice": {
"unlisted": "Ĉi tiu afiŝo ne estos videbla en la Publika historio kaj La tuta konata reto",
@@ -135,7 +139,8 @@
"preview": "Antaŭrigardo",
"direct_warning_to_first_only": "Ĉi tiu afiŝo estas nur videbla al uzantoj menciitaj je la komenco de la mesaĝo.",
"direct_warning_to_all": "Ĉi tiu afiŝo estos videbla al ĉiuj menciitaj uzantoj.",
- "media_description": "Priskribo de vidaŭdaĵo"
+ "media_description": "Priskribo de vidaŭdaĵo",
+ "post": "Afiŝo"
},
"registration": {
"bio": "Priskribo",
@@ -143,7 +148,7 @@
"fullname": "Prezenta nomo",
"password_confirm": "Konfirmo de pasvorto",
"registration": "Registriĝo",
- "token": "Invita ĵetono",
+ "token": "Invita peco",
"captcha": "TESTO DE HOMECO",
"new_captcha": "Klaku la bildon por akiri novan teston",
"username_placeholder": "ekz. lain",
@@ -158,7 +163,8 @@
"password_confirmation_match": "samu la pasvorton"
},
"reason_placeholder": "Ĉi-node oni aprobas registriĝojn permane.\nSciigu la administrantojn kial vi volas registriĝi.",
- "reason": "Kialo registriĝi"
+ "reason": "Kialo registriĝi",
+ "register": "Registriĝi"
},
"settings": {
"app_name": "Nomo de aplikaĵo",
@@ -244,9 +250,9 @@
"show_admin_badge": "Montri la insignon de administranto en mia profilo",
"show_moderator_badge": "Montri la insignon de reguligisto en mia profilo",
"nsfw_clickthrough": "Ŝalti traklakan kaŝadon de kunsendaĵoj kaj antaŭmontroj de ligiloj por konsternaj statoj",
- "oauth_tokens": "Ĵetonoj de OAuth",
- "token": "Ĵetono",
- "refresh_token": "Ĵetono de aktualigo",
+ "oauth_tokens": "Pecoj de OAuth",
+ "token": "Peco",
+ "refresh_token": "Aktualiga peco",
"valid_until": "Valida ĝis",
"revoke_token": "Senvalidigi",
"panelRadius": "Bretoj",
@@ -532,7 +538,22 @@
"hide_all_muted_posts": "Kaŝi silentigitajn afiŝojn",
"hide_media_previews": "Kaŝi antaŭrigardojn al vidaŭdaĵoj",
"word_filter": "Vortofiltro",
- "reply_visibility_self_short": "Montri nur respondojn por mi"
+ "reply_visibility_self_short": "Montri nur respondojn por mi",
+ "file_export_import": {
+ "errors": {
+ "file_slightly_new": "Etversio de dosiero malsamas, iuj agordoj eble ne funkcios",
+ "file_too_old": "Nekonforma ĉefa versio: {fileMajor}, versio de dosiero estas tro malnova kaj nesubtenata (minimuma estas {feMajor})",
+ "file_too_new": "Nekonforma ĉefa versio: {fileMajor}, ĉi tiu PleromaFE (agordoj je versio {feMajor}) tro malnovas por tio",
+ "invalid_file": "La elektita dosiero ne estas subtenata savkopio de agordoj de Pleroma. Nenio ŝanĝiĝis."
+ },
+ "restore_settings": "Rehavi agordojn el dosiero",
+ "backup_settings_theme": "Savkopii agordojn kaj haŭton al dosiero",
+ "backup_settings": "Savkopii agordojn al dosiero",
+ "backup_restore": "Savkopio de agordoj"
+ },
+ "right_sidebar": "Montri flankan breton dekstre",
+ "save": "Konservi ŝanĝojn",
+ "hide_shoutbox": "Kaŝi kriujon de nodo"
},
"timeline": {
"collapse": "Maletendi",
@@ -546,7 +567,9 @@
"no_more_statuses": "Neniuj pliaj statoj",
"no_statuses": "Neniuj statoj",
"reload": "Enlegi ree",
- "error": "Eraris akirado de historio: {0}"
+ "error": "Eraris akirado de historio: {0}",
+ "socket_reconnected": "Realtempa konekto fariĝis",
+ "socket_broke": "Realtempa konekto perdiĝis: CloseEvent code {0}"
},
"user_card": {
"approve": "Aprobi",
@@ -557,7 +580,6 @@
"follow": "Aboni",
"follow_sent": "Peto sendiĝis!",
"follow_progress": "Petante…",
- "follow_again": "Ĉu sendi peton ree?",
"follow_unfollow": "Malaboni",
"followees": "Abonatoj",
"followers": "Abonantoj",
@@ -696,7 +718,7 @@
"media_nsfw": "Devige marki vidaŭdaĵojn konsternaj",
"media_removal_desc": "Ĉi tiu nodo forigas vidaŭdaĵojn de afiŝoj el la jenaj nodoj:",
"media_removal": "Forigo de vidaŭdaĵoj",
- "ftl_removal": "Forigo el la historio de «La tuta konata reto»",
+ "ftl_removal": "Forigo el la historio de «Konata reto»",
"quarantine_desc": "Ĉi tiu nodo sendos nur publikajn afiŝojn al la jenaj nodoj:",
"quarantine": "Kvaranteno",
"reject_desc": "Ĉi tiu nodo ne akceptos mesaĝojn de la jenaj nodoj:",
@@ -704,7 +726,7 @@
"accept_desc": "Ĉi tiu nodo nur akceptas mesaĝojn de la jenaj nodoj:",
"accept": "Akcepti",
"simple_policies": "Specialaj politikoj de la nodo",
- "ftl_removal_desc": "Ĉi tiu nodo forigas la jenajn nodojn el la historio de «La tuta konata reto»:"
+ "ftl_removal_desc": "Ĉi tiu nodo forigas la jenajn nodojn el la historio de «Konata reto»:"
},
"mrf_policies": "Ŝaltis politikon de Mesaĝa ŝanĝilaro (MRF)",
"keyword": {
diff --git a/src/i18n/es.json b/src/i18n/es.json
@@ -43,7 +43,10 @@
"role": {
"admin": "Administrador/a",
"moderator": "Moderador/a"
- }
+ },
+ "flash_content": "Haga clic para mostrar contenido Flash usando Ruffle (experimental, puede que no funcione).",
+ "flash_security": "Tenga en cuenta que esto puede ser potencialmente peligroso ya que el contenido Flash sigue siendo código arbitrario.",
+ "flash_fail": "No se pudo cargar el contenido flash, consulte la consola para obtener más detalles."
},
"image_cropper": {
"crop_picture": "Recortar la foto",
@@ -147,7 +150,7 @@
"favs_repeats": "Favoritos y repetidos",
"follows": "Nuevos seguidores",
"load_older": "Cargar interacciones más antiguas",
- "moves": "Usuario Migrado"
+ "moves": "Usuario migrado"
},
"post_status": {
"new_status": "Publicar un nuevo estado",
@@ -181,7 +184,7 @@
"preview_empty": "Vacío",
"preview": "Vista previa",
"media_description": "Descripción multimedia",
- "post": "Publicación"
+ "post": "Publicar"
},
"registration": {
"bio": "Biografía",
@@ -585,13 +588,18 @@
"save": "Guardar los cambios",
"file_export_import": {
"errors": {
- "invalid_file": "El archivo seleccionado no es válido como copia de seguridad de Pleroma. No se han realizado cambios."
+ "invalid_file": "El archivo seleccionado no es válido como copia de seguridad de Pleroma. No se han realizado cambios.",
+ "file_too_new": "Versión principal incompatible: {fileMajor}, este \"FrontEnd\" de Pleroma (versión de configuración {feMajor}) es demasiado antiguo para manejarlo",
+ "file_too_old": "Versión principal incompatible: {fileMajor}, la versión del archivo es demasiado antigua y no es compatible (versión mínima {FeMajor})",
+ "file_slightly_new": "La versión secundaria del archivo es diferente, es posible que algunas configuraciones no se carguen"
},
"restore_settings": "Restaurar ajustes desde archivo",
- "backup_settings_theme": "Copia de seguridad de la configuración y tema a archivo",
- "backup_settings": "Copia de seguridad de la configuración a archivo",
+ "backup_settings_theme": "Descargar la copia de seguridad de la configuración y del tema",
+ "backup_settings": "Descargar la copia de seguridad de la configuración",
"backup_restore": "Copia de seguridad de la configuración"
- }
+ },
+ "hide_shoutbox": "Ocultar cuadro de diálogo de la instancia",
+ "right_sidebar": "Mostrar la barra lateral a la derecha"
},
"time": {
"day": "{0} día",
@@ -679,7 +687,6 @@
"follow": "Seguir",
"follow_sent": "¡Solicitud enviada!",
"follow_progress": "Solicitando…",
- "follow_again": "¿Enviar solicitud de nuevo?",
"follow_unfollow": "Dejar de seguir",
"followees": "Siguiendo",
"followers": "Seguidores",
@@ -735,7 +742,8 @@
"solid": "Fondo sólido",
"disabled": "Sin resaltado"
},
- "bot": "Bot"
+ "bot": "Bot",
+ "edit_profile": "Edita el perfil"
},
"user_profile": {
"timeline_title": "Línea temporal del usuario",
diff --git a/src/i18n/eu.json b/src/i18n/eu.json
@@ -43,7 +43,10 @@
"role": {
"moderator": "Moderatzailea",
"admin": "Administratzailea"
- }
+ },
+ "flash_content": "Klik egin Flash edukia erakusteko Ruffle erabilita (esperimentala, baliteke ez ibiltzea).",
+ "flash_security": "Kontuan izan arriskutsua izan daitekeela, Flash edukia kode arbitrarioa baita.",
+ "flash_fail": "Ezin izan da Flash edukia kargatu. Ikusi kontsola xehetasunetarako."
},
"image_cropper": {
"crop_picture": "Moztu argazkia",
@@ -96,7 +99,8 @@
"preferences": "Hobespenak",
"chats": "Txatak",
"timelines": "Denbora-lerroak",
- "bookmarks": "Laster-markak"
+ "bookmarks": "Laster-markak",
+ "home_timeline": "Denbora-lerro pertsonala"
},
"notifications": {
"broken_favorite": "Egoera ezezaguna, bilatzen…",
@@ -136,7 +140,8 @@
"add_emoji": "Emoji bat gehitu",
"custom": "Ohiko emojiak",
"unicode": "Unicode emojiak",
- "load_all": "{emojiAmount} emoji guztiak kargatzen"
+ "load_all": "{emojiAmount} emoji guztiak kargatzen",
+ "load_all_hint": "Lehenengo {saneAmount} emojia kargatuta, emoji guztiak kargatzeak errendimendu arazoak sor ditzake."
},
"stickers": {
"add_sticker": "Pegatina gehitu"
@@ -144,7 +149,8 @@
"interactions": {
"favs_repeats": "Errepikapen eta gogokoak",
"follows": "Jarraitzaile berriak",
- "load_older": "Kargatu elkarrekintza zaharragoak"
+ "load_older": "Kargatu elkarrekintza zaharragoak",
+ "moves": "Erabiltzailea migratuta"
},
"post_status": {
"new_status": "Mezu berri bat idatzi",
@@ -172,14 +178,20 @@
"private": "Jarraitzaileentzako bakarrik: bidali jarraitzaileentzat bakarrik",
"public": "Publikoa: bistaratu denbora-lerro publikoetan",
"unlisted": "Zerrendatu gabea: ez bidali denbora-lerro publikoetara"
- }
+ },
+ "media_description_error": "Ezin izan da artxiboa eguneratu, saiatu berriro",
+ "preview": "Aurrebista",
+ "media_description": "Media deskribapena",
+ "preview_empty": "Hutsik",
+ "post": "Bidali",
+ "empty_status_error": "Ezin da argitaratu ezer idatzi gabe edo eranskinik gabe"
},
"registration": {
"bio": "Biografia",
"email": "E-posta",
"fullname": "Erakutsi izena",
"password_confirm": "Pasahitza berretsi",
- "registration": "Izena ematea",
+ "registration": "Sortu kontua",
"token": "Gonbidapen txartela",
"captcha": "CAPTCHA",
"new_captcha": "Klikatu irudia captcha berri bat lortzeko",
@@ -193,7 +205,10 @@
"password_required": "Ezin da hutsik utzi",
"password_confirmation_required": "Ezin da hutsik utzi",
"password_confirmation_match": "Pasahitzaren berdina izan behar du"
- }
+ },
+ "reason": "Kontua sortzeko arrazoia",
+ "reason_placeholder": "Instantzia honek kontu berriak eskuz onartzen ditu.\nJakinarazi administrazioari zergatik erregistratu nahi duzun.",
+ "register": "Erregistratu"
},
"selectable_list": {
"select_all": "Hautatu denak"
@@ -210,7 +225,7 @@
"title": "Bi-faktore autentifikazioa",
"generate_new_recovery_codes": "Sortu berreskuratze kode berriak",
"warning_of_generate_new_codes": "Berreskuratze kode berriak sortzean, zure berreskuratze kode zaharrak ez dute balioko.",
- "recovery_codes": "Berreskuratze kodea",
+ "recovery_codes": "Berreskuratze kodea.",
"waiting_a_recovery_codes": "Babes-kopia kodeak jasotzen…",
"recovery_codes_warning": "Idatzi edo gorde kodeak leku seguruan - bestela ez dituzu berriro ikusiko. Zure 2FA aplikaziorako sarbidea eta berreskuratze kodeak galduz gero, zure kontutik blokeatuta egongo zara.",
"authentication_methods": "Autentifikazio metodoa",
@@ -468,7 +483,7 @@
"button": "Botoia",
"text": "Hamaika {0} eta {1}",
"mono": "edukia",
- "input": "Jadanik Los Angeles-en",
+ "input": "Jadanik Los Angeles-en.",
"faint_link": "laguntza",
"fine_print": "Irakurri gure {0} ezer erabilgarria ikasteko!",
"header_faint": "Ondo dago",
@@ -480,7 +495,11 @@
"title": "Bertsioa",
"backend_version": "Backend bertsioa",
"frontend_version": "Frontend bertsioa"
- }
+ },
+ "save": "Aldaketak gorde",
+ "setting_changed": "Ezarpena lehenetsitakoaren desberdina da",
+ "allow_following_move": "Baimendu jarraipen automatikoa, jarraitzen duzun kontua beste instantzia batera eramaten denean",
+ "new_email": "E-posta berria"
},
"time": {
"day": "{0} egun",
@@ -550,7 +569,6 @@
"follow": "Jarraitu",
"follow_sent": "Eskaera bidalita!",
"follow_progress": "Eskatzen…",
- "follow_again": "Eskaera berriro bidali?",
"follow_unfollow": "Jarraitzeari utzi",
"followees": "Jarraitzen",
"followers": "Jarraitzaileak",
@@ -691,5 +709,12 @@
},
"shoutbox": {
"title": "Oihu-kutxa"
+ },
+ "errors": {
+ "storage_unavailable": "Pleromak ezin izan du nabigatzailearen biltegira sartu. Hasiera-saioa edo tokiko ezarpenak ez dira gordeko eta ustekabeko arazoak sor ditzake. Saiatu cookie-ak gaitzen."
+ },
+ "remote_user_resolver": {
+ "searching_for": "Bilatzen",
+ "error": "Ez da aurkitu."
}
}
diff --git a/src/i18n/fi.json b/src/i18n/fi.json
@@ -579,7 +579,8 @@
"hide_full_subject": "Piilota koko otsikko",
"show_content": "Näytä sisältö",
"hide_content": "Piilota sisältö",
- "status_deleted": "Poistettu viesti"
+ "status_deleted": "Poistettu viesti",
+ "you": "(sinä)"
},
"user_card": {
"approve": "Hyväksy",
@@ -589,7 +590,6 @@
"follow": "Seuraa",
"follow_sent": "Pyyntö lähetetty!",
"follow_progress": "Pyydetään…",
- "follow_again": "Lähetä pyyntö uudestaan?",
"follow_unfollow": "Älä seuraa",
"followees": "Seuraa",
"followers": "Seuraajat",
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
@@ -43,7 +43,10 @@
"role": {
"moderator": "Modo'",
"admin": "Admin"
- }
+ },
+ "flash_content": "Clique pour afficher le contenu Flash avec Ruffle (Expérimental, peut ne pas fonctionner).",
+ "flash_security": "Cela reste potentiellement dangereux, Flash restant du code arbitraire.",
+ "flash_fail": "Échec de chargement du contenu Flash, voir la console pour les détails."
},
"image_cropper": {
"crop_picture": "Rogner l'image",
@@ -282,7 +285,7 @@
"new_password": "Nouveau mot de passe",
"notification_visibility": "Types de notifications à afficher",
"notification_visibility_follows": "Suivis",
- "notification_visibility_likes": "J'aime",
+ "notification_visibility_likes": "Favoris",
"notification_visibility_mentions": "Mentionnés",
"notification_visibility_repeats": "Partages",
"no_rich_text_description": "Ne formatez pas le texte",
@@ -553,7 +556,21 @@
"hide_wallpaper": "Cacher le fond d'écran",
"hide_all_muted_posts": "Cacher les messages masqués",
"word_filter": "Filtrage par mots",
- "save": "Enregistrer les changements"
+ "save": "Enregistrer les changements",
+ "file_export_import": {
+ "backup_settings_theme": "Sauvegarder les paramètres et le thème dans un fichier",
+ "errors": {
+ "invalid_file": "Le fichier sélectionné n'est pas un format supporté pour les sauvegarde Pleroma. Aucun changement n'a été fait.",
+ "file_too_new": "Version majeure incompatible. {fileMajor}, ce PleromaFE ({feMajor}) est trop ancien",
+ "file_too_old": "Version majeure incompatible : {fileMajor}, la version du fichier est trop vielle et n'est plus supportée (vers. min. {feMajor})",
+ "file_slightly_new": "La version mineure du fichier est différente, quelques paramètres on pût ne pas chargés"
+ },
+ "backup_restore": "Sauvegarde des Paramètres",
+ "backup_settings": "Sauvegarder les paramètres dans un fichier",
+ "restore_settings": "Restaurer les paramètres depuis un fichier"
+ },
+ "hide_shoutbox": "Cacher la shoutbox de l'instance",
+ "right_sidebar": "Afficher le paneau latéral à droite"
},
"timeline": {
"collapse": "Fermer",
@@ -607,7 +624,6 @@
"follow": "Suivre",
"follow_sent": "Demande envoyée !",
"follow_progress": "Demande en cours…",
- "follow_again": "Renvoyer la demande ?",
"follow_unfollow": "Désabonner",
"followees": "Suivis",
"followers": "Vous suivent",
@@ -663,7 +679,8 @@
"side": "Coté rayé",
"striped": "Fond rayé"
},
- "bot": "Robot"
+ "bot": "Robot",
+ "edit_profile": "Éditer le profil"
},
"user_profile": {
"timeline_title": "Flux du compte",
diff --git a/src/i18n/he.json b/src/i18n/he.json
@@ -312,7 +312,6 @@
"follow": "עקוב",
"follow_sent": "בקשה נשלחה!",
"follow_progress": "מבקש…",
- "follow_again": "שלח בקשה שוב?",
"follow_unfollow": "בטל עקיבה",
"followees": "נעקבים",
"followers": "עוקבים",
diff --git a/src/i18n/id.json b/src/i18n/id.json
@@ -0,0 +1,621 @@
+{
+ "settings": {
+ "style": {
+ "preview": {
+ "link": "sebuah tautan yang kecil nan bagus",
+ "header": "Pratinjau",
+ "error": "Contoh kesalahan",
+ "button": "Tombol",
+ "input": "Baru saja mendarat di L.A.",
+ "faint_link": "manual berguna",
+ "fine_print": "Baca {0} kami untuk belajar sesuatu yang tak ada gunanya!",
+ "header_faint": "Ini baik-baik saja",
+ "checkbox": "Saya telah membaca sekilas syarat dan ketentuan"
+ },
+ "advanced_colors": {
+ "alert_neutral": "Neutral",
+ "alert_warning": "Peringatan",
+ "alert_error": "Kesalahan",
+ "_tab_label": "Lanjutan",
+ "post": "Postingan/Bio pengguna",
+ "popover": "Tooltip, menu, popover",
+ "badge_notification": "Notifikasi",
+ "top_bar": "Bar atas",
+ "borders": "",
+ "buttons": "Tombol",
+ "wallpaper": "Latar belakang",
+ "panel_header": "Header panel",
+ "icons": "Ikon-ikon",
+ "disabled": "Dinonaktifkan"
+ },
+ "common_colors": {
+ "main": "Warna umum",
+ "_tab_label": "Umum"
+ },
+ "common": {
+ "contrast": {
+ "context": {
+ "text": "untuk teks",
+ "18pt": "Untuk teks besar (18pt+)"
+ }
+ },
+ "color": "Warna"
+ },
+ "switcher": {
+ "help": {
+ "upgraded_from_v2": "PleromaFE telah diperbarui, tema dapat terlihat sedikit berbeda dari apa yang Anda ingat.",
+ "future_version_imported": "Berkas yang Anda impor dibuat pada versi FE yang lebih baru.",
+ "older_version_imported": "Berkas yang Anda impor dibuat pada versi FE yang lebih lama.",
+ "fe_upgraded": "Mesin tema PleromaFE diperbarui setelah pembaruan versi."
+ },
+ "use_source": "Versi baru",
+ "use_snapshot": "Versi lama",
+ "load_theme": "Muat tema"
+ },
+ "fonts": {
+ "_tab_label": "Font",
+ "components": {
+ "interface": "Antarmuka",
+ "post": "Teks postingan"
+ },
+ "family": "Nama font",
+ "size": "Ukuran (dalam px)",
+ "weight": "Berat (ketebalan)"
+ },
+ "shadows": {
+ "components": {
+ "panel": "Panel",
+ "panelHeader": "Header panel"
+ }
+ }
+ },
+ "notification_setting_privacy": "Privasi",
+ "notifications": "Notifikasi",
+ "values": {
+ "true": "ya",
+ "false": "tidak"
+ },
+ "user_settings": "Pengaturan Pengguna",
+ "upload_a_photo": "Unggah foto",
+ "theme": "Tema",
+ "text": "Teks",
+ "settings": "Pengaturan",
+ "security_tab": "Keamanan",
+ "saving_ok": "Pengaturan disimpan",
+ "profile_tab": "Profil",
+ "profile_background": "Latar belakang profil",
+ "token": "Token",
+ "oauth_tokens": "Token OAuth",
+ "show_moderator_badge": "Tampilkan lencana \"Moderator\" di profil saya",
+ "show_admin_badge": "Tampilkan lencana \"Admin\" di profil saya",
+ "new_password": "Kata sandi baru",
+ "new_email": "Surel baru",
+ "name_bio": "Nama & bio",
+ "name": "Nama",
+ "profile_fields": {
+ "value": "Isi",
+ "name": "Label",
+ "label": "Metadata profil"
+ },
+ "limited_availability": "Tidak tersedia di browser Anda",
+ "invalid_theme_imported": "Berkas yang dipilih bukan sebuah tema yang didukung Pleroma. Tidak ada perbuahan yang dibuat pada tema Anda.",
+ "interfaceLanguage": "Bahasa antarmuka",
+ "interface": "Antarmuka",
+ "instance_default_simple": "(bawaan)",
+ "instance_default": "(bawaan: {value})",
+ "general": "Umum",
+ "delete_account_error": "Ada masalah ketika menghapus akun Anda. Jika ini terus terjadi harap hubungi adminstrator instansi Anda.",
+ "delete_account_description": "Hapus data Anda secara permanen dan menonaktifkan akun Anda.",
+ "delete_account": "Hapus akun",
+ "data_import_export_tab": "Impor / ekspor data",
+ "current_password": "Kata sandi saat ini",
+ "confirm_new_password": "Konfirmasi kata sandi baru",
+ "version": {
+ "title": "Versi",
+ "backend_version": "Versi backend",
+ "frontend_version": "Versi frontend"
+ },
+ "security": "Keamanan",
+ "changed_password": "Kata sandi berhasil diubah!",
+ "change_password_error": "Ada masalah ketika mengubah kata sandi Anda.",
+ "change_password": "Ubah kata sandi",
+ "changed_email": "Surel berhasil diubah!",
+ "change_email_error": "Ada masalah ketika mengubah surel Anda.",
+ "change_email": "Ubah surel",
+ "cRed": "Merah (Batal)",
+ "cBlue": "Biru (Balas, ikuti)",
+ "btnRadius": "Tombol",
+ "bot": "Ini adalah akun bot",
+ "block_export": "Ekspor blokiran",
+ "bio": "Bio",
+ "background": "Latar belakang",
+ "avatarRadius": "Avatar",
+ "avatar": "Avatar",
+ "attachments": "Lampiran",
+ "mfa": {
+ "scan": {
+ "title": "Pindai"
+ },
+ "confirm_and_enable": "Konfirmasi & aktifkan OTP",
+ "setup_otp": "Siapkan OTP",
+ "otp": "OTP",
+ "recovery_codes_warning": "Tulis kode-kode nya atau simpan mereka di tempat yang aman - jika tidak Anda tidak akan melihat mereka lagi. Jika Anda tidak dapat mengakses aplikasi 2FA Anda dan kode pemulihan Anda hilang Anda tidak akan bisa mengakses akun Anda.",
+ "authentication_methods": "Metode otentikasi",
+ "recovery_codes": "Kode pemulihan.",
+ "warning_of_generate_new_codes": "Ketika Anda menghasilkan kode pemulihan baru, kode lama Anda berhenti bekerja.",
+ "generate_new_recovery_codes": "Hasilkan kode pemulihan baru",
+ "title": "Otentikasi Dua-faktor",
+ "waiting_a_recovery_codes": "Menerima kode cadangan…",
+ "verify": {
+ "desc": "Untuk mengaktifkan otentikasi dua-faktor, masukkan kode dari aplikasi dua-faktor Anda:"
+ }
+ },
+ "app_name": "Nama aplikasi",
+ "save": "Simpan perubahan",
+ "valid_until": "Valid hingga",
+ "follow_import_error": "Terjadi kesalahan ketika mengimpor pengikut",
+ "emoji_reactions_on_timeline": "Tampilkan reaksi emoji pada linimasa",
+ "chatMessageRadius": "Pesan obrolan",
+ "cOrange": "Jingga (Favorit)",
+ "avatarAltRadius": "Avatar (notifikasi)",
+ "hide_shoutbox": "Sembunyikan kotak suara instansi",
+ "hide_followers_count_description": "Jangan tampilkan jumlah pengikut",
+ "hide_follows_count_description": "Jangan tampilkan jumlah mengikuti",
+ "hide_followers_description": "Jangan tampilkan siapa yang mengikuti saya",
+ "hide_follows_description": "Jangan tampilkan siapa yang saya ikuti",
+ "notification_visibility_emoji_reactions": "Reaksi",
+ "notification_visibility_follows": "Diikuti",
+ "notification_visibility_moves": "Pengguna Bermigrasi",
+ "notification_visibility_repeats": "Ulangan",
+ "notification_visibility_mentions": "Sebutan",
+ "notification_visibility_likes": "Favorit",
+ "notification_visibility": "Jenis notifikasi yang perlu ditampilkan",
+ "links": "Tautan",
+ "hide_user_stats": "Sembunyikan statistik pengguna (contoh. jumlah pengikut)",
+ "hide_post_stats": "Sembunyikan statistik postingan (contoh. jumlah favorit)",
+ "use_one_click_nsfw": "Buka lampiran NSFW hanya dengan satu klik",
+ "hide_wallpaper": "Sembunyikan latar belakang instansi",
+ "blocks_imported": "Blokiran diimpor! Pemrosesannya mungkin memakan sedikit waktu.",
+ "block_import_error": "Terjadi kesalahan ketika mengimpor blokiran",
+ "block_import": "Impor blokiran",
+ "block_export_button": "Ekspor blokiran Anda menjadi berkas csv",
+ "blocks_tab": "Blokiran",
+ "delete_account_instructions": "Ketik kata sandi Anda pada input di bawah untuk mengkonfirmasi penghapusan akun.",
+ "mutes_and_blocks": "Bisuan dan Blokiran",
+ "enter_current_password_to_confirm": "Masukkan kata sandi Anda saat ini untuk mengkonfirmasi identitas Anda",
+ "filtering": "Penyaringan",
+ "word_filter": "Penyaring kata",
+ "avatar_size_instruction": "Ukuran minimum gambar avatar yang disarankan adalah 150x150 piksel.",
+ "attachmentRadius": "Lampiran",
+ "cGreen": "Hijau (Retweet)",
+ "max_thumbnails": "Jumlah thumbnail maksimum per postingan",
+ "loop_video": "Ulang-ulang video",
+ "loop_video_silent_only": "Ulang-ulang video tanpa suara (seperti \"gif\" Mastodon)",
+ "pause_on_unfocused": "Jeda aliran ketika tab di dalam fokus",
+ "reply_visibility_following": "Hanya tampilkan balasan yang ditujukan kepada saya atau orang yang saya ikuti",
+ "reply_visibility_following_short": "Tampilkan balasan ke orang yang saya ikuti",
+ "saving_err": "Terjadi kesalahan ketika menyimpan pengaturan",
+ "search_user_to_block": "Cari siapa yang Anda ingin blokir",
+ "search_user_to_mute": "Cari siapa yang ingin Anda bisukan",
+ "set_new_avatar": "Tetapkan avatar baru",
+ "set_new_profile_background": "Tetapkan latar belakang profil baru",
+ "subject_line_behavior": "Salin subyek ketika membalas",
+ "subject_line_email": "Seperti surel: \"re: subyek\"",
+ "subject_line_mastodon": "Seperti mastodon: salin saja",
+ "subject_line_noop": "Jangan salin",
+ "useStreamingApiWarning": "(Tidak disarankan, eksperimental, diketahui dapat melewati postingan-postingan)",
+ "fun": "Seru",
+ "enable_web_push_notifications": "Aktifkan notifikasi push web",
+ "more_settings": "Lebih banyak pengaturan",
+ "reply_visibility_all": "Tampilkan semua balasan",
+ "reply_visibility_self": "Hanya tampilkan balasan yang ditujukan kepada saya"
+ },
+ "about": {
+ "mrf": {
+ "keyword": {
+ "reject": "Tolak",
+ "is_replaced_by": "→"
+ },
+ "simple": {
+ "quarantine_desc": "Instansi ini hanya akan mengirim postingan publik ke instansi-instansi berikut:",
+ "quarantine": "Karantina",
+ "reject_desc": "Instansi ini tidak akan menerima pesan dari instansi-instansi berikut:",
+ "reject": "Tolak",
+ "accept_desc": "Instansi ini hanya menerima pesan dari instansi-instansi berikut:",
+ "accept": "Terima"
+ },
+ "federation": "Federasi",
+ "mrf_policies": "Kebijakan MRF yang diaktifkan"
+ },
+ "staff": "Staf"
+ },
+ "time": {
+ "day": "{0} hari",
+ "days": "{0} hari",
+ "day_short": "{0}h",
+ "days_short": "{0}h",
+ "hour": "{0} jam",
+ "hours": "{0} jam",
+ "hour_short": "{0}j",
+ "hours_short": "{0}j",
+ "in_future": "dalam {0}",
+ "in_past": "{0} yang lalu",
+ "minute": "{0} menit",
+ "minutes": "{0} menit",
+ "minute_short": "{0}m",
+ "minutes_short": "{0}m",
+ "month": "{0} bulan",
+ "months": "{0} bulan",
+ "month_short": "{0}b",
+ "months_short": "{0}b",
+ "now": "baru saja",
+ "now_short": "sekarang",
+ "second": "{0} detik",
+ "seconds": "{0} detik",
+ "second_short": "{0}d",
+ "seconds_short": "{0}d",
+ "week": "{0} pekan",
+ "weeks": "{0} pekan",
+ "week_short": "{0}p",
+ "weeks_short": "{0}p",
+ "year": "{0} tahun",
+ "years": "{0} tahun",
+ "year_short": "{0}t",
+ "years_short": "{0}t"
+ },
+ "timeline": {
+ "conversation": "Percakapan",
+ "error": "Terjadi kesalahan memuat linimasa: {0}",
+ "no_retweet_hint": "Postingan ditandai sebagai hanya-pengikut atau langsung dan tidak dapat diulang",
+ "repeated": "diulangi",
+ "reload": "Muat ulang",
+ "no_more_statuses": "Tidak ada status lagi",
+ "no_statuses": "Tidak ada status"
+ },
+ "status": {
+ "favorites": "Favorit",
+ "repeats": "Ulangan",
+ "delete": "Hapus status",
+ "pin": "Sematkan di profil",
+ "unpin": "Berhenti menyematkan dari profil",
+ "pinned": "Disematkan",
+ "delete_confirm": "Apakah Anda benar-benar ingin menghapus status ini?",
+ "reply_to": "Balas ke",
+ "replies_list": "Balasan:",
+ "mute_conversation": "Bisukan percakapan",
+ "unmute_conversation": "Berhenti membisikan percakapan",
+ "status_unavailable": "Status tidak tersedia",
+ "thread_muted_and_words": ", memiliki kata:",
+ "hide_content": "",
+ "show_content": "",
+ "status_deleted": "Postingan ini telah dihapus",
+ "nsfw": "NSFW"
+ },
+ "user_card": {
+ "block": "Blokir",
+ "blocked": "Diblokir!",
+ "deny": "Tolak",
+ "edit_profile": "Sunting profil",
+ "favorites": "Favorit",
+ "follow": "Ikuti",
+ "follow_sent": "Permintaan dikirim!",
+ "follow_progress": "Meminta…",
+ "mute": "Bisukan",
+ "muted": "Dibisukan",
+ "per_day": "per hari",
+ "report": "Laporkan",
+ "statuses": "Status",
+ "unblock": "Berhenti memblokir",
+ "block_progress": "Memblokir…",
+ "unmute": "Berhenti membisukan",
+ "mute_progress": "Membisukan…",
+ "hide_repeats": "Sembunyikan ulangan",
+ "show_repeats": "Tampilkan ulangan",
+ "bot": "Bot",
+ "admin_menu": {
+ "moderation": "Moderasi",
+ "activate_account": "Aktifkan akun",
+ "deactivate_account": "Nonaktifkan akun",
+ "delete_account": "Hapus akun",
+ "force_nsfw": "Tandai semua postingan sebagai NSFW",
+ "strip_media": "Hapus media dari postingan-postingan",
+ "delete_user": "Hapus pengguna",
+ "delete_user_confirmation": "Apakah Anda benar-benar yakin? Tindakan ini tidak dapat dibatalkan."
+ },
+ "follow_unfollow": "Berhenti mengikuti",
+ "followees": "Mengikuti",
+ "followers": "Pengikut",
+ "following": "Diikuti!",
+ "follows_you": "Mengikuti Anda!",
+ "hidden": "Disembunyikan",
+ "its_you": "Ini Anda!",
+ "media": "Media",
+ "mention": "Sebut",
+ "message": "Kirimkan pesan"
+ },
+ "user_profile": {
+ "timeline_title": "Linimasa pengguna"
+ },
+ "user_reporting": {
+ "title": "Melaporkan {0}",
+ "add_comment_description": "Laporan ini akan dikirim ke moderator instansi Anda. Anda dapat menyediakan penjelasan mengapa Anda melaporkan akun ini di bawah:",
+ "additional_comments": "Komentar tambahan",
+ "forward_description": "Akun ini berada di server lain. Kirim salinan dari laporannya juga?",
+ "submit": "Kirim",
+ "generic_error": "Sebuah kesalahan terjadi ketika memproses permintaan Anda."
+ },
+ "notifications": {
+ "favorited_you": "memfavoritkan status Anda",
+ "reacted_with": "bereaksi dengan {0}",
+ "no_more_notifications": "Tidak ada notifikasi lagi",
+ "repeated_you": "mengulangi status Anda",
+ "read": "Dibaca!",
+ "notifications": "Notifikasi",
+ "follow_request": "ingin mengikuti Anda",
+ "followed_you": "mengikuti Anda",
+ "error": "Terjadi kesalahan ketika memuat notifikasi: {0}",
+ "migrated_to": "bermigrasi ke",
+ "load_older": "Muat notifikasi yang lebih lama",
+ "broken_favorite": "Status tak diketahui, mencarinya…"
+ },
+ "who_to_follow": {
+ "more": "Lebih banyak"
+ },
+ "tool_tip": {
+ "media_upload": "Unggah media",
+ "repeat": "Ulangi",
+ "reply": "Balas",
+ "favorite": "Favorit",
+ "add_reaction": "Tambahkan Reaksi",
+ "user_settings": "Pengaturan Pengguna"
+ },
+ "upload": {
+ "error": {
+ "base": "Pengunggahan gagal.",
+ "message": "Pengunggahan gagal: {0}",
+ "file_too_big": "Berkas terlalu besar [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
+ "default": "Coba lagi nanti"
+ },
+ "file_size_units": {
+ "B": "B",
+ "KiB": "KiB",
+ "MiB": "MiB",
+ "GiB": "GiB",
+ "TiB": "TiB"
+ }
+ },
+ "search": {
+ "people": "Orang",
+ "hashtags": "Tagar",
+ "person_talking": "{count} orang berbicara",
+ "people_talking": "{count} orang berbicara",
+ "no_results": "Tidak ada hasil"
+ },
+ "password_reset": {
+ "forgot_password": "Lupa kata sandi?",
+ "placeholder": "Surel atau nama pengguna Anda",
+ "return_home": "Kembali ke halaman beranda",
+ "too_many_requests": "Anda telah mencapai batas percobaan, coba lagi nanti.",
+ "instruction": "Masukkan surel atau nama pengguna Anda. Kami akan mengirimkan Anda tautan untuk mengatur ulang kata sandi.",
+ "password_reset": "Pengatur-ulangan kata sandi",
+ "password_reset_disabled": "Pengatur-ulangan kata sandi dinonaktifkan. Hubungi administrator instansi Anda.",
+ "password_reset_required": "Anda harus mengatur ulang kata sandi Anda untuk masuk.",
+ "password_reset_required_but_mailer_is_disabled": "Anda harus mengatur ulang kata sandi, tetapi pengatur-ulangan kata sandi dinonaktifkan. Silakan hubungi administrator instansi Anda."
+ },
+ "chats": {
+ "you": "Anda:",
+ "message_user": "Kirim Pesan ke {nickname}",
+ "delete": "Hapus",
+ "chats": "Obrolan",
+ "new": "Obrolan Baru",
+ "empty_message_error": "Tidak dapat memposting pesan yang kosong",
+ "more": "Lebih banyak",
+ "delete_confirm": "Apakah Anda benar-benar ingin menghapus pesan ini?",
+ "error_loading_chat": "Sesuatu yang salah terjadi ketika memuat obrolan.",
+ "error_sending_message": "Sesuatu yang salah terjadi ketika mengirim pesan.",
+ "empty_chat_list_placeholder": "Anda belum memiliki obrolan. Buat sbeuah obrolan baru!"
+ },
+ "file_type": {
+ "audio": "Audio",
+ "video": "Video",
+ "image": "Gambar",
+ "file": "Berkas"
+ },
+ "registration": {
+ "bio_placeholder": "contoh.\nHai, aku Lain.\nAku seorang putri anime yang tinggal di pinggiran kota Jepang. Kamu mungkin mengenal aku dari Wired.",
+ "validations": {
+ "password_confirmation_required": "tidak boleh kosong",
+ "password_required": "tidak boleh kosong",
+ "email_required": "tidak boleh kosong",
+ "fullname_required": "tidak boleh kosong",
+ "username_required": "tidak boleh kosong"
+ },
+ "register": "Daftar",
+ "fullname_placeholder": "contoh. Lain Iwakura",
+ "username_placeholder": "contoh. lain",
+ "new_captcha": "Klik gambarnya untuk mendapatkan captcha baru",
+ "captcha": "CAPTCHA",
+ "token": "Token undangan",
+ "password_confirm": "Konfirmasi kata sandi",
+ "email": "Surel",
+ "bio": "Bio",
+ "reason_placeholder": "Instansi ini menerima pendaftaran secara manual.\nBeritahu administrasinya mengapa Anda ingin mendaftar.",
+ "reason": "Alasan mendaftar",
+ "registration": "Pendaftaran"
+ },
+ "post_status": {
+ "preview_empty": "Kosong",
+ "default": "Baru saja mendarat di L.A.",
+ "content_warning": "Subyek (opsional)",
+ "content_type": {
+ "text/bbcode": "BBCode",
+ "text/markdown": "Markdown",
+ "text/html": "HTML",
+ "text/plain": "Teks biasa"
+ },
+ "media_description": "Keterangan media",
+ "attachments_sensitive": "Tandai lampiran sebagai sensitif",
+ "scope": {
+ "public": "Publik - posting ke linimasa publik",
+ "private": "Hanya-pengikut - posting hanya kepada pengikut",
+ "direct": "Langsung - posting hanya kepada pengguna yang disebut"
+ },
+ "preview": "Pratinjau",
+ "post": "Posting",
+ "posting": "Memposting",
+ "direct_warning_to_first_only": "Postingan ini akan terlihat oleh pengguna yang disebutkan di awal pesan.",
+ "direct_warning_to_all": "Postingan ini akan terlihat oleh pengguna yang disebutkan.",
+ "scope_notice": {
+ "private": "Postingan ini akan terlihat hanya oleh pengikut Anda",
+ "public": "Postingan ini akan terlihat oleh siapa saja"
+ },
+ "media_description_error": "Gagal memperbarui media, coba lagi",
+ "empty_status_error": "Tidak dapat memposting status kosong tanpa berkas",
+ "account_not_locked_warning_link": "terkunci",
+ "account_not_locked_warning": "Akun Anda tidak {0}. Siapapun dapat mengikuti Anda untuk melihat postingan hanya-pengikut Anda.",
+ "new_status": "Posting status baru"
+ },
+ "general": {
+ "apply": "Terapkan",
+ "flash_fail": "Gagal memuat konten flash, lihat console untuk keterangan.",
+ "flash_security": "Harap ingat ini dapat menjadi berbahaya karena konten Flash masih termasuk arbitrary code.",
+ "flash_content": "Klik untuk menampilkan konten Flash menggunakan Ruffle (Eksperimental, mungkin tidak bekerja).",
+ "role": {
+ "moderator": "Moderator",
+ "admin": "Admin"
+ },
+ "peek": "Intip",
+ "close": "Tutup",
+ "verify": "Verifikasi",
+ "confirm": "Konfirmasi",
+ "enable": "Aktifkan",
+ "disable": "Nonaktifkan",
+ "cancel": "Batal",
+ "show_less": "Tampilkan lebih sedikit",
+ "show_more": "Tampilkan lebih banyak",
+ "optional": "opsional",
+ "retry": "Coba lagi",
+ "error_retry": "Harap coba lagi",
+ "generic_error": "Terjadi kesalahan",
+ "loading": "Memuat…",
+ "more": "Lebih banyak",
+ "submit": "Kirim"
+ },
+ "remote_user_resolver": {
+ "error": "Tidak ditemukan."
+ },
+ "emoji": {
+ "load_all": "Memuat semua {emojiAmount} emoji",
+ "load_all_hint": "Memuat {saneAmount} emoji pertama, memuat semua emoji dapat menyebabkan masalah performa.",
+ "unicode": "Emoji unicode",
+ "add_emoji": "Sisipkan emoji",
+ "search_emoji": "Cari emoji",
+ "emoji": "Emoji",
+ "stickers": "Stiker",
+ "keep_open": "Tetap buka pemilih",
+ "custom": "Emoji kustom"
+ },
+ "polls": {
+ "expired": "Japat berakhir {0} yang lalu",
+ "expires_in": "Japat berakhir dalam {0}",
+ "expiry": "Usia japat",
+ "type": "Jenis japat",
+ "vote": "Pilih",
+ "votes_count": "{count} suara | {count} suara",
+ "people_voted_count": "{count} orang memilih | {count} orang memilih",
+ "votes": "suara",
+ "option": "Opsi",
+ "add_option": "Tambahkan opsi",
+ "add_poll": "Tambahkan japat",
+ "not_enough_options": "Terlalu sedikit opsi yang unik pada japat"
+ },
+ "nav": {
+ "preferences": "Preferensi",
+ "search": "Cari",
+ "user_search": "Pencarian Pengguna",
+ "home_timeline": "Linimasa beranda",
+ "timeline": "Linimasa",
+ "public_tl": "Linimasa publik",
+ "interactions": "Interaksi",
+ "mentions": "Sebutan",
+ "back": "Kembali",
+ "administration": "Administrasi",
+ "about": "Tentang",
+ "timelines": "Linimasa",
+ "chats": "Obrolan",
+ "dms": "Pesan langsung",
+ "friend_requests": "Ingin mengikuti"
+ },
+ "media_modal": {
+ "next": "Selanjutnya",
+ "previous": "Sebelum"
+ },
+ "login": {
+ "recovery_code": "Kode pemulihan",
+ "enter_recovery_code": "Masukkan kode pemulihan",
+ "authentication_code": "Kode otentikasi",
+ "hint": "Masuk untuk ikut berdiskusi",
+ "username": "Nama pengguna",
+ "register": "Daftar",
+ "placeholder": "contoh: lain",
+ "password": "Kata sandi",
+ "logout": "Keluar",
+ "description": "Masuk dengan OAuth",
+ "login": "Masuk",
+ "heading": {
+ "totp": "Otentikasi dua-faktor"
+ },
+ "enter_two_factor_code": "Masukkan kode dua-faktor"
+ },
+ "importer": {
+ "error": "Terjadi kesalahan ketika mnengimpor berkas ini.",
+ "success": "Berhasil mengimpor.",
+ "submit": "Kirim"
+ },
+ "image_cropper": {
+ "cancel": "Batal",
+ "save_without_cropping": "Simpan tanpa memotong",
+ "save": "Simpan",
+ "crop_picture": "Potong gambar"
+ },
+ "finder": {
+ "find_user": "Cari pengguna",
+ "error_fetching_user": "Terjadi kesalahan ketika memuat pengguna"
+ },
+ "features_panel": {
+ "title": "Fitur-fitur",
+ "text_limit": "Batas teks",
+ "gopher": "Gopher",
+ "pleroma_chat_messages": "Pleroma Obrolan",
+ "chat": "Obrolan",
+ "upload_limit": "Batas unggahan"
+ },
+ "exporter": {
+ "processing": "Memproses, Anda akan segera diminta untuk mengunduh berkas Anda",
+ "export": "Ekspor"
+ },
+ "domain_mute_card": {
+ "unmute": "Berhenti membisukan",
+ "mute_progress": "Membisukan…",
+ "mute": "Bisukan",
+ "unmute_progress": "Memberhentikan pembisuan…"
+ },
+ "display_date": {
+ "today": "Hari Ini"
+ },
+ "selectable_list": {
+ "select_all": "Pilih semua"
+ },
+ "interactions": {
+ "moves": "Pengguna yang bermigrasi",
+ "follows": "Pengikut baru",
+ "favs_repeats": "Ulangan dan favorit",
+ "load_older": "Muat interaksi yang lebih tua"
+ },
+ "errors": {
+ "storage_unavailable": "Pleroma tidak dapat mengakses penyimpanan browser. Login Anda atau pengaturan lokal Anda tidak akan tersimpan dan masalah yang tidak terduga dapat terjadi. Coba mengaktifkan kuki."
+ },
+ "shoutbox": {
+ "title": "Kotak Suara"
+ }
+}
diff --git a/src/i18n/it.json b/src/i18n/it.json
@@ -21,7 +21,10 @@
"role": {
"moderator": "Moderatore",
"admin": "Amministratore"
- }
+ },
+ "flash_fail": "Contenuto Flash non caricato, vedi console del browser.",
+ "flash_content": "Mostra contenuto Flash tramite Ruffle (funzione in prova).",
+ "flash_security": "Può essere pericoloso perché i contenuti in Flash sono eseguibili."
},
"nav": {
"mentions": "Menzioni",
@@ -65,13 +68,13 @@
"current_avatar": "La tua icona attuale",
"current_profile_banner": "Il tuo stendardo attuale",
"filtering": "Filtri",
- "filtering_explanation": "Tutti i post contenenti queste parole saranno silenziati, una per riga",
+ "filtering_explanation": "Tutti i messaggi contenenti queste parole saranno silenziati, una per riga",
"hide_attachments_in_convo": "Nascondi gli allegati presenti nelle conversazioni",
"hide_attachments_in_tl": "Nascondi gli allegati presenti nelle sequenze",
"name": "Nome",
"name_bio": "Nome ed introduzione",
"nsfw_clickthrough": "Fai click per visualizzare gli allegati offuscati",
- "profile_background": "Sfondo della tua pagina",
+ "profile_background": "Sfondo del tuo profilo",
"profile_banner": "Gonfalone del tuo profilo",
"set_new_avatar": "Scegli una nuova icona",
"set_new_profile_background": "Scegli un nuovo sfondo",
@@ -365,8 +368,8 @@
"search_user_to_mute": "Cerca utente da silenziare",
"search_user_to_block": "Cerca utente da bloccare",
"autohide_floating_post_button": "Nascondi automaticamente il pulsante di composizione (mobile)",
- "show_moderator_badge": "Mostra l'insegna di moderatore sulla mia pagina",
- "show_admin_badge": "Mostra l'insegna di amministratore sulla mia pagina",
+ "show_moderator_badge": "Mostra l'insegna di moderatore sul mio profilo",
+ "show_admin_badge": "Mostra l'insegna di amministratore sul mio profilo",
"hide_followers_count_description": "Non mostrare quanti seguaci ho",
"hide_follows_count_description": "Non mostrare quanti utenti seguo",
"hide_followers_description": "Non mostrare i miei seguaci",
@@ -443,7 +446,9 @@
"backup_settings_theme": "Archivia impostazioni e tema localmente",
"backup_settings": "Archivia impostazioni localmente",
"backup_restore": "Archiviazione impostazioni"
- }
+ },
+ "right_sidebar": "Mostra barra laterale a destra",
+ "hide_shoutbox": "Nascondi muro dei graffiti"
},
"timeline": {
"error_fetching": "Errore nell'aggiornamento",
@@ -511,7 +516,6 @@
"its_you": "Sei tu!",
"hidden": "Nascosto",
"follow_unfollow": "Disconosci",
- "follow_again": "Reinvio richiesta?",
"follow_progress": "Richiedo…",
"follow_sent": "Richiesta inviata!",
"favorites": "Preferiti",
@@ -522,7 +526,8 @@
"striped": "A righe",
"solid": "Un colore",
"disabled": "Nessun risalto"
- }
+ },
+ "edit_profile": "Modifica profilo"
},
"chat": {
"title": "Chat"
@@ -660,7 +665,7 @@
},
"domain_mute_card": {
"mute": "Silenzia",
- "mute_progress": "Silenzio…",
+ "mute_progress": "Procedo…",
"unmute": "Ascolta",
"unmute_progress": "Procedo…"
},
@@ -701,7 +706,7 @@
},
"interactions": {
"favs_repeats": "Condivisi e Graditi",
- "load_older": "Carica vecchie interazioni",
+ "load_older": "Carica interazioni precedenti",
"moves": "Utenti migrati",
"follows": "Nuovi seguìti"
},
diff --git a/src/i18n/ja_easy.json b/src/i18n/ja_easy.json
@@ -567,7 +567,6 @@
"follow": "フォロー",
"follow_sent": "リクエストを、おくりました!",
"follow_progress": "リクエストしています…",
- "follow_again": "ふたたびリクエストをおくりますか?",
"follow_unfollow": "フォローをやめる",
"followees": "フォロー",
"followers": "フォロワー",
diff --git a/src/i18n/ja_pedantic.json b/src/i18n/ja_pedantic.json
@@ -679,7 +679,6 @@
"follow": "フォロー",
"follow_sent": "リクエストを送りました!",
"follow_progress": "リクエストしています…",
- "follow_again": "再びリクエストを送りますか?",
"follow_unfollow": "フォローをやめる",
"followees": "フォロー",
"followers": "フォロワー",
diff --git a/src/i18n/ko.json b/src/i18n/ko.json
@@ -428,7 +428,6 @@
"follow": "팔로우",
"follow_sent": "요청 보내짐!",
"follow_progress": "요청 중…",
- "follow_again": "요청을 다시 보낼까요?",
"follow_unfollow": "팔로우 중지",
"followees": "팔로우 중",
"followers": "팔로워",
diff --git a/src/i18n/nb.json b/src/i18n/nb.json
@@ -516,7 +516,6 @@
"follow": "Følg",
"follow_sent": "Forespørsel sendt!",
"follow_progress": "Forespør…",
- "follow_again": "Gjenta forespørsel?",
"follow_unfollow": "Avfølg",
"followees": "Følger",
"followers": "Følgere",
diff --git a/src/i18n/nl.json b/src/i18n/nl.json
@@ -565,9 +565,9 @@
"deny": "Weigeren",
"favorites": "Favorieten",
"follow": "Volgen",
+ "follow_cancel": "Aanvraag annuleren",
"follow_sent": "Aanvraag verzonden!",
"follow_progress": "Aanvragen…",
- "follow_again": "Aanvraag opnieuw zenden?",
"follow_unfollow": "Stop volgen",
"followees": "Aan het volgen",
"followers": "Volgers",
@@ -670,6 +670,9 @@
"mrf_policies": "Ingeschakelde MRF-regels",
"simple": {
"simple_policies": "Instantiespecifieke regels",
+ "instance": "Instantie",
+ "reason": "Reden",
+ "not_applicable": "n.v.t.",
"accept": "Accepteren",
"accept_desc": "Deze instantie accepteert alleen berichten van de volgende instanties:",
"reject": "Afwijzen",
diff --git a/src/i18n/oc.json b/src/i18n/oc.json
@@ -465,7 +465,6 @@
"follow": "Seguir",
"follow_sent": "Demanda enviada !",
"follow_progress": "Demanda…",
- "follow_again": "Tornar enviar la demanda ?",
"follow_unfollow": "Quitar de seguir",
"followees": "Abonaments",
"followers": "Seguidors",
diff --git a/src/i18n/pl.json b/src/i18n/pl.json
@@ -19,8 +19,8 @@
"reject_desc": "Ta instancja odrzuca posty z wymienionych instancji:",
"quarantine": "Kwarantanna",
"quarantine_desc": "Ta instancja wysyła tylko publiczne posty do wymienionych instancji:",
- "ftl_removal": "Usunięcie z \"Całej znanej sieci\"",
- "ftl_removal_desc": "Ta instancja usuwa wymienionych instancje z \"Całej znanej sieci\":",
+ "ftl_removal": "Usunięcie z „Całej znanej sieci”",
+ "ftl_removal_desc": "Ta instancja usuwa wymienionych instancje z „Całej znanej sieci”:",
"media_removal": "Usuwanie multimediów",
"media_removal_desc": "Ta instancja usuwa multimedia z postów od wymienionych instancji:",
"media_nsfw": "Multimedia ustawione jako wrażliwe",
@@ -75,7 +75,13 @@
"loading": "Ładowanie…",
"retry": "Spróbuj ponownie",
"peek": "Spójrz",
- "error_retry": "Spróbuj ponownie"
+ "error_retry": "Spróbuj ponownie",
+ "flash_content": "Naciśnij, aby wyświetlić zawartości Flash z użyciem Ruffle (eksperymentalnie, może nie działać).",
+ "flash_fail": "Nie udało się załadować treści flash, zajrzyj do konsoli, aby odnaleźć szczegóły.",
+ "role": {
+ "moderator": "Moderator",
+ "admin": "Administrator"
+ }
},
"image_cropper": {
"crop_picture": "Przytnij obrazek",
@@ -118,7 +124,7 @@
"friend_requests": "Prośby o możliwość obserwacji",
"mentions": "Wzmianki",
"interactions": "Interakcje",
- "dms": "Wiadomości prywatne",
+ "dms": "Wiadomości bezpośrednie",
"public_tl": "Publiczna oś czasu",
"timeline": "Oś czasu",
"twkn": "Znana sieć",
@@ -128,7 +134,8 @@
"preferences": "Preferencje",
"bookmarks": "Zakładki",
"chats": "Czaty",
- "timelines": "Osie czasu"
+ "timelines": "Osie czasu",
+ "home_timeline": "Główna oś czasu"
},
"notifications": {
"broken_favorite": "Nieznany status, szukam go…",
@@ -156,7 +163,9 @@
"expiry": "Czas trwania ankiety",
"expires_in": "Ankieta kończy się za {0}",
"expired": "Ankieta skończyła się {0} temu",
- "not_enough_options": "Zbyt mało unikalnych opcji w ankiecie"
+ "not_enough_options": "Zbyt mało unikalnych opcji w ankiecie",
+ "people_voted_count": "{count} osoba zagłosowała | {count} osoby zagłosowały | {count} osób zagłosowało",
+ "votes_count": "{count} głos | {count} głosy | {count} głosów"
},
"emoji": {
"stickers": "Naklejki",
@@ -197,16 +206,17 @@
"unlisted": "Ten post nie będzie widoczny na publicznej osi czasu i całej znanej sieci"
},
"scope": {
- "direct": "Bezpośredni – Tylko dla wspomnianych użytkowników",
- "private": "Tylko dla obserwujących – Umieść dla osób, które cię obserwują",
- "public": "Publiczny – Umieść na publicznych osiach czasu",
- "unlisted": "Niewidoczny – Nie umieszczaj na publicznych osiach czasu"
+ "direct": "Bezpośredni – tylko dla wspomnianych użytkowników",
+ "private": "Tylko dla obserwujących – umieść dla osób, które cię obserwują",
+ "public": "Publiczny – umieść na publicznych osiach czasu",
+ "unlisted": "Niewidoczny – nie umieszczaj na publicznych osiach czasu"
},
"preview_empty": "Pusty",
"preview": "Podgląd",
"empty_status_error": "Nie można wysłać pustego wpisu bez plików",
"media_description_error": "Nie udało się zaktualizować mediów, spróbuj ponownie",
- "media_description": "Opis mediów"
+ "media_description": "Opis mediów",
+ "post": "Opublikuj"
},
"registration": {
"bio": "Bio",
@@ -227,7 +237,10 @@
"password_required": "nie może być puste",
"password_confirmation_required": "nie może być puste",
"password_confirmation_match": "musi być takie jak hasło"
- }
+ },
+ "reason": "Powód rejestracji",
+ "reason_placeholder": "Ta instancja ręcznie zatwierdza rejestracje.\nPoinformuj administratora, dlaczego chcesz się zarejestrować.",
+ "register": "Zarejestruj się"
},
"remote_user_resolver": {
"remote_user_resolver": "Wyszukiwarka użytkowników nietutejszych",
@@ -281,7 +294,7 @@
"cGreen": "Zielony (powtórzenia)",
"cOrange": "Pomarańczowy (ulubione)",
"cRed": "Czerwony (anuluj)",
- "change_email": "Zmień email",
+ "change_email": "Zmień e-mail",
"change_email_error": "Wystąpił problem podczas zmiany emaila.",
"changed_email": "Pomyślnie zmieniono email!",
"change_password": "Zmień hasło",
@@ -345,7 +358,7 @@
"use_contain_fit": "Nie przycinaj załączników na miniaturach",
"name": "Imię",
"name_bio": "Imię i bio",
- "new_email": "Nowy email",
+ "new_email": "Nowy e-mail",
"new_password": "Nowe hasło",
"notification_visibility": "Rodzaje powiadomień do wyświetlania",
"notification_visibility_follows": "Obserwacje",
@@ -361,8 +374,8 @@
"hide_followers_description": "Nie pokazuj kto mnie obserwuje",
"hide_follows_count_description": "Nie pokazuj licznika obserwowanych",
"hide_followers_count_description": "Nie pokazuj licznika obserwujących",
- "show_admin_badge": "Pokazuj odznakę Administrator na moim profilu",
- "show_moderator_badge": "Pokazuj odznakę Moderator na moim profilu",
+ "show_admin_badge": "Pokazuj odznakę „Administrator” na moim profilu",
+ "show_moderator_badge": "Pokazuj odznakę „Moderator” na moim profilu",
"nsfw_clickthrough": "Włącz domyślne ukrywanie załączników o treści nieprzyzwoitej (NSFW)",
"oauth_tokens": "Tokeny OAuth",
"token": "Token",
@@ -600,7 +613,27 @@
"mute_import": "Import wyciszeń",
"mute_export_button": "Wyeksportuj swoje wyciszenia do pliku .csv",
"mute_export": "Eksport wyciszeń",
- "hide_wallpaper": "Ukryj tło instancji"
+ "hide_wallpaper": "Ukryj tło instancji",
+ "save": "Zapisz zmiany",
+ "setting_changed": "Opcja różni się od domyślnej",
+ "right_sidebar": "Pokaż pasek boczny po prawej",
+ "file_export_import": {
+ "errors": {
+ "invalid_file": "Wybrany plik nie jest obsługiwaną kopią zapasową ustawień Pleromy. Nie dokonano żadnych zmian."
+ },
+ "backup_restore": "Kopia zapasowa ustawień",
+ "backup_settings": "Kopia zapasowa ustawień do pliku",
+ "backup_settings_theme": "Kopia zapasowa ustawień i motywu do pliku",
+ "restore_settings": "Przywróć ustawienia z pliku"
+ },
+ "more_settings": "Więcej ustawień",
+ "word_filter": "Filtr słów",
+ "hide_media_previews": "Ukryj podgląd mediów",
+ "hide_all_muted_posts": "Ukryj wyciszone słowa",
+ "reply_visibility_following_short": "Pokazuj odpowiedzi obserwującym",
+ "reply_visibility_self_short": "Pokazuj odpowiedzi tylko do mnie",
+ "sensitive_by_default": "Domyślnie oznaczaj wpisy jako wrażliwe",
+ "hide_shoutbox": "Ukryj shoutbox instancji"
},
"time": {
"day": "{0} dzień",
@@ -648,7 +681,9 @@
"no_more_statuses": "Brak kolejnych statusów",
"no_statuses": "Brak statusów",
"reload": "Odśwież",
- "error": "Błąd pobierania osi czasu: {0}"
+ "error": "Błąd pobierania osi czasu: {0}",
+ "socket_broke": "Utracono połączenie w czasie rzeczywistym: kod CloseEvent {0}",
+ "socket_reconnected": "Osiągnięto połączenie w czasie rzeczywistym"
},
"status": {
"favorites": "Ulubione",
@@ -686,7 +721,6 @@
"follow": "Obserwuj",
"follow_sent": "Wysłano prośbę!",
"follow_progress": "Wysyłam prośbę…",
- "follow_again": "Wysłać prośbę ponownie?",
"follow_unfollow": "Przestań obserwować",
"followees": "Obserwowani",
"followers": "Obserwujący",
@@ -731,7 +765,12 @@
"delete_user": "Usuń użytkownika",
"delete_user_confirmation": "Czy jesteś absolutnie pewny(-a)? Ta operacja nie może być cofnięta."
},
- "message": "Napisz"
+ "message": "Napisz",
+ "edit_profile": "Edytuj profil",
+ "highlight": {
+ "disabled": "Bez wyróżnienia"
+ },
+ "bot": "Bot"
},
"user_profile": {
"timeline_title": "Oś czasu użytkownika",
diff --git a/src/i18n/pt.json b/src/i18n/pt.json
@@ -575,7 +575,6 @@
"follow": "Seguir",
"follow_sent": "Pedido enviado!",
"follow_progress": "Enviando…",
- "follow_again": "Enviar solicitação novamente?",
"follow_unfollow": "Deixar de seguir",
"followees": "Seguindo",
"followers": "Seguidores",
diff --git a/src/i18n/ru.json b/src/i18n/ru.json
@@ -550,7 +550,6 @@
"follow": "Читать",
"follow_sent": "Запрос отправлен!",
"follow_progress": "Запрашиваем…",
- "follow_again": "Запросить еще раз?",
"follow_unfollow": "Перестать читать",
"followees": "Читаемые",
"followers": "Читатели",
diff --git a/src/i18n/te.json b/src/i18n/te.json
@@ -310,7 +310,6 @@
"user_card.follow": "Follow",
"user_card.follow_sent": "Request sent!",
"user_card.follow_progress": "Requesting…",
- "user_card.follow_again": "Send request again?",
"user_card.follow_unfollow": "Unfollow",
"user_card.followees": "Following",
"user_card.followers": "Followers",
diff --git a/src/i18n/uk.json b/src/i18n/uk.json
@@ -21,7 +21,10 @@
"role": {
"moderator": "Модератор",
"admin": "Адміністратор"
- }
+ },
+ "flash_content": "Натисніть для перегляду змісту Flash за допомогою Ruffle (експериментально, може не працювати).",
+ "flash_security": "Ця функція може становити ризик, оскільки Flash-вміст все ще є потенційно небезпечним.",
+ "flash_fail": "Не вдалося завантажити Flash-вміст, докладнішу інформацію дивись у консолі."
},
"finder": {
"error_fetching_user": "Користувача не знайдено",
@@ -633,7 +636,9 @@
"backup_settings_theme": "Резервне копіювання налаштувань та теми у файл",
"backup_settings": "Резервне копіювання налаштувань у файл",
"backup_restore": "Резервне копіювання налаштувань"
- }
+ },
+ "right_sidebar": "Показувати бокову панель справа",
+ "hide_shoutbox": "Приховати оголошення інстансу"
},
"selectable_list": {
"select_all": "Вибрати все"
@@ -743,7 +748,6 @@
"message": "Повідомлення",
"follow": "Підписатись",
"follow_unfollow": "Відписатись",
- "follow_again": "Відправити запит знову?",
"follow_sent": "Запит відправлено!",
"blocked": "Заблоковано!",
"admin_menu": {
@@ -799,7 +803,8 @@
"solid": "Суцільний фон",
"disabled": "Не виділяти"
},
- "bot": "Бот"
+ "bot": "Бот",
+ "edit_profile": "Редагувати профіль"
},
"status": {
"copy_link": "Скопіювати посилання на допис",
diff --git a/src/i18n/vi.json b/src/i18n/vi.json
@@ -0,0 +1,435 @@
+{
+ "about": {
+ "mrf": {
+ "federation": "Liên hợp",
+ "keyword": {
+ "keyword_policies": "Chính sách quan trọng",
+ "reject": "Từ chối",
+ "replace": "Thay thế",
+ "is_replaced_by": "→",
+ "ftl_removal": "Giới hạn chung"
+ },
+ "mrf_policies": "Kích hoạt chính sách MRF",
+ "simple": {
+ "simple_policies": "Quy tắc máy chủ",
+ "accept": "Đồng ý",
+ "accept_desc": "Máy chủ này chỉ chấp nhận tin nhắn từ những máy chủ:",
+ "reject": "Từ chối",
+ "quarantine": "Bảo hành",
+ "quarantine_desc": "Máy chủ này sẽ gửi tút công khai đến những máy chủ:",
+ "ftl_removal": "Giới hạn chung",
+ "media_removal": "Ẩn Media",
+ "media_removal_desc": "Media từ những máy chủ sau sẽ bị ẩn:",
+ "media_nsfw": "Áp đặt nhạy cảm",
+ "media_nsfw_desc": "Nội dung từ những máy chủ sau sẽ bị tự động gắn nhãn nhạy cảm:",
+ "reject_desc": "Máy chủ này không chấp nhận tin nhắn từ những máy chủ:",
+ "ftl_removal_desc": "Nội dung từ những máy chủ sau sẽ bị ẩn:"
+ },
+ "mrf_policies_desc": "Các chính sách MRF kiểm soát sự liên hợp của máy chủ. Các chính sách sau được bật:"
+ },
+ "staff": "Nhân viên"
+ },
+ "domain_mute_card": {
+ "mute": "Ẩn",
+ "mute_progress": "Đang ẩn…",
+ "unmute": "Ngưng ẩn",
+ "unmute_progress": "Đang ngưng ẩn…"
+ },
+ "exporter": {
+ "export": "Xuất dữ liệu",
+ "processing": "Đang chuẩn bị tập tin cho bạn tải về"
+ },
+ "features_panel": {
+ "chat": "Chat",
+ "pleroma_chat_messages": "Pleroma Chat",
+ "gopher": "Gopher",
+ "media_proxy": "Proxy media",
+ "text_limit": "Giới hạn ký tự",
+ "title": "Tính năng",
+ "who_to_follow": "Đề xuất theo dõi",
+ "upload_limit": "Giới hạn tải lên",
+ "scope_options": "Đa dạng kiểu đăng"
+ },
+ "finder": {
+ "error_fetching_user": "Lỗi người dùng",
+ "find_user": "Tìm người dùng"
+ },
+ "shoutbox": {
+ "title": "Chat cùng nhau"
+ },
+ "general": {
+ "apply": "Áp dụng",
+ "submit": "Gửi tặng",
+ "more": "Nhiều hơn",
+ "loading": "Đang tải…",
+ "generic_error": "Đã có lỗi xảy ra",
+ "error_retry": "Xin hãy thử lại",
+ "retry": "Thử lại",
+ "optional": "tùy chọn",
+ "show_more": "Xem thêm",
+ "show_less": "Thu gọn",
+ "dismiss": "Bỏ qua",
+ "cancel": "Hủy bỏ",
+ "disable": "Tắt",
+ "enable": "Bật",
+ "confirm": "Xác nhận",
+ "verify": "Xác thực",
+ "close": "Đóng",
+ "peek": "Thu gọn",
+ "role": {
+ "admin": "Quản trị viên",
+ "moderator": "Kiểm duyệt viên"
+ },
+ "flash_security": "Lưu ý rằng điều này có thể tiềm ẩn nguy hiểm vì nội dung Flash là mã lập trình tùy ý.",
+ "flash_fail": "Tải nội dung Flash thất bại, tham khảo chi tiết trong console.",
+ "flash_content": "Nhấn để hiện nội dung Flash bằng Ruffle (Thử nghiệm, có thể không dùng được)."
+ },
+ "image_cropper": {
+ "crop_picture": "Cắt hình ảnh",
+ "save": "Lưu",
+ "save_without_cropping": "Bỏ qua cắt",
+ "cancel": "Hủy bỏ"
+ },
+ "importer": {
+ "submit": "Gửi đi",
+ "success": "Đã nhập dữ liệu thành công.",
+ "error": "Có lỗi xảy ra khi nhập dữ liệu từ tập tin này."
+ },
+ "login": {
+ "login": "Đăng nhập",
+ "description": "Đăng nhập bằng OAuth",
+ "logout": "Đăng xuất",
+ "password": "Mật khẩu",
+ "placeholder": "vd: cobetronxinh",
+ "register": "Đăng ký",
+ "username": "Tên người dùng",
+ "hint": "Đăng nhập để cùng trò chuyện",
+ "authentication_code": "Mã truy cập",
+ "enter_recovery_code": "Nhập mã khôi phục",
+ "recovery_code": "Mã khôi phục",
+ "heading": {
+ "totp": "Xác thực hai bước",
+ "recovery": "Khôi phục hai bước"
+ },
+ "enter_two_factor_code": "Nhập mã xác thực hai bước"
+ },
+ "media_modal": {
+ "previous": "Trước đó",
+ "next": "Kế tiếp"
+ },
+ "nav": {
+ "about": "Về máy chủ này",
+ "administration": "Vận hành bởi",
+ "back": "Quay lại",
+ "friend_requests": "Yêu cầu theo dõi",
+ "mentions": "Lượt nhắc đến",
+ "interactions": "Giao tiếp",
+ "dms": "Nhắn tin",
+ "public_tl": "Bảng tin máy chủ",
+ "timeline": "Bảng tin",
+ "home_timeline": "Bảng tin của bạn",
+ "twkn": "Thế giới",
+ "bookmarks": "Đã lưu",
+ "user_search": "Tìm kiếm người dùng",
+ "search": "Tìm kiếm",
+ "who_to_follow": "Đề xuất theo dõi",
+ "preferences": "Thiết lập",
+ "timelines": "Bảng tin",
+ "chats": "Chat"
+ },
+ "notifications": {
+ "broken_favorite": "Trạng thái chưa rõ, đang tìm kiếm…",
+ "favorited_you": "thích tút của bạn",
+ "followed_you": "theo dõi bạn",
+ "follow_request": "yêu cầu theo dõi bạn",
+ "load_older": "Xem những thông báo cũ hơn",
+ "notifications": "Thông báo",
+ "read": "Đọc!",
+ "repeated_you": "chia sẻ tút của bạn",
+ "no_more_notifications": "Không còn thông báo nào",
+ "migrated_to": "chuyển sang",
+ "reacted_with": "chạm tới {0}",
+ "error": "Lỗi xử lý thông báo: {0}"
+ },
+ "polls": {
+ "add_poll": "Tạo bình chọn",
+ "option": "Lựa chọn",
+ "votes": "người bình chọn",
+ "people_voted_count": "{count} người bình chọn | {count} người bình chọn",
+ "vote": "Bình chọn",
+ "type": "Kiểu bình chọn",
+ "single_choice": "Chỉ được chọn một lựa chọn",
+ "multiple_choices": "Cho phép chọn nhiều lựa chọn",
+ "expiry": "Thời hạn bình chọn",
+ "expires_in": "Bình chọn kết thúc sau {0}",
+ "not_enough_options": "Không đủ lựa chọn tối thiểu",
+ "add_option": "Thêm lựa chọn",
+ "votes_count": "{count} bình chọn | {count} bình chọn",
+ "expired": "Bình chọn đã kết thúc {0} trước"
+ },
+ "emoji": {
+ "stickers": "Sticker",
+ "emoji": "Emoji",
+ "keep_open": "Mở khung lựa chọn",
+ "search_emoji": "Tìm emoji",
+ "add_emoji": "Nhập emoji",
+ "custom": "Tùy chỉnh emoji",
+ "unicode": "Unicode emoji",
+ "load_all_hint": "Tải trước {saneAmount} emoji, tải toàn bộ emoji có thể gây xử lí chậm.",
+ "load_all": "Đang tải {emojiAmount} emoji"
+ },
+ "interactions": {
+ "favs_repeats": "Tương tác",
+ "follows": "Lượt theo dõi mới",
+ "moves": "Người dùng chuyển đi",
+ "load_older": "Xem tương tác cũ hơn"
+ },
+ "post_status": {
+ "new_status": "Đăng tút",
+ "account_not_locked_warning": "Tài khoản của bạn chưa {0}. Bất kỳ ai cũng có thể xem những tút dành cho người theo dõi của bạn.",
+ "account_not_locked_warning_link": "đã khóa",
+ "attachments_sensitive": "Đánh dấu media là nhạy cảm",
+ "media_description": "Mô tả media",
+ "content_type": {
+ "text/plain": "Văn bản",
+ "text/html": "HTML",
+ "text/markdown": "Markdown",
+ "text/bbcode": "BBCode"
+ },
+ "content_warning": "Tiêu đề (tùy chọn)",
+ "default": "Just landed in L.A.",
+ "direct_warning_to_first_only": "Người đầu tiên được nhắc đến mới có thể thấy tút này.",
+ "posting": "Đang đăng tút",
+ "post": "Đăng",
+ "preview": "Xem trước",
+ "preview_empty": "Trống",
+ "empty_status_error": "Không thể đăng một tút trống và không có media",
+ "media_description_error": "Cập nhật media thất bại, thử lại sau",
+ "scope_notice": {
+ "private": "Chỉ những người theo dõi bạn mới thấy tút này",
+ "unlisted": "Tút này sẽ không hiện trong bảng tin máy chủ và thế giới",
+ "public": "Mọi người đều có thể thấy tút này"
+ },
+ "scope": {
+ "public": "Công khai - hiện trên bảng tin máy chủ",
+ "private": "Riêng tư - Chỉ dành cho người theo dõi",
+ "unlisted": "Hạn chế - không hiện trên bảng tin",
+ "direct": "Tin nhắn - chỉ người được nhắc đến mới thấy"
+ },
+ "direct_warning_to_all": "Những ai được nhắc đến sẽ đều thấy tút này."
+ },
+ "registration": {
+ "bio": "Tiểu sử",
+ "email": "Email",
+ "fullname": "Tên hiển thị",
+ "password_confirm": "Xác nhận mật khẩu",
+ "registration": "Đăng ký",
+ "token": "Lời mời",
+ "captcha": "CAPTCHA",
+ "new_captcha": "Nhấn vào hình ảnh để đổi captcha mới",
+ "username_placeholder": "vd: cobetronxinh",
+ "fullname_placeholder": "vd: Cô Bé Tròn Xinh",
+ "bio_placeholder": "vd:\nHi, I'm Cô Bé Tròn Xinh.\nI’m an anime girl living in suburban Vietnam. You may know me from the school.",
+ "reason": "Lý do đăng ký",
+ "reason_placeholder": "Máy chủ này phê duyệt đăng ký thủ công.\nHãy cho quản trị viên biết lý do bạn muốn đăng ký.",
+ "register": "Đăng ký",
+ "validations": {
+ "username_required": "không được để trống",
+ "fullname_required": "không được để trống",
+ "email_required": "không được để trống",
+ "password_confirmation_required": "không được để trống",
+ "password_confirmation_match": "phải trùng khớp với mật khẩu",
+ "password_required": "không được để trống"
+ }
+ },
+ "remote_user_resolver": {
+ "remote_user_resolver": "Giải quyết người dùng từ xa",
+ "searching_for": "Tìm kiếm",
+ "error": "Không tìm thấy."
+ },
+ "selectable_list": {
+ "select_all": "Chọn tất cả"
+ },
+ "settings": {
+ "app_name": "Tên app",
+ "save": "Lưu thay đổi",
+ "security": "Bảo mật",
+ "enter_current_password_to_confirm": "Nhập mật khẩu để xác thực",
+ "mfa": {
+ "otp": "OTP",
+ "setup_otp": "Thiết lập OTP",
+ "wait_pre_setup_otp": "hậu thiết lập OTP",
+ "confirm_and_enable": "Xác nhận và kích hoạt OTP",
+ "title": "Xác thực hai bước",
+ "recovery_codes": "Những mã khôi phục.",
+ "waiting_a_recovery_codes": "Đang nhận mã khôi phục…",
+ "authentication_methods": "Phương pháp xác thực",
+ "scan": {
+ "title": "Quét",
+ "desc": "Sử dụng app xác thực hai bước để quét mã QR hoặc nhập mã khôi phục:",
+ "secret_code": "Mã"
+ },
+ "verify": {
+ "desc": "Để bật xác thực hai bước, nhập mã từ app của bạn:"
+ },
+ "generate_new_recovery_codes": "Tạo mã khôi phục mới",
+ "warning_of_generate_new_codes": "Khi tạo mã khôi phục mới, những mã khôi phục cũ sẽ không sử dụng được nữa.",
+ "recovery_codes_warning": "Hãy viết lại mã và cất ở một nơi an toàn - những mã này sẽ không xuất hiện lại nữa. Nếu mất quyền sử dụng app 2FA app và mã khôi phục, tài khoản của bạn sẽ không thể truy cập."
+ },
+ "allow_following_move": "Cho phép tự động theo dõi lại khi tài khoản đang theo dõi chuyển sang máy chủ khác",
+ "attachmentRadius": "Tập tin tải lên",
+ "attachments": "Tập tin tải lên",
+ "avatar": "Ảnh đại diện",
+ "avatarAltRadius": "Ảnh đại diện (thông báo)",
+ "avatarRadius": "Ảnh đại diện",
+ "background": "Ảnh nền",
+ "bio": "Tiểu sử",
+ "block_export": "Xuất danh sách chặn",
+ "block_import": "Nhập danh sách chặn",
+ "block_import_error": "Lỗi khi nhập danh sách chặn",
+ "mute_export": "Xuất danh sách ẩn",
+ "mute_export_button": "Xuất danh sách ẩn ra tập tin CSV",
+ "mute_import": "Nhập danh sách ẩn",
+ "mute_import_error": "Lỗi khi nhập danh sách ẩn",
+ "mutes_imported": "Đã nhập danh sách ẩn! Sẽ mất một lúc nữa để hoàn thành.",
+ "import_mutes_from_a_csv_file": "Nhập danh sách ẩn từ tập tin CSV",
+ "blocks_tab": "Danh sách chặn",
+ "bot": "Đây là tài khoản Bot",
+ "btnRadius": "Nút",
+ "cBlue": "Xanh (Trả lời, theo dõi)",
+ "cOrange": "Cam (Thích)",
+ "cRed": "Đỏ (Hủy bỏ)",
+ "change_email": "Đổi email",
+ "change_email_error": "Có lỗi xảy ra khi đổi email.",
+ "changed_email": "Đã đổi email thành công!",
+ "change_password": "Đổi mật khẩu",
+ "changed_password": "Đổi mật khẩu thành công!",
+ "chatMessageRadius": "Tin nhắn chat",
+ "follows_imported": "Đã nhập danh sách theo dõi! Sẽ mất một lúc nữa để hoàn thành.",
+ "collapse_subject": "Thu gọn những tút có tựa đề",
+ "composing": "Thu gọn",
+ "current_password": "Mật khẩu cũ",
+ "mutes_and_blocks": "Ẩn và Chặn",
+ "data_import_export_tab": "Nhập / Xuất dữ liệu",
+ "default_vis": "Kiểu đăng tút mặc định",
+ "delete_account": "Xóa tài khoản",
+ "delete_account_error": "Có lỗi khi xóa tài khoản. Xin liên hệ quản trị viên máy chủ để tìm hiểu.",
+ "delete_account_instructions": "Nhập mật khẩu bên dưới để xác nhận.",
+ "domain_mutes": "Máy chủ",
+ "avatar_size_instruction": "Kích cỡ tối thiểu 150x150 pixels.",
+ "pad_emoji": "Nhớ chừa khoảng cách khi chèn emoji",
+ "emoji_reactions_on_timeline": "Hiện tương tác emoji trên bảng tin",
+ "export_theme": "Lưu mẫu",
+ "filtering": "Bộ lọc",
+ "filtering_explanation": "Những tút chứa từ sau sẽ bị ẩn, mỗi chữ một hàng",
+ "word_filter": "Bộ lọc từ ngữ",
+ "follow_export": "Xuất danh sách theo dõi",
+ "follow_import": "Nhập danh sách theo dõi",
+ "follow_import_error": "Lỗi khi nhập danh sách theo dõi",
+ "accent": "Màu chủ đạo",
+ "foreground": "Màu phối",
+ "general": "Chung",
+ "hide_attachments_in_convo": "Ẩn tập tin đính kèm trong thảo luận",
+ "hide_media_previews": "Ẩn xem trước media",
+ "hide_all_muted_posts": "Ẩn những tút đã ẩn",
+ "hide_muted_posts": "Ẩn tút từ các người dùng đã ẩn",
+ "max_thumbnails": "Số ảnh xem trước tối đa cho mỗi tút",
+ "hide_isp": "Ẩn thanh bên của máy chủ",
+ "hide_shoutbox": "Ẩn thanh chat máy chủ",
+ "hide_wallpaper": "Ẩn ảnh nền máy chủ",
+ "preload_images": "Tải trước hình ảnh",
+ "use_one_click_nsfw": "Xem nội dung nhạy cảm bằng cách nhấn vào",
+ "hide_user_stats": "Ẩn số liệu người dùng (vd: số người theo dõi)",
+ "hide_filtered_statuses": "Ẩn những tút đã lọc",
+ "import_followers_from_a_csv_file": "Nhập danh sách theo dõi từ tập tin CSV",
+ "import_theme": "Tải mẫu có sẵn",
+ "inputRadius": "Chỗ nhập vào",
+ "checkboxRadius": "Hộp kiểm",
+ "instance_default": "(mặc định: {value})",
+ "instance_default_simple": "(mặc định)",
+ "interface": "Giao diện",
+ "interfaceLanguage": "Ngôn ngữ",
+ "limited_availability": "Trình duyệt không hỗ trợ",
+ "links": "Liên kết",
+ "lock_account_description": "Tự phê duyệt yêu cầu theo dõi",
+ "loop_video": "Lặp lại video",
+ "loop_video_silent_only": "Chỉ lặp lại những video không có âm thanh",
+ "mutes_tab": "Ẩn",
+ "play_videos_in_modal": "Phát video trong khung hình riêng",
+ "file_export_import": {
+ "backup_restore": "Sao lưu",
+ "backup_settings": "Thiết lập sao lưu",
+ "restore_settings": "Khôi phục thiết lập từ tập tin",
+ "errors": {
+ "invalid_file": "Tập tin đã chọn không hỗ trợ bởi Pleroma. Giữ nguyên mọi thay đổi.",
+ "file_too_old": "Phiên bản không tương thích: {fileMajor}, phiên bản tập tin quá cũ và không được hỗ trợ (min. set. ver. {feMajor})",
+ "file_slightly_new": "Phiên bản tập tin khác biệt, không thể áp dụng một vài thay đổi",
+ "file_too_new": "Phiên bản không tương thích: {fileMajor}, phiên bản PleromaFE(settings ver {feMajor}) của máy chủ này quá cũ để sử dụng"
+ },
+ "backup_settings_theme": "Thiết lập sao lưu dữ liệu và giao diện"
+ },
+ "profile_fields": {
+ "label": "Metadata",
+ "add_field": "Thêm mục",
+ "name": "Nhãn",
+ "value": "Nội dung"
+ },
+ "use_contain_fit": "Không cắt ảnh đính kèm trong bản xem trước",
+ "name": "Tên",
+ "name_bio": "Tên & tiểu sử",
+ "new_email": "Email mới",
+ "new_password": "Mật khẩu mới",
+ "notification_visibility_follows": "Theo dõi",
+ "notification_visibility_mentions": "Lượt nhắc",
+ "notification_visibility_repeats": "Chia sẻ",
+ "notification_visibility_moves": "Chuyển máy chủ",
+ "notification_visibility_emoji_reactions": "Tương tác",
+ "no_blocks": "Không có chặn",
+ "no_mutes": "Không có ẩn",
+ "hide_follows_description": "Ẩn danh sách những người tôi theo dõi",
+ "hide_followers_description": "Ẩn danh sách những người theo dõi tôi",
+ "hide_followers_count_description": "Ẩn số lượng người theo dõi tôi",
+ "show_admin_badge": "Hiện huy hiệu \"Quản trị viên\" trên trang của tôi",
+ "show_moderator_badge": "Hiện huy hiệu \"Kiểm duyệt viên\" trên trang của tôi",
+ "oauth_tokens": "OAuth tokens",
+ "token": "Token",
+ "refresh_token": "Làm tươi token",
+ "valid_until": "Có giá trị tới",
+ "revoke_token": "Gỡ",
+ "panelRadius": "Panels",
+ "pause_on_unfocused": "Dừng phát khi đang lướt các tút khác",
+ "presets": "Mẫu có sẵn",
+ "profile_background": "Ảnh nền trang cá nhân",
+ "profile_banner": "Ảnh bìa trang cá nhân",
+ "profile_tab": "Trang cá nhân",
+ "radii_help": "Thiết lập góc bo tròn (bằng pixels)",
+ "replies_in_timeline": "Trả lời trong bảng tin",
+ "reply_visibility_all": "Hiện toàn bộ trả lời",
+ "reply_visibility_self": "Chỉ hiện những trả lời có nhắc tới tôi",
+ "reply_visibility_following_short": "Hiện trả lời có những người tôi theo dõi",
+ "reply_visibility_self_short": "Hiện trả lời của bản thân",
+ "setting_changed": "Thiết lập khác với mặc định",
+ "block_export_button": "Xuất danh sách chặn ra tập tin CSV",
+ "blocks_imported": "Đã nhập danh sách chặn! Sẽ mất một lúc nữa để hoàn thành.",
+ "cGreen": "Green (Chia sẻ)",
+ "change_password_error": "Có lỗi xảy ra khi đổi mật khẩu.",
+ "confirm_new_password": "Xác nhận mật khẩu mới",
+ "delete_account_description": "Xóa vĩnh viễn mọi dữ liệu và vô hiệu hóa tài khoản của bạn.",
+ "discoverable": "Hiện tài khoản trong công cụ tìm kiếm và những tính năng khác",
+ "follow_export_button": "Xuất danh sách theo dõi ra tập tin CSV",
+ "hide_attachments_in_tl": "Ẩn tập tin đính kèm trong bảng tin",
+ "right_sidebar": "Hiện thanh bên bên phải",
+ "hide_post_stats": "Ẩn tương tác của tút (vd: số lượt thích)",
+ "import_blocks_from_a_csv_file": "Nhập danh sách chặn từ tập tin CSV",
+ "invalid_theme_imported": "Tập tin đã chọn không hỗ trợ bởi Pleroma. Giao diện của bạn sẽ giữ nguyên.",
+ "notification_visibility": "Những loại thông báo sẽ hiện",
+ "notification_visibility_likes": "Thích",
+ "no_rich_text_description": "Không hiện rich text trong các tút",
+ "hide_follows_count_description": "Ẩn số lượng người tôi theo dõi",
+ "nsfw_clickthrough": "Cho phép nhấn vào xem các tút nhạy cảm",
+ "reply_visibility_following": "Chỉ hiện những trả lời có nhắc tới tôi hoặc từ những người mà tôi theo dõi"
+ },
+ "errors": {
+ "storage_unavailable": "Pleroma không thể truy cập lưu trữ trình duyệt. Thông tin đăng nhập và những thiết lập tạm thời sẽ bị mất. Hãy cho phép cookies."
+ }
+}
diff --git a/src/i18n/zh.json b/src/i18n/zh.json
@@ -43,7 +43,10 @@
"role": {
"moderator": "监察员",
"admin": "管理员"
- }
+ },
+ "flash_content": "点击以使用 Ruffle 显示 Flash 内容(实验性,可能无效)。",
+ "flash_security": "注意这可能有潜在的危险,因为 Flash 内容仍然是任意的代码。",
+ "flash_fail": "Flash 内容加载失败,请在控制台查看详情。"
},
"image_cropper": {
"crop_picture": "裁剪图片",
@@ -584,7 +587,9 @@
"backup_settings_theme": "备份设置和主题到文件",
"backup_settings": "备份设置到文件",
"backup_restore": "设置备份"
- }
+ },
+ "right_sidebar": "在右侧显示侧边栏",
+ "hide_shoutbox": "隐藏实例留言板"
},
"time": {
"day": "{0} 天",
@@ -672,7 +677,6 @@
"follow": "关注",
"follow_sent": "请求已发送!",
"follow_progress": "请求中…",
- "follow_again": "再次发送请求?",
"follow_unfollow": "取消关注",
"followees": "正在关注",
"followers": "关注者",
@@ -724,7 +728,8 @@
"striped": "条纹背景",
"solid": "单一颜色背景",
"disabled": "不突出显示"
- }
+ },
+ "edit_profile": "编辑个人资料"
},
"user_profile": {
"timeline_title": "用户时间线",
diff --git a/src/i18n/zh_Hant.json b/src/i18n/zh_Hant.json
@@ -115,7 +115,10 @@
"role": {
"moderator": "主持人",
"admin": "管理員"
- }
+ },
+ "flash_content": "點擊以使用 Ruffle 顯示 Flash 內容(實驗性,可能無效)。",
+ "flash_security": "請注意,這可能有潜在的危險,因為Flash內容仍然是武斷的程式碼。",
+ "flash_fail": "無法加載flash內容,請參閱控制台瞭解詳細資訊。"
},
"finder": {
"find_user": "尋找用戶",
@@ -556,7 +559,9 @@
"backup_settings": "備份設置到文件",
"backup_restore": "設定備份"
},
- "sensitive_by_default": "默認標記發文為敏感內容"
+ "sensitive_by_default": "默認標記發文為敏感內容",
+ "right_sidebar": "在右側顯示側邊欄",
+ "hide_shoutbox": "隱藏實例留言框"
},
"chats": {
"more": "更多",
@@ -766,7 +771,6 @@
"follow": "關注",
"follow_sent": "請求已發送!",
"follow_progress": "請求中…",
- "follow_again": "再次發送請求?",
"follow_unfollow": "取消關注",
"followees": "正在關注",
"followers": "關注者",
@@ -797,7 +801,8 @@
"striped": "條紋背景",
"side": "彩條"
},
- "bot": "機器人"
+ "bot": "機器人",
+ "edit_profile": "編輯個人資料"
},
"user_profile": {
"timeline_title": "用戶時間線",
diff --git a/src/modules/config.js b/src/modules/config.js
@@ -35,6 +35,7 @@ export const defaultState = {
loopVideoSilentOnly: true,
streaming: false,
emojiReactionsOnTimeline: true,
+ alwaysShowNewPostButton: false,
autohideFloatingPostButton: false,
pauseOnUnfocused: true,
stopGifs: false,
diff --git a/src/modules/users.js b/src/modules/users.js
@@ -246,6 +246,11 @@ export const getters = {
}
return result
},
+ findUserByUrl: state => query => {
+ return state.users
+ .find(u => u.statusnet_profile_url &&
+ u.statusnet_profile_url.toLowerCase() === query.toLowerCase())
+ },
relationship: state => id => {
const rel = id && state.relationships[id]
return rel || { id, loading: true }
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -54,17 +54,20 @@ export const parseUser = (data) => {
return output
}
- output.name = data.display_name
- output.name_html = addEmojis(escape(data.display_name), data.emojis)
+ output.emoji = data.emojis
+ output.name = escape(data.display_name)
+ output.name_html = output.name
+ output.name_unescaped = data.display_name
output.description = data.note
- output.description_html = addEmojis(data.note, data.emojis)
+ // TODO cleanup this shit, output.description is overriden with source data
+ output.description_html = data.note
output.fields = data.fields
output.fields_html = data.fields.map(field => {
return {
- name: addEmojis(escape(field.name), data.emojis),
- value: addEmojis(field.value, data.emojis)
+ name: escape(field.name),
+ value: field.value
}
})
output.fields_text = data.fields.map(field => {
@@ -239,16 +242,6 @@ export const parseAttachment = (data) => {
return output
}
-export const addEmojis = (string, emojis) => {
- const matchOperatorsRegex = /[|\\{}()[\]^$+*?.-]/g
- return emojis.reduce((acc, emoji) => {
- const regexSafeShortCode = emoji.shortcode.replace(matchOperatorsRegex, '\\$&')
- return acc.replace(
- new RegExp(`:${regexSafeShortCode}:`, 'g'),
- `<img src='${emoji.url}' alt=':${emoji.shortcode}:' title=':${emoji.shortcode}:' class='emoji' />`
- )
- }, string)
-}
export const parseStatus = (data) => {
const output = {}
@@ -266,7 +259,8 @@ export const parseStatus = (data) => {
output.type = data.reblog ? 'retweet' : 'status'
output.nsfw = data.sensitive
- output.statusnet_html = addEmojis(data.content, data.emojis)
+ output.raw_html = data.content
+ output.emojis = data.emojis
output.tags = data.tags
@@ -293,13 +287,13 @@ export const parseStatus = (data) => {
output.retweeted_status = parseStatus(data.reblog)
}
- output.summary_html = addEmojis(escape(data.spoiler_text), data.emojis)
+ output.summary_raw_html = escape(data.spoiler_text)
output.external_url = data.url
output.poll = data.poll
if (output.poll) {
output.poll.options = (output.poll.options || []).map(field => ({
...field,
- title_html: addEmojis(escape(field.title), data.emojis)
+ title_html: escape(field.title)
}))
}
output.pinned = data.pinned
@@ -325,7 +319,7 @@ export const parseStatus = (data) => {
output.nsfw = data.nsfw
}
- output.statusnet_html = data.statusnet_html
+ output.raw_html = data.statusnet_html
output.text = data.text
output.in_reply_to_status_id = data.in_reply_to_status_id
@@ -444,11 +438,8 @@ export const parseChatMessage = (message) => {
output.id = message.id
output.created_at = new Date(message.created_at)
output.chat_id = message.chat_id
- if (message.content) {
- output.content = addEmojis(message.content, message.emojis)
- } else {
- output.content = ''
- }
+ output.emojis = message.emojis
+ output.content = message.content
if (message.attachment) {
output.attachments = [parseAttachment(message.attachment)]
} else {
diff --git a/src/services/favicon_service/favicon_service.js b/src/services/favicon_service/favicon_service.js
@@ -1,52 +1,58 @@
-import { find } from 'lodash'
-
const createFaviconService = () => {
- let favimg, favcanvas, favcontext, favicon
+ const favicons = []
const faviconWidth = 128
const faviconHeight = 128
const badgeRadius = 32
const initFaviconService = () => {
- const nodes = document.getElementsByTagName('link')
- favicon = find(nodes, node => node.rel === 'icon')
- if (favicon) {
- favcanvas = document.createElement('canvas')
- favcanvas.width = faviconWidth
- favcanvas.height = faviconHeight
- favimg = new Image()
- favimg.src = favicon.href
- favcontext = favcanvas.getContext('2d')
- }
+ const nodes = document.querySelectorAll('link[rel="icon"]')
+ nodes.forEach(favicon => {
+ if (favicon) {
+ const favcanvas = document.createElement('canvas')
+ favcanvas.width = faviconWidth
+ favcanvas.height = faviconHeight
+ const favimg = new Image()
+ favimg.crossOrigin = 'anonymous'
+ favimg.src = favicon.href
+ const favcontext = favcanvas.getContext('2d')
+ favicons.push({ favcanvas, favimg, favcontext, favicon })
+ }
+ })
}
const isImageLoaded = (img) => img.complete && img.naturalHeight !== 0
const clearFaviconBadge = () => {
- if (!favimg || !favcontext || !favicon) return
+ if (favicons.length === 0) return
+ favicons.forEach(({ favimg, favcanvas, favcontext, favicon }) => {
+ if (!favimg || !favcontext || !favicon) return
- favcontext.clearRect(0, 0, faviconWidth, faviconHeight)
- if (isImageLoaded(favimg)) {
- favcontext.drawImage(favimg, 0, 0, favimg.width, favimg.height, 0, 0, faviconWidth, faviconHeight)
- }
- favicon.href = favcanvas.toDataURL('image/png')
+ favcontext.clearRect(0, 0, faviconWidth, faviconHeight)
+ if (isImageLoaded(favimg)) {
+ favcontext.drawImage(favimg, 0, 0, favimg.width, favimg.height, 0, 0, faviconWidth, faviconHeight)
+ }
+ favicon.href = favcanvas.toDataURL('image/png')
+ })
}
const drawFaviconBadge = () => {
- if (!favimg || !favcontext || !favcontext) return
-
+ if (favicons.length === 0) return
clearFaviconBadge()
+ favicons.forEach(({ favimg, favcanvas, favcontext, favicon }) => {
+ if (!favimg || !favcontext || !favcontext) return
+
+ const style = getComputedStyle(document.body)
+ const badgeColor = `${style.getPropertyValue('--badgeNotification') || 'rgb(240, 100, 100)'}`
- const style = getComputedStyle(document.body)
- const badgeColor = `${style.getPropertyValue('--badgeNotification') || 'rgb(240, 100, 100)'}`
-
- if (isImageLoaded(favimg)) {
- favcontext.drawImage(favimg, 0, 0, favimg.width, favimg.height, 0, 0, faviconWidth, faviconHeight)
- }
- favcontext.fillStyle = badgeColor
- favcontext.beginPath()
- favcontext.arc(faviconWidth - badgeRadius, badgeRadius, badgeRadius, 0, 2 * Math.PI, false)
- favcontext.fill()
- favicon.href = favcanvas.toDataURL('image/png')
+ if (isImageLoaded(favimg)) {
+ favcontext.drawImage(favimg, 0, 0, favimg.width, favimg.height, 0, 0, faviconWidth, faviconHeight)
+ }
+ favcontext.fillStyle = badgeColor
+ favcontext.beginPath()
+ favcontext.arc(faviconWidth - badgeRadius, badgeRadius, badgeRadius, 0, 2 * Math.PI, false)
+ favcontext.fill()
+ favicon.href = favcanvas.toDataURL('image/png')
+ })
}
return {
diff --git a/src/services/html_converter/html_line_converter.service.js b/src/services/html_converter/html_line_converter.service.js
@@ -0,0 +1,136 @@
+import { getTagName } from './utility.service.js'
+
+/**
+ * This is a tiny purpose-built HTML parser/processor. This basically detects
+ * any type of visual newline and converts entire HTML into a array structure.
+ *
+ * Text nodes are represented as object with single property - text - containing
+ * the visual line. Intended usage is to process the array with .map() in which
+ * map function returns a string and resulting array can be converted back to html
+ * with a .join('').
+ *
+ * Generally this isn't very useful except for when you really need to either
+ * modify visual lines (greentext i.e. simple quoting) or do something with
+ * first/last line.
+ *
+ * known issue: doesn't handle CDATA so nested CDATA might not work well
+ *
+ * @param {Object} input - input data
+ * @return {(string|{ text: string })[]} processed html in form of a list.
+ */
+export const convertHtmlToLines = (html = '') => {
+ // Elements that are implicitly self-closing
+ // https://developer.mozilla.org/en-US/docs/Glossary/empty_element
+ const emptyElements = new Set([
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
+ 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'
+ ])
+ // Block-level element (they make a visual line)
+ // https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements
+ const blockElements = new Set([
+ 'address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'dd',
+ 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form',
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li', 'main',
+ 'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul'
+ ])
+ // br is very weird in a way that it's technically not block-level, it's
+ // essentially converted to a \n (or \r\n). There's also wbr but it doesn't
+ // guarantee linebreak, only suggest it.
+ const linebreakElements = new Set(['br'])
+
+ const visualLineElements = new Set([
+ ...blockElements.values(),
+ ...linebreakElements.values()
+ ])
+
+ // All block-level elements that aren't empty elements, i.e. not <hr>
+ const nonEmptyElements = new Set(visualLineElements)
+ // Difference
+ for (let elem of emptyElements) {
+ nonEmptyElements.delete(elem)
+ }
+
+ // All elements that we are recognizing
+ const allElements = new Set([
+ ...nonEmptyElements.values(),
+ ...emptyElements.values()
+ ])
+
+ let buffer = [] // Current output buffer
+ const level = [] // How deep we are in tags and which tags were there
+ let textBuffer = '' // Current line content
+ let tagBuffer = null // Current tag buffer, if null = we are not currently reading a tag
+
+ const flush = () => { // Processes current line buffer, adds it to output buffer and clears line buffer
+ if (textBuffer.trim().length > 0) {
+ buffer.push({ level: [...level], text: textBuffer })
+ } else {
+ buffer.push(textBuffer)
+ }
+ textBuffer = ''
+ }
+
+ const handleBr = (tag) => { // handles single newlines/linebreaks/selfclosing
+ flush()
+ buffer.push(tag)
+ }
+
+ const handleOpen = (tag) => { // handles opening tags
+ flush()
+ buffer.push(tag)
+ level.unshift(getTagName(tag))
+ }
+
+ const handleClose = (tag) => { // handles closing tags
+ if (level[0] === getTagName(tag)) {
+ flush()
+ buffer.push(tag)
+ level.shift()
+ } else { // Broken case
+ textBuffer += tag
+ }
+ }
+
+ for (let i = 0; i < html.length; i++) {
+ const char = html[i]
+ if (char === '<' && tagBuffer === null) {
+ tagBuffer = char
+ } else if (char !== '>' && tagBuffer !== null) {
+ tagBuffer += char
+ } else if (char === '>' && tagBuffer !== null) {
+ tagBuffer += char
+ const tagFull = tagBuffer
+ tagBuffer = null
+ const tagName = getTagName(tagFull)
+ if (allElements.has(tagName)) {
+ if (linebreakElements.has(tagName)) {
+ handleBr(tagFull)
+ } else if (nonEmptyElements.has(tagName)) {
+ if (tagFull[1] === '/') {
+ handleClose(tagFull)
+ } else if (tagFull[tagFull.length - 2] === '/') {
+ // self-closing
+ handleBr(tagFull)
+ } else {
+ handleOpen(tagFull)
+ }
+ } else {
+ textBuffer += tagFull
+ }
+ } else {
+ textBuffer += tagFull
+ }
+ } else if (char === '\n') {
+ handleBr(char)
+ } else {
+ textBuffer += char
+ }
+ }
+ if (tagBuffer) {
+ textBuffer += tagBuffer
+ }
+
+ flush()
+
+ return buffer
+}
diff --git a/src/services/html_converter/html_tree_converter.service.js b/src/services/html_converter/html_tree_converter.service.js
@@ -0,0 +1,97 @@
+import { getTagName } from './utility.service.js'
+
+/**
+ * This is a not-so-tiny purpose-built HTML parser/processor. This parses html
+ * and converts it into a tree structure representing tag openers/closers and
+ * children.
+ *
+ * Structure follows this pattern: [opener, [...children], closer] except root
+ * node which is just [...children]. Text nodes can only be within children and
+ * are represented as strings.
+ *
+ * Intended use is to convert HTML structure and then recursively iterate over it
+ * most likely using a map. Very useful for dynamically rendering html replacing
+ * tags with JSX elements in a render function.
+ *
+ * known issue: doesn't handle CDATA so CDATA might not work well
+ * known issue: doesn't handle HTML comments
+ *
+ * @param {Object} input - input data
+ * @return {string} processed html
+ */
+export const convertHtmlToTree = (html = '') => {
+ // Elements that are implicitly self-closing
+ // https://developer.mozilla.org/en-US/docs/Glossary/empty_element
+ const emptyElements = new Set([
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
+ 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'
+ ])
+ // TODO For future - also parse HTML5 multi-source components?
+
+ const buffer = [] // Current output buffer
+ const levels = [['', buffer]] // How deep we are in tags and which tags were there
+ let textBuffer = '' // Current line content
+ let tagBuffer = null // Current tag buffer, if null = we are not currently reading a tag
+
+ const getCurrentBuffer = () => {
+ return levels[levels.length - 1][1]
+ }
+
+ const flushText = () => { // Processes current line buffer, adds it to output buffer and clears line buffer
+ if (textBuffer === '') return
+ getCurrentBuffer().push(textBuffer)
+ textBuffer = ''
+ }
+
+ const handleSelfClosing = (tag) => {
+ getCurrentBuffer().push([tag])
+ }
+
+ const handleOpen = (tag) => {
+ const curBuf = getCurrentBuffer()
+ const newLevel = [tag, []]
+ levels.push(newLevel)
+ curBuf.push(newLevel)
+ }
+
+ const handleClose = (tag) => {
+ const currentTag = levels[levels.length - 1]
+ if (getTagName(levels[levels.length - 1][0]) === getTagName(tag)) {
+ currentTag.push(tag)
+ levels.pop()
+ } else {
+ getCurrentBuffer().push(tag)
+ }
+ }
+
+ for (let i = 0; i < html.length; i++) {
+ const char = html[i]
+ if (char === '<' && tagBuffer === null) {
+ flushText()
+ tagBuffer = char
+ } else if (char !== '>' && tagBuffer !== null) {
+ tagBuffer += char
+ } else if (char === '>' && tagBuffer !== null) {
+ tagBuffer += char
+ const tagFull = tagBuffer
+ tagBuffer = null
+ const tagName = getTagName(tagFull)
+ if (tagFull[1] === '/') {
+ handleClose(tagFull)
+ } else if (emptyElements.has(tagName) || tagFull[tagFull.length - 2] === '/') {
+ // self-closing
+ handleSelfClosing(tagFull)
+ } else {
+ handleOpen(tagFull)
+ }
+ } else {
+ textBuffer += char
+ }
+ }
+ if (tagBuffer) {
+ textBuffer += tagBuffer
+ }
+
+ flushText()
+ return buffer
+}
diff --git a/src/services/html_converter/utility.service.js b/src/services/html_converter/utility.service.js
@@ -0,0 +1,73 @@
+/**
+ * Extract tag name from tag opener/closer.
+ *
+ * @param {String} tag - tag string, i.e. '<a href="...">'
+ * @return {String} - tagname, i.e. "div"
+ */
+export const getTagName = (tag) => {
+ const result = /(?:<\/(\w+)>|<(\w+)\s?.*?\/?>)/gi.exec(tag)
+ return result && (result[1] || result[2])
+}
+
+/**
+ * Extract attributes from tag opener.
+ *
+ * @param {String} tag - tag string, i.e. '<a href="...">'
+ * @return {Object} - map of attributes key = attribute name, value = attribute value
+ * attributes without values represented as boolean true
+ */
+export const getAttrs = tag => {
+ const innertag = tag
+ .substring(1, tag.length - 1)
+ .replace(new RegExp('^' + getTagName(tag)), '')
+ .replace(/\/?$/, '')
+ .trim()
+ const attrs = Array.from(innertag.matchAll(/([a-z0-9-]+)(?:=("[^"]+?"|'[^']+?'))?/gi))
+ .map(([trash, key, value]) => [key, value])
+ .map(([k, v]) => {
+ if (!v) return [k, true]
+ return [k, v.substring(1, v.length - 1)]
+ })
+ return Object.fromEntries(attrs)
+}
+
+/**
+ * Finds shortcodes in text
+ *
+ * @param {String} text - original text to find emojis in
+ * @param {{ url: String, shortcode: Sring }[]} emoji - list of shortcodes to find
+ * @param {Function} processor - function to call on each encountered emoji,
+ * function is passed single object containing matching emoji ({ url, shortcode })
+ * return value will be inserted into resulting array instead of :shortcode:
+ * @return {Array} resulting array with non-emoji parts of text and whatever {processor}
+ * returned for emoji
+ */
+export const processTextForEmoji = (text, emojis, processor) => {
+ const buffer = []
+ let textBuffer = ''
+ for (let i = 0; i < text.length; i++) {
+ const char = text[i]
+ if (char === ':') {
+ const next = text.slice(i + 1)
+ let found = false
+ for (let emoji of emojis) {
+ if (next.slice(0, emoji.shortcode.length + 1) === (emoji.shortcode + ':')) {
+ found = emoji
+ break
+ }
+ }
+ if (found) {
+ buffer.push(textBuffer)
+ textBuffer = ''
+ buffer.push(processor(found))
+ i += found.shortcode.length + 1
+ } else {
+ textBuffer += char
+ }
+ } else {
+ textBuffer += char
+ }
+ }
+ if (textBuffer) buffer.push(textBuffer)
+ return buffer
+}
diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js
@@ -369,6 +369,12 @@ export const SLOT_INHERITANCE = {
textColor: 'preserve'
},
+ postCyantext: {
+ depends: ['cBlue'],
+ layer: 'bg',
+ textColor: 'preserve'
+ },
+
border: {
depends: ['fg'],
opacity: 'border',
diff --git a/src/services/tiny_post_html_processor/tiny_post_html_processor.service.js b/src/services/tiny_post_html_processor/tiny_post_html_processor.service.js
@@ -1,94 +0,0 @@
-/**
- * This is a tiny purpose-built HTML parser/processor. This basically detects any type of visual newline and
- * allows it to be processed, useful for greentexting, mostly
- *
- * known issue: doesn't handle CDATA so nested CDATA might not work well
- *
- * @param {Object} input - input data
- * @param {(string) => string} processor - function that will be called on every line
- * @return {string} processed html
- */
-export const processHtml = (html, processor) => {
- const handledTags = new Set(['p', 'br', 'div'])
- const openCloseTags = new Set(['p', 'div'])
-
- let buffer = '' // Current output buffer
- const level = [] // How deep we are in tags and which tags were there
- let textBuffer = '' // Current line content
- let tagBuffer = null // Current tag buffer, if null = we are not currently reading a tag
-
- // Extracts tag name from tag, i.e. <span a="b"> => span
- const getTagName = (tag) => {
- const result = /(?:<\/(\w+)>|<(\w+)\s?[^/]*?\/?>)/gi.exec(tag)
- return result && (result[1] || result[2])
- }
-
- const flush = () => { // Processes current line buffer, adds it to output buffer and clears line buffer
- if (textBuffer.trim().length > 0) {
- buffer += processor(textBuffer)
- } else {
- buffer += textBuffer
- }
- textBuffer = ''
- }
-
- const handleBr = (tag) => { // handles single newlines/linebreaks/selfclosing
- flush()
- buffer += tag
- }
-
- const handleOpen = (tag) => { // handles opening tags
- flush()
- buffer += tag
- level.push(tag)
- }
-
- const handleClose = (tag) => { // handles closing tags
- flush()
- buffer += tag
- if (level[level.length - 1] === tag) {
- level.pop()
- }
- }
-
- for (let i = 0; i < html.length; i++) {
- const char = html[i]
- if (char === '<' && tagBuffer === null) {
- tagBuffer = char
- } else if (char !== '>' && tagBuffer !== null) {
- tagBuffer += char
- } else if (char === '>' && tagBuffer !== null) {
- tagBuffer += char
- const tagFull = tagBuffer
- tagBuffer = null
- const tagName = getTagName(tagFull)
- if (handledTags.has(tagName)) {
- if (tagName === 'br') {
- handleBr(tagFull)
- } else if (openCloseTags.has(tagName)) {
- if (tagFull[1] === '/') {
- handleClose(tagFull)
- } else if (tagFull[tagFull.length - 2] === '/') {
- // self-closing
- handleBr(tagFull)
- } else {
- handleOpen(tagFull)
- }
- }
- } else {
- textBuffer += tagFull
- }
- } else if (char === '\n') {
- handleBr(char)
- } else {
- textBuffer += char
- }
- }
- if (tagBuffer) {
- textBuffer += tagBuffer
- }
-
- flush()
-
- return buffer
-}
diff --git a/src/services/user_highlighter/user_highlighter.js b/src/services/user_highlighter/user_highlighter.js
@@ -8,6 +8,11 @@ const highlightStyle = (prefs) => {
const solidColor = `rgb(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)})`
const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .1)`
const tintColor2 = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .2)`
+ const customProps = {
+ '--____highlight-solidColor': solidColor,
+ '--____highlight-tintColor': tintColor,
+ '--____highlight-tintColor2': tintColor2
+ }
if (type === 'striped') {
return {
backgroundImage: [
@@ -17,11 +22,13 @@ const highlightStyle = (prefs) => {
`${tintColor2} 20px,`,
`${tintColor2} 40px`
].join(' '),
- backgroundPosition: '0 0'
+ backgroundPosition: '0 0',
+ ...customProps
}
} else if (type === 'solid') {
return {
- backgroundColor: tintColor2
+ backgroundColor: tintColor2,
+ ...customProps
}
} else if (type === 'side') {
return {
@@ -31,7 +38,8 @@ const highlightStyle = (prefs) => {
`${solidColor} 2px,`,
`transparent 6px`
].join(' '),
- backgroundPosition: '0 0'
+ backgroundPosition: '0 0',
+ ...customProps
}
}
}
diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
@@ -0,0 +1,480 @@
+import { mount, shallowMount, createLocalVue } from '@vue/test-utils'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
+
+const localVue = createLocalVue()
+const attentions = []
+
+const makeMention = (who) => {
+ attentions.push({ statusnet_profile_url: `https://fake.tld/@${who}` })
+ return `<span class="h-card"><a class="u-url mention" href="https://fake.tld/@${who}">@<span>${who}</span></a></span>`
+}
+const p = (...data) => `<p>${data.join('')}</p>`
+const compwrap = (...data) => `<span class="RichContent">${data.join('')}</span>`
+const mentionsLine = (times) => [
+ '<mentionsline-stub mentions="',
+ new Array(times).fill('[object Object]').join(','),
+ '"></mentionsline-stub>'
+].join('')
+
+describe('RichContent', () => {
+ it('renders simple post without exploding', () => {
+ const html = p('Hello world!')
+ const wrapper = shallowMount(RichContent, {
+ localVue,
+ propsData: {
+ attentions,
+ handleLinks: true,
+ greentext: true,
+ emoji: [],
+ html
+ }
+ })
+
+ expect(wrapper.html()).to.eql(compwrap(html))
+ })
+
+ it('unescapes everything as needed', () => {
+ const html = [
+ p('Testing 'em all'),
+ 'Testing 'em all'
+ ].join('')
+ const expected = [
+ p('Testing \'em all'),
+ 'Testing \'em all'
+ ].join('')
+ const wrapper = shallowMount(RichContent, {
+ localVue,
+ propsData: {
+ attentions,
+ handleLinks: true,
+ greentext: true,
+ emoji: [],
+ html
+ }
+ })
+
+ expect(wrapper.html()).to.eql(compwrap(expected))
+ })
+
+ it('replaces mention with mentionsline', () => {
+ const html = p(
+ makeMention('John'),
+ ' how are you doing today?'
+ )
+ const wrapper = shallowMount(RichContent, {
+ localVue,
+ propsData: {
+ attentions,
+ handleLinks: true,
+ greentext: true,
+ emoji: [],
+ html
+ }
+ })
+
+ expect(wrapper.html()).to.eql(compwrap(p(
+ mentionsLine(1),
+ ' how are you doing today?'
+ )))
+ })
+
+ it('replaces mentions at the end of the hellpost', () => {
+ const html = [
+ p('How are you doing today, fine gentlemen?'),
+ p(
+ makeMention('John'),
+ makeMention('Josh'),
+ makeMention('Jeremy')
+ )
+ ].join('')
+ const expected = [
+ p(
+ 'How are you doing today, fine gentlemen?'
+ ),
+ // TODO fix this extra line somehow?
+ p(
+ '<mentionsline-stub mentions="',
+ '[object Object],',
+ '[object Object],',
+ '[object Object]',
+ '"></mentionsline-stub>'
+ )
+ ].join('')
+
+ const wrapper = shallowMount(RichContent, {
+ localVue,
+ propsData: {
+ attentions,
+ handleLinks: true,
+ greentext: true,
+ emoji: [],
+ html
+ }
+ })
+
+ expect(wrapper.html()).to.eql(compwrap(expected))
+ })
+
+ it('Does not touch links if link handling is disabled', () => {
+ const html = [
+ [
+ makeMention('Jack'),
+ 'let\'s meet up with ',
+ makeMention('Janet')
+ ].join(''),
+ [
+ makeMention('John'),
+ makeMention('Josh'),
+ makeMention('Jeremy')
+ ].join('')
+ ].join('\n')
+
+ const wrapper = shallowMount(RichContent, {
+ localVue,
+ propsData: {
+ attentions,
+ handleLinks: false,
+ greentext: true,
+ emoji: [],
+ html
+ }
+ })
+
+ expect(wrapper.html()).to.eql(compwrap(html))
+ })
+
+ it('Adds greentext and cyantext to the post', () => {
+ const html = [
+ '>preordering videogames',
+ '>any year'
+ ].join('\n')
+ const expected = [
+ '<span class="greentext">>preordering videogames</span>',
+ '<span class="greentext">>any year</span>'
+ ].join('\n')
+
+ const wrapper = shallowMount(RichContent, {
+ localVue,
+ propsData: {
+ attentions,
+ handleLinks: false,
+ greentext: true,
+ emoji: [],
+ html
+ }
+ })
+
+ expect(wrapper.html()).to.eql(compwrap(expected))
+ })
+
+ it('Does not add greentext and cyantext if setting is set to false', () => {
+ const html = [
+ '>preordering videogames',
+ '>any year'
+ ].join('\n')
+
+ const wrapper = shallowMount(RichContent, {
+ localVue,
+ propsData: {
+ attentions,
+ handleLinks: false,
+ greentext: false,
+ emoji: [],
+ html
+ }
+ })
+
+ expect(wrapper.html()).to.eql(compwrap(html))
+ })
+
+ it('Adds emoji to post', () => {
+ const html = p('Ebin :DDDD :spurdo:')
+ const expected = p(
+ 'Ebin :DDDD ',
+ '<anonymous-stub alt=":spurdo:" src="about:blank" title=":spurdo:" class="emoji img"></anonymous-stub>'
+ )
+
+ const wrapper = shallowMount(RichContent, {
+ localVue,
+ propsData: {
+ attentions,
+ handleLinks: false,
+ greentext: false,
+ emoji: [{ url: 'about:blank', shortcode: 'spurdo' }],
+ html
+ }
+ })
+
+ expect(wrapper.html()).to.eql(compwrap(expected))
+ })
+
+ it('Doesn\'t add nonexistent emoji to post', () => {
+ const html = p('Lol :lol:')
+
+ const wrapper = shallowMount(RichContent, {
+ localVue,
+ propsData: {
+ attentions,
+ handleLinks: false,
+ greentext: false,
+ emoji: [],
+ html
+ }
+ })
+
+ expect(wrapper.html()).to.eql(compwrap(html))
+ })
+
+ it('Greentext + last mentions', () => {
+ const html = [
+ '>quote',
+ makeMention('lol'),
+ '>quote',
+ '>quote'
+ ].join('\n')
+ const expected = [
+ '<span class="greentext">>quote</span>',
+ mentionsLine(1),
+ '<span class="greentext">>quote</span>',
+ '<span class="greentext">>quote</span>'
+ ].join('\n')
+
+ const wrapper = shallowMount(RichContent, {
+ localVue,
+ propsData: {
+ attentions,
+ handleLinks: true,
+ greentext: true,
+ emoji: [],
+ html
+ }
+ })
+
+ expect(wrapper.html()).to.eql(compwrap(expected))
+ })
+
+ it('One buggy example', () => {
+ const html = [
+ 'Bruh',
+ 'Bruh',
+ [
+ makeMention('foo'),
+ makeMention('bar'),
+ makeMention('baz')
+ ].join(''),
+ 'Bruh'
+ ].join('<br>')
+ const expected = [
+ 'Bruh',
+ 'Bruh',
+ mentionsLine(3),
+ 'Bruh'
+ ].join('<br>')
+
+ const wrapper = shallowMount(RichContent, {
+ localVue,
+ propsData: {
+ attentions,
+ handleLinks: true,
+ greentext: true,
+ emoji: [],
+ html
+ }
+ })
+
+ expect(wrapper.html()).to.eql(compwrap(expected))
+ })
+
+ it('buggy example/hashtags', () => {
+ const html = [
+ '<p>',
+ '<a href="http://macrochan.org/images/N/H/NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg">',
+ 'NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg</a>',
+ ' <a class="hashtag" data-tag="nou" href="https://shitposter.club/tag/nou">',
+ '#nou</a>',
+ ' <a class="hashtag" data-tag="screencap" href="https://shitposter.club/tag/screencap">',
+ '#screencap</a>',
+ ' </p>'
+ ].join('')
+ const expected = [
+ '<p>',
+ '<a href="http://macrochan.org/images/N/H/NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg" target="_blank">',
+ 'NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg</a>',
+ ' <hashtaglink-stub url="https://shitposter.club/tag/nou" content="#nou" tag="nou">',
+ '</hashtaglink-stub>',
+ ' <hashtaglink-stub url="https://shitposter.club/tag/screencap" content="#screencap" tag="screencap">',
+ '</hashtaglink-stub>',
+ ' </p>'
+ ].join('')
+
+ const wrapper = shallowMount(RichContent, {
+ localVue,
+ propsData: {
+ attentions,
+ handleLinks: true,
+ greentext: true,
+ emoji: [],
+ html
+ }
+ })
+
+ expect(wrapper.html()).to.eql(compwrap(expected))
+ })
+
+ it('rich contents of a mention are handled properly', () => {
+ attentions.push({ statusnet_profile_url: 'lol' })
+ const html = [
+ p(
+ '<a href="lol" class="mention">',
+ '<span>',
+ 'https://</span>',
+ '<span>',
+ 'lol.tld/</span>',
+ '<span>',
+ '</span>',
+ '</a>'
+ ),
+ p(
+ 'Testing'
+ )
+ ].join('')
+ const expected = [
+ p(
+ '<span class="MentionsLine">',
+ '<span class="MentionLink mention-link">',
+ '<a href="lol" target="_blank" class="original">',
+ '<span>',
+ 'https://</span>',
+ '<span>',
+ 'lol.tld/</span>',
+ '<span>',
+ '</span>',
+ '</a>',
+ ' ',
+ '<!---->', // v-if placeholder, mentionlink's "new" (i.e. rich) display
+ '</span>',
+ '<!---->', // v-if placeholder, mentionsline's extra mentions and stuff
+ '</span>'
+ ),
+ p(
+ 'Testing'
+ )
+ ].join('')
+
+ const wrapper = mount(RichContent, {
+ localVue,
+ propsData: {
+ attentions,
+ handleLinks: true,
+ greentext: true,
+ emoji: [],
+ html
+ }
+ })
+
+ expect(wrapper.html()).to.eql(compwrap(expected))
+ })
+
+ it('rich contents of a link are handled properly', () => {
+ const html = [
+ '<p>',
+ 'Freenode is dead.</p>',
+ '<p>',
+ '<a href="https://isfreenodedeadyet.com/">',
+ '<span>',
+ 'https://</span>',
+ '<span>',
+ 'isfreenodedeadyet.com/</span>',
+ '<span>',
+ '</span>',
+ '</a>',
+ '</p>'
+ ].join('')
+ const expected = [
+ '<p>',
+ 'Freenode is dead.</p>',
+ '<p>',
+ '<a href="https://isfreenodedeadyet.com/" target="_blank">',
+ '<span>',
+ 'https://</span>',
+ '<span>',
+ 'isfreenodedeadyet.com/</span>',
+ '<span>',
+ '</span>',
+ '</a>',
+ '</p>'
+ ].join('')
+
+ const wrapper = shallowMount(RichContent, {
+ localVue,
+ propsData: {
+ attentions,
+ handleLinks: true,
+ greentext: true,
+ emoji: [],
+ html
+ }
+ })
+
+ expect(wrapper.html()).to.eql(compwrap(expected))
+ })
+
+ it.skip('[INFORMATIVE] Performance testing, 10 000 simple posts', () => {
+ const amount = 20
+
+ const onePost = p(
+ makeMention('Lain'),
+ makeMention('Lain'),
+ makeMention('Lain'),
+ makeMention('Lain'),
+ makeMention('Lain'),
+ makeMention('Lain'),
+ makeMention('Lain'),
+ makeMention('Lain'),
+ makeMention('Lain'),
+ makeMention('Lain'),
+ ' i just landed in l a where are you'
+ )
+
+ const TestComponent = {
+ template: `
+ <div v-if="!vhtml">
+ ${new Array(amount).fill(`<RichContent html="${onePost}" :greentext="true" :handleLinks="handeLinks" :emoji="[]" :attentions="attentions"/>`)}
+ </div>
+ <div v-else="vhtml">
+ ${new Array(amount).fill(`<div v-html="${onePost}"/>`)}
+ </div>
+ `,
+ props: ['handleLinks', 'attentions', 'vhtml']
+ }
+ console.log(1)
+
+ const ptest = (handleLinks, vhtml) => {
+ const t0 = performance.now()
+
+ const wrapper = mount(TestComponent, {
+ localVue,
+ propsData: {
+ attentions,
+ handleLinks,
+ vhtml
+ }
+ })
+
+ const t1 = performance.now()
+
+ wrapper.destroy()
+
+ const t2 = performance.now()
+
+ return `Mount: ${t1 - t0}ms, destroy: ${t2 - t1}ms, avg ${(t1 - t0) / amount}ms - ${(t2 - t1) / amount}ms per item`
+ }
+
+ console.log(`${amount} items with links handling:`)
+ console.log(ptest(true))
+ console.log(`${amount} items without links handling:`)
+ console.log(ptest(false))
+ console.log(`${amount} items plain v-html:`)
+ console.log(ptest(false, true))
+ })
+})
diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
@@ -1,4 +1,4 @@
-import { parseStatus, parseUser, parseNotification, addEmojis, parseLinkHeaderPagination } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js'
+import { parseStatus, parseUser, parseNotification, parseLinkHeaderPagination } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js'
import mastoapidata from '../../../../fixtures/mastoapi.json'
import qvitterapidata from '../../../../fixtures/statuses.json'
@@ -23,7 +23,6 @@ const makeMockStatusQvitter = (overrides = {}) => {
repeat_num: 0,
repeated: false,
statusnet_conversation_id: '16300488',
- statusnet_html: '<p>haha benis</p>',
summary: null,
tags: [],
text: 'haha benis',
@@ -232,22 +231,6 @@ describe('API Entities normalizer', () => {
expect(parsedRepeat).to.have.property('retweeted_status')
expect(parsedRepeat).to.have.deep.property('retweeted_status.id', 'deadbeef')
})
-
- it('adds emojis to post content', () => {
- const post = makeMockStatusMasto({ emojis: makeMockEmojiMasto(), content: 'Makes you think :thinking:' })
-
- const parsedPost = parseStatus(post)
-
- expect(parsedPost).to.have.property('statusnet_html').that.contains('<img')
- })
-
- it('adds emojis to subject line', () => {
- const post = makeMockStatusMasto({ emojis: makeMockEmojiMasto(), spoiler_text: 'CW: 300 IQ :thinking:' })
-
- const parsedPost = parseStatus(post)
-
- expect(parsedPost).to.have.property('summary_html').that.contains('<img')
- })
})
})
@@ -261,35 +244,6 @@ describe('API Entities normalizer', () => {
expect(parseUser(remote)).to.have.property('is_local', false)
})
- it('adds emojis to user name', () => {
- const user = makeMockUserMasto({ emojis: makeMockEmojiMasto(), display_name: 'The :thinking: thinker' })
-
- const parsedUser = parseUser(user)
-
- expect(parsedUser).to.have.property('name_html').that.contains('<img')
- })
-
- it('adds emojis to user bio', () => {
- const user = makeMockUserMasto({ emojis: makeMockEmojiMasto(), note: 'Hello i like to :thinking: a lot' })
-
- const parsedUser = parseUser(user)
-
- expect(parsedUser).to.have.property('description_html').that.contains('<img')
- })
-
- it('adds emojis to user profile fields', () => {
- const user = makeMockUserMasto({ emojis: makeMockEmojiMasto(), fields: [{ name: ':thinking:', value: ':image:' }] })
-
- const parsedUser = parseUser(user)
-
- expect(parsedUser).to.have.property('fields_html').to.be.an('array')
-
- const field = parsedUser.fields_html[0]
-
- expect(field).to.have.property('name').that.contains('<img')
- expect(field).to.have.property('value').that.contains('<img')
- })
-
it('removes html tags from user profile fields', () => {
const user = makeMockUserMasto({ emojis: makeMockEmojiMasto(), fields: [{ name: 'user', value: '<a rel="me" href="https://example.com/@user">@user</a>' }] })
@@ -355,41 +309,6 @@ describe('API Entities normalizer', () => {
})
})
- describe('MastoAPI emoji adder', () => {
- const emojis = makeMockEmojiMasto()
- const imageHtml = '<img src="https://example.com/image.png" alt=":image:" title=":image:" class="emoji" />'
- .replace(/"/g, '\'')
- const thinkHtml = '<img src="https://example.com/think.png" alt=":thinking:" title=":thinking:" class="emoji" />'
- .replace(/"/g, '\'')
-
- it('correctly replaces shortcodes in supplied string', () => {
- const result = addEmojis('This post has :image: emoji and :thinking: emoji', emojis)
- expect(result).to.include(thinkHtml)
- expect(result).to.include(imageHtml)
- })
-
- it('handles consecutive emojis correctly', () => {
- const result = addEmojis('Lelel emoji spam :thinking::thinking::thinking::thinking:', emojis)
- expect(result).to.include(thinkHtml + thinkHtml + thinkHtml + thinkHtml)
- })
-
- it('Doesn\'t replace nonexistent emojis', () => {
- const result = addEmojis('Admin add the :tenshi: emoji', emojis)
- expect(result).to.equal('Admin add the :tenshi: emoji')
- })
-
- it('Doesn\'t blow up on regex special characters', () => {
- const emojis = makeMockEmojiMasto([{
- shortcode: 'c++'
- }, {
- shortcode: '[a-z] {|}*'
- }])
- const result = addEmojis('This post has :c++: emoji and :[a-z] {|}*: emoji', emojis)
- expect(result).to.include('title=\':c++:\'')
- expect(result).to.include('title=\':[a-z] {|}*:\'')
- })
- })
-
describe('Link header pagination', () => {
it('Parses min and max ids as integers', () => {
const linkHeader = '<https://example.com/api/v1/notifications?max_id=861676>; rel="next", <https://example.com/api/v1/notifications?min_id=861741>; rel="prev"'
diff --git a/test/unit/specs/services/html_converter/html_line_converter.spec.js b/test/unit/specs/services/html_converter/html_line_converter.spec.js
@@ -0,0 +1,171 @@
+import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
+
+const greentextHandle = new Set(['p', 'div'])
+const mapOnlyText = (processor) => (input) => {
+ if (input.text && input.level.every(l => greentextHandle.has(l))) {
+ return processor(input.text)
+ } else if (input.text) {
+ return input.text
+ } else {
+ return input
+ }
+}
+
+describe('html_line_converter', () => {
+ describe('with processor that keeps original line should not make any changes to HTML when', () => {
+ const processorKeep = (line) => line
+ it('fed with regular HTML with newlines', () => {
+ const inputOutput = '1<br/>2<p class="lol">3 4</p> 5 \n 6 <p > 7 <br> 8 </p> <br>\n<br/>'
+ const result = convertHtmlToLines(inputOutput)
+ const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+ expect(comparableResult).to.eql(inputOutput)
+ })
+
+ it('fed with possibly broken HTML with invalid tags/composition', () => {
+ const inputOutput = '<feeee dwdwddddddw> <i>ayy<b>lm</i>ao</b> </section>'
+ const result = convertHtmlToLines(inputOutput)
+ const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+ expect(comparableResult).to.eql(inputOutput)
+ })
+
+ it('fed with very broken HTML with broken composition', () => {
+ const inputOutput = '</p> lmao what </div> whats going on <div> wha <p>'
+ const result = convertHtmlToLines(inputOutput)
+ const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+ expect(comparableResult).to.eql(inputOutput)
+ })
+
+ it('fed with sorta valid HTML but tags aren\'t closed', () => {
+ const inputOutput = 'just leaving a <div> hanging'
+ const result = convertHtmlToLines(inputOutput)
+ const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+ expect(comparableResult).to.eql(inputOutput)
+ })
+
+ it('fed with not really HTML at this point... tags that aren\'t finished', () => {
+ const inputOutput = 'do you expect me to finish this <div class='
+ const result = convertHtmlToLines(inputOutput)
+ const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+ expect(comparableResult).to.eql(inputOutput)
+ })
+
+ it('fed with dubiously valid HTML (p within p and also div inside p)', () => {
+ const inputOutput = 'look ma <p> p \nwithin <p> p! </p> and a <br/><div>div!</div></p>'
+ const result = convertHtmlToLines(inputOutput)
+ const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+ expect(comparableResult).to.eql(inputOutput)
+ })
+
+ it('fed with maybe valid HTML? self-closing divs and ps', () => {
+ const inputOutput = 'a <div class="what"/> what now <p aria-label="wtf"/> ?'
+ const result = convertHtmlToLines(inputOutput)
+ const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+ expect(comparableResult).to.eql(inputOutput)
+ })
+
+ it('fed with valid XHTML containing a CDATA', () => {
+ const inputOutput = 'Yes, it is me, <![CDATA[DIO]]>'
+ const result = convertHtmlToLines(inputOutput)
+ const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+ expect(comparableResult).to.eql(inputOutput)
+ })
+
+ it('fed with some recognized but not handled elements', () => {
+ const inputOutput = 'testing images\n\n<img src="benis.png">'
+ const result = convertHtmlToLines(inputOutput)
+ const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+ expect(comparableResult).to.eql(inputOutput)
+ })
+ })
+ describe('with processor that replaces lines with word "_" should match expected line when', () => {
+ const processorReplace = (line) => '_'
+ it('fed with regular HTML with newlines', () => {
+ const input = '1<br/>2<p class="lol">3 4</p> 5 \n 6 <p > 7 <br> 8 </p> <br>\n<br/>'
+ const output = '_<br/>_<p class="lol">_</p>_\n_<p >_<br>_</p> <br>\n<br/>'
+ const result = convertHtmlToLines(input)
+ const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+ expect(comparableResult).to.eql(output)
+ })
+
+ it('fed with possibly broken HTML with invalid tags/composition', () => {
+ const input = '<feeee dwdwddddddw> <i>ayy<b>lm</i>ao</b> </section>'
+ const output = '_'
+ const result = convertHtmlToLines(input)
+ const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+ expect(comparableResult).to.eql(output)
+ })
+
+ it('fed with very broken HTML with broken composition', () => {
+ const input = '</p> lmao what </div> whats going on <div> wha <p>'
+ const output = '_<div>_<p>'
+ const result = convertHtmlToLines(input)
+ const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+ expect(comparableResult).to.eql(output)
+ })
+
+ it('fed with sorta valid HTML but tags aren\'t closed', () => {
+ const input = 'just leaving a <div> hanging'
+ const output = '_<div>_'
+ const result = convertHtmlToLines(input)
+ const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+ expect(comparableResult).to.eql(output)
+ })
+
+ it('fed with not really HTML at this point... tags that aren\'t finished', () => {
+ const input = 'do you expect me to finish this <div class='
+ const output = '_'
+ const result = convertHtmlToLines(input)
+ const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+ expect(comparableResult).to.eql(output)
+ })
+
+ it('fed with dubiously valid HTML (p within p and also div inside p)', () => {
+ const input = 'look ma <p> p \nwithin <p> p! </p> and a <br/><div>div!</div></p>'
+ const output = '_<p>_\n_<p>_</p>_<br/><div>_</div></p>'
+ const result = convertHtmlToLines(input)
+ const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+ expect(comparableResult).to.eql(output)
+ })
+
+ it('fed with maybe valid HTML? (XHTML) self-closing divs and ps', () => {
+ const input = 'a <div class="what"/> what now <p aria-label="wtf"/> ?'
+ const output = '_<div class="what"/>_<p aria-label="wtf"/>_'
+ const result = convertHtmlToLines(input)
+ const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+ expect(comparableResult).to.eql(output)
+ })
+
+ it('fed with valid XHTML containing a CDATA', () => {
+ const input = 'Yes, it is me, <![CDATA[DIO]]>'
+ const output = '_'
+ const result = convertHtmlToLines(input)
+ const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+ expect(comparableResult).to.eql(output)
+ })
+
+ it('Testing handling ignored blocks', () => {
+ const input = `
+ <pre><code>> rei = "0"
+ '0'
+ > rei == 0
+ true
+ > rei == null
+ false</code></pre><blockquote>That, christian-like JS diagram but it’s evangelion instead.</blockquote>
+ `
+ const result = convertHtmlToLines(input)
+ const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+ expect(comparableResult).to.eql(input)
+ })
+ it('Testing handling ignored blocks 2', () => {
+ const input = `
+ <blockquote>An SSL error has happened.</blockquote><p>Shakespeare</p>
+ `
+ const output = `
+ <blockquote>An SSL error has happened.</blockquote><p>_</p>
+ `
+ const result = convertHtmlToLines(input)
+ const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+ expect(comparableResult).to.eql(output)
+ })
+ })
+})
diff --git a/test/unit/specs/services/html_converter/html_tree_converter.spec.js b/test/unit/specs/services/html_converter/html_tree_converter.spec.js
@@ -0,0 +1,132 @@
+import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
+
+describe('html_tree_converter', () => {
+ describe('convertHtmlToTree', () => {
+ it('converts html into a tree structure', () => {
+ const input = '1 <p>2</p> <b>3<img src="a">4</b>5'
+ expect(convertHtmlToTree(input)).to.eql([
+ '1 ',
+ [
+ '<p>',
+ ['2'],
+ '</p>'
+ ],
+ ' ',
+ [
+ '<b>',
+ [
+ '3',
+ ['<img src="a">'],
+ '4'
+ ],
+ '</b>'
+ ],
+ '5'
+ ])
+ })
+ it('converts html to tree while preserving tag formatting', () => {
+ const input = '1 <p >2</p><b >3<img src="a">4</b>5'
+ expect(convertHtmlToTree(input)).to.eql([
+ '1 ',
+ [
+ '<p >',
+ ['2'],
+ '</p>'
+ ],
+ [
+ '<b >',
+ [
+ '3',
+ ['<img src="a">'],
+ '4'
+ ],
+ '</b>'
+ ],
+ '5'
+ ])
+ })
+ it('converts semi-broken html', () => {
+ const input = '1 <br> 2 <p> 42'
+ expect(convertHtmlToTree(input)).to.eql([
+ '1 ',
+ ['<br>'],
+ ' 2 ',
+ [
+ '<p>',
+ [' 42']
+ ]
+ ])
+ })
+ it('realistic case 1', () => {
+ const input = '<p><span class="h-card"><a class="u-url mention" data-user="9wRC6T2ZZiKWJ0vUi8" href="https://cawfee.club/users/benis" rel="ugc">@<span>benis</span></a></span> <span class="h-card"><a class="u-url mention" data-user="194" href="https://shigusegubu.club/users/hj" rel="ugc">@<span>hj</span></a></span> nice</p>'
+ expect(convertHtmlToTree(input)).to.eql([
+ [
+ '<p>',
+ [
+ [
+ '<span class="h-card">',
+ [
+ [
+ '<a class="u-url mention" data-user="9wRC6T2ZZiKWJ0vUi8" href="https://cawfee.club/users/benis" rel="ugc">',
+ [
+ '@',
+ [
+ '<span>',
+ [
+ 'benis'
+ ],
+ '</span>'
+ ]
+ ],
+ '</a>'
+ ]
+ ],
+ '</span>'
+ ],
+ ' ',
+ [
+ '<span class="h-card">',
+ [
+ [
+ '<a class="u-url mention" data-user="194" href="https://shigusegubu.club/users/hj" rel="ugc">',
+ [
+ '@',
+ [
+ '<span>',
+ [
+ 'hj'
+ ],
+ '</span>'
+ ]
+ ],
+ '</a>'
+ ]
+ ],
+ '</span>'
+ ],
+ ' nice'
+ ],
+ '</p>'
+ ]
+ ])
+ })
+ it('realistic case 2', () => {
+ const inputOutput = 'Country improv: give me a city<br/>Audience: Memphis<br/>Improv troupe: come on, a better one<br/>Audience: el paso'
+ expect(convertHtmlToTree(inputOutput)).to.eql([
+ 'Country improv: give me a city',
+ [
+ '<br/>'
+ ],
+ 'Audience: Memphis',
+ [
+ '<br/>'
+ ],
+ 'Improv troupe: come on, a better one',
+ [
+ '<br/>'
+ ],
+ 'Audience: el paso'
+ ])
+ })
+ })
+})
diff --git a/test/unit/specs/services/html_converter/utility.spec.js b/test/unit/specs/services/html_converter/utility.spec.js
@@ -0,0 +1,37 @@
+import { processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
+
+describe('html_converter utility', () => {
+ describe('processTextForEmoji', () => {
+ it('processes all emoji in text', () => {
+ const input = 'Hello from finland! :lol: We have best water! :lmao:'
+ const emojis = [
+ { shortcode: 'lol', src: 'LOL' },
+ { shortcode: 'lmao', src: 'LMAO' }
+ ]
+ const processor = ({ shortcode, src }) => ({ shortcode, src })
+ expect(processTextForEmoji(input, emojis, processor)).to.eql([
+ 'Hello from finland! ',
+ { shortcode: 'lol', src: 'LOL' },
+ ' We have best water! ',
+ { shortcode: 'lmao', src: 'LMAO' }
+ ])
+ })
+ it('leaves text as is', () => {
+ const input = 'Number one: that\'s terror'
+ const emojis = []
+ const processor = ({ shortcode, src }) => ({ shortcode, src })
+ expect(processTextForEmoji(input, emojis, processor)).to.eql([
+ 'Number one: that\'s terror'
+ ])
+ })
+ })
+
+ describe('getAttrs', () => {
+ it('extracts arguments from tag', () => {
+ const input = '<img src="boop" cool ebin=\'true\'>'
+ const output = { src: 'boop', cool: true, ebin: 'true' }
+
+ expect(getAttrs(input)).to.eql(output)
+ })
+ })
+})
diff --git a/test/unit/specs/services/tiny_post_html_processor/tiny_post_html_processor.spec.js b/test/unit/specs/services/tiny_post_html_processor/tiny_post_html_processor.spec.js
@@ -1,96 +0,0 @@
-import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
-
-describe('TinyPostHTMLProcessor', () => {
- describe('with processor that keeps original line should not make any changes to HTML when', () => {
- const processorKeep = (line) => line
- it('fed with regular HTML with newlines', () => {
- const inputOutput = '1<br/>2<p class="lol">3 4</p> 5 \n 6 <p > 7 <br> 8 </p> <br>\n<br/>'
- expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
- })
-
- it('fed with possibly broken HTML with invalid tags/composition', () => {
- const inputOutput = '<feeee dwdwddddddw> <i>ayy<b>lm</i>ao</b> </section>'
- expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
- })
-
- it('fed with very broken HTML with broken composition', () => {
- const inputOutput = '</p> lmao what </div> whats going on <div> wha <p>'
- expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
- })
-
- it('fed with sorta valid HTML but tags aren\'t closed', () => {
- const inputOutput = 'just leaving a <div> hanging'
- expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
- })
-
- it('fed with not really HTML at this point... tags that aren\'t finished', () => {
- const inputOutput = 'do you expect me to finish this <div class='
- expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
- })
-
- it('fed with dubiously valid HTML (p within p and also div inside p)', () => {
- const inputOutput = 'look ma <p> p \nwithin <p> p! </p> and a <br/><div>div!</div></p>'
- expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
- })
-
- it('fed with maybe valid HTML? self-closing divs and ps', () => {
- const inputOutput = 'a <div class="what"/> what now <p aria-label="wtf"/> ?'
- expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
- })
-
- it('fed with valid XHTML containing a CDATA', () => {
- const inputOutput = 'Yes, it is me, <![CDATA[DIO]]>'
- expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
- })
- })
- describe('with processor that replaces lines with word "_" should match expected line when', () => {
- const processorReplace = (line) => '_'
- it('fed with regular HTML with newlines', () => {
- const input = '1<br/>2<p class="lol">3 4</p> 5 \n 6 <p > 7 <br> 8 </p> <br>\n<br/>'
- const output = '_<br/>_<p class="lol">_</p>_\n_<p >_<br>_</p> <br>\n<br/>'
- expect(processHtml(input, processorReplace)).to.eql(output)
- })
-
- it('fed with possibly broken HTML with invalid tags/composition', () => {
- const input = '<feeee dwdwddddddw> <i>ayy<b>lm</i>ao</b> </section>'
- const output = '_'
- expect(processHtml(input, processorReplace)).to.eql(output)
- })
-
- it('fed with very broken HTML with broken composition', () => {
- const input = '</p> lmao what </div> whats going on <div> wha <p>'
- const output = '</p>_</div>_<div>_<p>'
- expect(processHtml(input, processorReplace)).to.eql(output)
- })
-
- it('fed with sorta valid HTML but tags aren\'t closed', () => {
- const input = 'just leaving a <div> hanging'
- const output = '_<div>_'
- expect(processHtml(input, processorReplace)).to.eql(output)
- })
-
- it('fed with not really HTML at this point... tags that aren\'t finished', () => {
- const input = 'do you expect me to finish this <div class='
- const output = '_'
- expect(processHtml(input, processorReplace)).to.eql(output)
- })
-
- it('fed with dubiously valid HTML (p within p and also div inside p)', () => {
- const input = 'look ma <p> p \nwithin <p> p! </p> and a <br/><div>div!</div></p>'
- const output = '_<p>_\n_<p>_</p>_<br/><div>_</div></p>'
- expect(processHtml(input, processorReplace)).to.eql(output)
- })
-
- it('fed with maybe valid HTML? self-closing divs and ps', () => {
- const input = 'a <div class="what"/> what now <p aria-label="wtf"/> ?'
- const output = '_<div class="what"/>_<p aria-label="wtf"/>_'
- expect(processHtml(input, processorReplace)).to.eql(output)
- })
-
- it('fed with valid XHTML containing a CDATA', () => {
- const input = 'Yes, it is me, <![CDATA[DIO]]>'
- const output = '_'
- expect(processHtml(input, processorReplace)).to.eql(output)
- })
- })
-})
diff --git a/yarn.lock b/yarn.lock
@@ -1011,23 +1011,86 @@
resolved "https://registry.yarnpkg.com/@ungap/event-target/-/event-target-0.1.0.tgz#88d527d40de86c4b0c99a060ca241d755999915b"
integrity sha512-W2oyj0Fe1w/XhPZjkI3oUcDUAmu5P4qsdT2/2S8aMhtAWM/CE/jYWtji0pKNPDfxLI75fa5gWSEmnynKMNP/oA==
-"@vue/babel-helper-vue-jsx-merge-props@^1.0.0":
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.0.0.tgz#048fe579958da408fb7a8b2a3ec050b50a661040"
- integrity sha512-6tyf5Cqm4m6v7buITuwS+jHzPlIPxbFzEhXR5JGZpbrvOcp1hiQKckd305/3C7C36wFekNTQSxAtgeM0j0yoUw==
+"@vue/babel-helper-vue-jsx-merge-props@^1.2.1":
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.2.1.tgz#31624a7a505fb14da1d58023725a4c5f270e6a81"
+ integrity sha512-QOi5OW45e2R20VygMSNhyQHvpdUwQZqGPc748JLGCYEy+yp8fNFNdbNIGAgZmi9e+2JHPd6i6idRuqivyicIkA==
-"@vue/babel-plugin-transform-vue-jsx@^1.1.2":
- version "1.1.2"
- resolved "https://registry.yarnpkg.com/@vue/babel-plugin-transform-vue-jsx/-/babel-plugin-transform-vue-jsx-1.1.2.tgz#c0a3e6efc022e75e4247b448a8fc6b86f03e91c0"
- integrity sha512-YfdaoSMvD1nj7+DsrwfTvTnhDXI7bsuh+Y5qWwvQXlD24uLgnsoww3qbiZvWf/EoviZMrvqkqN4CBw0W3BWUTQ==
+"@vue/babel-plugin-transform-vue-jsx@^1.2.1":
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/@vue/babel-plugin-transform-vue-jsx/-/babel-plugin-transform-vue-jsx-1.2.1.tgz#646046c652c2f0242727f34519d917b064041ed7"
+ integrity sha512-HJuqwACYehQwh1fNT8f4kyzqlNMpBuUK4rSiSES5D4QsYncv5fxFsLyrxFPG2ksO7t5WP+Vgix6tt6yKClwPzA==
dependencies:
"@babel/helper-module-imports" "^7.0.0"
"@babel/plugin-syntax-jsx" "^7.2.0"
- "@vue/babel-helper-vue-jsx-merge-props" "^1.0.0"
+ "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1"
html-tags "^2.0.0"
lodash.kebabcase "^4.1.1"
svg-tags "^1.0.0"
+"@vue/babel-preset-jsx@^1.2.4":
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/@vue/babel-preset-jsx/-/babel-preset-jsx-1.2.4.tgz#92fea79db6f13b01e80d3a0099e2924bdcbe4e87"
+ integrity sha512-oRVnmN2a77bYDJzeGSt92AuHXbkIxbf/XXSE3klINnh9AXBmVS1DGa1f0d+dDYpLfsAKElMnqKTQfKn7obcL4w==
+ dependencies:
+ "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1"
+ "@vue/babel-plugin-transform-vue-jsx" "^1.2.1"
+ "@vue/babel-sugar-composition-api-inject-h" "^1.2.1"
+ "@vue/babel-sugar-composition-api-render-instance" "^1.2.4"
+ "@vue/babel-sugar-functional-vue" "^1.2.2"
+ "@vue/babel-sugar-inject-h" "^1.2.2"
+ "@vue/babel-sugar-v-model" "^1.2.3"
+ "@vue/babel-sugar-v-on" "^1.2.3"
+
+"@vue/babel-sugar-composition-api-inject-h@^1.2.1":
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/@vue/babel-sugar-composition-api-inject-h/-/babel-sugar-composition-api-inject-h-1.2.1.tgz#05d6e0c432710e37582b2be9a6049b689b6f03eb"
+ integrity sha512-4B3L5Z2G+7s+9Bwbf+zPIifkFNcKth7fQwekVbnOA3cr3Pq71q71goWr97sk4/yyzH8phfe5ODVzEjX7HU7ItQ==
+ dependencies:
+ "@babel/plugin-syntax-jsx" "^7.2.0"
+
+"@vue/babel-sugar-composition-api-render-instance@^1.2.4":
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/@vue/babel-sugar-composition-api-render-instance/-/babel-sugar-composition-api-render-instance-1.2.4.tgz#e4cbc6997c344fac271785ad7a29325c51d68d19"
+ integrity sha512-joha4PZznQMsxQYXtR3MnTgCASC9u3zt9KfBxIeuI5g2gscpTsSKRDzWQt4aqNIpx6cv8On7/m6zmmovlNsG7Q==
+ dependencies:
+ "@babel/plugin-syntax-jsx" "^7.2.0"
+
+"@vue/babel-sugar-functional-vue@^1.2.2":
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/@vue/babel-sugar-functional-vue/-/babel-sugar-functional-vue-1.2.2.tgz#267a9ac8d787c96edbf03ce3f392c49da9bd2658"
+ integrity sha512-JvbgGn1bjCLByIAU1VOoepHQ1vFsroSA/QkzdiSs657V79q6OwEWLCQtQnEXD/rLTA8rRit4rMOhFpbjRFm82w==
+ dependencies:
+ "@babel/plugin-syntax-jsx" "^7.2.0"
+
+"@vue/babel-sugar-inject-h@^1.2.2":
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/@vue/babel-sugar-inject-h/-/babel-sugar-inject-h-1.2.2.tgz#d738d3c893367ec8491dcbb669b000919293e3aa"
+ integrity sha512-y8vTo00oRkzQTgufeotjCLPAvlhnpSkcHFEp60+LJUwygGcd5Chrpn5480AQp/thrxVm8m2ifAk0LyFel9oCnw==
+ dependencies:
+ "@babel/plugin-syntax-jsx" "^7.2.0"
+
+"@vue/babel-sugar-v-model@^1.2.3":
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/@vue/babel-sugar-v-model/-/babel-sugar-v-model-1.2.3.tgz#fa1f29ba51ebf0aa1a6c35fa66d539bc459a18f2"
+ integrity sha512-A2jxx87mySr/ulAsSSyYE8un6SIH0NWHiLaCWpodPCVOlQVODCaSpiR4+IMsmBr73haG+oeCuSvMOM+ttWUqRQ==
+ dependencies:
+ "@babel/plugin-syntax-jsx" "^7.2.0"
+ "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1"
+ "@vue/babel-plugin-transform-vue-jsx" "^1.2.1"
+ camelcase "^5.0.0"
+ html-tags "^2.0.0"
+ svg-tags "^1.0.0"
+
+"@vue/babel-sugar-v-on@^1.2.3":
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/@vue/babel-sugar-v-on/-/babel-sugar-v-on-1.2.3.tgz#342367178586a69f392f04bfba32021d02913ada"
+ integrity sha512-kt12VJdz/37D3N3eglBywV8GStKNUhNrsxChXIV+o0MwVXORYuhDTHJRKPgLJRb/EY3vM2aRFQdxJBp9CLikjw==
+ dependencies:
+ "@babel/plugin-syntax-jsx" "^7.2.0"
+ "@vue/babel-plugin-transform-vue-jsx" "^1.2.1"
+ camelcase "^5.0.0"
+
"@vue/test-utils@^1.0.0-beta.26":
version "1.0.0-beta.28"
resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.0.0-beta.28.tgz#767c43413df8cde86128735e58923803e444b9a5"