* origin/develop: (169 commits) Improve the user card for deactivated users Update CHANGELOG.md Update CHANGELOG.md Allow canceling a follow request Simple policy reasons for instance specific policies entity_normalizer: Escape name when parsing user Translated using Weblate (Spanish) Translated using Weblate (Catalan) Translated using Weblate (Korean) Translated using Weblate (Japanese (ja_PEDANTIC)) Translated using Weblate (Indonesian) Translated using Weblate (Esperanto) Translated using Weblate (Vietnamese) Translated using Weblate (Italian) Translated using Weblate (Vietnamese) Translated using Weblate (Indonesian) Translated using Weblate (Italian) Translated using Weblate (Vietnamese) Translated using Weblate (Indonesian) Translated using Weblate (Chinese (Simplified)) ...merge-requests/1069/merge
commit
9ea0f10abb
@ -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
|
||||
}
|
||||
|
@ -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
|
@ -0,0 +1,6 @@
|
||||
.HashtagLink {
|
||||
position: relative;
|
||||
white-space: normal;
|
||||
display: inline-block;
|
||||
color: var(--link);
|
||||
}
|
@ -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"/>
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
@ -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"/>
|
@ -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
|
@ -0,0 +1,11 @@
|
||||
.MentionsLine {
|
||||
.showMoreLess {
|
||||
white-space: normal;
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.fullExtraMentions,
|
||||
.mention-link:not(:last-child) {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
}
|
@ -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" />
|
@ -0,0 +1,21 @@
|
||||
.mrf-section {
|
||||
margin: 1em;
|
||||
|
||||
table {
|
||||
width:100%;
|
||||
text-align: left;
|
||||
padding-left:10px;
|
||||
padding-bottom:20px;
|
||||
|
||||
th, td {
|
||||
width: 180px;
|
||||
max-width: 360px;
|
||||
overflow: hidden;
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
th+th, td+td {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,327 @@
|
||||
import Vue from 'vue'
|
||||
import { unescape, flattenDeep } from 'lodash'
|
||||
import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
|
||||
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
|
||||
import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
|
||||
import StillImage from 'src/components/still-image/still-image.vue'
|
||||
import MentionsLine, { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.vue'
|
||||
import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
|
||||
|
||||
import './rich_content.scss'
|
||||
|
||||
/**
|
||||
* RichContent, The Über-powered component for rendering Post HTML.
|
||||
*
|
||||
* This takes post HTML and does multiple things to it:
|
||||
* - Groups all mentions into <MentionsLine>, this affects all mentions regardles
|
||||
* of where they are (beginning/middle/end), even single mentions are converted
|
||||
* to a <MentionsLine> containing single <MentionLink>.
|
||||
* - Replaces emoji shortcodes with <StillImage>'d images.
|
||||
*
|
||||
* There are two problems with this component's architecture:
|
||||
* 1. Parsing HTML and rendering are inseparable. Attempts to separate the two
|
||||
* proven to be a massive overcomplication due to amount of things done here.
|
||||
* 2. We need to output both render and some extra data, which seems to be imp-
|
||||
* possible in vue. Current solution is to emit 'parseReady' event when parsing
|
||||
* is done within render() function.
|
||||
*
|
||||
* Apart from that one small hiccup with emit in render this _should_ be vue3-ready
|
||||
*/
|
||||
export default Vue.component('RichContent', {
|
||||
name: 'RichContent',
|
||||
props: {
|
||||
// Original html content
|
||||
html: {
|
||||
required: true,
|
||||
type: String
|
||||
},
|
||||
attentions: {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
// Emoji object, as in status.emojis, note the "s" at the end...
|
||||
emoji: {
|
||||
required: true,
|
||||
type: Array
|
||||
},
|
||||
// Whether to handle links or not (posts: yes, everything else: no)
|
||||
handleLinks: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// Meme arrows
|
||||
greentext: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
// NEVER EVER TOUCH DATA INSIDE RENDER
|
||||
render (h) {
|
||||
// Pre-process HTML
|
||||
const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
|
||||
let currentMentions = null // Current chain of mentions, we group all mentions together
|
||||
// This is used to recover spacing removed when parsing mentions
|
||||
let lastSpacing = ''
|
||||
|
||||
const lastTags = [] // Tags that appear at the end of post body
|
||||
const writtenMentions = [] // All mentions that appear in post body
|
||||
const invisibleMentions = [] // All mentions that go beyond the limiter (see MentionsLine)
|
||||
// to collapse too many mentions in a row
|
||||
const writtenTags = [] // All tags that appear in post body
|
||||
// unique index for vue "tag" property
|
||||
let mentionIndex = 0
|
||||
let tagsIndex = 0
|
||||
|
||||
const renderImage = (tag) => {
|
||||
return <StillImage
|
||||
{...{ attrs: getAttrs(tag) }}
|
||||
class="img"
|
||||
/>
|
||||
}
|
||||
|
||||
const renderHashtag = (attrs, children, encounteredTextReverse) => {
|
||||
const linkData = getLinkData(attrs, children, tagsIndex++)
|
||||
writtenTags.push(linkData)
|
||||
if (!encounteredTextReverse) {
|
||||
lastTags.push(linkData)
|
||||
}
|
||||
return <HashtagLink {...{ props: linkData }}/>
|
||||
}
|
||||
|
||||
const renderMention = (attrs, children) => {
|
||||
const linkData = getLinkData(attrs, children, mentionIndex++)
|
||||
linkData.notifying = this.attentions.some(a => a.statusnet_profile_url === linkData.url)
|
||||
writtenMentions.push(linkData)
|
||||
if (currentMentions === null) {
|
||||
currentMentions = []
|
||||
}
|
||||
currentMentions.push(linkData)
|
||||
if (currentMentions.length > MENTIONS_LIMIT) {
|
||||
invisibleMentions.push(linkData)
|
||||
}
|
||||
if (currentMentions.length === 1) {
|
||||
return <MentionsLine mentions={ currentMentions } />
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Processor to use with html_tree_converter
|
||||
const processItem = (item, index, array, what) => {
|
||||
// Handle text nodes - just add emoji
|
||||
if (typeof item === 'string') {
|
||||
const emptyText = item.trim() === ''
|
||||
if (item.includes('\n')) {
|
||||
currentMentions = null
|
||||
}
|
||||
if (emptyText) {
|
||||
// don't include spaces when processing mentions - we'll include them
|
||||
// in MentionsLine
|
||||
lastSpacing = item
|
||||
return currentMentions !== null ? item.trim() : item
|
||||
}
|
||||
|
||||
currentMentions = null
|
||||
if (item.includes(':')) {
|
||||
item = ['', processTextForEmoji(
|
||||
item,
|
||||
this.emoji,
|
||||
({ shortcode, url }) => {
|
||||
return <StillImage
|
||||
class="emoji img"
|
||||
src={url}
|
||||
title={`:${shortcode}:`}
|
||||
alt={`:${shortcode}:`}
|
||||
/>
|
||||
}
|
||||
)]
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// Handle tag nodes
|
||||
if (Array.isArray(item)) {
|
||||
const [opener, children, closer] = item
|
||||
const Tag = getTagName(opener)
|
||||
const attrs = getAttrs(opener)
|
||||
const previouslyMentions = currentMentions !== null
|
||||
/* During grouping of mentions we trim all the empty text elements
|
||||
* This padding is added to recover last space removed in case
|
||||
* we have a tag right next to mentions
|
||||
*/
|
||||
const mentionsLinePadding =
|
||||
// Padding is only needed if we just finished parsing mentions
|
||||
previouslyMentions &&
|
||||
// Don't add padding if content is string and has padding already
|
||||
!(children && typeof children[0] === 'string' && children[0].match(/^\s/))
|
||||
? lastSpacing
|
||||
: ''
|
||||
switch (Tag) {
|
||||
case 'br':
|
||||
currentMentions = null
|
||||
break
|
||||
case 'img': // replace images with StillImage
|
||||
return ['', [mentionsLinePadding, renderImage(opener)], '']
|
||||
case 'a': // replace mentions with MentionLink
|
||||
if (!this.handleLinks) break
|
||||
if (attrs['class'] && attrs['class'].includes('mention')) {
|
||||
// Handling mentions here
|
||||
return renderMention(attrs, children)
|
||||
} else {
|
||||
currentMentions = null
|
||||
break
|
||||
}
|
||||
case 'span':
|
||||
if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) {
|
||||
return ['', children.map(processItem), '']
|
||||
}
|
||||
}
|
||||
|
||||
if (children !== undefined) {
|
||||
return [
|
||||
'',
|
||||
[
|
||||
mentionsLinePadding,
|
||||
[opener, children.map(processItem), closer]
|
||||
],
|
||||
''
|
||||
]
|
||||
} else {
|
||||
return ['', [mentionsLinePadding, item], '']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Processor for back direction (for finding "last" stuff, just easier this way)
|
||||
let encounteredTextReverse = false
|
||||
const processItemReverse = (item, index, array, what) => {
|
||||
// Handle text nodes - just add emoji
|
||||
if (typeof item === 'string') {
|
||||
const emptyText = item.trim() === ''
|
||||
if (emptyText) return item
|
||||
if (!encounteredTextReverse) encounteredTextReverse = true
|
||||
return unescape(item)
|
||||
} else if (Array.isArray(item)) {
|
||||
// Handle tag nodes
|
||||
const [opener, children] = item
|
||||
const Tag = opener === '' ? '' : getTagName(opener)
|
||||
switch (Tag) {
|
||||
case 'a': // replace mentions with MentionLink
|
||||
if (!this.handleLinks) break
|
||||
const attrs = getAttrs(opener)
|
||||
// should only be this
|
||||
if (
|
||||
(attrs['class'] && attrs['class'].includes('hashtag')) || // Pleroma style
|
||||
(attrs['rel'] === 'tag') // Mastodon style
|
||||
) {
|
||||
return renderHashtag(attrs, children, encounteredTextReverse)
|
||||
} else {
|
||||
attrs.target = '_blank'
|
||||
const newChildren = [...children].reverse().map(processItemReverse).reverse()
|
||||
|
||||
return <a {...{ attrs }}>
|
||||
{ newChildren }
|
||||
</a>
|
||||
}
|
||||
case '':
|
||||
return [...children].reverse().map(processItemReverse).reverse()
|
||||
}
|
||||
|
||||
// Render tag as is
|
||||
if (children !== undefined) {
|
||||
const newChildren = Array.isArray(children)
|
||||
? [...children].reverse().map(processItemReverse).reverse()
|
||||
: children
|
||||
return <Tag {...{ attrs: getAttrs(opener) }}>
|
||||
{ newChildren }
|
||||
</Tag>
|
||||
} else {
|
||||
return <Tag/>
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
const pass1 = convertHtmlToTree(html).map(processItem)
|
||||
const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
|
||||
// DO NOT USE SLOTS they cause a re-render feedback loop here.
|
||||
// slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
|
||||
// at least until vue3?
|
||||
const result = <span class="RichContent">
|
||||
{ pass2 }
|
||||
</span>
|
||||
|
||||
const event = {
|
||||
lastTags,
|
||||
writtenMentions,
|
||||
writtenTags,
|
||||
invisibleMentions
|
||||
}
|
||||
|
||||
// DO NOT MOVE TO UPDATE. BAD IDEA.
|
||||
this.$emit('parseReady', event)
|
||||
|
||||
return result
|
||||
}
|
||||
})
|
||||
|
||||
const getLinkData = (attrs, children, index) => {
|
||||
const stripTags = (item) => {
|
||||
if (typeof item === 'string') {
|
||||
return item
|
||||
} else {
|
||||
return item[1].map(stripTags).join('')
|
||||
}
|
||||
}
|
||||
const textContent = children.map(stripTags).join('')
|
||||
return {
|
||||
index,
|
||||
url: attrs.href,
|
||||
tag: attrs['data-tag'],
|
||||
content: flattenDeep(children).join(''),
|
||||
textContent
|
||||
}
|
||||
}
|
||||
|
||||
/** Pre-processing HTML
|
||||
*
|
||||
* Currently this does one thing:
|
||||
* - add green/cyantexting
|
||||
*
|
||||
* @param {String} html - raw HTML to process
|
||||
* @param {Boolean} greentext - whether to enable greentexting or not
|
||||
*/
|
||||
export const preProcessPerLine = (html, greentext) => {
|
||||
const greentextHandle = new Set(['p', 'div'])
|
||||
|
||||
const lines = convertHtmlToLines(html)
|
||||
const newHtml = lines.reverse().map((item, index, array) => {
|
||||
if (!item.text) return item
|
||||
const string = item.text
|
||||
|
||||
// Greentext stuff
|
||||
if (
|
||||
// Only if greentext is engaged
|
||||
greentext &&
|
||||
// Only handle p's and divs. Don't want to affect blockquotes, code etc
|
||||
item.level.every(l => greentextHandle.has(l)) &&
|
||||
// Only if line begins with '>' or '<'
|
||||
(string.includes('>') || string.includes('<'))
|
||||
) {
|
||||
const cleanedString = string.replace(/<[^>]+?>/gi, '') // remove all tags
|
||||
.replace(/@\w+/gi, '') // remove mentions (even failed ones)
|
||||
.trim()
|
||||
if (cleanedString.startsWith('>')) {
|
||||
return `<span class='greentext'>${string}</span>`
|
||||
} else if (cleanedString.startsWith('<')) {
|
||||
return `<span class='cyantext'>${string}</span>`
|
||||
}
|
||||
}
|
||||
|
||||
return string
|
||||
}).reverse().join('')
|
||||
|
||||
return { newHtml }
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
@ -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);
|
||||
}
|
||||
}
|
@ -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" />
|
@ -0,0 +1,631 @@
|
||||
{
|
||||
"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",
|
||||
"hide_muted_posts": "Sembunyikan postingan-postingan dari pengguna yang dibisukan",
|
||||
"import_blocks_from_a_csv_file": "Impor blokiran dari berkas csv",
|
||||
"domain_mutes": "Domain",
|
||||
"composing": "Menulis",
|
||||
"no_blocks": "Tidak ada yang diblokir",
|
||||
"no_mutes": "Tidak ada yang dibisukan"
|
||||
},
|
||||
"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",
|
||||
"media_removal": "Penghapusan Media",
|
||||
"media_removal_desc": "Instansi ini menghapus media dari postingan yang berasal dari instansi-instansi berikut:"
|
||||
},
|
||||
"federation": "Federasi",
|
||||
"mrf_policies": "Kebijakan MRF yang diaktifkan"
|
||||
},
|
||||
"staff": "Staf"
|
||||
},
|
||||
"time": {
|
||||
"day": "{0} hari",
|
||||
"days": "{0} hari",
|
||||
"day_short": "{0}h",
|
||||
"days_short": "{0}h",
|
||||
"hour": "{0} jam",
|
||||
"hours": "{0} jam",
|
||||
"hour_short": "{0}j",
|
||||
"hours_short": "{0}j",
|
||||
"in_future": "dalam {0}",
|
||||
"in_past": "{0} yang lalu",
|
||||
"minute": "{0} menit",
|
||||
"minutes": "{0} menit",
|
||||
"minute_short": "{0}m",
|
||||
"minutes_short": "{0}m",
|
||||
"month": "{0} bulan",
|
||||
"months": "{0} bulan",
|
||||
"month_short": "{0}b",
|
||||
"months_short": "{0}b",
|
||||
"now": "baru saja",
|
||||
"now_short": "sekarang",
|
||||
"second": "{0} detik",
|
||||
"seconds": "{0} detik",
|
||||
"second_short": "{0}d",
|
||||
"seconds_short": "{0}d",
|
||||
"week": "{0} pekan",
|
||||
"weeks": "{0} pekan",
|
||||
"week_short": "{0}p",
|
||||
"weeks_short": "{0}p",
|
||||
"year": "{0} tahun",
|
||||
"years": "{0} tahun",
|
||||
"year_short": "{0}t",
|
||||
"years_short": "{0}t"
|
||||
},
|
||||
"timeline": {
|
||||
"conversation": "Percakapan",
|
||||
"error": "Terjadi kesalahan memuat linimasa: {0}",
|
||||
"no_retweet_hint": "Postingan ditandai sebagai hanya-pengikut atau langsung dan tidak dapat diulang",
|
||||
"repeated": "diulangi",
|
||||
"reload": "Muat ulang",
|
||||
"no_more_statuses": "Tidak ada status lagi",
|
||||
"no_statuses": "Tidak ada status"
|
||||
},
|
||||
"status": {
|
||||
"favorites": "Favorit",
|
||||
"repeats": "Ulangan",
|
||||
"delete": "Hapus status",
|
||||
"pin": "Sematkan di profil",
|
||||
"unpin": "Berhenti menyematkan dari profil",
|
||||
"pinned": "Disematkan",
|
||||
"delete_confirm": "Apakah Anda benar-benar ingin menghapus status ini?",
|
||||
"reply_to": "Balas ke",
|
||||
"replies_list": "Balasan:",
|
||||
"mute_conversation": "Bisukan percakapan",
|
||||
"unmute_conversation": "Berhenti membisikan percakapan",
|
||||
"status_unavailable": "Status tidak tersedia",
|
||||
"thread_muted_and_words": ", memiliki kata:",
|
||||
"hide_content": "",
|
||||
"show_content": "",
|
||||
"status_deleted": "Postingan ini telah dihapus",
|
||||
"nsfw": "NSFW"
|
||||
},
|
||||
"user_card": {
|
||||
"block": "Blokir",
|
||||
"blocked": "Diblokir!",
|
||||
"deny": "Tolak",
|
||||
"edit_profile": "Sunting profil",
|
||||
"favorites": "Favorit",
|
||||
"follow": "Ikuti",
|
||||
"follow_sent": "Permintaan dikirim!",
|
||||
"follow_progress": "Meminta…",
|
||||
"mute": "Bisukan",
|
||||
"muted": "Dibisukan",
|
||||
"per_day": "per hari",
|
||||
"report": "Laporkan",
|
||||
"statuses": "Status",
|
||||
"unblock": "Berhenti memblokir",
|
||||
"block_progress": "Memblokir…",
|
||||
"unmute": "Berhenti membisukan",
|
||||
"mute_progress": "Membisukan…",
|
||||
"hide_repeats": "Sembunyikan ulangan",
|
||||
"show_repeats": "Tampilkan ulangan",
|
||||
"bot": "Bot",
|
||||
"admin_menu": {
|
||||
"moderation": "Moderasi",
|
||||
"activate_account": "Aktifkan akun",
|
||||
"deactivate_account": "Nonaktifkan akun",
|
||||
"delete_account": "Hapus akun",
|
||||
"force_nsfw": "Tandai semua postingan sebagai NSFW",
|
||||
"strip_media": "Hapus media dari postingan-postingan",
|
||||
"delete_user": "Hapus pengguna",
|
||||
"delete_user_confirmation": "Apakah Anda benar-benar yakin? Tindakan ini tidak dapat dibatalkan."
|
||||
},
|
||||
"follow_unfollow": "Berhenti mengikuti",
|
||||
"followees": "Mengikuti",
|
||||
"followers": "Pengikut",
|
||||
"following": "Diikuti!",
|
||||
"follows_you": "Mengikuti Anda!",
|
||||
"hidden": "Disembunyikan",
|
||||
"its_you": "Ini Anda!",
|
||||
"media": "Media",
|
||||
"mention": "Sebut",
|
||||
"message": "Kirimkan pesan"
|
||||
},
|
||||
"user_profile": {
|
||||
"timeline_title": "Linimasa pengguna",
|
||||
"profile_does_not_exist": "Maaf, profil ini tidak ada.",
|
||||
"profile_loading_error": "Maaf, terjadi kesalahan ketika memuat profil ini."
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -0,0 +1,480 @@
|
||||
import { mount, shallowMount, createLocalVue } from '@vue/test-utils'
|
||||
import RichContent from 'src/components/rich_content/rich_content.jsx'
|
||||
|
||||
const localVue = createLocalVue()
|
||||
const attentions = []
|
||||
|
||||
const makeMention = (who) => {
|
||||
attentions.push({ statusnet_profile_url: `https://fake.tld/@${who}` })
|
||||
return `<span class="h-card"><a class="u-url mention" href="https://fake.tld/@${who}">@<span>${who}</span></a></span>`
|
||||
}
|
||||
const p = (...data) => `<p>${data.join('')}</p>`
|
||||
const compwrap = (...data) => `<span class="RichContent">${data.join('')}</span>`
|
||||
const mentionsLine = (times) => [
|
||||
'<mentionsline-stub mentions="',
|
||||
new Array(times).fill('[object Object]').join(','),
|
||||
'"></mentionsline-stub>'
|
||||
].join('')
|
||||
|
||||
describe('RichContent', () => {
|
||||
it('renders simple post without exploding', () => {
|
||||
const html = p('Hello world!')
|
||||
const wrapper = shallowMount(RichContent, {
|
||||
localVue,
|
||||
propsData: {
|
||||
attentions,
|
||||
handleLinks: true,
|
||||
greentext: true,
|
||||
emoji: [],
|
||||
html
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.html()).to.eql(compwrap(html))
|
||||
})
|
||||
|
||||
it('unescapes everything as needed', () => {
|
||||
const html = [
|
||||
p('Testing 'em all'),
|
||||
'Testing 'em all'
|
||||
].join('')
|
||||
const expected = [
|
||||
p('Testing \'em all'),
|
||||
'Testing \'em all'
|
||||
].join('')
|
||||
const wrapper = shallowMount(RichContent, {
|
||||
localVue,
|
||||
propsData: {
|
||||
attentions,
|
||||
handleLinks: true,
|
||||
greentext: true,
|
||||
emoji: [],
|
||||
html
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.html()).to.eql(compwrap(expected))
|
||||
})
|
||||
|
||||
it('replaces mention with mentionsline', () => {
|
||||
const html = p(
|
||||
makeMention('John'),
|
||||
' how are you doing today?'
|
||||
)
|
||||
const wrapper = shallowMount(RichContent, {
|
||||
localVue,
|
||||
propsData: {
|
||||
attentions,
|
||||
handleLinks: true,
|
||||
greentext: true,
|
||||
emoji: [],
|
||||
html
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.html()).to.eql(compwrap(p(
|
||||
mentionsLine(1),
|
||||
' how are you doing today?'
|
||||
)))
|
||||
})
|
||||
|
||||
it('replaces mentions at the end of the hellpost', () => {
|
||||
const html = [
|
||||
p('How are you doing today, fine gentlemen?'),
|
||||
p(
|
||||
makeMention('John'),
|
||||
makeMention('Josh'),
|
||||
makeMention('Jeremy')
|
||||
)
|
||||
].join('')
|
||||
const expected = [
|
||||
p(
|
||||
'How are you doing today, fine gentlemen?'
|
||||
),
|
||||
// TODO fix this extra line somehow?
|
||||
p(
|
||||
'<mentionsline-stub mentions="',
|
||||
'[object Object],',
|
||||
'[object Object],',
|
||||
'[object Object]',
|
||||
'"></mentionsline-stub>'
|
||||
)
|
||||
].join('')
|
||||
|
||||
const wrapper = shallowMount(RichContent, {
|
||||
localVue,
|
||||
propsData: {
|
||||
attentions,
|
||||
handleLinks: true,
|
||||
greentext: true,
|
||||
emoji: [],
|
||||
html
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.html()).to.eql(compwrap(expected))
|
||||
})
|
||||
|
||||
it('Does not touch links if link handling is disabled', () => {
|
||||
const html = [
|
||||
[
|
||||
makeMention('Jack'),
|
||||
'let\'s meet up with ',
|
||||
makeMention('Janet')
|
||||
].join(''),
|
||||
[
|
||||
makeMention('John'),
|
||||
makeMention('Josh'),
|
||||
makeMention('Jeremy')
|
||||
].join('')
|
||||
].join('\n')
|
||||
|
||||
const wrapper = shallowMount(RichContent, {
|
||||
localVue,
|
||||
propsData: {
|
||||
attentions,
|
||||
handleLinks: false,
|
||||
greentext: true,
|
||||
emoji: [],
|
||||
html
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.html()).to.eql(compwrap(html))
|
||||
})
|
||||
|
||||
it('Adds greentext and cyantext to the post', () => {
|
||||
const html = [
|
||||
'>preordering videogames',
|
||||
'>any year'
|
||||
].join('\n')
|
||||
const expected = [
|
||||
'<span class="greentext">>preordering videogames</span>',
|
||||
'<span class="greentext">>any year</span>'
|
||||
].join('\n')
|
||||
|
||||
const wrapper = shallowMount(RichContent, {
|
||||
localVue,
|
||||
propsData: {
|
||||
attentions,
|
||||
handleLinks: false,
|
||||
greentext: true,
|
||||
emoji: [],
|
||||
html
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.html()).to.eql(compwrap(expected))
|
||||
})
|
||||
|
||||
it('Does not add greentext and cyantext if setting is set to false', () => {
|
||||
const html = [
|
||||
'>preordering videogames',
|
||||
'>any year'
|
||||
].join('\n')
|
||||
|
||||
const wrapper = shallowMount(RichContent, {
|
||||
localVue,
|
||||
propsData: {
|
||||
attentions,
|
||||
handleLinks: false,
|
||||
greentext: false,
|
||||
emoji: [],
|
||||
html
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.html()).to.eql(compwrap(html))
|
||||
})
|
||||
|
||||
it('Adds emoji to post', () => {
|
||||
const html = p('Ebin :DDDD :spurdo:')
|
||||
const expected = p(
|
||||
'Ebin :DDDD ',
|
||||
'<anonymous-stub alt=":spurdo:" src="about:blank" title=":spurdo:" class="emoji img"></anonymous-stub>'
|
||||
)
|
||||
|
||||
const wrapper = shallowMount(RichContent, {
|
||||
localVue,
|
||||
propsData: {
|
||||
attentions,
|
||||
handleLinks: false,
|
||||
greentext: false,
|
||||
emoji: [{ url: 'about:blank', shortcode: 'spurdo' }],
|
||||
html
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.html()).to.eql(compwrap(expected))
|
||||
})
|
||||
|
||||
it('Doesn\'t add nonexistent emoji to post', () => {
|
||||
const html = p('Lol :lol:')
|
||||
|
||||
const wrapper = shallowMount(RichContent, {
|
||||
localVue,
|
||||
propsData: {
|
||||
attentions,
|
||||
handleLinks: false,
|
||||
greentext: false,
|
||||
emoji: [],
|
||||
html
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.html()).to.eql(compwrap(html))
|
||||
})
|
||||
|
||||
it('Greentext + last mentions', () => {
|
||||
const html = [
|
||||
'>quote',
|
||||
makeMention('lol'),
|
||||
'>quote',
|
||||
'>quote'
|
||||
].join('\n')
|
||||
const expected = [
|
||||
'<span class="greentext">>quote</span>',
|
||||
mentionsLine(1),
|
||||
'<span class="greentext">>quote</span>',
|
||||
'<span class="greentext">>quote</span>'
|
||||
].join('\n')
|
||||
|
||||
const wrapper = shallowMount(RichContent, {
|
||||
localVue,
|
||||
propsData: {
|
||||
attentions,
|
||||
handleLinks: true,
|
||||
greentext: true,
|
||||
emoji: [],
|
||||
html
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.html()).to.eql(compwrap(expected))
|
||||
})
|
||||
|
||||
it('One buggy example', () => {
|
||||
const html = [
|
||||
'Bruh',
|
||||
'Bruh',
|
||||
[
|
||||
makeMention('foo'),
|
||||
makeMention('bar'),
|
||||
makeMention('baz')
|
||||
].join(''),
|
||||
'Bruh'
|
||||
].join('<br>')
|
||||
const expected = [
|
||||
'Bruh',
|
||||
'Bruh',
|
||||
mentionsLine(3),
|
||||
'Bruh'
|
||||
].join('<br>')
|
||||
|
||||
const wrapper = shallowMount(RichContent, {
|
||||
localVue,
|
||||
propsData: {
|
||||
attentions,
|
||||
handleLinks: true,
|
||||
greentext: true,
|
||||
emoji: [],
|
||||
html
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.html()).to.eql(compwrap(expected))
|
||||
})
|
||||
|
||||
it('buggy example/hashtags', () => {
|
||||
const html = [
|
||||
'<p>',
|
||||
'<a href="http://macrochan.org/images/N/H/NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg">',
|
||||
'NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg</a>',
|
||||
' <a class="hashtag" data-tag="nou" href="https://shitposter.club/tag/nou">',
|
||||
'#nou</a>',
|
||||
' <a class="hashtag" data-tag="screencap" href="https://shitposter.club/tag/screencap">',
|
||||
'#screencap</a>',
|
||||
' </p>'
|
||||
].join('')
|
||||
const expected = [
|
||||
'<p>',
|
||||
'<a href="http://macrochan.org/images/N/H/NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg" target="_blank">',
|
||||
'NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg</a>',
|
||||
' <hashtaglink-stub url="https://shitposter.club/tag/nou" content="#nou" tag="nou">',
|
||||
'</hashtaglink-stub>',
|
||||
' <hashtaglink-stub url="https://shitposter.club/tag/screencap" content="#screencap" tag="screencap">',
|
||||
'</hashtaglink-stub>',
|
||||
' </p>'
|
||||
].join('')
|
||||
|
||||
const wrapper = shallowMount(RichContent, {
|
||||
localVue,
|
||||
propsData: {
|
||||
attentions,
|
||||
handleLinks: true,
|
||||
greentext: true,
|
||||
emoji: [],
|
||||
html
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.html()).to.eql(compwrap(expected))
|
||||
})
|
||||
|
||||
it('rich contents of a mention are handled properly', () => {
|
||||
attentions.push({ statusnet_profile_url: 'lol' })
|
||||
const html = [
|
||||
p(
|
||||
'<a href="lol" class="mention">',
|
||||
'<span>',
|
||||
'https://</span>',
|
||||
'<span>',
|
||||
'lol.tld/</span>',
|
||||
'<span>',
|
||||
'</span>',
|
||||
'</a>'
|
||||
),
|
||||
p(
|
||||
'Testing'
|
||||
)
|
||||
].join('')
|
||||
const expected = [
|
||||
p(
|
||||
'<span class="MentionsLine">',
|
||||
'<span class="MentionLink mention-link">',
|
||||
'<a href="lol" target="_blank" class="original">',
|
||||
'<span>',
|
||||
'https://</span>',
|
||||
'<span>',
|
||||
'lol.tld/</span>',
|
||||
'<span>',
|
||||
'</span>',
|
||||
'</a>',
|
||||
' ',
|
||||
'<!---->', // v-if placeholder, mentionlink's "new" (i.e. rich) display
|
||||
'</span>',
|
||||
'<!---->', // v-if placeholder, mentionsline's extra mentions and stuff
|
||||
'</span>'
|
||||
),
|
||||
p(
|
||||
'Testing'
|
||||
)
|
||||
].join('')
|
||||
|
||||
const wrapper = mount(RichContent, {
|
||||
localVue,
|
||||
propsData: {
|
||||
attentions,
|
||||
handleLinks: true,
|
||||
greentext: true,
|
||||
emoji: [],
|
||||
html
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.html()).to.eql(compwrap(expected))
|
||||
})
|
||||
|
||||
it('rich contents of a link are handled properly', () => {
|
||||
const html = [
|
||||
'<p>',
|
||||
'Freenode is dead.</p>',
|
||||
'<p>',
|
||||
'<a href="https://isfreenodedeadyet.com/">',
|
||||
'<span>',
|
||||
'https://</span>',
|
||||
'<span>',
|
||||
'isfreenodedeadyet.com/</span>',
|
||||
'<span>',
|
||||
'</span>',
|
||||
'</a>',
|
||||
'</p>'
|
||||
].join('')
|
||||
const expected = [
|
||||
'<p>',
|
||||
'Freenode is dead.</p>',
|
||||
'<p>',
|
||||
'<a href="https://isfreenodedeadyet.com/" target="_blank">',
|
||||
'<span>',
|
||||
'https://</span>',
|
||||
'<span>',
|
||||
'isfreenodedeadyet.com/</span>',
|
||||
'<span>',
|
||||
'</span>',
|
||||
'</a>',
|
||||
'</p>'
|
||||
].join('')
|
||||
|
||||
const wrapper = shallowMount(RichContent, {
|
||||
localVue,
|
||||
propsData: {
|
||||
attentions,
|
||||
handleLinks: true,
|
||||
greentext: true,
|
||||
emoji: [],
|
||||
html
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.html()).to.eql(compwrap(expected))
|
||||
})
|
||||
|
||||
it.skip('[INFORMATIVE] Performance testing, 10 000 simple posts', () => {
|
||||
const amount = 20
|
||||
|
||||
const onePost = p(
|
||||
makeMention('Lain'),
|
||||
makeMention('Lain'),
|
||||
makeMention('Lain'),
|
||||
makeMention('Lain'),
|
||||
makeMention('Lain'),
|
||||
makeMention('Lain'),
|
||||
makeMention('Lain'),
|
||||
makeMention('Lain'),
|
||||
makeMention('Lain'),
|
||||
makeMention('Lain'),
|
||||
' i just landed in l a where are you'
|
||||
)
|
||||
|
||||
const TestComponent = {
|
||||
template: `
|
||||
<div v-if="!vhtml">
|
||||
${new Array(amount).fill(`<RichContent html="${onePost}" :greentext="true" :handleLinks="handeLinks" :emoji="[]" :attentions="attentions"/>`)}
|
||||
</div>
|
||||
<div v-else="vhtml">
|
||||
${new Array(amount).fill(`<div v-html="${onePost}"/>`)}
|
||||
</div>
|
||||
`,
|
||||
props: ['handleLinks', 'attentions', 'vhtml']
|
||||
}
|
||||
console.log(1)
|
||||
|
||||
const ptest = (handleLinks, vhtml) => {
|
||||
const t0 = performance.now()
|
||||
|
||||
const wrapper = mount(TestComponent, {
|
||||
localVue,
|
||||
propsData: {
|
||||
attentions,
|
||||
handleLinks,
|
||||
vhtml
|
||||
}
|
||||
})
|
||||
|
||||
const t1 = performance.now()
|
||||
|
||||
wrapper.destroy()
|
||||
|
||||
const t2 = performance.now()
|
||||
|
||||
return `Mount: ${t1 - t0}ms, destroy: ${t2 - t1}ms, avg ${(t1 - t0) / amount}ms - ${(t2 - t1) / amount}ms per item`
|
||||
}
|
||||
|
||||
console.log(`${amount} items with links handling:`)
|
||||
console.log(ptest(true))
|
||||
console.log(`${amount} items without links handling:`)
|
||||
console.log(ptest(false))
|
||||
console.log(`${amount} items plain v-html:`)
|
||||
console.log(ptest(false, true))
|
||||
})
|
||||
})
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue