diff options
47 files changed, 2922 insertions, 713 deletions
@@ -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 99301266..5134a8b1 100644 --- a/package.json +++ b/package.json @@ -47,8 +47,8 @@ "@babel/preset-env": "^7.7.6", "@babel/register": "^7.7.4", "@ungap/event-target": "^0.1.0", - "@vue/babel-helper-vue-jsx-merge-props": "^1.0.0", - "@vue/babel-plugin-transform-vue-jsx": "^1.1.2", + "@vue/babel-helper-vue-jsx-merge-props": "^1.2.1", + "@vue/babel-preset-jsx": "^1.2.4", "@vue/test-utils": "^1.0.0-beta.26", "autoprefixer": "^6.4.0", "babel-eslint": "^7.0.0", diff --git a/src/App.scss b/src/App.scss index 45071ba2..bc027f4f 100644 --- a/src/App.scss +++ b/src/App.scss @@ -88,6 +88,10 @@ a { font-family: sans-serif; font-family: var(--interfaceFont, sans-serif); + &.-sublime { + background: transparent; + } + i[class*=icon-], .svg-inline--fa { color: $fallback--text; diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js index bee1ad53..e5032176 100644 --- a/src/components/chat_list_item/chat_list_item.js +++ b/src/components/chat_list_item/chat_list_item.js @@ -1,5 +1,5 @@ import { mapState } from 'vuex' -import StatusContent from '../status_content/status_content.vue' +import StatusBody from '../status_content/status_content.vue' import fileType from 'src/services/file_type/file_type.service' import UserAvatar from '../user_avatar/user_avatar.vue' import AvatarList from '../avatar_list/avatar_list.vue' @@ -16,7 +16,7 @@ const ChatListItem = { AvatarList, Timeago, ChatTitle, - StatusContent + StatusBody }, computed: { ...mapState({ @@ -38,12 +38,14 @@ const ChatListItem = { }, messageForStatusContent () { const message = this.chat.lastMessage + const messageEmojis = message ? message.emojis : [] const isYou = message && message.account_id === this.currentUser.id const content = message ? (this.attachmentInfo || message.content) : '' const messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content return { summary: '', - statusnet_html: messagePreview, + emojis: messageEmojis, + raw_html: messagePreview, text: messagePreview, attachments: [] } diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss index 9e97b28e..57332bed 100644 --- a/src/components/chat_list_item/chat_list_item.scss +++ b/src/components/chat_list_item/chat_list_item.scss @@ -77,18 +77,15 @@ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); } - .StatusContent { - img.emoji { - width: 1.4em; - height: 1.4em; - } + .chat-preview-body { + --emoji-size: 1.4em; } .time-wrapper { line-height: 1.4em; } - .single-line { + .chat-preview-body { padding-right: 1em; } } diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue index cd3f436e..c7c0e878 100644 --- a/src/components/chat_list_item/chat_list_item.vue +++ b/src/components/chat_list_item/chat_list_item.vue @@ -29,7 +29,8 @@ </div> </div> <div class="chat-preview"> - <StatusContent + <StatusBody + class="chat-preview-body" :status="messageForStatusContent" :single-line="true" /> diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js index bb380f87..eb195bc1 100644 --- a/src/components/chat_message/chat_message.js +++ b/src/components/chat_message/chat_message.js @@ -57,8 +57,9 @@ const ChatMessage = { messageForStatusContent () { return { summary: '', - statusnet_html: this.message.content, - text: this.message.content, + emojis: this.message.emojis, + raw_html: this.message.content || '', + text: this.message.content || '', attachments: this.message.attachments } }, diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss index e4351d3b..fcfa7c8a 100644 --- a/src/components/chat_message/chat_message.scss +++ b/src/components/chat_message/chat_message.scss @@ -89,8 +89,9 @@ } .without-attachment { - .status-content { - &::after { + .message-content { + // TODO figure out how to do it properly + .RichContent::after { margin-right: 5.4em; content: " "; display: inline-block; @@ -162,6 +163,7 @@ .visible { opacity: 1; } + } .chat-message-date-separator { diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue index 0f3fc97d..d62b831d 100644 --- a/src/components/chat_message/chat_message.vue +++ b/src/components/chat_message/chat_message.vue @@ -71,6 +71,7 @@ </Popover> </div> <StatusContent + class="message-content" :status="messageForStatusContent" :full-content="true" > diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue index e6f9a9d3..aa2950ce 100644 --- a/src/components/emoji_input/emoji_input.vue +++ b/src/components/emoji_input/emoji_input.vue @@ -1,9 +1,9 @@ <template> <div + ref="root" v-click-outside="onClickOutside" class="emoji-input" :class="{ 'with-picker': !hideEmojiButton }" - ref='root' > <slot /> <template v-if="enableEmojiPicker"> diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js new file mode 100644 index 00000000..eec116db --- /dev/null +++ b/src/components/mention_link/mention_link.js @@ -0,0 +1,99 @@ +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.getters.findUserByUrl(this.url) + }, + isYou () { + // FIXME why user !== currentUser??? + return this.user && this.user.screen_name === this.currentUser.screen_name + }, + 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) + }, + oldStyle () { + return !this.mergedConfig.mentionsNewStyle + }, + style () { + if (this.highlight) { + const { + backgroundColor, + backgroundPosition, + backgroundImage, + ...rest + } = highlightStyle(this.highlight) + return rest + } + }, + classnames () { + return [ + { + '-you': this.isYou, + '-highlighted': this.highlight, + '-oldStyle': this.oldStyle + }, + this.highlightType + ] + }, + ...mapGetters(['mergedConfig']), + ...mapState({ + currentUser: state => state.users.currentUser + }) + } +} + +export default MentionLink diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss new file mode 100644 index 00000000..5f5da98f --- /dev/null +++ b/src/components/mention_link/mention_link.scss @@ -0,0 +1,132 @@ +.MentionLink { + position: relative; + white-space: normal; + display: inline-block; + color: var(--link); + + & .new, + & .original { + display: inline-block; + border-radius: 2px; + } + + .original { + margin-right: 0.25em; + } + + .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 { + margin-right: 0.25em; + + &.-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; + } + + &:not(.-oldStyle) { + .short { + padding-left: 0.25em; + padding-right: 0; + padding-top: 0; + padding-bottom: 0; + line-height: 1.5; + font-size: inherit; + + .at { + color: var(--faint); + opacity: 0.8; + padding-right: 0.25em; + vertical-align: -20%; + } + } + + .you { + padding-right: 0.25em; + } + + .userName { + display: inline-block; + color: var(--link); + line-height: inherit; + margin-left: 0; + padding-left: 0.125em; + padding-right: 0.25em; + padding-top: 0; + padding-bottom: 0; + border-top-right-radius: var(--btnRadius); + border-bottom-right-radius: var(--btnRadius); + } + } + + &.-striped { + & .userName, + & .full { + background-image: + repeating-linear-gradient( + 135deg, + var(--____highlight-tintColor), + var(--____highlight-tintColor) 5px, + var(--____highlight-tintColor2) 5px, + var(--____highlight-tintColor2) 10px + ); + } + } + + &.-solid { + & .userName, + & .full { + background-image: linear-gradient(var(--____highlight-tintColor2), var(--____highlight-tintColor2)); + } + } + + &.-side { + & .userName, + & .userNameFull { + box-shadow: 0 -5px 3px -4px inset var(--____highlight-solidColor); + } + } + } + + &:hover .new .full { + opacity: 1; + pointer-events: initial; + } +} diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue new file mode 100644 index 00000000..e4d395fa --- /dev/null +++ b/src/components/mention_link/mention_link.vue @@ -0,0 +1,55 @@ +<template> + <span + class="MentionLink" + > + <!-- eslint-disable vue/no-v-html --> + <a + v-if="!user" + href="url" + class="original" + v-html="content" + /> + <!-- eslint-enable vue/no-v-html --> + <span + v-if="user" + class="new" + :style="style" + :class="classnames" + > + <button + class="short" + :class="[{ '-sublime': !highlight }, oldStyle ? 'button-unstyled' : 'button-default']" + @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 --> + </button> + <span + v-if="userName !== userNameFull" + class="full popover-default" + :class="[highlightType]" + > + <span + class="userNameFull" + v-text="'@' + userNameFull" + /> + </span> + </span> + </span> +</template> + +<script src="./mention_link.js"/> + +<style lang="scss" src="./mention_link.scss"/> diff --git a/src/components/mentions_line/mentions_line.js b/src/components/mentions_line/mentions_line.js new file mode 100644 index 00000000..e52045ec --- /dev/null +++ b/src/components/mentions_line/mentions_line.js @@ -0,0 +1,51 @@ +import MentionLink from 'src/components/mention_link/mention_link.vue' +import { mapGetters } from 'vuex' + +const MentionsLine = { + name: 'MentionsLine', + props: { + mentions: { + required: true, + type: Array + } + }, + data: () => ({ expanded: false }), + components: { + MentionLink + }, + computed: { + oldStyle () { + return !this.mergedConfig.mentionsNewStyle + }, + limit () { + return 6 + }, + mentionsComputed () { + return this.mentions.slice(0, this.limit) + }, + extraMentions () { + return this.mentions.slice(this.limit) + }, + manyMentions () { + return this.extraMentions.length > 0 + }, + buttonClasses () { + return [ + this.oldStyle + ? 'button-unstyled' + : 'button-default -sublime', + this.oldStyle + ? '-oldStyle' + : '-newStyle' + ] + }, + ...mapGetters(['mergedConfig']) + }, + methods: { + toggleShowMore () { + this.expanded = !this.expanded + } + } +} + +export default MentionsLine diff --git a/src/components/mentions_line/mentions_line.scss b/src/components/mentions_line/mentions_line.scss new file mode 100644 index 00000000..90d1e0a4 --- /dev/null +++ b/src/components/mentions_line/mentions_line.scss @@ -0,0 +1,17 @@ +.MentionsLine { + .showMoreLess { + white-space: normal; + + &.-newStyle { + line-height: 1.5; + font-size: inherit; + display: inline-block; + padding-top: 0; + padding-bottom: 0; + } + + &.-oldStyle { + color: var(--link); + } + } +} diff --git a/src/components/mentions_line/mentions_line.vue b/src/components/mentions_line/mentions_line.vue new file mode 100644 index 00000000..f4b3abb9 --- /dev/null +++ b/src/components/mentions_line/mentions_line.vue @@ -0,0 +1,45 @@ +<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="showMoreLess" + :class="buttonClasses" + @click="toggleShowMore" + > + {{ $t('status.plus_more', { number: extraMentions.length }) }} + </button><button + v-if="expanded" + class="showMoreLess" + :class="buttonClasses" + @click="toggleShowMore" + > + {{ $t('general.show_less') }} + </button> + </span> + </span> +</template> +<script src="./mentions_line.js" ></script> +<style lang="scss" src="./mentions_line.scss" /> diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx new file mode 100644 index 00000000..ce562f13 --- /dev/null +++ b/src/components/rich_content/rich_content.jsx @@ -0,0 +1,387 @@ +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 MentionLink from 'src/components/mention_link/mention_link.vue' +import MentionsLine from 'src/components/mentions_line/mentions_line.vue' + +import './rich_content.scss' + +/** + * RichContent, The Über-powered component for rendering Post HTML. + * + * This takes post HTML and does multiple things to it: + * - Converts mention links to <MentionLink>-s + * - Removes mentions from beginning and end (hellthread style only) + * - Replaces emoji shortcodes with <StillImage>'d images. + * + * Stuff like removing mentions from beginning and end is done so that they could + * be either replaced by collapsible <MentionsLine> or moved to separate place. + * 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 + }, + // 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 + }, + 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.handleLinks) + 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 + let firstMentionReplaced = false + + const renderImage = (tag) => { + return <StillImage + {...{ attrs: getAttrs(tag) }} + class="img" + /> + } + + const renderHashtag = (attrs, children, encounteredTextReverse) => { + const linkData = getLinkData(attrs, children, tagsIndex++) + writtenTags.push(linkData) + attrs.target = '_blank' + if (!encounteredTextReverse) { + lastTags.push(linkData) + } + 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) + if (!firstMentionReplaced && !this.hideMentions) { + firstMentionReplaced = true + return <MentionsLine mentions={ firstMentions } /> + } else { + 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 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) { + return encounteredText ? item : item.trim() + } + let unescapedItem = unescape(item) + if (!encounteredText) { + unescapedItem = unescapedItem.trimStart() + encounteredText = true + } + if (item.includes(':')) { + unescapedItem = ['', processTextForEmoji( + unescapedItem, + this.emoji, + ({ shortcode, url }) => { + return <StillImage + class="emoji img" + src={url} + title={`:${shortcode}:`} + alt={`:${shortcode}:`} + /> + } + )] + } + return unescapedItem + } + + // Handle tag nodes + if (Array.isArray(item)) { + const [opener, children, closer] = 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 > 1 && lastMentions.length > 1) { + break + } else { + return !this.hideMentions ? <MentionsLine mentions={lastMentions} /> : '' + } + } else { + break + } + case 'img': // replace images with StillImage + return 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, encounteredText) + } else { + // Everything else will be handled in reverse pass + encounteredText = true + return item // We'll handle it later + } + } + + if (children !== undefined) { + return [opener, children.map(processItem), closer] + } else { + return 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 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')) { + return renderHashtag(attrs, children, encounteredTextReverse) + } else { + attrs.target = '_blank' + html.includes('freenode') && console.log('PASS1', children) + const newChildren = [...children].reverse().map(processItemReverse).reverse() + html.includes('freenode') && console.log('PASS1b', newChildren) + + return <a {...{ attrs }}> + { newChildren } + </a> + } + case '': + return [...children].reverse().map(processItemReverse).reverse() + } + + // Render tag as is + if (children !== undefined) { + html.includes('freenode') && console.log('PASS2', children) + 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 = { + 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 + * @param {Boolean} handleLinks - whether to handle links or not + */ +export const preProcessPerLine = (html, greentext, handleLinks) => { + const lastMentions = [] + const greentextHandle = new Set(['p', 'div']) + + let nonEmptyIndex = -1 + const lines = convertHtmlToLines(html) + const linesNum = lines.filter(c => c.text).length + const newHtml = lines.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 + nonEmptyIndex += 1 + + // Greentext stuff + if ( + // Only if greentext is engaged + greentext && + // Only handle p's and divs. Don't want to affect blocquotes, 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>` + } + } + + // 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 mentionsNum = 0 + 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') { + if (!handleLinks) return [opener, children, closer] + const attrs = getAttrs(opener) + if (attrs['class'] && attrs['class'].includes('mention')) { + // Got mentions + mentionsNum++ + 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) + + if ( + handleLinks && // Do we handle links at all? + mentionsNum > 1 && // Does it have more than one mention? + !hasLooseText && // Don't do anything if it has something besides mentions + nonEmptyIndex === 0 && // Only check last (first since list is reversed) line + nonEmptyIndex !== linesNum - 1 // Don't do anything if there's only one line + ) { + 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 } +} diff --git a/src/components/rich_content/rich_content.scss b/src/components/rich_content/rich_content.scss new file mode 100644 index 00000000..12cb9776 --- /dev/null +++ b/src/components/rich_content/rich_content.scss @@ -0,0 +1,63 @@ +.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 { + width: var(--emoji-size, 32px); + height: var(--emoji-size, 32px); + } + + .img, + video { + max-width: 100%; + max-height: 400px; + vertical-align: middle; + object-fit: contain; + } +} diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue index d3e71b31..71780e00 100644 --- a/src/components/settings_modal/tabs/general_tab.vue +++ b/src/components/settings_modal/tabs/general_tab.vue @@ -42,6 +42,16 @@ </BooleanSetting> </li> <li> + <BooleanSetting path="mentionsOwnLine"> + {{ $t('settings.mentions_new_place') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="mentionsNewStyle"> + {{ $t('settings.mentions_new_style') }} + </BooleanSetting> + </li> + <li> <BooleanSetting path="streaming"> {{ $t('settings.streaming') }} </BooleanSetting> diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js index 1388f74b..85749045 100644 --- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js @@ -474,7 +474,7 @@ export default { this.loadThemeFromLocalStorage(false, true) break case 'file': - console.err('Forcing snapshout from file is not supported yet') + console.error('Forcing snapshot from file is not supported yet') break } this.dismissWarning() diff --git a/src/components/status/status.js b/src/components/status/status.js index 470c01f1..3c21cb76 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -9,9 +9,12 @@ import UserAvatar from '../user_avatar/user_avatar.vue' import AvatarList from '../avatar_list/avatar_list.vue' import Timeago from '../timeago/timeago.vue' import StatusContent from '../status_content/status_content.vue' +import RichContent from 'src/components/rich_content/rich_content.jsx' import StatusPopover from '../status_popover/status_popover.vue' import UserListPopover from '../user_list_popover/user_list_popover.vue' import EmojiReactions from '../emoji_reactions/emoji_reactions.vue' +import MentionsLine from 'src/components/mentions_line/mentions_line.vue' +import MentionLink from 'src/components/mention_link/mention_link.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import { muteWordHits } from '../../services/status_parser/status_parser.js' @@ -68,7 +71,10 @@ const Status = { StatusPopover, UserListPopover, EmojiReactions, - StatusContent + StatusContent, + RichContent, + MentionLink, + MentionsLine }, props: [ 'statusoid', @@ -92,7 +98,8 @@ const Status = { userExpanded: false, mediaPlaying: [], suspendable: true, - error: null + error: null, + headTailLinks: null } }, computed: { @@ -132,12 +139,15 @@ const Status = { }, replyProfileLink () { if (this.isReply) { - return this.generateUserProfileLink(this.status.in_reply_to_user_id, this.replyToName) + const user = this.$store.getters.findUser(this.status.in_reply_to_user_id) + // FIXME Why user not found sometimes??? + return user ? user.statusnet_profile_url : 'NOT_FOUND' } }, retweet () { return !!this.statusoid.retweeted_status }, + retweeterUser () { return this.statusoid.user }, retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name_ui }, - retweeterHtml () { return this.statusoid.user.name_html }, + retweeterHtml () { return this.statusoid.user.name }, retweeterProfileLink () { return this.generateUserProfileLink(this.statusoid.user.id, this.statusoid.user.screen_name) }, status () { if (this.retweet) { @@ -156,6 +166,32 @@ const Status = { muteWordHits () { return muteWordHits(this.status, this.muteWords) }, + 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) + }) + }, + 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 const { status } = this @@ -303,6 +339,9 @@ const Status = { }, removeMediaPlaying (id) { this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id) + }, + setHeadTailLinks (headTailLinks) { + this.headTailLinks = headTailLinks } }, watch: { diff --git a/src/components/status/status.scss b/src/components/status/status.scss index 58b55bc8..71305dd7 100644 --- a/src/components/status/status.scss +++ b/src/components/status/status.scss @@ -1,10 +1,10 @@ - @import '../../_variables.scss'; $status-margin: 0.75em; .Status { min-width: 0; + white-space: normal; &:hover { --_still-image-img-visibility: visible; @@ -93,12 +93,8 @@ $status-margin: 0.75em; margin-right: 0.4em; text-overflow: ellipsis; - .emoji { - width: 14px; - height: 14px; - vertical-align: middle; - object-fit: contain; - } + --_still_image-label-scale: 0.25; + --emoji-size: 14px; } .status-favicon { @@ -155,35 +151,24 @@ $status-margin: 0.75em; } } + .glued-label { + display: inline-flex; + white-space: nowrap; + } + .timeago { margin-right: 0.2em; } - .heading-reply-row { + & .heading-reply-row { position: relative; align-content: baseline; font-size: 12px; - line-height: 18px; + line-height: 160%; max-width: 100%; - display: flex; - flex-wrap: wrap; align-items: stretch; } - .reply-to-and-accountname { - display: flex; - height: 18px; - margin-right: 0.5em; - max-width: 100%; - - .reply-to-link { - white-space: nowrap; - word-break: break-word; - text-overflow: ellipsis; - overflow-x: hidden; - } - } - & .reply-to-popover, & .reply-to-no-popover { min-width: 0; @@ -220,21 +205,27 @@ $status-margin: 0.75em; } } - .reply-to { + & .mentions, + & .reply-to { + white-space: nowrap; position: relative; + padding-right: 0.25em; } - .reply-to-text { + & .mentions-text, + & .reply-to-text { + color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .replies-separator { - margin-left: 0.4em; + .mentions-line { + display: inline; } .replies { + margin-top: 0.25em; line-height: 18px; font-size: 12px; display: flex; diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 00e962f3..a5f347a6 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -1,5 +1,4 @@ <template> - <!-- eslint-disable vue/no-v-html --> <div v-if="!hideStatus" class="Status" @@ -89,8 +88,12 @@ <router-link v-if="retweeterHtml" :to="retweeterProfileLink" - v-html="retweeterHtml" - /> + > + <RichContent + :html="retweeterHtml" + :emoji="retweeterUser.emoji" + /> + </router-link> <router-link v-else :to="retweeterProfileLink" @@ -145,8 +148,12 @@ v-if="status.user.name_html" class="status-username" :title="status.user.name" - v-html="status.user.name_html" - /> + > + <RichContent + :html="status.user.name" + :emoji="status.user.emoji" + /> + </h4> <h4 v-else class="status-username" @@ -214,11 +221,13 @@ </button> </span> </div> - - <div class="heading-reply-row"> - <div + <div + v-if="isReply || hasMentionsLine" + class="heading-reply-row" + > + <span v-if="isReply" - class="reply-to-and-accountname" + class="glued-label" > <StatusPopover v-if="!isPreview" @@ -238,7 +247,7 @@ flip="horizontal" /> <span - class="faint-link reply-to-text" + class="reply-to-text" > {{ $t('status.reply_to') }} </span> @@ -251,50 +260,77 @@ > <span class="reply-to-text">{{ $t('status.reply_to') }}</span> </span> - <router-link - class="reply-to-link" - :title="replyToName" - :to="replyProfileLink" - > - {{ replyToName }} - </router-link> - <span - v-if="replies && replies.length" - class="faint replies-separator" - > - - - </span> - </div> - <div - v-if="inConversation && !isPreview && replies && replies.length" - class="replies" + <MentionLink + :content="replyToName" + :url="replyProfileLink" + :user-id="status.in_reply_to_user_id" + :user-screen-name="status.in_reply_to_screen_name" + :first-mention="false" + /> + </span> + + <!-- This little wrapper is made for sole purpose of "gluing" --> + <!-- "Mentions" label to the first mention --> + <span + v-if="hasMentionsLine" + class="glued-label" > - <span class="faint">{{ $t('status.replies_list') }}</span> - <StatusPopover - v-for="reply in replies" - :key="reply.id" - :status-id="reply.id" + <span + class="mentions" + :aria-label="$t('tool_tip.mentions')" + @click.prevent="gotoOriginal(status.in_reply_to_status_id)" > - <button - class="button-unstyled -link reply-link" - @click.prevent="gotoOriginal(reply.id)" + <span + class="mentions-text" > - {{ reply.name }} - </button> - </StatusPopover> - </div> + {{ $t('status.mentions') }} + </span> + </span> + <MentionsLine + v-if="hasMentionsLine" + :mentions="mentionsLine.slice(0, 1)" + class="mentions-line-first" + /> + </span> + <MentionsLine + v-if="hasMentionsLine" + :mentions="mentionsLine.slice(1)" + class="mentions-line" + /> </div> </div> <StatusContent + ref="content" :status="status" :no-heading="noHeading" :highlight="highlight" :focused="isFocused" + :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" @@ -402,7 +438,6 @@ </div> </template> </div> -<!-- eslint-enable vue/no-v-html --> </template> <script src="./status.js" ></script> diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js new file mode 100644 index 00000000..601c963b --- /dev/null +++ b/src/components/status_body/status_body.js @@ -0,0 +1,101 @@ +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', + '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 + } + }, + 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.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) + }, + 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: { + toggleShowMore () { + if (this.mightHideBecauseTall) { + this.showingTall = !this.showingTall + } else if (this.mightHideBecauseSubject) { + this.expandingSubject = !this.expandingSubject + } + }, + generateTagLink (tag) { + return `/tag/${tag}` + } + } +} + +export default StatusContent diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss new file mode 100644 index 00000000..c7732bfe --- /dev/null +++ b/src/components/status_body/status_body.scss @@ -0,0 +1,118 @@ +@import '../../_variables.scss'; + +.StatusBody { + + .emoji { + --_still_image-label-scale: 0.5; + } + + & .text, + & .summary { + font-family: var(--postFont, sans-serif); + white-space: pre-wrap; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + line-height: 1.4em; + } + + .summary { + display: block; + font-style: italic; + padding-bottom: 0.5em; + } + + .text { + &.-single-line { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + height: 1.4em; + } + } + + .summary-wrapper { + margin-bottom: 0.5em; + border-style: solid; + border-width: 0 0 1px 0; + border-color: var(--border, $fallback--border); + flex-grow: 0; + + &.-tall { + position: relative; + + .summary { + max-height: 2em; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + } + + .text-wrapper { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + + &.-tall-status { + position: relative; + height: 220px; + overflow-x: hidden; + overflow-y: hidden; + z-index: 1; + + .media-body { + min-height: 0; + mask: + linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat, + linear-gradient(to top, white, white); + + /* Autoprefixed seem to ignore this one, and also syntax is different */ + -webkit-mask-composite: xor; + mask-composite: exclude; + } + } + } + + & .tall-status-hider, + & .tall-subject-hider, + & .status-unhider, + & .cw-status-hider { + display: inline-block; + word-break: break-all; + width: 100%; + text-align: center; + } + + .tall-status-hider { + position: absolute; + height: 70px; + margin-top: 150px; + line-height: 110px; + z-index: 2; + } + + .tall-subject-hider { + // position: absolute; + padding-bottom: 0.5em; + } + + & .status-unhider, + & .cw-status-hider { + word-break: break-all; + + svg { + color: inherit; + } + } + + .greentext { + color: $fallback--cGreen; + color: var(--postGreentext, $fallback--cGreen); + } + + .cyantext { + color: var(--postCyantext, $fallback--cBlue); + } +} diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue new file mode 100644 index 00000000..2be46303 --- /dev/null +++ b/src/components/status_body/status_body.vue @@ -0,0 +1,97 @@ +<template> + <div class="StatusBody"> + <div class="body"> + <div + v-if="status.summary_raw_html" + class="summary-wrapper" + :class="{ '-tall': (longSubject && !showingLongSubject) }" + > + <RichContent + class="media-body summary" + :html="status.summary_raw_html" + :emoji="status.emojis" + /> + <button + v-if="longSubject && showingLongSubject" + class="button-unstyled -link tall-subject-hider" + @click.prevent="showingLongSubject=false" + > + {{ $t("status.hide_full_subject") }} + </button> + <button + v-else-if="longSubject" + class="button-unstyled -link tall-subject-hider" + @click.prevent="showingLongSubject=true" + > + {{ $t("status.show_full_subject") }} + </button> + </div> + <div + :class="{'-tall-status': hideTallStatus}" + class="text-wrapper" + > + <button + v-if="hideTallStatus" + class="button-unstyled -link tall-status-hider" + :class="{ '-focused': focused }" + @click.prevent="toggleShowMore" + > + {{ $t("general.show_more") }} + </button> + <RichContent + v-if="!hideSubjectStatus && !(singleLine && status.summary_raw_html)" + :class="{ '-single-line': singleLine }" + class="text media-body" + :html="status.raw_html" + :emoji="status.emojis" + :handle-links="true" + :hide-mentions="hideMentions" + :greentext="mergedConfig.greentext" + @parseReady="$emit('parseReady', $event)" + /> + + <button + v-if="hideSubjectStatus" + class="button-unstyled -link cw-status-hider" + @click.prevent="toggleShowMore" + > + {{ $t("status.show_content") }} + <FAIcon + v-if="attachmentTypes.includes('image')" + icon="image" + /> + <FAIcon + v-if="attachmentTypes.includes('video')" + icon="video" + /> + <FAIcon + v-if="attachmentTypes.includes('audio')" + icon="music" + /> + <FAIcon + v-if="attachmentTypes.includes('unknown')" + icon="file" + /> + <FAIcon + v-if="status.poll && status.poll.options" + icon="poll-h" + /> + <FAIcon + v-if="status.card" + icon="link" + /> + </button> + <button + v-if="showingMore && !fullContent" + class="button-unstyled -link status-unhider" + @click.prevent="toggleShowMore" + > + {{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }} + </button> + </div> + </div> + <slot v-if="!hideSubjectStatus" /> + </div> +</template> +<script src="./status_body.js" ></script> +<style lang="scss" src="./status_body.scss" /> diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js index a6f79d76..51895ef6 100644 --- a/src/components/status_content/status_content.js +++ b/src/components/status_content/status_content.js @@ -1,11 +1,9 @@ import Attachment from '../attachment/attachment.vue' import Poll from '../poll/poll.vue' import Gallery from '../gallery/gallery.vue' +import StatusBody from 'src/components/status_body/status_body.vue' import LinkPreview from '../link-preview/link-preview.vue' -import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import fileType from 'src/services/file_type/file_type.service' -import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js' -import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js' import { mapGetters, mapState } from 'vuex' import { library } from '@fortawesome/fontawesome-svg-core' import { @@ -33,54 +31,14 @@ 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 - } - }, computed: { - localCollapseSubjectDefault () { - return this.mergedConfig.collapseMessageWithSubject - }, hideAttachments () { return (this.mergedConfig.hideAttachments && !this.inConversation) || (this.mergedConfig.hideAttachmentsInConv && this.inConversation) }, - // This is a bit hacky, but we want to approximate post height before rendering - // so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them) - // as well as approximate line count by counting characters and approximating ~80 - // per line. - // - // Using max-height + overflow: auto for status components resulted in false positives - // very often with japanese characters, and it was very annoying. - tallStatus () { - const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80 - return lengthScore > 20 - }, - longSubject () { - return this.status.summary.length > 240 - }, - // When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status. - mightHideBecauseSubject () { - return !!this.status.summary && this.localCollapseSubjectDefault - }, - mightHideBecauseTall () { - return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault) - }, - hideSubjectStatus () { - return this.mightHideBecauseSubject && !this.expandingSubject - }, - hideTallStatus () { - return this.mightHideBecauseTall && !this.showingTall - }, - showingMore () { - return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject) - }, nsfwClickthrough () { if (!this.status.nsfw) { return false @@ -118,45 +76,11 @@ const StatusContent = { file => !fileType.fileMatchesSomeType(this.galleryTypes, file) ) }, - attachmentTypes () { - return this.status.attachments.map(file => fileType.fileType(file.mimetype)) - }, maxThumbnails () { return this.mergedConfig.maxThumbnails }, - postBodyHtml () { - const html = this.status.statusnet_html - - if (this.mergedConfig.greentext) { - try { - if (html.includes('>')) { - // 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 }) }, @@ -164,48 +88,10 @@ const StatusContent = { Attachment, Poll, Gallery, - LinkPreview + LinkPreview, + StatusBody }, methods: { - linkClicked (event) { - const target = event.target.closest('.status-content a') - if (target) { - if (target.className.match(/mention/)) { - const href = target.href - const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href)) - if (attn) { - event.stopPropagation() - event.preventDefault() - const link = this.generateUserProfileLink(attn.id, attn.screen_name) - this.$router.push(link) - return - } - } - if (target.rel.match(/(?:^|\s)tag(?:$|\s)/) || target.className.match(/hashtag/)) { - // Extract tag name from dataset or link url - const tag = target.dataset.tag || extractTagFromUrl(target.href) - if (tag) { - const link = this.generateTagLink(tag) - this.$router.push(link) - return - } - } - window.open(target.href, '_blank') - } - }, - toggleShowMore () { - if (this.mightHideBecauseTall) { - this.showingTall = !this.showingTall - } else if (this.mightHideBecauseSubject) { - this.expandingSubject = !this.expandingSubject - } - }, - generateUserProfileLink (id, name) { - return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames) - }, - generateTagLink (tag) { - return `/tag/${tag}` - }, setMedia () { const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments return () => this.$store.dispatch('setMedia', attachments) diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue index 90bfaf40..2e71757d 100644 --- a/src/components/status_content/status_content.vue +++ b/src/components/status_content/status_content.vue @@ -1,133 +1,53 @@ <template> - <!-- eslint-disable vue/no-v-html --> <div class="StatusContent"> <slot name="header" /> - <div - v-if="status.summary_html" - class="summary-wrapper" - :class="{ 'tall-subject': (longSubject && !showingLongSubject) }" + <StatusBody + :status="status" + :single-line="singleLine" + :hide-mentions="hideMentions" + @parseReady="$emit('parseReady', $event)" > + <div v-if="status.poll && status.poll.options"> + <poll :base-poll="status.poll" /> + </div> + <div - class="media-body summary" - @click.prevent="linkClicked" - v-html="status.summary_html" - /> - <button - v-if="longSubject && showingLongSubject" - class="button-unstyled -link tall-subject-hider" - @click.prevent="showingLongSubject=false" - > - {{ $t("status.hide_full_subject") }} - </button> - <button - v-else-if="longSubject" - class="button-unstyled -link tall-subject-hider" - :class="{ 'tall-subject-hider_focused': focused }" - @click.prevent="showingLongSubject=true" - > - {{ $t("status.show_full_subject") }} - </button> - </div> - <div - :class="{'tall-status': hideTallStatus}" - class="status-content-wrapper" - > - <button - v-if="hideTallStatus" - class="button-unstyled -link tall-status-hider" - :class="{ 'tall-status-hider_focused': focused }" - @click.prevent="toggleShowMore" - > - {{ $t("general.show_more") }} - </button> - <div - v-if="!hideSubjectStatus" - :class="{ 'single-line': singleLine }" - class="status-content media-body" - @click.prevent="linkClicked" - v-html="postBodyHtml" - /> - <button - v-if="hideSubjectStatus" - class="button-unstyled -link cw-status-hider" - @click.prevent="toggleShowMore" + v-if="status.attachments.length !== 0" + class="attachments media-body" > - {{ $t("status.show_content") }} - <FAIcon - v-if="attachmentTypes.includes('image')" - icon="image" + <attachment + v-for="attachment in nonGalleryAttachments" + :key="attachment.id" + class="non-gallery" + :size="attachmentSize" + :nsfw="nsfwClickthrough" + :attachment="attachment" + :allow-play="true" + :set-media="setMedia()" + @play="$emit('mediaplay', attachment.id)" + @pause="$emit('mediapause', attachment.id)" /> - <FAIcon - v-if="attachmentTypes.includes('video')" - icon="video" + <gallery + v-if="galleryAttachments.length > 0" + :nsfw="nsfwClickthrough" + :attachments="galleryAttachments" + :set-media="setMedia()" /> - <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> - <div v-if="status.poll && status.poll.options && !hideSubjectStatus"> - <poll :base-poll="status.poll" /> - </div> - - <div - v-if="status.attachments.length !== 0 && (!hideSubjectStatus || showingLongSubject)" - class="attachments media-body" - > - <attachment - v-for="attachment in nonGalleryAttachments" - :key="attachment.id" - class="non-gallery" - :size="attachmentSize" - :nsfw="nsfwClickthrough" - :attachment="attachment" - :allow-play="true" - :set-media="setMedia()" - @play="$emit('mediaplay', attachment.id)" - @pause="$emit('mediapause', attachment.id)" - /> - <gallery - v-if="galleryAttachments.length > 0" - :nsfw="nsfwClickthrough" - :attachments="galleryAttachments" - :set-media="setMedia()" - /> - </div> - - <div - v-if="status.card && !hideSubjectStatus && !noHeading" - class="link-preview media-body" - > - <link-preview - :card="status.card" - :size="attachmentSize" - :nsfw="nsfwClickthrough" - /> - </div> + <div + v-if="status.card && !noHeading" + class="link-preview media-body" + > + <link-preview + :card="status.card" + :size="attachmentSize" + :nsfw="nsfwClickthrough" + /> + </div> + </StatusBody> <slot name="footer" /> </div> - <!-- eslint-enable vue/no-v-html --> </template> <script src="./status_content.js" ></script> @@ -139,156 +59,5 @@ $status-margin: 0.75em; .StatusContent { flex: 1; min-width: 0; - - .status-content-wrapper { - display: flex; - flex-direction: column; - flex-wrap: nowrap; - } - - .tall-status { - position: relative; - height: 220px; - overflow-x: hidden; - overflow-y: hidden; - z-index: 1; - .status-content { - min-height: 0; - mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat, - linear-gradient(to top, white, white); - /* Autoprefixed seem to ignore this one, and also syntax is different */ - -webkit-mask-composite: xor; - mask-composite: exclude; - } - } - - .tall-status-hider { - display: inline-block; - word-break: break-all; - position: absolute; - height: 70px; - margin-top: 150px; - width: 100%; - text-align: center; - line-height: 110px; - z-index: 2; - } - - .status-unhider, .cw-status-hider { - width: 100%; - text-align: center; - display: inline-block; - word-break: break-all; - - svg { - color: inherit; - } - } - - img, video { - max-width: 100%; - max-height: 400px; - vertical-align: middle; - object-fit: contain; - - &.emoji { - width: 32px; - height: 32px; - } - } - - .summary-wrapper { - margin-bottom: 0.5em; - border-style: solid; - border-width: 0 0 1px 0; - border-color: var(--border, $fallback--border); - flex-grow: 0; - } - - .summary { - font-style: italic; - padding-bottom: 0.5em; - } - - .tall-subject { - position: relative; - .summary { - max-height: 2em; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - } - - .tall-subject-hider { - display: inline-block; - word-break: break-all; - // position: absolute; - width: 100%; - text-align: center; - padding-bottom: 0.5em; - } - - .status-content { - font-family: var(--postFont, sans-serif); - line-height: 1.4em; - white-space: pre-wrap; - overflow-wrap: break-word; - word-wrap: break-word; - word-break: break-word; - - blockquote { - margin: 0.2em 0 0.2em 2em; - font-style: italic; - } - - pre { - overflow: auto; - } - - code, samp, kbd, var, pre { - font-family: var(--postCodeFont, monospace); - } - - p { - margin: 0 0 1em 0; - } - - p:last-child { - margin: 0 0 0 0; - } - - h1 { - font-size: 1.1em; - line-height: 1.2em; - margin: 1.4em 0; - } - - h2 { - font-size: 1.1em; - margin: 1.0em 0; - } - - h3 { - font-size: 1em; - margin: 1.2em 0; - } - - h4 { - margin: 1.1em 0; - } - - &.single-line { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - height: 1.4em; - } - } -} - -.greentext { - color: $fallback--cGreen; - color: var(--postGreentext, $fallback--cGreen); } </style> diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index d3eb5925..0623b42e 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -30,7 +30,7 @@ position: relative; line-height: 0; overflow: hidden; - display: flex; + display: inline-flex; align-items: center; canvas { @@ -47,12 +47,13 @@ img { width: 100%; - min-height: 100%; + height: 100%; object-fit: contain; } &.animated { &::before { + zoom: var(--_still_image-label-scale, 1); content: 'gif'; position: absolute; line-height: 10px; diff --git a/src/i18n/en.json b/src/i18n/en.json index 6511019c..86870447 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -259,6 +259,8 @@ "security": "Security", "setting_changed": "Setting is different from default", "enter_current_password_to_confirm": "Enter your current password to confirm your identity", + "mentions_new_style": "Fancier mention links", + "mentions_new_place": "Put mentions on a separate line", "mfa": { "otp": "OTP", "setup_otp": "Setup OTP", @@ -698,6 +700,7 @@ "unbookmark": "Unbookmark", "delete_confirm": "Do you really want to delete this status?", "reply_to": "Reply to", + "mentions": "Mentions", "replies_list": "Replies:", "mute_conversation": "Mute conversation", "unmute_conversation": "Unmute conversation", @@ -712,7 +715,9 @@ "hide_content": "Hide content", "status_deleted": "This post was deleted", "nsfw": "NSFW", - "expand": "Expand" + "expand": "Expand", + "you": "(You)", + "plus_more": "+{number} more" }, "user_card": { "approve": "Approve", diff --git a/src/i18n/fi.json b/src/i18n/fi.json index 2524f278..ebcad804 100644 --- a/src/i18n/fi.json +++ b/src/i18n/fi.json @@ -579,7 +579,8 @@ "hide_full_subject": "Piilota koko otsikko", "show_content": "Näytä sisältö", "hide_content": "Piilota sisältö", - "status_deleted": "Poistettu viesti" + "status_deleted": "Poistettu viesti", + "you": "(sinä)" }, "user_card": { "approve": "Hyväksy", diff --git a/src/modules/config.js b/src/modules/config.js index bdab3f4d..db9d5ffb 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -55,6 +55,8 @@ export const defaultState = { interfaceLanguage: browserLocale, hideScopeNotice: false, useStreamingApi: false, + mentionsOwnLine: false, + mentionsNewStyle: false, sidebarRight: undefined, // instance default scopeCopy: undefined, // instance default subjectLineBehavior: undefined, // instance default diff --git a/src/modules/users.js b/src/modules/users.js index 2b416f94..fb92cc91 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -246,6 +246,11 @@ export const getters = { } return result }, + findUserByUrl: state => query => { + return state.users + .find(u => u.statusnet_profile_url && + u.statusnet_profile_url.toLowerCase() === query.toLowerCase()) + }, relationship: state => id => { const rel = id && state.relationships[id] return rel || { id, loading: true } diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index a4ddf927..477b861f 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -54,6 +54,7 @@ export const parseUser = (data) => { return output } + output.emoji = data.emojis output.name = data.display_name output.name_html = addEmojis(escape(data.display_name), data.emojis) @@ -266,7 +267,8 @@ export const parseStatus = (data) => { output.type = data.reblog ? 'retweet' : 'status' output.nsfw = data.sensitive - output.statusnet_html = addEmojis(data.content, data.emojis) + output.raw_html = data.content + output.emojis = data.emojis output.tags = data.tags @@ -293,7 +295,7 @@ export const parseStatus = (data) => { output.retweeted_status = parseStatus(data.reblog) } - output.summary_html = addEmojis(escape(data.spoiler_text), data.emojis) + output.summary_raw_html = escape(data.spoiler_text) output.external_url = data.url output.poll = data.poll if (output.poll) { @@ -325,7 +327,7 @@ export const parseStatus = (data) => { output.nsfw = data.nsfw } - output.statusnet_html = data.statusnet_html + output.raw_html = data.statusnet_html output.text = data.text output.in_reply_to_status_id = data.in_reply_to_status_id @@ -444,11 +446,8 @@ export const parseChatMessage = (message) => { output.id = message.id output.created_at = new Date(message.created_at) output.chat_id = message.chat_id - if (message.content) { - output.content = addEmojis(message.content, message.emojis) - } else { - output.content = '' - } + output.emojis = message.emojis + output.content = message.content if (message.attachment) { output.attachments = [parseAttachment(message.attachment)] } else { diff --git a/src/services/html_converter/html_line_converter.service.js b/src/services/html_converter/html_line_converter.service.js new file mode 100644 index 00000000..f43d162a --- /dev/null +++ b/src/services/html_converter/html_line_converter.service.js @@ -0,0 +1,134 @@ +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 if (char === '\n') { + handleBr(char) + } else { + textBuffer += char + } + } + if (tagBuffer) { + textBuffer += tagBuffer + } + + flush() + + return buffer +} diff --git a/src/services/html_converter/html_tree_converter.service.js b/src/services/html_converter/html_tree_converter.service.js new file mode 100644 index 00000000..804d35d7 --- /dev/null +++ b/src/services/html_converter/html_tree_converter.service.js @@ -0,0 +1,97 @@ +import { getTagName } from './utility.service.js' + +/** + * This is a not-so-tiny purpose-built HTML parser/processor. This parses html + * and converts it into a tree structure representing tag openers/closers and + * children. + * + * Structure follows this pattern: [opener, [...children], closer] except root + * node which is just [...children]. Text nodes can only be within children and + * are represented as strings. + * + * Intended use is to convert HTML structure and then recursively iterate over it + * most likely using a map. Very useful for dynamically rendering html replacing + * tags with JSX elements in a render function. + * + * known issue: doesn't handle CDATA so CDATA might not work well + * known issue: doesn't handle HTML comments + * + * @param {Object} input - input data + * @return {string} processed html + */ +export const convertHtmlToTree = (html) => { + // Elements that are implicitly self-closing + // https://developer.mozilla.org/en-US/docs/Glossary/empty_element + const emptyElements = new Set([ + 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', + 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr' + ]) + // TODO For future - also parse HTML5 multi-source components? + + const buffer = [] // Current output buffer + const levels = [['', buffer]] // How deep we are in tags and which tags were there + let textBuffer = '' // Current line content + let tagBuffer = null // Current tag buffer, if null = we are not currently reading a tag + + const getCurrentBuffer = () => { + return levels[levels.length - 1][1] + } + + const flushText = () => { // Processes current line buffer, adds it to output buffer and clears line buffer + if (textBuffer === '') return + getCurrentBuffer().push(textBuffer) + textBuffer = '' + } + + const handleSelfClosing = (tag) => { + getCurrentBuffer().push([tag]) + } + + const handleOpen = (tag) => { + const curBuf = getCurrentBuffer() + const newLevel = [tag, []] + levels.push(newLevel) + curBuf.push(newLevel) + } + + const handleClose = (tag) => { + const currentTag = levels[levels.length - 1] + if (getTagName(levels[levels.length - 1][0]) === getTagName(tag)) { + currentTag.push(tag) + levels.pop() + } else { + getCurrentBuffer().push(tag) + } + } + + for (let i = 0; i < html.length; i++) { + const char = html[i] + if (char === '<' && tagBuffer === null) { + flushText() + tagBuffer = char + } else if (char !== '>' && tagBuffer !== null) { + tagBuffer += char + } else if (char === '>' && tagBuffer !== null) { + tagBuffer += char + const tagFull = tagBuffer + tagBuffer = null + const tagName = getTagName(tagFull) + if (tagFull[1] === '/') { + handleClose(tagFull) + } else if (emptyElements.has(tagName) || tagFull[tagFull.length - 2] === '/') { + // self-closing + handleSelfClosing(tagFull) + } else { + handleOpen(tagFull) + } + } else { + textBuffer += char + } + } + if (tagBuffer) { + textBuffer += tagBuffer + } + + flushText() + return buffer +} diff --git a/src/services/html_converter/utility.service.js b/src/services/html_converter/utility.service.js new file mode 100644 index 00000000..4d0c36c2 --- /dev/null +++ b/src/services/html_converter/utility.service.js @@ -0,0 +1,73 @@ +/** + * Extract tag name from tag opener/closer. + * + * @param {String} tag - tag string, i.e. '<a href="...">' + * @return {String} - tagname, i.e. "div" + */ +export const getTagName = (tag) => { + const result = /(?:<\/(\w+)>|<(\w+)\s?.*?\/?>)/gi.exec(tag) + return result && (result[1] || result[2]) +} + +/** + * Extract attributes from tag opener. + * + * @param {String} tag - tag string, i.e. '<a href="...">' + * @return {Object} - map of attributes key = attribute name, value = attribute value + * attributes without values represented as boolean true + */ +export const getAttrs = tag => { + const innertag = tag + .substring(1, tag.length - 1) + .replace(new RegExp('^' + getTagName(tag)), '') + .replace(/\/?$/, '') + .trim() + const attrs = Array.from(innertag.matchAll(/([a-z0-9-]+)(?:=("[^"]+?"|'[^']+?'))?/gi)) + .map(([trash, key, value]) => [key, value]) + .map(([k, v]) => { + if (!v) return [k, true] + return [k, v.substring(1, v.length - 1)] + }) + return Object.fromEntries(attrs) +} + +/** + * Finds shortcodes in text + * + * @param {String} text - original text to find emojis in + * @param {{ url: String, shortcode: Sring }[]} emoji - list of shortcodes to find + * @param {Function} processor - function to call on each encountered emoji, + * function is passed single object containing matching emoji ({ url, shortcode }) + * return value will be inserted into resulting array instead of :shortcode: + * @return {Array} resulting array with non-emoji parts of text and whatever {processor} + * returned for emoji + */ +export const processTextForEmoji = (text, emojis, processor) => { + const buffer = [] + let textBuffer = '' + for (let i = 0; i < text.length; i++) { + const char = text[i] + if (char === ':') { + const next = text.slice(i + 1) + let found = false + for (let emoji of emojis) { + if (next.slice(0, emoji.shortcode.length + 1) === (emoji.shortcode + ':')) { + found = emoji + break + } + } + if (found) { + buffer.push(textBuffer) + textBuffer = '' + buffer.push(processor(found)) + i += found.shortcode.length + 1 + } else { + textBuffer += char + } + } else { + textBuffer += char + } + } + if (textBuffer) buffer.push(textBuffer) + return buffer +} diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js index 14aac975..c2983be7 100644 --- a/src/services/theme_data/pleromafe.js +++ b/src/services/theme_data/pleromafe.js @@ -369,6 +369,12 @@ export const SLOT_INHERITANCE = { textColor: 'preserve' }, + postCyantext: { + depends: ['cBlue'], + layer: 'bg', + textColor: 'preserve' + }, + border: { depends: ['fg'], opacity: 'border', diff --git a/src/services/tiny_post_html_processor/tiny_post_html_processor.service.js b/src/services/tiny_post_html_processor/tiny_post_html_processor.service.js deleted file mode 100644 index de6f20ef..00000000 --- a/src/services/tiny_post_html_processor/tiny_post_html_processor.service.js +++ /dev/null @@ -1,94 +0,0 @@ -/** - * This is a tiny purpose-built HTML parser/processor. This basically detects any type of visual newline and - * allows it to be processed, useful for greentexting, mostly - * - * known issue: doesn't handle CDATA so nested CDATA might not work well - * - * @param {Object} input - input data - * @param {(string) => string} processor - function that will be called on every line - * @return {string} processed html - */ -export const processHtml = (html, processor) => { - const handledTags = new Set(['p', 'br', 'div']) - const openCloseTags = new Set(['p', 'div']) - - let buffer = '' // Current output buffer - const level = [] // How deep we are in tags and which tags were there - let textBuffer = '' // Current line content - let tagBuffer = null // Current tag buffer, if null = we are not currently reading a tag - - // Extracts tag name from tag, i.e. <span a="b"> => span - const getTagName = (tag) => { - const result = /(?:<\/(\w+)>|<(\w+)\s?[^/]*?\/?>)/gi.exec(tag) - return result && (result[1] || result[2]) - } - - const flush = () => { // Processes current line buffer, adds it to output buffer and clears line buffer - if (textBuffer.trim().length > 0) { - buffer += processor(textBuffer) - } else { - buffer += textBuffer - } - textBuffer = '' - } - - const handleBr = (tag) => { // handles single newlines/linebreaks/selfclosing - flush() - buffer += tag - } - - const handleOpen = (tag) => { // handles opening tags - flush() - buffer += tag - level.push(tag) - } - - const handleClose = (tag) => { // handles closing tags - flush() - buffer += tag - if (level[level.length - 1] === tag) { - level.pop() - } - } - - for (let i = 0; i < html.length; i++) { - const char = html[i] - if (char === '<' && tagBuffer === null) { - tagBuffer = char - } else if (char !== '>' && tagBuffer !== null) { - tagBuffer += char - } else if (char === '>' && tagBuffer !== null) { - tagBuffer += char - const tagFull = tagBuffer - tagBuffer = null - const tagName = getTagName(tagFull) - if (handledTags.has(tagName)) { - if (tagName === 'br') { - handleBr(tagFull) - } else if (openCloseTags.has(tagName)) { - if (tagFull[1] === '/') { - handleClose(tagFull) - } else if (tagFull[tagFull.length - 2] === '/') { - // self-closing - handleBr(tagFull) - } else { - handleOpen(tagFull) - } - } - } else { - textBuffer += tagFull - } - } else if (char === '\n') { - handleBr(char) - } else { - textBuffer += char - } - } - if (tagBuffer) { - textBuffer += tagBuffer - } - - flush() - - return buffer -} diff --git a/src/services/user_highlighter/user_highlighter.js b/src/services/user_highlighter/user_highlighter.js index b91c0f78..3b07592e 100644 --- a/src/services/user_highlighter/user_highlighter.js +++ b/src/services/user_highlighter/user_highlighter.js @@ -8,6 +8,11 @@ const highlightStyle = (prefs) => { const solidColor = `rgb(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)})` const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .1)` const tintColor2 = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .2)` + const customProps = { + '--____highlight-solidColor': solidColor, + '--____highlight-tintColor': tintColor, + '--____highlight-tintColor2': tintColor2 + } if (type === 'striped') { return { backgroundImage: [ @@ -17,11 +22,13 @@ const highlightStyle = (prefs) => { `${tintColor2} 20px,`, `${tintColor2} 40px` ].join(' '), - backgroundPosition: '0 0' + backgroundPosition: '0 0', + ...customProps } } else if (type === 'solid') { return { - backgroundColor: tintColor2 + backgroundColor: tintColor2, + ...customProps } } else if (type === 'side') { return { @@ -31,7 +38,8 @@ const highlightStyle = (prefs) => { `${solidColor} 2px,`, `transparent 6px` ].join(' '), - backgroundPosition: '0 0' + backgroundPosition: '0 0', + ...customProps } } } diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js new file mode 100644 index 00000000..96c480ea --- /dev/null +++ b/test/unit/specs/components/rich_content.spec.js @@ -0,0 +1,786 @@ +import { mount, shallowMount, createLocalVue } from '@vue/test-utils' +import RichContent from 'src/components/rich_content/rich_content.jsx' + +const localVue = createLocalVue() + +const makeMention = (who) => `<span class="h-card"><a class="u-url mention" href="https://fake.tld/@${who}">@<span>${who}</span></a></span>` +const stubMention = (who) => `<span class="h-card"><mentionlink-stub url="https://fake.tld/@${who}" content="@<span>${who}</span>"></mentionlink-stub></span>` +const lastMentions = (...data) => `<span class="lastMentions">${data.join('')}</span>` +const p = (...data) => `<p>${data.join('')}</p>` +const compwrap = (...data) => `<span class="RichContent">${data.join('')}</span>` +const removedMentionSpan = '<span class="h-card"></span>' + +describe('RichContent', () => { + it('renders simple post without exploding', () => { + const html = p('Hello world!') + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + hideMentions: true, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(html)) + }) + + it('removes mentions from the beginning of post', () => { + const html = p( + makeMention('John'), + ' how are you doing thoday?' + ) + const expected = p( + removedMentionSpan, + 'how are you doing thoday?' + ) + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + hideMentions: true, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('replaces first mention with mentionsline if hideMentions=false', () => { + const html = p( + makeMention('John'), + ' how are you doing thoday?' + ) + const expected = p( + '<span class="h-card">', + '<mentionsline-stub mentions="', + '[object Object]', + '"></mentionsline-stub>', + '</span>', + 'how are you doing thoday?' + ) + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + hideMentions: false, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('removes mentions from the end of the hellpost (<p>)', () => { + 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() + ].join('') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + hideMentions: true, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('replaces mentions at the end of the hellpost if hideMentions=false (<p>)', () => { + 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: { + hideMentions: false, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('removes mentions from the end of the hellpost (<br>)', () => { + const html = [ + 'How are you doing today, fine gentlemen?', + [ + makeMention('John'), + makeMention('Josh'), + makeMention('Jeremy') + ].join('') + ].join('<br>') + const expected = [ + 'How are you doing today, fine gentlemen?', + // TODO fix this extra line somehow? + '<br>' + ].join('') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + hideMentions: true, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('removes mentions from the end of the hellpost (\\n)', () => { + const html = [ + 'How are you doing today, fine gentlemen?', + [ + makeMention('John'), + makeMention('Josh'), + makeMention('Jeremy') + ].join('') + ].join('\n') + const expected = [ + 'How are you doing today, fine gentlemen?', + // TODO fix this extra line somehow? + '' + ].join('\n') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + hideMentions: true, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('Does not remove mentions in the middle or at the end of text string', () => { + const html = [ + [ + makeMention('Jack'), + 'let\'s meet up with ', + makeMention('Janet') + ].join(''), + [ + 'cc: ', + makeMention('John'), + makeMention('Josh'), + makeMention('Jeremy') + ].join('') + ].join('\n') + const expected = [ + [ + removedMentionSpan, + 'let\'s meet up with ', + stubMention('Janet') + ].join(''), + [ + 'cc: ', + stubMention('John'), + stubMention('Josh'), + stubMention('Jeremy') + ].join('') + ].join('\n') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + hideMentions: true, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('removes mentions from the end if there\'s only one first mention', () => { + const html = [ + p( + makeMention('Todd'), + 'so anyway you are wrong' + ), + p( + makeMention('Tom'), + makeMention('Trace'), + makeMention('Theodor') + ) + ].join('') + const expected = [ + p( + removedMentionSpan, + 'so anyway you are wrong' + ), + // TODO fix this extra line somehow? + p() + ].join('') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + hideMentions: true, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('does not remove mentions from the end if there\'s more than one first mention', () => { + const html = [ + p( + makeMention('Zacharie'), + makeMention('Zinaide'), + 'you guys have cool names, and so do these guys: ' + ), + p( + makeMention('Watson'), + makeMention('Wallace'), + makeMention('Wakamoto') + ) + ].join('') + const expected = [ + p( + removedMentionSpan, + removedMentionSpan, + 'you guys have cool names, and so do these guys: ' + ), + p( + lastMentions( + stubMention('Watson'), + stubMention('Wallace'), + stubMention('Wakamoto') + ) + ) + ].join('') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + hideMentions: true, + 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: { + hideMentions: true, + 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: { + hideMentions: true, + 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: { + hideMentions: true, + 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: { + hideMentions: true, + 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: { + hideMentions: true, + 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>', + stubMention('lol'), + '<span class="greentext">>quote</span>', + '<span class="greentext">>quote</span>' + ].join('\n') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + 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', + [ + stubMention('foo'), + stubMention('bar'), + stubMention('baz') + ].join(''), + 'Bruh' + ].join('<br>') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + hideMentions: true, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('Don\'t remove last mention if it\'s the only one', () => { + const html = [ + 'Bruh', + 'Bruh', + makeMention('foo'), + makeMention('bar'), + makeMention('baz') + ].join('<br>') + const expected = [ + 'Bruh', + 'Bruh', + stubMention('foo'), + stubMention('bar'), + stubMention('baz') + ].join('<br>') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + hideMentions: true, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('Don\'t remove last mentions if there are more than one first mention - remove first instead', () => { + const html = [ + [ + makeMention('foo'), + makeMention('bar') + ].join(' '), + 'Bruh', + 'Bruh', + [ + makeMention('foo'), + makeMention('bar'), + makeMention('baz') + ].join(' ') + ].join('\n') + + const expected = [ + [ + removedMentionSpan, + removedMentionSpan, + 'Bruh' // Due to trim we remove extra newline + ].join(''), + 'Bruh', + lastMentions([ + stubMention('foo'), + stubMention('bar'), + stubMention('baz') + ].join(' ')) + ].join('\n') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + hideMentions: true, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('Remove last mentions if there\'s just one first mention - remove all', () => { + const html = [ + [ + makeMention('foo') + ].join(' '), + 'Bruh', + 'Bruh', + [ + makeMention('foo'), + makeMention('bar'), + makeMention('baz') + ].join(' ') + ].join('\n') + + const expected = [ + [ + removedMentionSpan, + 'Bruh' // Due to trim we remove extra newline + ].join(''), + 'Bruh\n' // Can't remove this one yet + ].join('\n') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + hideMentions: true, + 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>', + ' <a class="hashtag" data-tag="nou" href="https://shitposter.club/tag/nou" target="_blank">', + '#nou</a>', + ' <a class="hashtag" data-tag="screencap" href="https://shitposter.club/tag/screencap" target="_blank">', + '#screencap</a>', + ' </p>' + ].join('') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + hideMentions: true, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('rich contents of a mention are handled properly', () => { + const html = [ + p( + 'Testing' + ), + p( + '<a href="lol" class="mention">', + '<span>', + 'https://</span>', + '<span>', + 'lol.tld/</span>', + '<span>', + '</span>', + '</a>' + ) + ].join('') + const expected = [ + p( + 'Testing' + ), + p( + '<mentionlink-stub url="lol" content="', + '<span>', + 'https://</span>', + '<span>', + 'lol.tld/</span>', + '<span>', + '</span>', + '">', + '</mentionlink-stub>' + ) + ].join('') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + hideMentions: false, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('rich contents of a mention in beginning are handled properly', () => { + 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">', + '<mentionlink-stub content="', + '<span>', + 'https://</span>', + '<span>', + 'lol.tld/</span>', + '<span>', + '</span>', + '" url="lol" class="mention-link">', + '</mentionlink-stub>', + '<!---->', // v-if placeholder + '</span>' + ), + p( + 'Testing' + ) + ].join('') + + const wrapper = mount(RichContent, { + localVue, + stubs: { + MentionLink: true + }, + propsData: { + hideMentions: false, + 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: { + hideMentions: false, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) +}) diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js index 759539e0..8a5a6ef9 100644 --- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js +++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js @@ -23,7 +23,6 @@ const makeMockStatusQvitter = (overrides = {}) => { repeat_num: 0, repeated: false, statusnet_conversation_id: '16300488', - statusnet_html: '<p>haha benis</p>', summary: null, tags: [], text: 'haha benis', @@ -232,22 +231,6 @@ describe('API Entities normalizer', () => { expect(parsedRepeat).to.have.property('retweeted_status') expect(parsedRepeat).to.have.deep.property('retweeted_status.id', 'deadbeef') }) - - it('adds emojis to post content', () => { - const post = makeMockStatusMasto({ emojis: makeMockEmojiMasto(), content: 'Makes you think :thinking:' }) - - const parsedPost = parseStatus(post) - - expect(parsedPost).to.have.property('statusnet_html').that.contains('<img') - }) - - it('adds emojis to subject line', () => { - const post = makeMockStatusMasto({ emojis: makeMockEmojiMasto(), spoiler_text: 'CW: 300 IQ :thinking:' }) - - const parsedPost = parseStatus(post) - - expect(parsedPost).to.have.property('summary_html').that.contains('<img') - }) }) }) diff --git a/test/unit/specs/services/html_converter/html_line_converter.spec.js b/test/unit/specs/services/html_converter/html_line_converter.spec.js new file mode 100644 index 00000000..de7c7fc2 --- /dev/null +++ b/test/unit/specs/services/html_converter/html_line_converter.spec.js @@ -0,0 +1,164 @@ +import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js' + +const greentextHandle = new Set(['p', 'div']) +const mapOnlyText = (processor) => (input) => { + if (input.text && input.level.every(l => greentextHandle.has(l))) { + return processor(input.text) + } else if (input.text) { + return input.text + } else { + return input + } +} + +describe('html_line_converter', () => { + describe('with processor that keeps original line should not make any changes to HTML when', () => { + const processorKeep = (line) => line + it('fed with regular HTML with newlines', () => { + const inputOutput = '1<br/>2<p class="lol">3 4</p> 5 \n 6 <p > 7 <br> 8 </p> <br>\n<br/>' + const result = convertHtmlToLines(inputOutput) + const comparableResult = result.map(mapOnlyText(processorKeep)).join('') + expect(comparableResult).to.eql(inputOutput) + }) + + it('fed with possibly broken HTML with invalid tags/composition', () => { + const inputOutput = '<feeee dwdwddddddw> <i>ayy<b>lm</i>ao</b> </section>' + const result = convertHtmlToLines(inputOutput) + const comparableResult = result.map(mapOnlyText(processorKeep)).join('') + expect(comparableResult).to.eql(inputOutput) + }) + + it('fed with very broken HTML with broken composition', () => { + const inputOutput = '</p> lmao what </div> whats going on <div> wha <p>' + const result = convertHtmlToLines(inputOutput) + const comparableResult = result.map(mapOnlyText(processorKeep)).join('') + expect(comparableResult).to.eql(inputOutput) + }) + + it('fed with sorta valid HTML but tags aren\'t closed', () => { + const inputOutput = 'just leaving a <div> hanging' + const result = convertHtmlToLines(inputOutput) + const comparableResult = result.map(mapOnlyText(processorKeep)).join('') + expect(comparableResult).to.eql(inputOutput) + }) + + it('fed with not really HTML at this point... tags that aren\'t finished', () => { + const inputOutput = 'do you expect me to finish this <div class=' + const result = convertHtmlToLines(inputOutput) + const comparableResult = result.map(mapOnlyText(processorKeep)).join('') + expect(comparableResult).to.eql(inputOutput) + }) + + it('fed with dubiously valid HTML (p within p and also div inside p)', () => { + const inputOutput = 'look ma <p> p \nwithin <p> p! </p> and a <br/><div>div!</div></p>' + const result = convertHtmlToLines(inputOutput) + const comparableResult = result.map(mapOnlyText(processorKeep)).join('') + expect(comparableResult).to.eql(inputOutput) + }) + + it('fed with maybe valid HTML? self-closing divs and ps', () => { + const inputOutput = 'a <div class="what"/> what now <p aria-label="wtf"/> ?' + const result = convertHtmlToLines(inputOutput) + const comparableResult = result.map(mapOnlyText(processorKeep)).join('') + expect(comparableResult).to.eql(inputOutput) + }) + + it('fed with valid XHTML containing a CDATA', () => { + const inputOutput = 'Yes, it is me, <![CDATA[DIO]]>' + const result = convertHtmlToLines(inputOutput) + const comparableResult = result.map(mapOnlyText(processorKeep)).join('') + expect(comparableResult).to.eql(inputOutput) + }) + }) + describe('with processor that replaces lines with word "_" should match expected line when', () => { + const processorReplace = (line) => '_' + it('fed with regular HTML with newlines', () => { + const input = '1<br/>2<p class="lol">3 4</p> 5 \n 6 <p > 7 <br> 8 </p> <br>\n<br/>' + const output = '_<br/>_<p class="lol">_</p>_\n_<p >_<br>_</p> <br>\n<br/>' + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + + it('fed with possibly broken HTML with invalid tags/composition', () => { + const input = '<feeee dwdwddddddw> <i>ayy<b>lm</i>ao</b> </section>' + const output = '_' + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + + it('fed with very broken HTML with broken composition', () => { + const input = '</p> lmao what </div> whats going on <div> wha <p>' + const output = '_<div>_<p>' + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + + it('fed with sorta valid HTML but tags aren\'t closed', () => { + const input = 'just leaving a <div> hanging' + const output = '_<div>_' + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + + it('fed with not really HTML at this point... tags that aren\'t finished', () => { + const input = 'do you expect me to finish this <div class=' + const output = '_' + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + + it('fed with dubiously valid HTML (p within p and also div inside p)', () => { + const input = 'look ma <p> p \nwithin <p> p! </p> and a <br/><div>div!</div></p>' + const output = '_<p>_\n_<p>_</p>_<br/><div>_</div></p>' + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + + it('fed with maybe valid HTML? (XHTML) self-closing divs and ps', () => { + const input = 'a <div class="what"/> what now <p aria-label="wtf"/> ?' + const output = '_<div class="what"/>_<p aria-label="wtf"/>_' + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + + it('fed with valid XHTML containing a CDATA', () => { + const input = 'Yes, it is me, <![CDATA[DIO]]>' + const output = '_' + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + + it('Testing handling ignored blocks', () => { + const input = ` + <pre><code>> rei = "0" + '0' + > rei == 0 + true + > rei == null + false</code></pre><blockquote>That, christian-like JS diagram but it’s evangelion instead.</blockquote> + ` + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(input) + }) + it('Testing handling ignored blocks 2', () => { + const input = ` + <blockquote>An SSL error has happened.</blockquote><p>Shakespeare</p> + ` + const output = ` + <blockquote>An SSL error has happened.</blockquote><p>_</p> + ` + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + }) +}) diff --git a/test/unit/specs/services/html_converter/html_tree_converter.spec.js b/test/unit/specs/services/html_converter/html_tree_converter.spec.js new file mode 100644 index 00000000..7283021b --- /dev/null +++ b/test/unit/specs/services/html_converter/html_tree_converter.spec.js @@ -0,0 +1,132 @@ +import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js' + +describe('html_tree_converter', () => { + describe('convertHtmlToTree', () => { + it('converts html into a tree structure', () => { + const input = '1 <p>2</p> <b>3<img src="a">4</b>5' + expect(convertHtmlToTree(input)).to.eql([ + '1 ', + [ + '<p>', + ['2'], + '</p>' + ], + ' ', + [ + '<b>', + [ + '3', + ['<img src="a">'], + '4' + ], + '</b>' + ], + '5' + ]) + }) + it('converts html to tree while preserving tag formatting', () => { + const input = '1 <p >2</p><b >3<img src="a">4</b>5' + expect(convertHtmlToTree(input)).to.eql([ + '1 ', + [ + '<p >', + ['2'], + '</p>' + ], + [ + '<b >', + [ + '3', + ['<img src="a">'], + '4' + ], + '</b>' + ], + '5' + ]) + }) + it('converts semi-broken html', () => { + const input = '1 <br> 2 <p> 42' + expect(convertHtmlToTree(input)).to.eql([ + '1 ', + ['<br>'], + ' 2 ', + [ + '<p>', + [' 42'] + ] + ]) + }) + it('realistic case 1', () => { + const input = '<p><span class="h-card"><a class="u-url mention" data-user="9wRC6T2ZZiKWJ0vUi8" href="https://cawfee.club/users/benis" rel="ugc">@<span>benis</span></a></span> <span class="h-card"><a class="u-url mention" data-user="194" href="https://shigusegubu.club/users/hj" rel="ugc">@<span>hj</span></a></span> nice</p>' + expect(convertHtmlToTree(input)).to.eql([ + [ + '<p>', + [ + [ + '<span class="h-card">', + [ + [ + '<a class="u-url mention" data-user="9wRC6T2ZZiKWJ0vUi8" href="https://cawfee.club/users/benis" rel="ugc">', + [ + '@', + [ + '<span>', + [ + 'benis' + ], + '</span>' + ] + ], + '</a>' + ] + ], + '</span>' + ], + ' ', + [ + '<span class="h-card">', + [ + [ + '<a class="u-url mention" data-user="194" href="https://shigusegubu.club/users/hj" rel="ugc">', + [ + '@', + [ + '<span>', + [ + 'hj' + ], + '</span>' + ] + ], + '</a>' + ] + ], + '</span>' + ], + ' nice' + ], + '</p>' + ] + ]) + }) + it('realistic case 2', () => { + const inputOutput = 'Country improv: give me a city<br/>Audience: Memphis<br/>Improv troupe: come on, a better one<br/>Audience: el paso' + expect(convertHtmlToTree(inputOutput)).to.eql([ + 'Country improv: give me a city', + [ + '<br/>' + ], + 'Audience: Memphis', + [ + '<br/>' + ], + 'Improv troupe: come on, a better one', + [ + '<br/>' + ], + 'Audience: el paso' + ]) + }) + }) +}) diff --git a/test/unit/specs/services/html_converter/utility.spec.js b/test/unit/specs/services/html_converter/utility.spec.js new file mode 100644 index 00000000..cf6fd99b --- /dev/null +++ b/test/unit/specs/services/html_converter/utility.spec.js @@ -0,0 +1,37 @@ +import { processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js' + +describe('html_converter utility', () => { + describe('processTextForEmoji', () => { + it('processes all emoji in text', () => { + const input = 'Hello from finland! :lol: We have best water! :lmao:' + const emojis = [ + { shortcode: 'lol', src: 'LOL' }, + { shortcode: 'lmao', src: 'LMAO' } + ] + const processor = ({ shortcode, src }) => ({ shortcode, src }) + expect(processTextForEmoji(input, emojis, processor)).to.eql([ + 'Hello from finland! ', + { shortcode: 'lol', src: 'LOL' }, + ' We have best water! ', + { shortcode: 'lmao', src: 'LMAO' } + ]) + }) + it('leaves text as is', () => { + const input = 'Number one: that\'s terror' + const emojis = [] + const processor = ({ shortcode, src }) => ({ shortcode, src }) + expect(processTextForEmoji(input, emojis, processor)).to.eql([ + 'Number one: that\'s terror' + ]) + }) + }) + + describe('getAttrs', () => { + it('extracts arguments from tag', () => { + const input = '<img src="boop" cool ebin=\'true\'>' + const output = { src: 'boop', cool: true, ebin: 'true' } + + expect(getAttrs(input)).to.eql(output) + }) + }) +}) diff --git a/test/unit/specs/services/tiny_post_html_processor/tiny_post_html_processor.spec.js b/test/unit/specs/services/tiny_post_html_processor/tiny_post_html_processor.spec.js deleted file mode 100644 index f301429d..00000000 --- a/test/unit/specs/services/tiny_post_html_processor/tiny_post_html_processor.spec.js +++ /dev/null @@ -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) - }) - }) -}) @@ -1011,23 +1011,86 @@ resolved "https://registry.yarnpkg.com/@ungap/event-target/-/event-target-0.1.0.tgz#88d527d40de86c4b0c99a060ca241d755999915b" integrity sha512-W2oyj0Fe1w/XhPZjkI3oUcDUAmu5P4qsdT2/2S8aMhtAWM/CE/jYWtji0pKNPDfxLI75fa5gWSEmnynKMNP/oA== -"@vue/babel-helper-vue-jsx-merge-props@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.0.0.tgz#048fe579958da408fb7a8b2a3ec050b50a661040" - integrity sha512-6tyf5Cqm4m6v7buITuwS+jHzPlIPxbFzEhXR5JGZpbrvOcp1hiQKckd305/3C7C36wFekNTQSxAtgeM0j0yoUw== +"@vue/babel-helper-vue-jsx-merge-props@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.2.1.tgz#31624a7a505fb14da1d58023725a4c5f270e6a81" + integrity sha512-QOi5OW45e2R20VygMSNhyQHvpdUwQZqGPc748JLGCYEy+yp8fNFNdbNIGAgZmi9e+2JHPd6i6idRuqivyicIkA== -"@vue/babel-plugin-transform-vue-jsx@^1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@vue/babel-plugin-transform-vue-jsx/-/babel-plugin-transform-vue-jsx-1.1.2.tgz#c0a3e6efc022e75e4247b448a8fc6b86f03e91c0" - integrity sha512-YfdaoSMvD1nj7+DsrwfTvTnhDXI7bsuh+Y5qWwvQXlD24uLgnsoww3qbiZvWf/EoviZMrvqkqN4CBw0W3BWUTQ== +"@vue/babel-plugin-transform-vue-jsx@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@vue/babel-plugin-transform-vue-jsx/-/babel-plugin-transform-vue-jsx-1.2.1.tgz#646046c652c2f0242727f34519d917b064041ed7" + integrity sha512-HJuqwACYehQwh1fNT8f4kyzqlNMpBuUK4rSiSES5D4QsYncv5fxFsLyrxFPG2ksO7t5WP+Vgix6tt6yKClwPzA== dependencies: "@babel/helper-module-imports" "^7.0.0" "@babel/plugin-syntax-jsx" "^7.2.0" - "@vue/babel-helper-vue-jsx-merge-props" "^1.0.0" + "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1" html-tags "^2.0.0" lodash.kebabcase "^4.1.1" svg-tags "^1.0.0" +"@vue/babel-preset-jsx@^1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@vue/babel-preset-jsx/-/babel-preset-jsx-1.2.4.tgz#92fea79db6f13b01e80d3a0099e2924bdcbe4e87" + integrity sha512-oRVnmN2a77bYDJzeGSt92AuHXbkIxbf/XXSE3klINnh9AXBmVS1DGa1f0d+dDYpLfsAKElMnqKTQfKn7obcL4w== + dependencies: + "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1" + "@vue/babel-plugin-transform-vue-jsx" "^1.2.1" + "@vue/babel-sugar-composition-api-inject-h" "^1.2.1" + "@vue/babel-sugar-composition-api-render-instance" "^1.2.4" + "@vue/babel-sugar-functional-vue" "^1.2.2" + "@vue/babel-sugar-inject-h" "^1.2.2" + "@vue/babel-sugar-v-model" "^1.2.3" + "@vue/babel-sugar-v-on" "^1.2.3" + +"@vue/babel-sugar-composition-api-inject-h@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@vue/babel-sugar-composition-api-inject-h/-/babel-sugar-composition-api-inject-h-1.2.1.tgz#05d6e0c432710e37582b2be9a6049b689b6f03eb" + integrity sha512-4B3L5Z2G+7s+9Bwbf+zPIifkFNcKth7fQwekVbnOA3cr3Pq71q71goWr97sk4/yyzH8phfe5ODVzEjX7HU7ItQ== + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@vue/babel-sugar-composition-api-render-instance@^1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@vue/babel-sugar-composition-api-render-instance/-/babel-sugar-composition-api-render-instance-1.2.4.tgz#e4cbc6997c344fac271785ad7a29325c51d68d19" + integrity sha512-joha4PZznQMsxQYXtR3MnTgCASC9u3zt9KfBxIeuI5g2gscpTsSKRDzWQt4aqNIpx6cv8On7/m6zmmovlNsG7Q== + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@vue/babel-sugar-functional-vue@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@vue/babel-sugar-functional-vue/-/babel-sugar-functional-vue-1.2.2.tgz#267a9ac8d787c96edbf03ce3f392c49da9bd2658" + integrity sha512-JvbgGn1bjCLByIAU1VOoepHQ1vFsroSA/QkzdiSs657V79q6OwEWLCQtQnEXD/rLTA8rRit4rMOhFpbjRFm82w== + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@vue/babel-sugar-inject-h@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@vue/babel-sugar-inject-h/-/babel-sugar-inject-h-1.2.2.tgz#d738d3c893367ec8491dcbb669b000919293e3aa" + integrity sha512-y8vTo00oRkzQTgufeotjCLPAvlhnpSkcHFEp60+LJUwygGcd5Chrpn5480AQp/thrxVm8m2ifAk0LyFel9oCnw== + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@vue/babel-sugar-v-model@^1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@vue/babel-sugar-v-model/-/babel-sugar-v-model-1.2.3.tgz#fa1f29ba51ebf0aa1a6c35fa66d539bc459a18f2" + integrity sha512-A2jxx87mySr/ulAsSSyYE8un6SIH0NWHiLaCWpodPCVOlQVODCaSpiR4+IMsmBr73haG+oeCuSvMOM+ttWUqRQ== + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1" + "@vue/babel-plugin-transform-vue-jsx" "^1.2.1" + camelcase "^5.0.0" + html-tags "^2.0.0" + svg-tags "^1.0.0" + +"@vue/babel-sugar-v-on@^1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@vue/babel-sugar-v-on/-/babel-sugar-v-on-1.2.3.tgz#342367178586a69f392f04bfba32021d02913ada" + integrity sha512-kt12VJdz/37D3N3eglBywV8GStKNUhNrsxChXIV+o0MwVXORYuhDTHJRKPgLJRb/EY3vM2aRFQdxJBp9CLikjw== + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + "@vue/babel-plugin-transform-vue-jsx" "^1.2.1" + camelcase "^5.0.0" + "@vue/test-utils@^1.0.0-beta.26": version "1.0.0-beta.28" resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.0.0-beta.28.tgz#767c43413df8cde86128735e58923803e444b9a5" |
