diff options
Diffstat (limited to 'src/components/status_content')
| -rw-r--r-- | src/components/status_content/status_content.js | 210 | ||||
| -rw-r--r-- | src/components/status_content/status_content.vue | 240 |
2 files changed, 450 insertions, 0 deletions
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js new file mode 100644 index 00000000..c0a71e8f --- /dev/null +++ b/src/components/status_content/status_content.js @@ -0,0 +1,210 @@ +import Attachment from '../attachment/attachment.vue' +import Poll from '../poll/poll.vue' +import Gallery from '../gallery/gallery.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' + +const StatusContent = { + name: 'StatusContent', + props: [ + 'status', + 'focused', + 'noHeading', + 'fullContent' + ], + data () { + return { + showingTall: 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 > 900 + }, + // 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.tallStatus || this.localCollapseSubjectDefault) + }, + mightHideBecauseTall () { + return this.tallStatus && (!this.status.summary || !this.localCollapseSubjectDefault) + }, + hideSubjectStatus () { + return this.mightHideBecauseSubject && !this.expandingSubject + }, + hideTallStatus () { + return this.mightHideBecauseTall && !this.showingTall + }, + showingMore () { + return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject) + }, + nsfwClickthrough () { + if (!this.status.nsfw) { + return false + } + if (this.status.summary && this.localCollapseSubjectDefault) { + return false + } + return true + }, + attachmentSize () { + if ((this.mergedConfig.hideAttachments && !this.inConversation) || + (this.mergedConfig.hideAttachmentsInConv && this.inConversation) || + (this.status.attachments.length > this.maxThumbnails)) { + return 'hide' + } else if (this.compact) { + return 'small' + } + return 'normal' + }, + galleryTypes () { + if (this.attachmentSize === 'hide') { + return [] + } + return this.mergedConfig.playVideosInModal + ? ['image', 'video'] + : ['image'] + }, + galleryAttachments () { + return this.status.attachments.filter( + file => fileType.fileMatchesSomeType(this.galleryTypes, file) + ) + }, + nonGalleryAttachments () { + return this.status.attachments.filter( + file => !fileType.fileMatchesSomeType(this.galleryTypes, file) + ) + }, + hasImageAttachments () { + return this.status.attachments.some( + file => fileType.fileType(file.mimetype) === 'image' + ) + }, + hasVideoAttachments () { + return this.status.attachments.some( + file => fileType.fileType(file.mimetype) === 'video' + ) + }, + 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 + } + }, + contentHtml () { + if (!this.status.summary_html) { + return this.postBodyHtml + } + return this.status.summary_html + '<br />' + this.postBodyHtml + }, + ...mapGetters(['mergedConfig']), + ...mapState({ + betterShadow: state => state.interface.browserSupport.cssFilter, + currentUser: state => state.users.currentUser + }) + }, + components: { + Attachment, + Poll, + Gallery, + LinkPreview + }, + methods: { + linkClicked (event) { + const target = event.target.closest('.status-content a') + if (target) { + if (target.className.match(/mention/)) { + const href = target.href + const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href)) + if (attn) { + event.stopPropagation() + event.preventDefault() + const link = this.generateUserProfileLink(attn.id, attn.screen_name) + this.$router.push(link) + return + } + } + if (target.rel.match(/(?:^|\s)tag(?:$|\s)/) || target.className.match(/hashtag/)) { + // Extract tag name from dataset or link url + const tag = target.dataset.tag || extractTagFromUrl(target.href) + if (tag) { + const link = this.generateTagLink(tag) + this.$router.push(link) + return + } + } + window.open(target.href, '_blank') + } + }, + toggleShowMore () { + if (this.mightHideBecauseTall) { + this.showingTall = !this.showingTall + } else if (this.mightHideBecauseSubject) { + this.expandingSubject = !this.expandingSubject + } + }, + generateUserProfileLink (id, name) { + return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames) + }, + generateTagLink (tag) { + return `/tag/${tag}` + }, + setMedia () { + const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments + return () => this.$store.dispatch('setMedia', attachments) + } + } +} + +export default StatusContent diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue new file mode 100644 index 00000000..7adb67ae --- /dev/null +++ b/src/components/status_content/status_content.vue @@ -0,0 +1,240 @@ +<template> + <!-- eslint-disable vue/no-v-html --> + <div class="status-body"> + <slot name="header" /> + <div + v-if="longSubject" + class="status-content-wrapper" + :class="{ 'tall-status': !showingLongSubject }" + > + <a + v-if="!showingLongSubject" + class="tall-status-hider" + :class="{ 'tall-status-hider_focused': focused }" + href="#" + @click.prevent="showingLongSubject=true" + > + {{ $t("general.show_more") }} + <span + v-if="hasImageAttachments" + class="icon-picture" + /> + <span + v-if="hasVideoAttachments" + class="icon-video" + /> + <span + v-if="status.card" + class="icon-link" + /> + </a> + <div + class="status-content media-body" + @click.prevent="linkClicked" + v-html="contentHtml" + /> + <a + v-if="showingLongSubject" + href="#" + class="status-unhider" + @click.prevent="showingLongSubject=false" + >{{ $t("general.show_less") }}</a> + </div> + <div + v-else + :class="{'tall-status': hideTallStatus}" + class="status-content-wrapper" + > + <a + v-if="hideTallStatus" + class="tall-status-hider" + :class="{ 'tall-status-hider_focused': focused }" + href="#" + @click.prevent="toggleShowMore" + >{{ $t("general.show_more") }}</a> + <div + v-if="!hideSubjectStatus" + class="status-content media-body" + @click.prevent="linkClicked" + v-html="contentHtml" + /> + <div + v-else + class="status-content media-body" + @click.prevent="linkClicked" + v-html="status.summary_html" + /> + <a + v-if="hideSubjectStatus" + href="#" + class="cw-status-hider" + @click.prevent="toggleShowMore" + >{{ $t("general.show_more") }}</a> + <a + v-if="showingMore" + href="#" + class="status-unhider" + @click.prevent="toggleShowMore" + >{{ $t("general.show_less") }}</a> + </div> + + <div v-if="status.poll && status.poll.options"> + <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()" + /> + <gallery + v-if="galleryAttachments.length > 0" + :nsfw="nsfwClickthrough" + :attachments="galleryAttachments" + :set-media="setMedia()" + /> + </div> + + <div + v-if="status.card && !hideSubjectStatus && !noHeading" + class="link-preview media-body" + > + <link-preview + :card="status.card" + :size="attachmentSize" + :nsfw="nsfwClickthrough" + /> + </div> + <slot name="footer" /> + </div> + <!-- eslint-enable vue/no-v-html --> +</template> + +<script src="./status_content.js" ></script> +<style lang="scss"> +@import '../../_variables.scss'; + +$status-margin: 0.75em; + +.status-body { + flex: 1; + min-width: 0; + + .tall-status { + position: relative; + height: 220px; + overflow-x: hidden; + overflow-y: hidden; + z-index: 1; + .status-content { + height: 100%; + 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; + } + + .status-content { + font-family: var(--postFont, sans-serif); + line-height: 1.4em; + white-space: pre-wrap; + + img, video { + max-width: 100%; + max-height: 400px; + vertical-align: middle; + object-fit: contain; + + &.emoji { + width: 32px; + height: 32px; + } + } + + 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; + } + } +} + +.greentext { + color: $fallback--cGreen; + color: var(--postGreentext, $fallback--cGreen); +} + +.timeline :not(.panel-disabled) > { + .status-el:last-child { + border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; + border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); + border-bottom: none; + } +} + +</style> |
