logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe git clone https://hacktivis.me/git/pleroma-fe.git
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:

M.babelrc4++--
MCHANGELOG.md14++++++++++++++
Mpackage.json4++--
Msrc/App.js3+++
Msrc/App.scss4++++
Msrc/App.vue1+
Msrc/components/basic_user_card/basic_user_card.js4+++-
Msrc/components/basic_user_card/basic_user_card.vue12+++---------
Msrc/components/chat_list_item/chat_list_item.js8+++++---
Msrc/components/chat_list_item/chat_list_item.scss9+++------
Msrc/components/chat_list_item/chat_list_item.vue3++-
Msrc/components/chat_message/chat_message.js5+++--
Msrc/components/chat_message/chat_message.scss6++++--
Msrc/components/chat_message/chat_message.vue1+
Msrc/components/follow_button/follow_button.js4++--
Asrc/components/hashtag_link/hashtag_link.js36++++++++++++++++++++++++++++++++++++
Asrc/components/hashtag_link/hashtag_link.scss6++++++
Asrc/components/hashtag_link/hashtag_link.vue19+++++++++++++++++++
Asrc/components/mention_link/mention_link.js95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/mention_link/mention_link.scss91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/mention_link/mention_link.vue56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/mentions_line/mentions_line.js37+++++++++++++++++++++++++++++++++++++
Asrc/components/mentions_line/mentions_line.scss11+++++++++++
Asrc/components/mentions_line/mentions_line.vue43+++++++++++++++++++++++++++++++++++++++++++
Msrc/components/mobile_post_status_button/mobile_post_status_button.js3+++
Msrc/components/mobile_post_status_button/mobile_post_status_button.vue4++--
Msrc/components/mrf_transparency_panel/mrf_transparency_panel.js51+++++++++++++++++++++++++++++++++++++++++++++------
Asrc/components/mrf_transparency_panel/mrf_transparency_panel.scss21+++++++++++++++++++++
Msrc/components/mrf_transparency_panel/mrf_transparency_panel.vue155++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Msrc/components/notification/notification.js4+++-
Msrc/components/notification/notification.scss2++
Msrc/components/notification/notification.vue14++++++++------
Msrc/components/notifications/notifications.scss7-------
Msrc/components/poll/poll.js10+++++++---
Msrc/components/poll/poll.vue14++++++++++----
Asrc/components/rich_content/rich_content.jsx327+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/rich_content/rich_content.scss64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/settings_modal/tabs/general_tab.vue5+++++
Msrc/components/settings_modal/tabs/profile_tab.js2+-
Msrc/components/settings_modal/tabs/theme_tab/theme_tab.js2+-
Msrc/components/settings_modal/tabs/theme_tab/theme_tab.scss3+++
Msrc/components/shout_panel/shout_panel.vue9++++++++-
Msrc/components/side_drawer/side_drawer.js1+
Msrc/components/side_drawer/side_drawer.vue4++--
Msrc/components/status/status.js40++++++++++++++++++++++++++++++++++++----
Msrc/components/status/status.scss49++++++++++++++++++++-----------------------------
Msrc/components/status/status.vue114+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Asrc/components/status_body/status_body.js127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/status_body/status_body.scss118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/status_body/status_body.vue97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/status_content/status_content.js121++-----------------------------------------------------------------------------
Msrc/components/status_content/status_content.vue311+++++++++++--------------------------------------------------------------------
Msrc/components/still-image/still-image.vue5+++--
Msrc/components/user_card/user_card.js4+++-
Msrc/components/user_card/user_card.vue65++++++++++++++++-------------------------------------------------
Msrc/components/user_profile/user_profile.js4+++-
Msrc/components/user_profile/user_profile.vue20++++++++++++--------
Msrc/i18n/ca.json544++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/i18n/cs.json1-
Msrc/i18n/de.json14+++++++++-----
Msrc/i18n/en.json13+++++++++++--
Msrc/i18n/eo.json56+++++++++++++++++++++++++++++++++++++++-----------------
Msrc/i18n/es.json26+++++++++++++++++---------
Msrc/i18n/eu.json47++++++++++++++++++++++++++++++++++++-----------
Msrc/i18n/fi.json4++--
Msrc/i18n/fr.json27++++++++++++++++++++++-----
Msrc/i18n/he.json1-
Asrc/i18n/id.json621+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/i18n/it.json25+++++++++++++++----------
Msrc/i18n/ja_easy.json1-
Msrc/i18n/ja_pedantic.json1-
Msrc/i18n/ko.json1-
Msrc/i18n/nb.json1-
Msrc/i18n/nl.json5++++-
Msrc/i18n/oc.json1-
Msrc/i18n/pl.json79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Msrc/i18n/pt.json1-
Msrc/i18n/ru.json1-
Msrc/i18n/te.json1-
Msrc/i18n/uk.json13+++++++++----
Asrc/i18n/vi.json435+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/i18n/zh.json13+++++++++----
Msrc/i18n/zh_Hant.json13+++++++++----
Msrc/modules/config.js1+
Msrc/modules/users.js5+++++
Msrc/services/entity_normalizer/entity_normalizer.service.js39+++++++++++++++------------------------
Msrc/services/favicon_service/favicon_service.js70++++++++++++++++++++++++++++++++++++++--------------------------------
Asrc/services/html_converter/html_line_converter.service.js136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/services/html_converter/html_tree_converter.service.js97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/services/html_converter/utility.service.js73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/services/theme_data/pleromafe.js6++++++
Dsrc/services/tiny_post_html_processor/tiny_post_html_processor.service.js94-------------------------------------------------------------------------------
Msrc/services/user_highlighter/user_highlighter.js14+++++++++++---
Atest/unit/specs/components/rich_content.spec.js480+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/unit/specs/services/entity_normalizer/entity_normalizer.spec.js83+------------------------------------------------------------------------------
Atest/unit/specs/services/html_converter/html_line_converter.spec.js171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/unit/specs/services/html_converter/html_tree_converter.spec.js132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/unit/specs/services/html_converter/utility.spec.js37+++++++++++++++++++++++++++++++++++++
Dtest/unit/specs/services/tiny_post_html_processor/tiny_post_html_processor.spec.js96-------------------------------------------------------------------------------
Myarn.lock81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
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('&gt;') || string.includes('&lt;')) + ) { + const cleanedString = string.replace(/<[^>]+?>/gi, '') // remove all tags + .replace(/@\w+/gi, '') // remove mentions (even failed ones) + .trim() + if (cleanedString.startsWith('&gt;')) { + return `<span class='greentext'>${string}</span>` + } else if (cleanedString.startsWith('&lt;')) { + 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('&gt;')) { - // This checks if post has '>' at the beginning, excluding mentions so that @mention >impying works - return processHtml(html, (string) => { - if (string.includes('&gt;') && - string - .replace(/<[^>]+?>/gi, '') // remove all tags - .replace(/@\w+/gi, '') // remove mentions (even failed ones) - .trim() - .startsWith('&gt;')) { - 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 &#39;em all'), + 'Testing &#39;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 = [ + '&gt;preordering videogames', + '&gt;any year' + ].join('\n') + const expected = [ + '<span class="greentext">&gt;preordering videogames</span>', + '<span class="greentext">&gt;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 = [ + '&gt;preordering videogames', + '&gt;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 = [ + '&gt;quote', + makeMention('lol'), + '&gt;quote', + '&gt;quote' + ].join('\n') + const expected = [ + '<span class="greentext">&gt;quote</span>', + mentionsLine(1), + '<span class="greentext">&gt;quote</span>', + '<span class="greentext">&gt;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>&gt; rei = &quot;0&quot; + &#39;0&#39; + &gt; rei == 0 + true + &gt; 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"