Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85830763
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
45 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/.babelrc b/.babelrc
index 3c732dd1bc..945211470b 100644
--- a/.babelrc
+++ b/.babelrc
@@ -1,5 +1,5 @@
{
- "presets": ["@babel/preset-env"],
- "plugins": ["@babel/plugin-transform-runtime", "lodash", "@vue/babel-plugin-transform-vue-jsx"],
+ "presets": ["@babel/preset-env", "@vue/babel-preset-jsx"],
+ "plugins": ["@babel/plugin-transform-runtime", "lodash"],
"comments": false
}
diff --git a/package.json b/package.json
index 9930126650..5134a8b157 100644
--- a/package.json
+++ b/package.json
@@ -1,125 +1,125 @@
{
"name": "pleroma_fe",
"version": "1.0.0",
"description": "A Qvitter-style frontend for certain GS servers.",
"author": "Roger Braun <roger@rogerbraun.net>",
"private": true,
"scripts": {
"dev": "node build/dev-server.js",
"build": "node build/build.js",
"unit": "karma start test/unit/karma.conf.js --single-run",
"unit:watch": "karma start test/unit/karma.conf.js --single-run=false",
"e2e": "node test/e2e/runner.js",
"test": "npm run unit && npm run e2e",
"stylelint": "npx stylelint src/components/status/status.scss",
"lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs",
"lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs"
},
"dependencies": {
"@babel/runtime": "^7.7.6",
"@chenfengyuan/vue-qrcode": "^1.0.0",
"@fortawesome/fontawesome-svg-core": "^1.2.32",
"@fortawesome/free-regular-svg-icons": "^5.15.1",
"@fortawesome/free-solid-svg-icons": "^5.15.1",
"@fortawesome/vue-fontawesome": "^2.0.0",
"body-scroll-lock": "^2.6.4",
"chromatism": "^3.0.0",
"cropperjs": "^1.4.3",
"diff": "^3.0.1",
"escape-html": "^1.0.3",
"localforage": "^1.5.0",
"parse-link-header": "^1.0.1",
"phoenix": "^1.3.0",
"portal-vue": "^2.1.4",
"punycode.js": "^2.1.0",
"ruffle-mirror": "^2021.4.10",
"v-click-outside": "^2.1.1",
"vue": "^2.6.11",
"vue-i18n": "^7.3.2",
"vue-router": "^3.0.1",
"vue-template-compiler": "^2.6.11",
"vuelidate": "^0.7.4",
"vuex": "^3.0.1"
},
"devDependencies": {
"@babel/core": "^7.7.5",
"@babel/plugin-transform-runtime": "^7.7.6",
"@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",
"babel-loader": "^8.0.6",
"babel-plugin-lodash": "^3.3.4",
"chai": "^3.5.0",
"chalk": "^1.1.3",
"chromedriver": "^87.0.1",
"connect-history-api-fallback": "^1.1.0",
"copy-webpack-plugin": "^6.4.1",
"cross-spawn": "^4.0.2",
"css-loader": "^0.28.0",
"custom-event-polyfill": "^1.0.7",
"eslint": "^5.16.0",
"eslint-config-standard": "^12.0.0",
"eslint-friendly-formatter": "^2.0.5",
"eslint-loader": "^2.1.0",
"eslint-plugin-import": "^2.13.0",
"eslint-plugin-node": "^7.0.0",
"eslint-plugin-promise": "^4.0.0",
"eslint-plugin-standard": "^4.0.0",
"eslint-plugin-vue": "^5.2.2",
"eventsource-polyfill": "^0.9.6",
"express": "^4.13.3",
"file-loader": "^3.0.1",
"function-bind": "^1.0.2",
"html-webpack-plugin": "^3.0.0",
"http-proxy-middleware": "^0.17.2",
"inject-loader": "^2.0.1",
"iso-639-1": "^2.0.3",
"isparta-loader": "^2.0.0",
"json-loader": "^0.5.4",
"karma": "^3.0.0",
"karma-coverage": "^1.1.1",
"karma-firefox-launcher": "^1.1.0",
"karma-mocha": "^1.2.0",
"karma-mocha-reporter": "^2.2.1",
"karma-sinon-chai": "^2.0.2",
"karma-sourcemap-loader": "^0.3.7",
"karma-spec-reporter": "0.0.26",
"karma-webpack": "^4.0.0-rc.3",
"lodash": "^4.16.4",
"lolex": "^1.4.0",
"mini-css-extract-plugin": "^0.5.0",
"mocha": "^3.1.0",
"nightwatch": "^0.9.8",
"opn": "^4.0.2",
"ora": "^0.3.0",
"postcss-loader": "^3.0.0",
"raw-loader": "^0.5.1",
"sass": "^1.17.3",
"sass-loader": "git://github.com/webpack-contrib/sass-loader",
"selenium-server": "2.53.1",
"semver": "^5.3.0",
"serviceworker-webpack-plugin": "^1.0.0",
"shelljs": "^0.8.4",
"sinon": "^2.1.0",
"sinon-chai": "^2.8.0",
"stylelint": "^13.6.1",
"stylelint-config-standard": "^20.0.0",
"stylelint-rscss": "^0.4.0",
"url-loader": "^1.1.2",
"vue-loader": "^14.0.0",
"vue-style-loader": "^4.0.0",
"webpack": "^4.44.0",
"webpack-dev-middleware": "^3.6.0",
"webpack-hot-middleware": "^2.12.2",
"webpack-merge": "^0.14.1"
},
"engines": {
"node": ">= 4.0.0",
"npm": ">= 3.0.0"
}
}
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
new file mode 100644
index 0000000000..3b29eb4c1c
--- /dev/null
+++ b/src/components/rich_content/rich_content.jsx
@@ -0,0 +1,66 @@
+import Vue from 'vue'
+import { mapGetters } from 'vuex'
+import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
+import { convertHtml, getTagName, processTextForEmoji, getAttrs } from 'src/services/mini_html_converter/mini_html_converter.service.js'
+import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import StillImage from 'src/components/still-image/still-image.vue'
+
+import './rich_content.scss'
+
+export default Vue.component('RichContent', {
+ name: 'RichContent',
+ props: {
+ html: {
+ required: true,
+ type: String
+ },
+ emoji: {
+ required: true,
+ type: Array
+ }
+ },
+ render (h) {
+ const renderImage = (tag) => {
+ return <StillImage {...{ attrs: getAttrs(tag) }} />
+ }
+ const structure = convertHtml(this.html)
+ const processItem = (item) => {
+ if (typeof item === 'string') {
+ if (item.includes(':')) {
+ return processTextForEmoji(
+ item,
+ this.emoji,
+ ({ shortcode, url }) => {
+ return <StillImage
+ class="emoji"
+ src={url}
+ title={`:${shortcode}:`}
+ alt={`:${shortcode}:`}
+ />
+ }
+ )
+ } else {
+ return item
+ }
+ }
+ if (Array.isArray(item)) {
+ const [opener, children] = item
+ const Tag = getTagName(opener)
+ if (Tag === 'img') {
+ return renderImage(opener)
+ }
+ if (children !== undefined) {
+ return <Tag {...{ attrs: getAttrs(opener) }}>
+ { children.map(processItem) }
+ </Tag>
+ } else {
+ return <Tag/>
+ }
+ }
+ }
+ return <div>
+ { structure.map(processItem) }
+ </div>
+ }
+})
diff --git a/src/components/rich_content/rich_content.scss b/src/components/rich_content/rich_content.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index a6f79d766f..571f1a78e6 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -1,216 +1,218 @@
import Attachment from '../attachment/attachment.vue'
import Poll from '../poll/poll.vue'
import Gallery from '../gallery/gallery.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
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 {
faCircleNotch,
faFile,
faMusic,
faImage,
faLink,
faPollH
} from '@fortawesome/free-solid-svg-icons'
library.add(
faCircleNotch,
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
}
},
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
}
if (this.status.summary && this.localCollapseSubjectDefault) {
return false
}
return true
},
attachmentSize () {
if ((this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation) ||
(this.status.attachments.length > this.maxThumbnails)) {
return 'hide'
} else if (this.compact) {
return 'small'
}
return 'normal'
},
galleryTypes () {
if (this.attachmentSize === 'hide') {
return []
}
return this.mergedConfig.playVideosInModal
? ['image', 'video']
: ['image']
},
galleryAttachments () {
return this.status.attachments.filter(
file => fileType.fileMatchesSomeType(this.galleryTypes, file)
)
},
nonGalleryAttachments () {
return this.status.attachments.filter(
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
+ const html = this.status.raw_html
if (this.mergedConfig.greentext) {
try {
if (html.includes('>')) {
// This checks if post has '>' at the beginning, excluding mentions so that @mention >impying works
return processHtml(html, (string) => {
if (string.includes('>') &&
string
.replace(/<[^>]+?>/gi, '') // remove all tags
.replace(/@\w+/gi, '') // remove mentions (even failed ones)
.trim()
.startsWith('>')) {
return `<span class='greentext'>${string}</span>`
} else {
return string
}
})
} else {
return html
}
} catch (e) {
console.err('Failed to process status html', e)
return html
}
} else {
return html
}
},
...mapGetters(['mergedConfig']),
...mapState({
betterShadow: state => state.interface.browserSupport.cssFilter,
currentUser: state => state.users.currentUser
})
},
components: {
Attachment,
Poll,
Gallery,
- LinkPreview
+ LinkPreview,
+ RichContent
},
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)
}
}
}
export default StatusContent
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 90bfaf40ce..c1a78db9ea 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -1,294 +1,292 @@
<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) }"
>
- <div
+ <RichContent
class="media-body summary"
@click.prevent="linkClicked"
- v-html="status.summary_html"
- />
+ :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"
:class="{ 'tall-subject-hider_focused': focused }"
@click.prevent="showingLongSubject=true"
>
{{ $t("status.show_full_subject") }}
</button>
</div>
<div
:class="{'tall-status': hideTallStatus}"
class="status-content-wrapper"
>
<button
v-if="hideTallStatus"
class="button-unstyled -link tall-status-hider"
:class="{ 'tall-status-hider_focused': focused }"
@click.prevent="toggleShowMore"
>
{{ $t("general.show_more") }}
</button>
- <div
+ <RichContent
v-if="!hideSubjectStatus"
:class="{ 'single-line': singleLine }"
class="status-content media-body"
@click.prevent="linkClicked"
- v-html="postBodyHtml"
- />
+ :html="postBodyHtml"
+ :emoji="status.emojis"/>
<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 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>
<slot name="footer" />
</div>
- <!-- eslint-enable vue/no-v-html -->
</template>
<script src="./status_content.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
$status-margin: 0.75em;
.StatusContent {
flex: 1;
min-width: 0;
.status-content-wrapper {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
}
.tall-status {
position: relative;
height: 220px;
overflow-x: hidden;
overflow-y: hidden;
z-index: 1;
.status-content {
min-height: 0;
mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */
-webkit-mask-composite: xor;
mask-composite: exclude;
}
}
.tall-status-hider {
display: inline-block;
word-break: break-all;
position: absolute;
height: 70px;
margin-top: 150px;
width: 100%;
text-align: center;
line-height: 110px;
z-index: 2;
}
.status-unhider, .cw-status-hider {
width: 100%;
text-align: center;
display: inline-block;
word-break: break-all;
svg {
color: inherit;
}
}
img, video {
max-width: 100%;
max-height: 400px;
vertical-align: middle;
object-fit: contain;
&.emoji {
width: 32px;
height: 32px;
}
}
.summary-wrapper {
margin-bottom: 0.5em;
border-style: solid;
border-width: 0 0 1px 0;
border-color: var(--border, $fallback--border);
flex-grow: 0;
}
.summary {
font-style: italic;
padding-bottom: 0.5em;
}
.tall-subject {
position: relative;
.summary {
max-height: 2em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.tall-subject-hider {
display: inline-block;
word-break: break-all;
// position: absolute;
width: 100%;
text-align: center;
padding-bottom: 0.5em;
}
.status-content {
font-family: var(--postFont, sans-serif);
line-height: 1.4em;
white-space: pre-wrap;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
blockquote {
margin: 0.2em 0 0.2em 2em;
font-style: italic;
}
pre {
overflow: auto;
}
code, samp, kbd, var, pre {
font-family: var(--postCodeFont, monospace);
}
p {
margin: 0 0 1em 0;
}
p:last-child {
margin: 0 0 0 0;
}
h1 {
font-size: 1.1em;
line-height: 1.2em;
margin: 1.4em 0;
}
h2 {
font-size: 1.1em;
margin: 1.0em 0;
}
h3 {
font-size: 1em;
margin: 1.2em 0;
}
h4 {
margin: 1.1em 0;
}
&.single-line {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
height: 1.4em;
}
}
}
.greentext {
color: $fallback--cGreen;
color: var(--postGreentext, $fallback--cGreen);
}
</style>
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index a4ddf92722..9f63feb620 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -1,462 +1,465 @@
import escape from 'escape-html'
import parseLinkHeader from 'parse-link-header'
import { isStatusNotification } from '../notification_utils/notification_utils.js'
import punycode from 'punycode.js'
/** NOTICE! **
* Do not initialize UI-generated data here.
* It will override existing data.
*
* i.e. user.pinnedStatusIds was set to [] here
* UI code would update it with data but upon next user fetch
* it would be reverted back to []
*/
const qvitterStatusType = (status) => {
if (status.is_post_verb) {
return 'status'
}
if (status.retweeted_status) {
return 'retweet'
}
if ((typeof status.uri === 'string' && status.uri.match(/(fave|objectType=Favourite)/)) ||
(typeof status.text === 'string' && status.text.match(/favorited/))) {
return 'favorite'
}
if (status.text.match(/deleted notice {{tag/) || status.qvitter_delete_notice) {
return 'deletion'
}
if (status.text.match(/started following/) || status.activity_type === 'follow') {
return 'follow'
}
return 'unknown'
}
export const parseUser = (data) => {
const output = {}
const masto = data.hasOwnProperty('acct')
// case for users in "mentions" property for statuses in MastoAPI
const mastoShort = masto && !data.hasOwnProperty('avatar')
output.id = String(data.id)
if (masto) {
output.screen_name = data.acct
output.statusnet_profile_url = data.url
// There's nothing else to get
if (mastoShort) {
return output
}
output.name = data.display_name
output.name_html = addEmojis(escape(data.display_name), data.emojis)
output.description = data.note
output.description_html = addEmojis(data.note, data.emojis)
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)
}
})
output.fields_text = data.fields.map(field => {
return {
name: unescape(field.name.replace(/<[^>]*>/g, '')),
value: unescape(field.value.replace(/<[^>]*>/g, ''))
}
})
// Utilize avatar_static for gif avatars?
output.profile_image_url = data.avatar
output.profile_image_url_original = data.avatar
// Same, utilize header_static?
output.cover_photo = data.header
output.friends_count = data.following_count
output.bot = data.bot
if (data.pleroma) {
const relationship = data.pleroma.relationship
output.background_image = data.pleroma.background_image
output.favicon = data.pleroma.favicon
output.token = data.pleroma.chat_token
if (relationship) {
output.relationship = relationship
}
output.allow_following_move = data.pleroma.allow_following_move
output.hide_follows = data.pleroma.hide_follows
output.hide_followers = data.pleroma.hide_followers
output.hide_follows_count = data.pleroma.hide_follows_count
output.hide_followers_count = data.pleroma.hide_followers_count
output.rights = {
moderator: data.pleroma.is_moderator,
admin: data.pleroma.is_admin
}
// TODO: Clean up in UI? This is duplication from what BE does for qvitterapi
if (output.rights.admin) {
output.role = 'admin'
} else if (output.rights.moderator) {
output.role = 'moderator'
} else {
output.role = 'member'
}
}
if (data.source) {
output.description = data.source.note
output.default_scope = data.source.privacy
output.fields = data.source.fields
if (data.source.pleroma) {
output.no_rich_text = data.source.pleroma.no_rich_text
output.show_role = data.source.pleroma.show_role
output.discoverable = data.source.pleroma.discoverable
}
}
// TODO: handle is_local
output.is_local = !output.screen_name.includes('@')
} else {
output.screen_name = data.screen_name
output.name = data.name
output.name_html = data.name_html
output.description = data.description
output.description_html = data.description_html
output.profile_image_url = data.profile_image_url
output.profile_image_url_original = data.profile_image_url_original
output.cover_photo = data.cover_photo
output.friends_count = data.friends_count
// output.bot = ??? missing
output.statusnet_profile_url = data.statusnet_profile_url
output.is_local = data.is_local
output.role = data.role
output.show_role = data.show_role
if (data.rights) {
output.rights = {
moderator: data.rights.delete_others_notice,
admin: data.rights.admin
}
}
output.no_rich_text = data.no_rich_text
output.default_scope = data.default_scope
output.hide_follows = data.hide_follows
output.hide_followers = data.hide_followers
output.hide_follows_count = data.hide_follows_count
output.hide_followers_count = data.hide_followers_count
output.background_image = data.background_image
// Websocket token
output.token = data.token
// Convert relationsip data to expected format
output.relationship = {
muting: data.muted,
blocking: data.statusnet_blocking,
followed_by: data.follows_you,
following: data.following
}
}
output.created_at = new Date(data.created_at)
output.locked = data.locked
output.followers_count = data.followers_count
output.statuses_count = data.statuses_count
if (data.pleroma) {
output.follow_request_count = data.pleroma.follow_request_count
output.tags = data.pleroma.tags
// deactivated was changed to is_active in Pleroma 2.3.0
// so check if is_active is present
output.deactivated = typeof data.pleroma.is_active !== 'undefined'
? !data.pleroma.is_active // new backend
: data.pleroma.deactivated // old backend
output.notification_settings = data.pleroma.notification_settings
output.unread_chat_count = data.pleroma.unread_chat_count
}
output.tags = output.tags || []
output.rights = output.rights || {}
output.notification_settings = output.notification_settings || {}
// Convert punycode to unicode for UI
output.screen_name_ui = output.screen_name
if (output.screen_name && output.screen_name.includes('@')) {
const parts = output.screen_name.split('@')
let unicodeDomain = punycode.toUnicode(parts[1])
if (unicodeDomain !== parts[1]) {
// Add some identifier so users can potentially spot spoofing attempts:
// lain.com and xn--lin-6cd.com would appear identical otherwise.
unicodeDomain = '🌏' + unicodeDomain
output.screen_name_ui = [parts[0], unicodeDomain].join('@')
}
}
return output
}
export const parseAttachment = (data) => {
const output = {}
const masto = !data.hasOwnProperty('oembed')
if (masto) {
// Not exactly same...
output.mimetype = data.pleroma ? data.pleroma.mime_type : data.type
output.meta = data.meta // not present in BE yet
output.id = data.id
} else {
output.mimetype = data.mimetype
// output.meta = ??? missing
}
output.url = data.url
output.large_thumb_url = data.preview_url
output.description = data.description
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 = {}
const masto = data.hasOwnProperty('account')
if (masto) {
output.favorited = data.favourited
output.fave_num = data.favourites_count
output.repeated = data.reblogged
output.repeat_num = data.reblogs_count
output.bookmarked = data.bookmarked
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
if (data.pleroma) {
const { pleroma } = data
output.text = pleroma.content ? data.pleroma.content['text/plain'] : data.content
output.summary = pleroma.spoiler_text ? data.pleroma.spoiler_text['text/plain'] : data.spoiler_text
output.statusnet_conversation_id = data.pleroma.conversation_id
output.is_local = pleroma.local
output.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct
output.thread_muted = pleroma.thread_muted
output.emoji_reactions = pleroma.emoji_reactions
output.parent_visible = pleroma.parent_visible === undefined ? true : pleroma.parent_visible
} else {
output.text = data.content
output.summary = data.spoiler_text
}
output.in_reply_to_status_id = data.in_reply_to_id
output.in_reply_to_user_id = data.in_reply_to_account_id
output.replies_count = data.replies_count
if (output.type === 'retweet') {
output.retweeted_status = parseStatus(data.reblog)
}
+ output.summary_raw_html = escape(data.spoiler_text)
output.summary_html = addEmojis(escape(data.spoiler_text), data.emojis)
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)
}))
}
output.pinned = data.pinned
output.muted = data.muted
} else {
output.favorited = data.favorited
output.fave_num = data.fave_num
output.repeated = data.repeated
output.repeat_num = data.repeat_num
// catchall, temporary
// Object.assign(output, data)
output.type = qvitterStatusType(data)
if (data.nsfw === undefined) {
output.nsfw = isNsfw(data)
if (data.retweeted_status) {
output.nsfw = data.retweeted_status.nsfw
}
} else {
output.nsfw = data.nsfw
}
output.statusnet_html = data.statusnet_html
output.text = data.text
output.in_reply_to_status_id = data.in_reply_to_status_id
output.in_reply_to_user_id = data.in_reply_to_user_id
output.in_reply_to_screen_name = data.in_reply_to_screen_name
output.statusnet_conversation_id = data.statusnet_conversation_id
if (output.type === 'retweet') {
output.retweeted_status = parseStatus(data.retweeted_status)
}
output.summary = data.summary
output.summary_html = data.summary_html
output.external_url = data.external_url
output.is_local = data.is_local
}
output.id = String(data.id)
output.visibility = data.visibility
output.card = data.card
output.created_at = new Date(data.created_at)
// Converting to string, the right way.
output.in_reply_to_status_id = output.in_reply_to_status_id
? String(output.in_reply_to_status_id)
: null
output.in_reply_to_user_id = output.in_reply_to_user_id
? String(output.in_reply_to_user_id)
: null
output.user = parseUser(masto ? data.account : data.user)
output.attentions = ((masto ? data.mentions : data.attentions) || []).map(parseUser)
output.attachments = ((masto ? data.media_attachments : data.attachments) || [])
.map(parseAttachment)
const retweetedStatus = masto ? data.reblog : data.retweeted_status
if (retweetedStatus) {
output.retweeted_status = parseStatus(retweetedStatus)
}
output.favoritedBy = []
output.rebloggedBy = []
return output
}
export const parseNotification = (data) => {
const mastoDict = {
'favourite': 'like',
'reblog': 'repeat'
}
const masto = !data.hasOwnProperty('ntype')
const output = {}
if (masto) {
output.type = mastoDict[data.type] || data.type
output.seen = data.pleroma.is_seen
output.status = isStatusNotification(output.type) ? parseStatus(data.status) : null
output.action = output.status // TODO: Refactor, this is unneeded
output.target = output.type !== 'move'
? null
: parseUser(data.target)
output.from_profile = parseUser(data.account)
output.emoji = data.emoji
} else {
const parsedNotice = parseStatus(data.notice)
output.type = data.ntype
output.seen = Boolean(data.is_seen)
output.status = output.type === 'like'
? parseStatus(data.notice.favorited_status)
: parsedNotice
output.action = parsedNotice
output.from_profile = output.type === 'pleroma:chat_mention' ? parseUser(data.account) : parseUser(data.from_profile)
}
output.created_at = new Date(data.created_at)
output.id = parseInt(data.id)
return output
}
const isNsfw = (status) => {
const nsfwRegex = /#nsfw/i
return (status.tags || []).includes('nsfw') || !!(status.text || '').match(nsfwRegex)
}
export const parseLinkHeaderPagination = (linkHeader, opts = {}) => {
const flakeId = opts.flakeId
const parsedLinkHeader = parseLinkHeader(linkHeader)
if (!parsedLinkHeader) return
const maxId = parsedLinkHeader.next.max_id
const minId = parsedLinkHeader.prev.min_id
return {
maxId: flakeId ? maxId : parseInt(maxId, 10),
minId: flakeId ? minId : parseInt(minId, 10)
}
}
export const parseChat = (chat) => {
const output = {}
output.id = chat.id
output.account = parseUser(chat.account)
output.unread = chat.unread
output.lastMessage = parseChatMessage(chat.last_message)
output.updated_at = new Date(chat.updated_at)
return output
}
export const parseChatMessage = (message) => {
if (!message) { return }
if (message.isNormalized) { return message }
const output = 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 = ''
}
if (message.attachment) {
output.attachments = [parseAttachment(message.attachment)]
} else {
output.attachments = []
}
output.pending = !!message.pending
output.error = false
output.idempotency_key = message.idempotency_key
output.isNormalized = true
return output
}
diff --git a/src/services/mini_html_converter/mini_html_converter.service.js b/src/services/mini_html_converter/mini_html_converter.service.js
new file mode 100644
index 0000000000..00d2001948
--- /dev/null
+++ b/src/services/mini_html_converter/mini_html_converter.service.js
@@ -0,0 +1,137 @@
+/**
+ * This is a not-so-tiny purpose-built HTML parser/processor. It was made for use
+ * with StatusText component for purpose of replacing tags with vue components
+ *
+ * known issue: doesn't handle CDATA so nested CDATA might not work well
+ *
+ * @param {Object} input - input data
+ * @param {(string) => string} lineProcessor - function that will be called on every line
+ * @param {{ key[string]: (string) => string}} tagProcessor - map of processors for tags
+ * @return {string} processed html
+ */
+export const convertHtml = (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
+}
+
+// Extracts tag name from tag, i.e. <span a="b"> => span
+export const getTagName = (tag) => {
+ const result = /(?:<\/(\w+)>|<(\w+)\s?.*?\/?>)/gi.exec(tag)
+ return result && (result[1] || result[2])
+}
+
+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
+ }
+ }
+ return buffer
+}
+
+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]
+ })
+ return Object.fromEntries(attrs)
+}
diff --git a/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js b/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js
new file mode 100644
index 0000000000..41818f57b0
--- /dev/null
+++ b/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js
@@ -0,0 +1,130 @@
+import { convertHtml, processTextForEmoji } from 'src/services/mini_html_converter/mini_html_converter.service.js'
+
+describe('MiniHtmlConverter', () => {
+ describe('convertHtml', () => {
+ it('converts html into a tree structure', () => {
+ const inputOutput = '1 <p>2</p> <b>3<img src="a">4</b>5'
+ expect(convertHtml(inputOutput)).to.eql([
+ '1 ',
+ [
+ '<p>',
+ ['2'],
+ '</p>'
+ ],
+ ' ',
+ [
+ '<b>',
+ [
+ '3',
+ ['<img src="a">'],
+ '4'
+ ],
+ '</b>'
+ ],
+ '5'
+ ])
+ })
+ it('converts html to tree while preserving tag formatting', () => {
+ const inputOutput = '1 <p >2</p><b >3<img src="a">4</b>5'
+ expect(convertHtml(inputOutput)).to.eql([
+ '1 ',
+ [
+ '<p >',
+ ['2'],
+ '</p>'
+ ],
+ [
+ '<b >',
+ [
+ '3',
+ ['<img src="a">'],
+ '4'
+ ],
+ '</b>'
+ ],
+ '5'
+ ])
+ })
+ it('converts semi-broken html', () => {
+ const inputOutput = '1 <br> 2 <p> 42'
+ expect(convertHtml(inputOutput)).to.eql([
+ '1 ',
+ ['<br>'],
+ ' 2 ',
+ [
+ '<p>',
+ [' 42']
+ ]
+ ])
+ })
+ it('realistic case', () => {
+ const inputOutput = '<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(convertHtml(inputOutput)).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>'
+ ]
+ ])
+ })
+ })
+ describe('processTextForEmoji', () => {
+ it('processes all emoji in text', () => {
+ const inputOutput = '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(inputOutput, emojis, processor)).to.eql([
+ 'Hello from finland! ',
+ { shortcode: 'lol', src: 'LOL' },
+ ' We have best water! ',
+ { shortcode: 'lmao', src: 'LMAO' }
+ ])
+ })
+ })
+})
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Nov 1, 6:35 AM (11 h, 27 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1802688
Default Alt Text
(45 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline