Merge branch 'better-still-emoji' into shigusegubu

* better-still-emoji:
  lint & cleanup
  fix #935
  fixed console errors, improved user-selecting, added cyantexting
  fix infinite loop
  better handling of hellthreads with mentions at bottom
  stylistic changes
  Hellthread(tm) Certified
  don't hide mentions for OPs
  mentions on same line as replies
  use icon instead of symbol for @ in mentions links
  stylistic improvements for single-line mentions
  proper cachin of headTailLinks, show mentions in notificaitons always
  moved mentions onto reply line, replies moved below post body
  Moved greentext to RichContent, improved how first mentions are restored, now shows mentions not uh, mention in post body
This commit is contained in:
Henry Jameson 2021-06-11 12:20:10 +03:00
commit 3fbf6fc5ac
21 changed files with 618 additions and 330 deletions

View file

@ -1,11 +1,20 @@
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: {
@ -19,11 +28,6 @@ const MentionLink = {
userScreenName: {
required: false,
type: String
},
firstMention: {
required: false,
type: Boolean,
default: false
}
},
methods: {
@ -61,9 +65,6 @@ const MentionLink = {
highlightClass () {
if (this.highlight) return highlightClass(this.user)
},
oldPlace () {
return !this.mergedConfig.mentionsOwnLine
},
oldStyle () {
return !this.mergedConfig.mentionsNewStyle
},
@ -83,7 +84,6 @@ const MentionLink = {
{
'-you': this.isYou,
'-highlighted': this.highlight,
'-firstMention': this.firstMention,
'-oldStyle': this.oldStyle
},
this.highlightType

View file

@ -28,22 +28,21 @@
z-index: 1;
margin-top: 0.25em;
padding: 0.5em;
user-select: all;
}
.short {
user-select: none;
}
& .short,
& .full {
&::before {
content: '@';
}
white-space: nowrap;
}
.new {
margin-right: 0.25em;
&.-firstMention {
display: none;
}
&.-you {
& .shortName,
& .full {
@ -51,17 +50,31 @@
}
}
.at {
color: var(--link);
opacity: 0.8;
display: inline-block;
height: 50%;
line-height: 1;
padding: 0 0.1em;
vertical-align: -25%;
margin: 0;
}
&:not(.-oldStyle) {
.short {
padding-left: 0.25em;
padding-right: 0;
padding-top: 0;
padding-bottom: 0;
line-height: 1.5;
font-size: inherit;
&::before {
.at {
color: var(--faint);
display: inline-block;
height: 50%;
line-height: 1;
vertical-align: 6%;
opacity: 0.8;
padding-right: 0.25em;
vertical-align: -20%;
}
}
@ -69,18 +82,11 @@
padding-right: 0.25em;
}
.short {
padding-left: 0.25em;
padding-right: 0;
padding-top: 0;
padding-bottom: 0;
}
.userName {
display: inline-block;
color: var(--link);
line-height: inherit;
margin-left: 0.125em;
margin-left: 0;
padding-left: 0.125em;
padding-right: 0.25em;
padding-top: 0;

View file

@ -1,7 +1,6 @@
<template>
<span
class="MentionLink"
:class="{ '-oldPlace': oldPlace }"
>
<!-- eslint-disable vue/no-v-html -->
<a
@ -23,7 +22,11 @@
@click.prevent="onClick"
>
<!-- eslint-disable vue/no-v-html -->
<span class="shortName"><span
<FAIcon
size="sm"
icon="at"
class="at"
/><span class="shortName"><span
class="userName"
v-html="userName"
/></span>
@ -41,7 +44,7 @@
<!-- eslint-disable vue/no-v-html -->
<span
class="userNameFull"
v-html="userNameFull"
v-text="'@' + userNameFull"
/>
<!-- eslint-enable vue/no-v-html -->
</span>

View file

@ -4,7 +4,7 @@ import { mapGetters } from 'vuex'
const MentionsLine = {
name: 'MentionsLine',
props: {
attentions: {
mentions: {
required: true,
type: Array
}
@ -20,11 +20,11 @@ const MentionsLine = {
limit () {
return 6
},
mentions () {
return this.attentions.slice(0, this.limit)
mentionsComputed () {
return this.mentions.slice(0, this.limit)
},
extraMentions () {
return this.attentions.slice(this.limit)
return this.mentions.slice(this.limit)
},
manyMentions () {
return this.extraMentions.length > 0

View file

@ -1,5 +1,6 @@
.MentionsLine {
.showMoreLess {
white-space: normal;
&.-newStyle {
line-height: 1.5;
font-size: inherit;

View file

@ -1,11 +1,11 @@
<template>
<span class="MentionsLine">
<MentionLink
v-for="mention in mentions"
:key="mention.statusnet_profile_url"
v-for="mention in mentionsComputed"
:key="mention.index"
class="mention-link"
:content="mention.statusnet_profile_url"
:url="mention.statusnet_profile_url"
:content="mention.content"
:url="mention.url"
:first-mention="false"
/><span
v-if="manyMentions"
@ -17,10 +17,10 @@
>
<MentionLink
v-for="mention in extraMentions"
:key="mention.statusnet_profile_url"
:key="mention.index"
class="mention-link"
:content="mention.statusnet_profile_url"
:url="mention.statusnet_profile_url"
:content="mention.content"
:url="mention.url"
:first-mention="false"
/>
</span><button

View file

@ -1,6 +1,7 @@
import Vue from 'vue'
import { unescape, flattenDeep } from 'lodash'
import { convertHtml, getTagName, processTextForEmoji, getAttrs } from 'src/services/mini_html_converter/mini_html_converter.service.js'
import { convertHtmlToTree, getTagName, processTextForEmoji, getAttrs } 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 MentionLink from 'src/components/mention_link/mention_link.vue'
@ -24,27 +25,72 @@ export default Vue.component('RichContent', {
required: false,
type: Boolean,
default: false
},
// Meme arrows
greentext: {
required: false,
type: Boolean,
default: false
},
// Whether to hide last mentions (hellthreads)
hideMentions: {
required: false,
type: Boolean,
default: false
}
},
// NEVER EVER TOUCH DATA INSIDE RENDER
render (h) {
// Pre-process HTML
const { newHtml: html, lastMentions } = preProcessPerLine(this.html, this.greentext, this.hideMentions)
const firstMentions = [] // Mentions that appear in the beginning of post body
const lastTags = [] // Tags that appear at the end of post body
const writtenMentions = [] // All mentions that appear in post body
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 renderMention = (attrs, children, encounteredText) => {
return <MentionLink
url={attrs.href}
content={flattenDeep(children).join('')}
firstMention={!encounteredText}
/>
const renderHashtag = (attrs, children, encounteredTextReverse) => {
const linkData = getLinkData(attrs, children, tagsIndex++)
writtenTags.push(linkData)
attrs.target = '_blank'
if (!encounteredTextReverse) {
lastTags.push(linkData)
attrs['data-parser-last'] = true
}
return <a {...{ attrs }}>
{ children.map(processItem) }
</a>
}
const renderMention = (attrs, children, encounteredText) => {
const linkData = getLinkData(attrs, children, mentionIndex++)
writtenMentions.push(linkData)
if (!encounteredText) {
firstMentions.push(linkData)
return ''
} else {
return <MentionLink
url={attrs.href}
content={flattenDeep(children).join('')}
/>
}
}
// We stop treating mentions as "first" ones when we encounter
// non-whitespace text
let encounteredText = false
// Processor to use with mini_html_converter
const processItem = (item) => {
// Handle text noes - just add emoji
// 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 (emptyText) {
@ -56,7 +102,7 @@ export default Vue.component('RichContent', {
encounteredText = true
}
if (item.includes(':')) {
return processTextForEmoji(
unescapedItem = processTextForEmoji(
unescapedItem,
this.emoji,
({ shortcode, url }) => {
@ -68,24 +114,42 @@ export default Vue.component('RichContent', {
/>
}
)
} else {
return unescapedItem
}
return unescapedItem
}
// Handle tag nodes
if (Array.isArray(item)) {
const [opener, children] = item
const Tag = getTagName(opener)
const attrs = getAttrs(opener)
switch (Tag) {
case 'span': // replace images with StillImage
if (attrs['class'] && attrs['class'].includes('lastMentions')) {
if (firstMentions.length > 0) {
break
} else {
return ''
}
} else {
break
}
case 'img': // replace images with StillImage
return renderImage(opener)
case 'a': // replace mentions with MentionLink
if (!this.handleLinks) break
const attrs = getAttrs(opener)
if (attrs['class'] && attrs['class'].includes('mention')) {
return renderMention(attrs, children, encounteredText)
} else if (attrs['class'] && attrs['class'].includes('hashtag')) {
return item // We'll handle it later
} else {
attrs.target = '_blank'
return <a {...{ attrs }}>
{ children.map(processItem) }
</a>
}
}
// Render tag as is
if (children !== undefined) {
return <Tag {...{ attrs: getAttrs(opener) }}>
@ -96,8 +160,163 @@ export default Vue.component('RichContent', {
}
}
}
return <span class="RichContent">
{ convertHtml(this.html).map(processItem) }
// 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 encounteredTextReverse ? item : item.trim()
if (!encounteredTextReverse) encounteredTextReverse = true
return item
} else if (Array.isArray(item)) {
// Handle tag nodes
const [opener, children] = item
const Tag = 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')) {
return renderHashtag(attrs, children, encounteredTextReverse)
}
}
}
return item
}
// 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">
{ convertHtmlToTree(html).map(processItem).reverse().map(processItemReverse).reverse() }
</span>
const event = {
firstMentions,
lastMentions,
lastTags,
writtenMentions,
writtenTags
}
// DO NOT MOVE TO UPDATE. BAD IDEA.
this.$emit('parseReady', event)
return result
}
})
const getLinkData = (attrs, children, index) => {
return {
index,
url: attrs.href,
hashtag: attrs['data-tag'],
content: flattenDeep(children).join('')
}
}
/** Pre-processing HTML
*
* Currently this does two things:
* - add green/cyantexting
* - wrap and mark last line containing only mentions as ".lastMentionsLine" for
* more compact hellthreads.
*
* @param {String} html - raw HTML to process
* @param {Boolean} greentext - whether to enable greentexting or not
*/
export const preProcessPerLine = (html, greentext) => {
const lastMentions = []
const newHtml = convertHtmlToLines(html).reverse().map((item, index, array) => {
// Going over each line in reverse to detect last mentions,
// keeping non-text stuff as-is
if (!item.text) return item
const string = item.text
// Greentext stuff
if (greentext && (string.includes('&gt;') || string.includes('&lt;'))) {
const cleanedString = string.replace(/<[^>]+?>/gi, '') // remove all tags
.replace(/@\w+/gi, '') // remove mentions (even failed ones)
.trim()
if (cleanedString.startsWith('&gt;')) {
return `<span class='greentext'>${string}</span>`
} else if (cleanedString.startsWith('&lt;')) {
return `<span class='cyantext'>${string}</span>`
}
}
// Converting that line part into tree
const tree = convertHtmlToTree(string)
// If line has loose text, i.e. text outside a mention or a tag
// we won't touch mentions.
let hasLooseText = false
let hasMentions = false
const process = (item) => {
if (Array.isArray(item)) {
const [opener, children, closer] = item
const tag = getTagName(opener)
// If we have a link we probably have mentions
if (tag === 'a') {
const attrs = getAttrs(opener)
if (attrs['class'] && attrs['class'].includes('mention')) {
// Got mentions
hasMentions = true
return [opener, children, closer]
} else {
// Not a mention? Means we have loose text or whatever
hasLooseText = true
return [opener, children, closer]
}
} else if (tag === 'span' || tag === 'p') {
// For span and p we need to go deeper
return [opener, [...children].map(process), closer]
} else {
// Everything else equals to a loose text
hasLooseText = true
return [opener, children, closer]
}
}
if (typeof item === 'string') {
if (item.trim() !== '') {
// only meaningful strings are loose text
hasLooseText = true
}
return item
}
}
// We now processed our tree, now we need to mark line as lastMentions
const result = [...tree].map(process)
// Only check last (first since list is reversed) line
if (hasMentions && !hasLooseText && index === 0) {
let mentionIndex = 0
const process = (item) => {
if (Array.isArray(item)) {
const [opener, children] = item
const tag = getTagName(opener)
if (tag === 'a') {
const attrs = getAttrs(opener)
lastMentions.push(getLinkData(attrs, children, mentionIndex++))
} else if (children) {
children.forEach(process)
}
}
}
result.forEach(process)
// we DO need mentions here so that we conditionally remove them if don't
// have first mentions
return ['<span class="lastMentions">', flattenDeep(result).join(''), '</span>'].join('')
} else {
return flattenDeep(result).join('')
}
}).reverse().join('')
return { newHtml, lastMentions }
}

View file

@ -100,7 +100,8 @@ const Status = {
userExpanded: false,
mediaPlaying: [],
suspendable: true,
error: null
error: null,
headTailLinks: null
}
},
computed: {
@ -141,7 +142,8 @@ const Status = {
replyProfileLink () {
if (this.isReply) {
const user = this.$store.getters.findUser(this.status.in_reply_to_user_id)
return user && user.statusnet_profile_url
// FIXME Why user not found sometimes???
return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
retweet () { return !!this.statusoid.retweeted_status },
@ -166,17 +168,31 @@ const Status = {
muteWordHits () {
return muteWordHits(this.status, this.muteWords)
},
mentionsOwnLine () {
return this.mergedConfig.mentionsOwnLine
},
mentions () {
return this.status.attentions.filter(attn => {
return attn.screen_name !== this.replyToName &&
attn.screen_name !== this.status.user.screen_name
}).map(attn => ({
url: attn.statusnet_profile_url,
content: attn.screen_name,
userId: attn.id
}))
},
alsoMentions () {
if (!this.headTailLinks) return []
const set = new Set(this.headTailLinks.writtenMentions.map(m => m.url))
return this.headTailLinks.writtenMentions.filter(mention => {
return !set.has(mention.url)
})
},
hasMentions () {
return this.mentions.length > 0
mentionsLine () {
return this.mentionsOwnLine ? this.mentions : this.alsoMentions
},
mentionsOwnLine () {
return this.mergedConfig.mentionsOwnLine
},
hasMentionsLine () {
return this.mentionsLine.length > 0
},
muted () {
if (this.statusoid.user.id === this.currentUser.id) return false
@ -343,6 +359,9 @@ const Status = {
},
removeMediaPlaying (id) {
this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id)
},
setHeadTailLinks (headTailLinks) {
this.headTailLinks = headTailLinks
}
},
watch: {
@ -353,7 +372,7 @@ const Status = {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.height >= (window.innerHeight - 50)) {
// Post we want to see is taller than screen so match its top to screen top
// Post we wahttp://localhost:8080/users/hj/dmsnt to see is taller than screen so match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.bottom > window.innerHeight - 50) {
// Post is below screen, match its bottom to screen bottom

View file

@ -1,4 +1,3 @@
@import '../../_variables.scss';
$status-margin: 0.75em;
@ -151,36 +150,24 @@ $status-margin: 0.75em;
}
}
.glued-label {
display: inline-flex;
white-space: nowrap;
}
.timeago {
margin-right: 0.2em;
}
& .heading-mentions-row,
& .heading-reply-row {
position: relative;
align-content: baseline;
font-size: 12px;
line-height: 18px;
line-height: 160%;
max-width: 100%;
display: flex;
flex-wrap: wrap;
align-items: stretch;
}
.reply-to-and-accountname {
display: flex;
height: 18px;
margin-right: 0.5em;
max-width: 100%;
.reply-to-link {
white-space: nowrap;
word-break: break-word;
text-overflow: ellipsis;
overflow-x: hidden;
}
}
& .reply-to-popover,
& .reply-to-no-popover {
min-width: 0;
@ -217,21 +204,25 @@ $status-margin: 0.75em;
}
}
.reply-to {
& .mentions,
& .reply-to {
white-space: nowrap;
position: relative;
padding-right: 0.25em;
}
.reply-to-text {
& .reply-to-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.replies-separator {
margin-left: 0.4em;
.mentions-line {
display: inline;
}
.replies {
margin-top: 0.25em;
line-height: 18px;
font-size: 12px;
display: flex;

View file

@ -221,10 +221,13 @@
</button>
</span>
</div>
<div class="heading-reply-row">
<div
<div
v-if="isReply || hasMentionsLine"
class="heading-reply-row"
>
<span
v-if="isReply"
class="reply-to-and-accountname"
class="glued-label"
>
<StatusPopover
v-if="!isPreview"
@ -257,81 +260,77 @@
>
<span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span>
<MentionLink
class="mention-link"
:content="replyToName"
:url="replyProfileLink"
:user-id="status.in_reply_to_user_id"
:user-screen-name="status.in_reply_to_screen_name"
:first-mention="false"
/>
<span
v-if="replies && replies.length"
class="faint replies-separator"
>
-
</span>
</div>
<div
v-if="inConversation && !isPreview && replies && replies.length"
class="replies"
>
<span class="faint">{{ $t('status.replies_list') }}</span>
<StatusPopover
v-for="reply in replies"
:key="reply.id"
:status-id="reply.id"
>
<button
class="button-unstyled -link reply-link"
@click.prevent="gotoOriginal(reply.id)"
>
{{ reply.name }}
</button>
</StatusPopover>
</div>
</div>
</span>
<div
v-if="hasMentions && mentionsOwnLine"
class="heading-mentions-row"
>
<div
class="mentions"
<!-- This little wrapper is made for sole purpose of "gluing" -->
<!-- "Mentions" label to the first mention -->
<span
v-if="hasMentionsLine"
class="glued-label"
>
<span
class="button-unstyled reply-to"
:aria-label="$t('tool_tip.reply')"
class="mentions"
:aria-label="$t('tool_tip.mentions')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="at"
/>
<span
class="faint-link reply-to-text"
class="faint-link mentions-text"
>
{{ $t('status.mentions') }}
</span>
</span>
<MentionsLine
:attentions="mentions"
class="mentions-line"
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(0, 1)"
class="mentions-line-first"
/>
</div>
</span>
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(1)"
class="mentions-line"
/>
</div>
</div>
<StatusContent
ref="content"
:status="status"
:no-heading="noHeading"
:highlight="highlight"
:focused="isFocused"
:hide-mentions="mentionsOwnLine && (isReply || true)"
@mediaplay="addMediaPlaying($event)"
@mediapause="removeMediaPlaying($event)"
@parseReady="setHeadTailLinks"
/>
<div
v-if="inConversation && !isPreview && replies && replies.length"
class="replies"
>
<span class="faint">{{ $t('status.replies_list') }}</span>
<StatusPopover
v-for="reply in replies"
:key="reply.id"
:status-id="reply.id"
>
<button
class="button-unstyled -link reply-link"
@click.prevent="gotoOriginal(reply.id)"
>
{{ reply.name }}
</button>
</StatusPopover>
</div>
<transition name="fade">
<div
v-if="!hidePostStats && isFocused && combinedFavsAndRepeatsUsers.length > 0"

View file

@ -1,10 +1,9 @@
import fileType from 'src/services/file_type/file_type.service'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import RichContent, { getHeadTailLinks } from 'src/components/rich_content/rich_content.jsx'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
import { extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import { set } from 'vue'
import {
faFile,
faMusic,
@ -28,14 +27,18 @@ const StatusContent = {
'focused',
'noHeading',
'fullContent',
'singleLine'
'singleLine',
'hideMentions'
],
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
expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject,
headTailLinks: null,
firstMentions: [],
lastMentions: []
}
},
computed: {
@ -72,45 +75,9 @@ const StatusContent = {
showingMore () {
return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject)
},
postBodyHtml () {
const html = this.status.raw_html
if (this.mergedConfig.greentext) {
try {
if (html.includes('&gt;')) {
// This checks if post has '>' at the beginning, excluding mentions so that @mention >impying works
return processHtml(html, (string) => {
if (string.includes('&gt;') &&
string
.replace(/<[^>]+?>/gi, '') // remove all tags
.replace(/@\w+/gi, '') // remove mentions (even failed ones)
.trim()
.startsWith('&gt;')) {
return `<span class='greentext'>${string}</span>`
} else {
return string
}
})
} else {
return html
}
} catch (e) {
console.error('Failed to process status html', e)
return html
}
} else {
return html
}
},
attachmentTypes () {
return this.status.attachments.map(file => fileType.fileType(file.mimetype))
},
mentionsOwnLine () {
return this.mergedConfig.mentionsOwnLine
},
mentions () {
return this.status.attentions
},
...mapGetters(['mergedConfig'])
},
components: {
@ -124,21 +91,6 @@ const StatusContent = {
})
},
methods: {
linkClicked (event) {
const target = event.target.closest('.status-content a')
if (target) {
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
@ -146,6 +98,11 @@ const StatusContent = {
this.expandingSubject = !this.expandingSubject
}
},
setHeadTailLinks (headTailLinks) {
set(this, 'headTailLinks', headTailLinks)
set(this, 'firstMentions', headTailLinks.firstMentions)
set(this, 'lastMentions', headTailLinks.lastMentions)
},
generateTagLink (tag) {
return `/tag/${tag}`
}

View file

@ -112,6 +112,10 @@
color: var(--postGreentext, $fallback--cGreen);
}
.cyantext {
color: var(--postCyantext, $fallback--cBlue);
}
/* Not sure if this is necessary */
video {
max-width: 100%;

View file

@ -10,7 +10,6 @@
class="media-body summary"
:html="status.summary_raw_html"
:emoji="status.emojis"
@click.prevent="linkClicked"
/>
<button
v-if="longSubject && showingLongSubject"
@ -39,22 +38,24 @@
>
{{ $t("general.show_more") }}
</button>
<span
class="text-wrapper"
v-if="!hideSubjectStatus && !(singleLine && status.summary_html)"
>
<span v-if="!hideSubjectStatus && !(singleLine && status.summary_html)">
<MentionsLine
v-if="!mentionsOwnLine"
:attentions="status.attentions"
class="mentions-line"
v-if="!hideMentions && firstMentions && firstMentions.length > 0"
:mentions="firstMentions"
/>
<RichContent
:class="{ '-single-line': singleLine }"
class="text media-body"
:html="postBodyHtml"
:html="status.raw_html"
:emoji="status.emojis"
:handle-links="true"
@click.prevent="linkClicked"
:hide-mentions="hideMentions"
:greentext="mergedConfig.greentext"
@parseReady="setHeadTailLinks"
/>
<MentionsLine
v-if="!hideMentions && lastMentions.length > 0 && firstMentions.length === 0"
:mentions="lastMentions"
/>
</span>

View file

@ -31,7 +31,8 @@ const StatusContent = {
'focused',
'noHeading',
'fullContent',
'singleLine'
'singleLine',
'hideMentions'
],
computed: {
hideAttachments () {
@ -91,6 +92,9 @@ const StatusContent = {
StatusBody
},
methods: {
setHeadTailLinks (headTailLinks) {
this.$emit('parseReady', headTailLinks)
},
setMedia () {
const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
return () => this.$store.dispatch('setMedia', attachments)

View file

@ -4,6 +4,8 @@
<StatusBody
:status="status"
:single-line="singleLine"
:hide-mentions="hideMentions"
@parseReady="setHeadTailLinks"
>
<div v-if="status.poll && status.poll.options">
<poll :base-poll="status.poll" />

View file

@ -1,18 +1,27 @@
/**
* 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
* 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
* @param {(string) => string} processor - function that will be called on every line
* @return {string} processed html
* @return {(string|{ text: string })[]} processed html in form of a list.
*/
export const processHtml = (html, processor) => {
const handledTags = new Set(['p', 'br', 'div'])
const openCloseTags = new Set(['p', 'div'])
export const convertHtmlToLines = (html) => {
const ignoredTags = new Set(['code', 'blockquote'])
const handledTags = new Set(['p', 'br', 'div', 'pre', 'code', 'blockquote'])
const openCloseTags = new Set(['p', 'div', 'pre', 'code', 'blockquote'])
let buffer = '' // Current output buffer
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
@ -24,30 +33,30 @@ export const processHtml = (html, processor) => {
}
const flush = () => { // Processes current line buffer, adds it to output buffer and clears line buffer
if (textBuffer.trim().length > 0) {
buffer += processor(textBuffer)
if (textBuffer.trim().length > 0 && !level.some(l => ignoredTags.has(l))) {
buffer.push({ text: textBuffer })
} else {
buffer += textBuffer
buffer.push(textBuffer)
}
textBuffer = ''
}
const handleBr = (tag) => { // handles single newlines/linebreaks/selfclosing
flush()
buffer += tag
buffer.push(tag)
}
const handleOpen = (tag) => { // handles opening tags
flush()
buffer += tag
level.push(tag)
buffer.push(tag)
level.unshift(getTagName(tag))
}
const handleClose = (tag) => { // handles closing tags
flush()
buffer += tag
if (level[level.length - 1] === tag) {
level.pop()
buffer.push(tag)
if (level[0] === getTagName(tag)) {
level.shift()
}
}

View file

@ -1,15 +1,23 @@
/**
* This is a not-so-tiny purpose-built HTML parser/processor. It was made for use
* with StatusBody component for purpose of replacing tags with vue components
* 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.
*
* known issue: doesn't handle CDATA so nested CDATA might not work well
* 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
* @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) => {
export const convertHtmlToTree = (html) => {
// Elements that are implicitly self-closing
// https://developer.mozilla.org/en-US/docs/Glossary/empty_element
const emptyElements = new Set([

View file

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

View file

@ -0,0 +1,155 @@
import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
const mapOnlyText = (processor) => (input) => input.text ? processor(input.text) : input
describe('TinyPostHTMLProcessor', () => {
describe('with processor that keeps original line should not make any changes to HTML when', () => {
const processorKeep = (line) => line
it('fed with regular HTML with newlines', () => {
const inputOutput = '1<br/>2<p class="lol">3 4</p> 5 \n 6 <p > 7 <br> 8 </p> <br>\n<br/>'
const result = convertHtmlToLines(inputOutput)
const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
expect(comparableResult).to.eql(inputOutput)
})
it('fed with possibly broken HTML with invalid tags/composition', () => {
const inputOutput = '<feeee dwdwddddddw> <i>ayy<b>lm</i>ao</b> </section>'
const result = convertHtmlToLines(inputOutput)
const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
expect(comparableResult).to.eql(inputOutput)
})
it('fed with very broken HTML with broken composition', () => {
const inputOutput = '</p> lmao what </div> whats going on <div> wha <p>'
const result = convertHtmlToLines(inputOutput)
const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
expect(comparableResult).to.eql(inputOutput)
})
it('fed with sorta valid HTML but tags aren\'t closed', () => {
const inputOutput = 'just leaving a <div> hanging'
const result = convertHtmlToLines(inputOutput)
const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
expect(comparableResult).to.eql(inputOutput)
})
it('fed with not really HTML at this point... tags that aren\'t finished', () => {
const inputOutput = 'do you expect me to finish this <div class='
const result = convertHtmlToLines(inputOutput)
const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
expect(comparableResult).to.eql(inputOutput)
})
it('fed with dubiously valid HTML (p within p and also div inside p)', () => {
const inputOutput = 'look ma <p> p \nwithin <p> p! </p> and a <br/><div>div!</div></p>'
const result = convertHtmlToLines(inputOutput)
const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
expect(comparableResult).to.eql(inputOutput)
})
it('fed with maybe valid HTML? self-closing divs and ps', () => {
const inputOutput = 'a <div class="what"/> what now <p aria-label="wtf"/> ?'
const result = convertHtmlToLines(inputOutput)
const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
expect(comparableResult).to.eql(inputOutput)
})
it('fed with valid XHTML containing a CDATA', () => {
const inputOutput = 'Yes, it is me, <![CDATA[DIO]]>'
const result = convertHtmlToLines(inputOutput)
const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
expect(comparableResult).to.eql(inputOutput)
})
})
describe('with processor that replaces lines with word "_" should match expected line when', () => {
const processorReplace = (line) => '_'
it('fed with regular HTML with newlines', () => {
const input = '1<br/>2<p class="lol">3 4</p> 5 \n 6 <p > 7 <br> 8 </p> <br>\n<br/>'
const output = '_<br/>_<p class="lol">_</p>_\n_<p >_<br>_</p> <br>\n<br/>'
const result = convertHtmlToLines(input)
const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
expect(comparableResult).to.eql(output)
})
it('fed with possibly broken HTML with invalid tags/composition', () => {
const input = '<feeee dwdwddddddw> <i>ayy<b>lm</i>ao</b> </section>'
const output = '_'
const result = convertHtmlToLines(input)
const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
expect(comparableResult).to.eql(output)
})
it('fed with very broken HTML with broken composition', () => {
const input = '</p> lmao what </div> whats going on <div> wha <p>'
const output = '</p>_</div>_<div>_<p>'
const result = convertHtmlToLines(input)
const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
expect(comparableResult).to.eql(output)
})
it('fed with sorta valid HTML but tags aren\'t closed', () => {
const input = 'just leaving a <div> hanging'
const output = '_<div>_'
const result = convertHtmlToLines(input)
const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
expect(comparableResult).to.eql(output)
})
it('fed with not really HTML at this point... tags that aren\'t finished', () => {
const input = 'do you expect me to finish this <div class='
const output = '_'
const result = convertHtmlToLines(input)
const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
expect(comparableResult).to.eql(output)
})
it('fed with dubiously valid HTML (p within p and also div inside p)', () => {
const input = 'look ma <p> p \nwithin <p> p! </p> and a <br/><div>div!</div></p>'
const output = '_<p>_\n_<p>_</p>_<br/><div>_</div></p>'
const result = convertHtmlToLines(input)
const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
expect(comparableResult).to.eql(output)
})
it('fed with maybe valid HTML? self-closing divs and ps', () => {
const input = 'a <div class="what"/> what now <p aria-label="wtf"/> ?'
const output = '_<div class="what"/>_<p aria-label="wtf"/>_'
const result = convertHtmlToLines(input)
const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
expect(comparableResult).to.eql(output)
})
it('fed with valid XHTML containing a CDATA', () => {
const input = 'Yes, it is me, <![CDATA[DIO]]>'
const output = '_'
const result = convertHtmlToLines(input)
const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
expect(comparableResult).to.eql(output)
})
it('Testing handling ignored blocks', () => {
const input = `
<pre><code>&gt; rei = &quot;0&quot;
&#39;0&#39;
&gt; rei == 0
true
&gt; rei == null
false</code></pre><blockquote>That, christian-like JS diagram but its evangelion instead.</blockquote>
`
const result = convertHtmlToLines(input)
const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
expect(comparableResult).to.eql(input)
})
it('Testing handling ignored blocks 2', () => {
const input = `
<blockquote>An SSL error has happened.</blockquote><p>Shakespeare</p>
`
const output = `
<blockquote>An SSL error has happened.</blockquote><p>_</p>
`
const result = convertHtmlToLines(input)
const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
expect(comparableResult).to.eql(output)
})
})
})

View file

@ -1,10 +1,10 @@
import { convertHtml, processTextForEmoji, getAttrs } from 'src/services/mini_html_converter/mini_html_converter.service.js'
import { convertHtmlToTree, processTextForEmoji, getAttrs } from 'src/services/html_converter/html_tree_converter.service.js'
describe('MiniHtmlConverter', () => {
describe('convertHtml', () => {
describe('convertHtmlToTree', () => {
it('converts html into a tree structure', () => {
const input = '1 <p>2</p> <b>3<img src="a">4</b>5'
expect(convertHtml(input)).to.eql([
expect(convertHtmlToTree(input)).to.eql([
'1 ',
[
'<p>',
@ -26,7 +26,7 @@ describe('MiniHtmlConverter', () => {
})
it('converts html to tree while preserving tag formatting', () => {
const input = '1 <p >2</p><b >3<img src="a">4</b>5'
expect(convertHtml(input)).to.eql([
expect(convertHtmlToTree(input)).to.eql([
'1 ',
[
'<p >',
@ -47,7 +47,7 @@ describe('MiniHtmlConverter', () => {
})
it('converts semi-broken html', () => {
const input = '1 <br> 2 <p> 42'
expect(convertHtml(input)).to.eql([
expect(convertHtmlToTree(input)).to.eql([
'1 ',
['<br>'],
' 2 ',
@ -59,7 +59,7 @@ describe('MiniHtmlConverter', () => {
})
it('realistic case 1', () => {
const input = '<p><span class="h-card"><a class="u-url mention" data-user="9wRC6T2ZZiKWJ0vUi8" href="https://cawfee.club/users/benis" rel="ugc">@<span>benis</span></a></span> <span class="h-card"><a class="u-url mention" data-user="194" href="https://shigusegubu.club/users/hj" rel="ugc">@<span>hj</span></a></span> nice</p>'
expect(convertHtml(input)).to.eql([
expect(convertHtmlToTree(input)).to.eql([
[
'<p>',
[
@ -112,7 +112,7 @@ describe('MiniHtmlConverter', () => {
})
it('realistic case 2', () => {
const inputOutput = 'Country improv: give me a city<br/>Audience: Memphis<br/>Improv troupe: come on, a better one<br/>Audience: el paso'
expect(convertHtml(inputOutput)).to.eql([
expect(convertHtmlToTree(inputOutput)).to.eql([
'Country improv: give me a city',
[
'<br/>'

View file

@ -1,96 +0,0 @@
import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
describe('TinyPostHTMLProcessor', () => {
describe('with processor that keeps original line should not make any changes to HTML when', () => {
const processorKeep = (line) => line
it('fed with regular HTML with newlines', () => {
const inputOutput = '1<br/>2<p class="lol">3 4</p> 5 \n 6 <p > 7 <br> 8 </p> <br>\n<br/>'
expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
})
it('fed with possibly broken HTML with invalid tags/composition', () => {
const inputOutput = '<feeee dwdwddddddw> <i>ayy<b>lm</i>ao</b> </section>'
expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
})
it('fed with very broken HTML with broken composition', () => {
const inputOutput = '</p> lmao what </div> whats going on <div> wha <p>'
expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
})
it('fed with sorta valid HTML but tags aren\'t closed', () => {
const inputOutput = 'just leaving a <div> hanging'
expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
})
it('fed with not really HTML at this point... tags that aren\'t finished', () => {
const inputOutput = 'do you expect me to finish this <div class='
expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
})
it('fed with dubiously valid HTML (p within p and also div inside p)', () => {
const inputOutput = 'look ma <p> p \nwithin <p> p! </p> and a <br/><div>div!</div></p>'
expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
})
it('fed with maybe valid HTML? self-closing divs and ps', () => {
const inputOutput = 'a <div class="what"/> what now <p aria-label="wtf"/> ?'
expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
})
it('fed with valid XHTML containing a CDATA', () => {
const inputOutput = 'Yes, it is me, <![CDATA[DIO]]>'
expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
})
})
describe('with processor that replaces lines with word "_" should match expected line when', () => {
const processorReplace = (line) => '_'
it('fed with regular HTML with newlines', () => {
const input = '1<br/>2<p class="lol">3 4</p> 5 \n 6 <p > 7 <br> 8 </p> <br>\n<br/>'
const output = '_<br/>_<p class="lol">_</p>_\n_<p >_<br>_</p> <br>\n<br/>'
expect(processHtml(input, processorReplace)).to.eql(output)
})
it('fed with possibly broken HTML with invalid tags/composition', () => {
const input = '<feeee dwdwddddddw> <i>ayy<b>lm</i>ao</b> </section>'
const output = '_'
expect(processHtml(input, processorReplace)).to.eql(output)
})
it('fed with very broken HTML with broken composition', () => {
const input = '</p> lmao what </div> whats going on <div> wha <p>'
const output = '</p>_</div>_<div>_<p>'
expect(processHtml(input, processorReplace)).to.eql(output)
})
it('fed with sorta valid HTML but tags aren\'t closed', () => {
const input = 'just leaving a <div> hanging'
const output = '_<div>_'
expect(processHtml(input, processorReplace)).to.eql(output)
})
it('fed with not really HTML at this point... tags that aren\'t finished', () => {
const input = 'do you expect me to finish this <div class='
const output = '_'
expect(processHtml(input, processorReplace)).to.eql(output)
})
it('fed with dubiously valid HTML (p within p and also div inside p)', () => {
const input = 'look ma <p> p \nwithin <p> p! </p> and a <br/><div>div!</div></p>'
const output = '_<p>_\n_<p>_</p>_<br/><div>_</div></p>'
expect(processHtml(input, processorReplace)).to.eql(output)
})
it('fed with maybe valid HTML? self-closing divs and ps', () => {
const input = 'a <div class="what"/> what now <p aria-label="wtf"/> ?'
const output = '_<div class="what"/>_<p aria-label="wtf"/>_'
expect(processHtml(input, processorReplace)).to.eql(output)
})
it('fed with valid XHTML containing a CDATA', () => {
const input = 'Yes, it is me, <![CDATA[DIO]]>'
const output = '_'
expect(processHtml(input, processorReplace)).to.eql(output)
})
})
})