Page MenuHomePhorge

No OneTemporary

Size
53 KB
Referenced Files
None
Subscribers
None
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 61d66301c8..ab3de5fc19 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,463 +1,283 @@
-import Attachment from '../attachment/attachment.vue'
import FavoriteButton from '../favorite_button/favorite_button.vue'
import ReactButton from '../react_button/react_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue'
-import Poll from '../poll/poll.vue'
import ExtraButtons from '../extra_buttons/extra_buttons.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCard from '../user_card/user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
-import Gallery from '../gallery/gallery.vue'
-import LinkPreview from '../link-preview/link-preview.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
+import StatusContent from '../status_content/status_content.vue'
import StatusPopover from '../status_popover/status_popover.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.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 { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
-import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
import { filter, unescape, uniqBy } from 'lodash'
import { mapGetters, mapState } from 'vuex'
const Status = {
name: 'Status',
props: [
'statusoid',
'expandable',
'inConversation',
'focused',
'highlight',
'compact',
'replies',
'isPreview',
'noHeading',
'inlineExpanded',
'showPinned',
'inProfile',
'profileUserId'
],
data () {
return {
replying: false,
unmuted: false,
userExpanded: false,
- showingTall: this.inConversation && this.focused,
- showingLongSubject: false,
- error: null,
- // not as computed because it sets the initial state which will be changed later
- expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
+ error: null
}
},
computed: {
- localCollapseSubjectDefault () {
- return this.mergedConfig.collapseMessageWithSubject
- },
muteWords () {
return this.mergedConfig.muteWords
},
repeaterClass () {
const user = this.statusoid.user
return highlightClass(user)
},
userClass () {
const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user
return highlightClass(user)
},
deleted () {
return this.statusoid.deleted
},
repeaterStyle () {
const user = this.statusoid.user
const highlight = this.mergedConfig.highlight
return highlightStyle(highlight[user.screen_name])
},
userStyle () {
if (this.noHeading) return
const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user
const highlight = this.mergedConfig.highlight
return highlightStyle(highlight[user.screen_name])
},
- hideAttachments () {
- return (this.mergedConfig.hideAttachments && !this.inConversation) ||
- (this.mergedConfig.hideAttachmentsInConv && this.inConversation)
- },
userProfileLink () {
return this.generateUserProfileLink(this.status.user.id, this.status.user.screen_name)
},
replyProfileLink () {
if (this.isReply) {
return this.generateUserProfileLink(this.status.in_reply_to_user_id, this.replyToName)
}
},
retweet () { return !!this.statusoid.retweeted_status },
retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name },
retweeterHtml () { return this.statusoid.user.name_html },
retweeterProfileLink () { return this.generateUserProfileLink(this.statusoid.user.id, this.statusoid.user.screen_name) },
status () {
if (this.retweet) {
return this.statusoid.retweeted_status
} else {
return this.statusoid
}
},
statusFromGlobalRepository () {
// NOTE: Consider to replace status with statusFromGlobalRepository
return this.$store.state.statuses.allStatusesObject[this.status.id]
},
loggedIn () {
return !!this.currentUser
},
muteWordHits () {
const statusText = this.status.text.toLowerCase()
const statusSummary = this.status.summary.toLowerCase()
const hits = filter(this.muteWords, (muteWord) => {
return statusText.includes(muteWord.toLowerCase()) || statusSummary.includes(muteWord.toLowerCase())
})
return hits
},
muted () { return !this.unmuted && ((!(this.inProfile && this.status.user.id === this.profileUserId) && this.status.user.muted) || (!this.inConversation && this.status.thread_muted) || this.muteWordHits.length > 0) },
hideFilteredStatuses () {
return this.mergedConfig.hideFilteredStatuses
},
hideStatus () {
return (this.hideReply || this.deleted) || (this.muted && this.hideFilteredStatuses)
},
isFocused () {
// retweet or root of an expanded conversation
if (this.focused) {
return true
} else if (!this.inConversation) {
return false
}
// use conversation highlight only when in conversation
return this.status.id === this.highlight
},
- // 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
- },
isReply () {
return !!(this.status.in_reply_to_status_id && this.status.in_reply_to_user_id)
},
replyToName () {
if (this.status.in_reply_to_screen_name) {
return this.status.in_reply_to_screen_name
} else {
const user = this.$store.getters.findUser(this.status.in_reply_to_user_id)
return user && user.screen_name
}
},
hideReply () {
if (this.mergedConfig.replyVisibility === 'all') {
return false
}
if (this.inConversation || !this.isReply) {
return false
}
if (this.status.user.id === this.currentUser.id) {
return false
}
if (this.status.type === 'retweet') {
return false
}
const checkFollowing = this.mergedConfig.replyVisibility === 'following'
for (var i = 0; i < this.status.attentions.length; ++i) {
if (this.status.user.id === this.status.attentions[i].id) {
continue
}
const taggedUser = this.$store.getters.findUser(this.status.attentions[i].id)
if (checkFollowing && taggedUser && taggedUser.following) {
return false
}
if (this.status.attentions[i].id === this.currentUser.id) {
return false
}
}
return this.status.attentions.length > 0
},
-
- // 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
- },
replySubject () {
if (!this.status.summary) return ''
const decodedSummary = unescape(this.status.summary)
const behavior = this.mergedConfig.subjectLineBehavior
const startsWithRe = decodedSummary.match(/^re[: ]/i)
if ((behavior !== 'noop' && startsWithRe) || behavior === 'masto') {
return decodedSummary
} else if (behavior === 'email') {
return 're: '.concat(decodedSummary)
} else if (behavior === 'noop') {
return ''
}
},
- 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('&gt;')) {
- // This checks if post has '>' at the beginning, excluding mentions so that @mention >impying works
- return processHtml(html, (string) => {
- if (string.includes('&gt;') &&
- string
- .replace(/<[^>]+?>/gi, '') // remove all tags
- .replace(/@\w+/gi, '') // remove mentions (even failed ones)
- .trim()
- .startsWith('&gt;')) {
- return `<span class='greentext'>${string}</span>`
- } else {
- return string
- }
- })
- } else {
- return html
- }
- } catch (e) {
- console.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
- },
combinedFavsAndRepeatsUsers () {
// Use the status from the global status repository since favs and repeats are saved in it
const combinedUsers = [].concat(
this.statusFromGlobalRepository.favoritedBy,
this.statusFromGlobalRepository.rebloggedBy
)
return uniqBy(combinedUsers, 'id')
},
- ownStatus () {
- return this.status.user.id === this.currentUser.id
- },
tags () {
return this.status.tags.filter(tagObj => tagObj.hasOwnProperty('name')).map(tagObj => tagObj.name).join(' ')
},
hidePostStats () {
return this.mergedConfig.hidePostStats
},
...mapGetters(['mergedConfig']),
...mapState({
betterShadow: state => state.interface.browserSupport.cssFilter,
currentUser: state => state.users.currentUser
})
},
components: {
- Attachment,
FavoriteButton,
ReactButton,
RetweetButton,
ExtraButtons,
PostStatusForm,
- Poll,
UserCard,
UserAvatar,
- Gallery,
- LinkPreview,
AvatarList,
Timeago,
StatusPopover,
- EmojiReactions
+ EmojiReactions,
+ StatusContent
},
methods: {
visibilityIcon (visibility) {
switch (visibility) {
case 'private':
return 'icon-lock'
case 'unlisted':
return 'icon-lock-open-alt'
case 'direct':
return 'icon-mail-alt'
default:
return 'icon-globe'
}
},
showError (error) {
this.error = error
},
clearError () {
this.error = undefined
},
- 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 link url
- const tag = extractTagFromUrl(target.href)
- if (tag) {
- const link = this.generateTagLink(tag)
- this.$router.push(link)
- return
- }
- }
- window.open(target.href, '_blank')
- }
- },
toggleReplying () {
this.replying = !this.replying
},
gotoOriginal (id) {
if (this.inConversation) {
this.$emit('goto', id)
}
},
toggleExpanded () {
this.$emit('toggleExpanded')
},
toggleMute () {
this.unmuted = !this.unmuted
},
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
- 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)
}
},
watch: {
'highlight': function (id) {
if (this.status.id === id) {
let rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.height >= (window.innerHeight - 50)) {
// Post we want to see is taller than screen so match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.bottom > window.innerHeight - 50) {
// Post is below screen, match its bottom to screen bottom
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
},
'status.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (this.isFocused && this.statusFromGlobalRepository.rebloggedBy && this.statusFromGlobalRepository.rebloggedBy.length !== num) {
this.$store.dispatch('fetchRepeats', this.status.id)
}
},
'status.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (this.isFocused && this.statusFromGlobalRepository.favoritedBy && this.statusFromGlobalRepository.favoritedBy.length !== num) {
this.$store.dispatch('fetchFavs', this.status.id)
}
}
},
filters: {
capitalize: function (str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
}
}
export default Status
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 76b5c5abf3..6d862ca0cd 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,944 +1,726 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<div
v-if="!hideStatus"
class="status-el"
:class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"
>
<div
v-if="error"
class="alert error"
>
{{ error }}
<i
class="button-icon icon-cancel"
@click="clearError"
/>
</div>
<template v-if="muted && !isPreview">
<div class="media status container muted">
<small>
<router-link :to="userProfileLink">
{{ status.user.screen_name }}
</router-link>
</small>
<small class="muteWords">{{ muteWordHits.join(', ') }}</small>
<a
href="#"
class="unmute"
@click.prevent="toggleMute"
><i class="button-icon icon-eye-off" /></a>
</div>
</template>
<template v-else>
<div
v-if="showPinned"
class="status-pin"
>
<i class="fa icon-pin faint" />
<span class="faint">{{ $t('status.pinned') }}</span>
</div>
<div
v-if="retweet && !noHeading && !inConversation"
:class="[repeaterClass, { highlighted: repeaterStyle }]"
:style="[repeaterStyle]"
class="media container retweet-info"
>
<UserAvatar
v-if="retweet"
class="media-left"
:better-shadow="betterShadow"
:user="statusoid.user"
/>
<div class="media-body faint">
<span class="user-name">
<router-link
v-if="retweeterHtml"
:to="retweeterProfileLink"
v-html="retweeterHtml"
/>
<router-link
v-else
:to="retweeterProfileLink"
>{{ retweeter }}</router-link>
</span>
<i
class="fa icon-retweet retweeted"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
</div>
</div>
<div
:class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]"
:style="[ userStyle ]"
class="media status"
:data-tags="tags"
>
<div
v-if="!noHeading"
class="media-left"
>
<router-link
:to="userProfileLink"
@click.stop.prevent.capture.native="toggleUserExpanded"
>
<UserAvatar
:compact="compact"
:better-shadow="betterShadow"
:user="status.user"
/>
</router-link>
</div>
<div class="status-body">
<UserCard
v-if="userExpanded"
:user="status.user"
:rounded="true"
:bordered="true"
class="status-usercard"
/>
<div
v-if="!noHeading"
class="media-heading"
>
<div class="heading-name-row">
<div class="name-and-account-name">
<h4
v-if="status.user.name_html"
class="user-name"
v-html="status.user.name_html"
/>
<h4
v-else
class="user-name"
>
{{ status.user.name }}
</h4>
<router-link
class="account-name"
:to="userProfileLink"
>
{{ status.user.screen_name }}
</router-link>
</div>
<span class="heading-right">
<router-link
class="timeago faint-link"
:to="{ name: 'conversation', params: { id: status.id } }"
>
<Timeago
:time="status.created_at"
:auto-update="60"
/>
</router-link>
<div
v-if="status.visibility"
class="button-icon visibility-icon"
>
<i
:class="visibilityIcon(status.visibility)"
:title="status.visibility | capitalize"
/>
</div>
<a
v-if="!status.is_local && !isPreview"
:href="status.external_url"
target="_blank"
class="source_url"
title="Source"
>
<i class="button-icon icon-link-ext-alt" />
</a>
<template v-if="expandable && !isPreview">
<a
href="#"
title="Expand"
@click.prevent="toggleExpanded"
>
<i class="button-icon icon-plus-squared" />
</a>
</template>
<a
v-if="unmuted"
href="#"
@click.prevent="toggleMute"
><i class="button-icon icon-eye-off" /></a>
</span>
</div>
<div class="heading-reply-row">
<div
v-if="isReply"
class="reply-to-and-accountname"
>
<StatusPopover
v-if="!isPreview"
:status-id="status.in_reply_to_status_id"
class="reply-to-popover"
style="min-width: 0"
>
<a
class="reply-to"
href="#"
:aria-label="$t('tool_tip.reply')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<i class="button-icon icon-reply" />
<span class="faint-link reply-to-text">{{ $t('status.reply_to') }}</span>
</a>
</StatusPopover>
<span
v-else
class="reply-to"
>
<span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span>
<router-link :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"
>
<span class="faint">{{ $t('status.replies_list') }}</span>
<StatusPopover
v-for="reply in replies"
:key="reply.id"
:status-id="reply.id"
>
<a
href="#"
class="reply-link"
@click.prevent="gotoOriginal(reply.id)"
>{{ reply.name }}</a>
</StatusPopover>
</div>
</div>
</div>
- <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': isFocused }"
- href="#"
- @click.prevent="showingLongSubject=true"
- >{{ $t("general.show_more") }}</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': isFocused }"
- 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") }}
- <span
- v-if="hasImageAttachments"
- class="icon-picture"
- />
- <span
- v-if="hasVideoAttachments"
- class="icon-video"
- />
- <span
- v-if="status.card"
- class="icon-link"
- />
- </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 && (!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>
+ <StatusContent
+ :status="status"
+ :no-heading="noHeading"
+ :highlight="highlight"
+ :focused="isFocused"
+ />
<transition name="fade">
<div
v-if="!hidePostStats && isFocused && combinedFavsAndRepeatsUsers.length > 0"
class="favs-repeated-users"
>
<div class="stats">
<div
v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0"
class="stat-count"
>
<a class="stat-title">{{ $t('status.repeats') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.rebloggedBy.length }}
</div>
</div>
<div
v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0"
class="stat-count"
>
<a class="stat-title">{{ $t('status.favorites') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.favoritedBy.length }}
</div>
</div>
<div class="avatar-row">
<AvatarList :users="combinedFavsAndRepeatsUsers" />
</div>
</div>
</div>
</transition>
<EmojiReactions
v-if="(mergedConfig.emojiReactionsOnTimeline || isFocused) && (!noHeading && !isPreview)"
:status="status"
/>
<div
v-if="!noHeading && !isPreview"
class="status-actions media-body"
>
<div>
<i
v-if="loggedIn"
class="button-icon icon-reply"
:title="$t('tool_tip.reply')"
:class="{'button-icon-active': replying}"
@click.prevent="toggleReplying"
/>
<i
v-else
class="button-icon button-icon-disabled icon-reply"
:title="$t('tool_tip.reply')"
/>
<span v-if="status.replies_count > 0">{{ status.replies_count }}</span>
</div>
<retweet-button
:visibility="status.visibility"
:logged-in="loggedIn"
:status="status"
/>
<favorite-button
:logged-in="loggedIn"
:status="status"
/>
<ReactButton
v-if="loggedIn"
:status="status"
/>
<extra-buttons
:status="status"
@onError="showError"
@onSuccess="clearError"
/>
</div>
</div>
</div>
<div
v-if="replying"
class="container"
>
<PostStatusForm
class="reply-body"
:reply-to="status.id"
:attentions="status.attentions"
:replied-user="status.user"
:copy-message-scope="status.visibility"
:subject="replySubject"
@posted="toggleReplying"
/>
</div>
</template>
</div>
<!-- eslint-enable vue/no-v-html -->
</template>
<script src="./status.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
$status-margin: 0.75em;
.status-body {
flex: 1;
min-width: 0;
}
.status-pin {
padding: $status-margin $status-margin 0;
display: flex;
align-items: center;
justify-content: flex-end;
}
.media-left {
margin-right: $status-margin;
}
.status-el {
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
border-left-width: 0px;
min-width: 0;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
border-left: 4px $fallback--cRed;
border-left: 4px var(--cRed, $fallback--cRed);
&_focused {
background-color: $fallback--lightBg;
background-color: var(--selectedPost, $fallback--lightBg);
color: $fallback--text;
color: var(--selectedPostText, $fallback--text);
--lightText: var(--selectedPostLightText, $fallback--light);
--faint: var(--selectedPostFaintText, $fallback--faint);
--faintLink: var(--selectedPostFaintLink, $fallback--faint);
--postLink: var(--selectedPostPostLink, $fallback--faint);
--postFaintLink: var(--selectedPostFaintPostLink, $fallback--faint);
--icon: var(--selectedPostIcon, $fallback--icon);
}
.timeline & {
border-bottom-width: 1px;
border-bottom-style: solid;
}
.media-body {
flex: 1;
padding: 0;
}
.status-usercard {
margin-bottom: $status-margin;
}
.user-name {
white-space: nowrap;
font-size: 14px;
overflow: hidden;
flex-shrink: 0;
max-width: 85%;
font-weight: bold;
img {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
.media-heading {
padding: 0;
vertical-align: bottom;
flex-basis: 100%;
margin-bottom: 0.5em;
small {
font-weight: lighter;
}
.heading-name-row {
padding: 0;
display: flex;
justify-content: space-between;
line-height: 18px;
a {
display: inline-block;
word-break: break-all;
}
.name-and-account-name {
display: flex;
min-width: 0;
}
.user-name {
flex-shrink: 1;
margin-right: 0.4em;
overflow: hidden;
text-overflow: ellipsis;
}
.account-name {
min-width: 1.6em;
margin-right: 0.4em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 0;
}
}
.heading-right {
display: flex;
flex-shrink: 0;
}
.timeago {
margin-right: 0.2em;
}
.heading-reply-row {
position: relative;
align-content: baseline;
font-size: 12px;
line-height: 18px;
max-width: 100%;
display: flex;
flex-wrap: wrap;
align-items: stretch;
> .reply-to-and-accountname > a {
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-all;
}
}
.reply-to-and-accountname {
display: flex;
height: 18px;
margin-right: 0.5em;
max-width: 100%;
.icon-reply {
transform: scaleX(-1);
}
}
.reply-info {
display: flex;
}
.reply-to-popover {
min-width: 0;
}
.reply-to {
display: flex;
}
.reply-to-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0 0.4em 0 0.2em;
}
.replies-separator {
margin-left: 0.4em;
}
.replies {
line-height: 18px;
font-size: 12px;
display: flex;
flex-wrap: wrap;
& > * {
margin-right: 0.4em;
}
}
.reply-link {
height: 17px;
}
}
- .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;
-
- a {
- color: $fallback--link;
- color: var(--postLink, $fallback--link);
- }
-
- 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;
- }
- }
-
.retweet-info {
padding: 0.4em $status-margin;
margin: 0;
.avatar.still-image {
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
margin-left: 28px;
width: 20px;
height: 20px;
}
.media-body {
font-size: 1em;
line-height: 22px;
display: flex;
align-content: center;
flex-wrap: wrap;
.user-name {
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
img {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
i {
padding: 0 0.2em;
}
a {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
.status-fadein {
animation-duration: 0.4s;
animation-name: fadein;
}
@keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
-.greentext {
- color: $fallback--cGreen;
- color: var(--cGreen, $fallback--cGreen);
-}
-
.status-conversation {
border-left-style: solid;
}
.status-actions {
position: relative;
width: 100%;
display: flex;
margin-top: $status-margin;
> * {
max-width: 4em;
flex: 1;
}
}
.button-icon.icon-reply {
&:not(.button-icon-disabled):hover,
&.button-icon-active {
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
}
.button-icon.icon-reply {
&:not(.button-icon-disabled) {
cursor: pointer;
}
}
.status:hover .animated.avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
.status {
display: flex;
padding: $status-margin;
&.is-retweet {
padding-top: 0;
}
}
.status-conversation:last-child {
border-bottom: none;
}
.muted {
padding: 0.25em 0.5em;
button {
margin-left: auto;
}
.muteWords {
margin-left: 10px;
}
}
a.unmute {
display: block;
margin-left: auto;
}
.reply-body {
flex: 1;
}
-.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;
- }
-}
-
.favs-repeated-users {
margin-top: $status-margin;
.stats {
width: 100%;
display: flex;
line-height: 1em;
.stat-count {
margin-right: $status-margin;
.stat-title {
color: var(--faint, $fallback--faint);
font-size: 12px;
text-transform: uppercase;
position: relative;
}
.stat-number {
font-weight: bolder;
font-size: 16px;
line-height: 1em;
}
}
.avatar-row {
flex: 1;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
&::before {
content: '';
position: absolute;
height: 100%;
width: 1px;
left: 0;
background-color: var(--faint, $fallback--faint);
}
}
}
}
@media all and (max-width: 800px) {
.status-el {
.retweet-info {
.avatar.still-image {
margin-left: 20px;
}
}
}
.status {
max-width: 100%;
}
.status .avatar.still-image {
width: 40px;
height: 40px;
&.avatar-compact {
width: 32px;
height: 32px;
}
}
}
</style>
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
new file mode 100644
index 0000000000..ccc01b6f96
--- /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('&gt;')) {
+ // This checks if post has '>' at the beginning, excluding mentions so that @mention >impying works
+ return processHtml(html, (string) => {
+ if (string.includes('&gt;') &&
+ string
+ .replace(/<[^>]+?>/gi, '') // remove all tags
+ .replace(/@\w+/gi, '') // remove mentions (even failed ones)
+ .trim()
+ .startsWith('&gt;')) {
+ return `<span class='greentext'>${string}</span>`
+ } else {
+ return string
+ }
+ })
+ } else {
+ return html
+ }
+ } catch (e) {
+ console.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 link url
+ const 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 0000000000..8c2e87498f
--- /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(--cGreen, $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>

File Metadata

Mime Type
text/x-diff
Expires
Sun, Aug 30, 6:36 AM (1 d, 15 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1737836
Default Alt Text
(53 KB)

Event Timeline