1
0
mirror of https://git.pleroma.social/sjw/pleroma-fe.git synced 2024-12-23 00:09:42 +01:00

Merge branch 'develop' into 'themeApply'

# Conflicts:
#   CHANGELOG.md
This commit is contained in:
HJ 2021-09-09 21:51:39 +00:00
commit 370f1e55ad
83 changed files with 4489 additions and 1043 deletions

View File

@ -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
}

View File

@ -3,7 +3,7 @@ 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/).
## [Unreleased]
## [2.4.0] - 2021-08-08
### Added
- Added a quick settings to timeline header for easier access
- Added option to mark posts as sensitive by default
@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Implemented user option to hide floating shout panel
- Implemented "edit profile" button if viewing own profile which opens profile settings
- 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)
### Fixed
- Fixed follow request count showing in the wrong location in mobile view

View File

@ -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",

View File

@ -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
},

View File

@ -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;

View File

@ -53,6 +53,7 @@
v-if="currentUser && shout && !hideShoutbox"
:floating="true"
class="floating-shout mobile-hidden"
:class="{ 'left': shoutboxPosition }"
/>
<MobilePostStatusButton />
<UserReportingModal />

View File

@ -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 () {

View File

@ -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

View File

@ -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: []
}

View File

@ -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;
}
}

View File

@ -29,7 +29,8 @@
</div>
</div>
<div class="chat-preview">
<StatusContent
<StatusBody
class="chat-preview-body"
:status="messageForStatusContent"
:single-line="true"
/>

View File

@ -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
}
},

View File

@ -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 {

View File

@ -71,6 +71,7 @@
</Popover>
</div>
<StatusContent
class="message-content"
:status="messageForStatusContent"
:full-content="true"
>

View File

@ -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

View File

@ -0,0 +1,6 @@
.HashtagLink {
position: relative;
white-space: normal;
display: inline-block;
color: var(--link);
}

View File

@ -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"/>

View File

@ -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

View File

@ -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;
}
}

View File

@ -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"/>

View File

@ -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

View File

@ -0,0 +1,11 @@
.MentionsLine {
.showMoreLess {
white-space: normal;
color: var(--link);
}
.fullExtraMentions,
.mention-link:not(:last-child) {
margin-right: 0.25em;
}
}

View File

@ -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" />

View File

@ -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
}

View File

@ -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;
}
}

View File

@ -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 () {

View File

@ -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;

View File

@ -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

View File

@ -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 {

View File

@ -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,

View File

@ -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>

View File

@ -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 }
}

View File

@ -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;
}
}

View File

@ -122,6 +122,11 @@
{{ $t('settings.sensitive_by_default') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="alwaysShowNewPostButton">
{{ $t('settings.always_show_post_button') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="autohideFloatingPostButton">
{{ $t('settings.autohide_floating_post_button') }}

View File

@ -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()

View File

@ -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;

View File

@ -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)
},

View File

@ -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"

View File

@ -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: {

View File

@ -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;

View File

@ -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>

View File

@ -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

View File

@ -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);
}
}

View File

@ -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" />

View File

@ -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)

View File

@ -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"
v-if="status.attachments.length !== 0"
class="attachments media-body"
>
{{ $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>
<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
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.card && !noHeading"
class="link-preview media-body"
>
{{ $t("status.show_content") }}
<FAIcon
v-if="attachmentTypes.includes('image')"
icon="image"
<link-preview
:card="status.card"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
/>
<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 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
v-if="status.card && !hideSubjectStatus && !noHeading"
class="link-preview media-body"
>
<link-preview
:card="status.card"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
/>
</div>
</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>

View File

@ -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;

View File

@ -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 () {

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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

View File

@ -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,60 @@
"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",
"follow_again": "Envia de nou la petició?",
"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 +663,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 +687,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 +699,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 +714,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 +728,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"
}
}

View File

@ -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",
@ -779,7 +784,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!",

View File

@ -259,6 +259,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 +352,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 +701,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 +716,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",

View File

@ -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",
@ -696,7 +719,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 +727,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": {

View File

@ -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",
@ -735,7 +743,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",

View File

@ -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",
@ -691,5 +710,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."
}
}

View File

@ -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",

View File

@ -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",
@ -663,7 +680,8 @@
"side": "Coté rayé",
"striped": "Fond rayé"
},
"bot": "Robot"
"bot": "Robot",
"edit_profile": "Éditer le profil"
},
"user_profile": {
"timeline_title": "Flux du compte",

622
src/i18n/id.json Normal file
View File

@ -0,0 +1,622 @@
{
"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_again": "Kirim permintaan lagi?",
"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"
}
}

View File

@ -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",
@ -522,7 +527,8 @@
"striped": "A righe",
"solid": "Un colore",
"disabled": "Nessun risalto"
}
},
"edit_profile": "Modifica profilo"
},
"chat": {
"title": "Chat"
@ -660,7 +666,7 @@
},
"domain_mute_card": {
"mute": "Silenzia",
"mute_progress": "Silenzio…",
"mute_progress": "Procedo…",
"unmute": "Ascolta",
"unmute_progress": "Procedo…"
},
@ -701,7 +707,7 @@
},
"interactions": {
"favs_repeats": "Condivisi e Graditi",
"load_older": "Carica vecchie interazioni",
"load_older": "Carica interazioni precedenti",
"moves": "Utenti migrati",
"follows": "Nuovi seguìti"
},

View File

@ -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",
@ -731,7 +766,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",

View File

@ -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": "Вибрати все"
@ -799,7 +804,8 @@
"solid": "Суцільний фон",
"disabled": "Не виділяти"
},
"bot": "Бот"
"bot": "Бот",
"edit_profile": "Редагувати профіль"
},
"status": {
"copy_link": "Скопіювати посилання на допис",

435
src/i18n/vi.json Normal file
View File

@ -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.\nIm 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."
}
}

View File

@ -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} 天",
@ -724,7 +729,8 @@
"striped": "条纹背景",
"solid": "单一颜色背景",
"disabled": "不突出显示"
}
},
"edit_profile": "编辑个人资料"
},
"user_profile": {
"timeline_title": "用户时间线",

View File

@ -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": "更多",
@ -797,7 +802,8 @@
"striped": "條紋背景",
"side": "彩條"
},
"bot": "機器人"
"bot": "機器人",
"edit_profile": "編輯個人資料"
},
"user_profile": {
"timeline_title": "用戶時間線",

View File

@ -35,6 +35,7 @@ export const defaultState = {
loopVideoSilentOnly: true,
streaming: false,
emojiReactionsOnTimeline: true,
alwaysShowNewPostButton: false,
autohideFloatingPostButton: false,
pauseOnUnfocused: true,
stopGifs: false,

View File

@ -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 }

View File

@ -54,17 +54,19 @@ export const parseUser = (data) => {
return output
}
output.emoji = data.emojis
output.name = data.display_name
output.name_html = addEmojis(escape(data.display_name), data.emojis)
output.name_html = escape(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 +241,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 +258,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 +286,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 +318,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 +437,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 {

View File

@ -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 {

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -369,6 +369,12 @@ export const SLOT_INHERITANCE = {
textColor: 'preserve'
},
postCyantext: {
depends: ['cBlue'],
layer: 'bg',
textColor: 'preserve'
},
border: {
depends: ['fg'],
opacity: 'border',

View File

@ -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
}

View File

@ -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
}
}
}

View File

@ -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))
})
})

View File

@ -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"'

View File

@ -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 its 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)
})
})
})

View File

@ -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'
])
})
})
})

View File

@ -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)
})
})
})

View File

@ -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)
})
})
})

View File

@ -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"