Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85804366
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
80 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index cff7f655ff..c94769809a 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -1,174 +1,176 @@
import { mapActions, mapState } from 'pinia'
import Report from 'src/components/report/report.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
+import StatusHeader from 'src/components/status_header/status_header.vue'
import Timeago from 'src/components/timeago/timeago.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { isStatusNotification } from '../../services/notification_utils/notification_utils_sw.js'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import { useFollowRequestsStore } from 'src/stores/follow_requests.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { useUsersStore } from 'src/stores/users.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faCheck,
faCompressAlt,
faExpandAlt,
faEyeSlash,
faRetweet,
faStar,
faSuitcaseRolling,
faTimes,
faUser,
faUserPlus,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faCheck,
faTimes,
faStar,
faRetweet,
faUserPlus,
faUser,
faEyeSlash,
faSuitcaseRolling,
faExpandAlt,
faCompressAlt,
)
const Notification = {
data() {
return {
selecting: false,
statusExpanded: false,
unmuted: false,
}
},
props: ['notification'],
emits: ['interacted'],
components: {
StatusContent,
+ StatusHeader,
UserAvatar,
Timeago,
Report,
UserPopover,
UserLink,
},
mounted() {
document.addEventListener('selectionchange', this.onContentSelect)
},
unmounted() {
document.removeEventListener('selectionchange', this.onContentSelect)
},
methods: {
toggleStatusExpanded() {
if (!this.expandable) return
this.statusExpanded = !this.statusExpanded
},
onContentSelect() {
const { isCollapsed, anchorNode, offsetNode } = document.getSelection()
if (isCollapsed) {
this.selecting = false
return
}
const within =
this.$refs.root.contains(anchorNode) ||
this.$refs.root.contains(offsetNode)
if (within) {
this.selecting = true
} else {
this.selecting = false
}
},
onContentClick(e) {
if (
!this.selecting &&
!e.target.closest('a') &&
!e.target.closest('button')
) {
this.toggleStatusExpanded()
}
},
generateUserProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
useInstanceStore().restrictedNicknames,
)
},
interacted() {
this.$emit('interacted')
},
toggleMute() {
this.unmuted = !this.unmuted
},
...mapActions(useFollowRequestsStore, ['approve', 'deny']),
},
computed: {
status() {
// Used for StatusContent
if (this.notification.status) {
return useStatusesStore().allStatuses.get(this.notification.status.id)
}
},
userClass() {
return highlightClass(this.notification.from_profile)
},
userStyle() {
const user = this.notification.from_profile.screen_name
return highlightStyle(useUserHighlightStore().get(user))
},
expandable() {
return new Set(['like', 'pleroma:emoji_reaction', 'repeat', 'poll']).has(
this.notification.type,
)
},
user() {
return useUsersStore().findUser(this.notification.from_profile.id)
},
userProfileLink() {
return this.generateUserProfileLink(this.user)
},
targetUser() {
return useUsersStore().findUser(this.notification.target.id)
},
targetUserProfileLink() {
return this.generateUserProfileLink(this.targetUser)
},
needMute() {
return useUsersStore().relationship(this.user.id).muting
},
isStatusNotification() {
return isStatusNotification(this.notification.type)
},
mergedConfig() {
return useMergedConfigStore().mergedConfig
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
...mapState(useUsersStore, ['currentUser']),
},
}
export default Notification
diff --git a/src/components/notification/notification.scss b/src/components/notification/notification.scss
index 8ba66a0e14..68803500b7 100644
--- a/src/components/notification/notification.scss
+++ b/src/components/notification/notification.scss
@@ -1,230 +1,174 @@
// TODO Copypaste from Status, should unify it somehow
.Notification {
box-sizing: border-box;
border-bottom: 1px solid;
border-color: var(--border);
overflow-wrap: break-word;
text-wrap: pretty;
.status-content {
cursor: pointer;
+ margin-top: calc(var(--status-margin) / 2);
}
--emoji-size: 1em;
&:hover {
--_still-image-img-visibility: visible;
--_still-image-canvas-visibility: hidden;
--_still-image-label-visibility: hidden;
}
&.-muted {
padding: 0.25em 0.6em;
height: 1.2em;
line-height: 1.2em;
text-overflow: ellipsis;
overflow: hidden;
display: flex;
flex-wrap: nowrap;
gap: 1ex;
& .status-username,
& .mute-thread,
& .mute-words {
white-space: nowrap;
}
& .status-username,
& .mute-words {
text-overflow: ellipsis;
overflow: hidden;
}
.status-username {
font-weight: normal;
flex: 0 1 auto;
margin-right: 0.2em;
font-size: smaller;
}
.mute-thread {
flex: 0 0 auto;
}
.mute-words {
flex: 1 0 5em;
margin-left: 0.2em;
&::before {
content: " ";
}
}
.unmute {
flex: 0 0 auto;
margin-left: auto;
display: block;
}
}
.type-icon {
margin: 0 0.1em;
}
&.-type--repeat .type-icon {
color: var(--cGreen);
}
&.-type--follow .type-icon {
color: var(--cBlue);
}
&.-type--follow-request .type-icon {
color: var(--cBlue);
}
&.-type--like .type-icon {
color: var(--cOrange);
}
&.-type--move .type-icon {
color: var(--cBlue);
}
.Status {
flex: 1;
}
&:hover .animated.Avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
&.-non-mention {
display: flex;
flex: 1;
flex-wrap: nowrap;
- padding: 0.6em;
+ padding: var(--status-margin);
+ gap: var(--status-margin);
min-width: 0;
}
.follow-request-accept {
&:hover {
color: var(--text);
}
}
.follow-request-reject {
&:hover {
color: var(--cRed);
}
}
.follow-text,
.move-text {
padding: 0.5em 0;
overflow-wrap: break-word;
display: flex;
justify-content: space-between;
.follow-name {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
time {
white-space: nowrap;
}
.notification-right {
- flex: 1;
- padding-left: 0.8em;
+ flex: 1 1 auto;
min-width: 0;
- margin-right: calc(var(--status-margin) / -2);
-
- .button-unstyled {
- margin: calc(var(--status-margin) / -2) 0;
- padding: calc(var(--status-margin) / 2);
- }
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--status-margin) / 4);
}
.emoji-reaction-emoji {
font-size: 1.3em;
max-width: 1.25em;
height: 1.25em;
width: auto;
&.-wide {
max-width: 3.75em;
}
}
.emoji-reaction-emoji-image {
vertical-align: middle;
object-fit: contain;
}
-
- .notification-details {
- min-width: 0;
- line-height: var(--post-line-height);
- position: relative;
- overflow: hidden;
- width: 100%;
- flex: 1 1 0;
- display: flex;
- flex-wrap: nowrap;
- justify-content: space-between;
-
- .name-and-action {
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- line-height: 1.3;
- }
-
- .username {
- font-weight: bolder;
- max-width: 100%;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .timeago {
- margin-right: 0.2em;
- }
-
- .status-content {
- margin: 0;
- max-height: 300px;
- }
-
- h1 {
- overflow-wrap: break-word;
- text-wrap: pretty;
- margin: 0 0 0.3em;
- padding: 0;
- font-size: 1em;
- line-height: 1.5;
-
- small {
- font-weight: lighter;
- }
- }
-
- p {
- margin: 0;
- margin-top: 0;
- margin-bottom: 0.3em;
- }
- }
}
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 29d83e86cf..bbc97f05d9 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -1,276 +1,209 @@
<template>
<article
v-if="notification.type === 'mention' || notification.type === 'status'"
ref="root"
>
<Status
class="Notification panel-body"
:compact="true"
:status-id="notification.status?.id"
@click="interacted"
/>
</article>
<article
v-else
ref="root"
class="NotificationParent panel-body"
:class="{ '-expandable': expandable }"
>
<div
v-if="needMute && !unmuted"
:id="'notif-' +notification.id"
:aria-expanded="statusExpanded"
:aria-controls="'notif-' +notification.id"
class="Notification container -muted"
>
<small>
<UserLink
:user="notification.from_profile"
:at="false"
/>
</small>
<button
class="button-unstyled unmute"
@click.prevent="toggleMute"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="eye-slash"
/>
</button>
</div>
<div
v-else
class="Notification -non-mention"
:class="[userClass, { highlighted: userStyle }, '-type--' + notification.type]"
:style="[ userStyle ]"
>
<a
class="avatar-container"
:href="$router.resolve(userProfileLink).href"
@click.prevent
>
<UserPopover
:user-id="notification.from_profile.id"
:overlay-centers="true"
>
<UserAvatar
class="post-avatar"
:compact="true"
:user-id="notification.from_profile.id"
/>
</UserPopover>
</a>
<div class="notification-right">
- <span class="notification-details">
- <div class="name-and-action">
- <!-- eslint-disable vue/no-v-html -->
- <bdi v-if="!!notification.from_profile.name_html">
- <RichContent
- class="username"
- :title="'@'+notification.from_profile.screen_name_ui"
- :html="notification.from_profile.name_html"
- :emoji="notification.from_profile.emoji"
- :allow-non-square-emoji="allowNonSquareEmoji"
- :pause-mfm="pauseMfm"
- :scale-mfm="scaleMfm"
- :is-local="notification.from_profile.is_local"
- />
- </bdi>
- <!-- eslint-enable vue/no-v-html -->
- <span
- v-else
- class="username"
- :title="'@'+notification.from_profile.screen_name_ui"
- >
- {{ notification.from_profile.name }}
- </span>
+ <StatusHeader
+ :statusoid="status"
+ :time="notification.created_at"
+ :user-id="notification.from_profile.id"
+ @toggle-expanded="toggleStatusExpanded"
+ :expanded="statusExpanded"
+ non-mention
+ />
+ <div class="action">
+ <span v-if="notification.type === 'like'">
+ <FAIcon
+ class="type-icon"
+ icon="star"
+ />
{{ ' ' }}
- <span v-if="notification.type === 'like'">
- <FAIcon
- class="type-icon"
- icon="star"
- />
- {{ ' ' }}
- <small>{{ $t('notifications.favorited_you') }}</small>
- </span>
- <span v-if="notification.type === 'repeat'">
- <FAIcon
- class="type-icon"
- icon="retweet"
- :title="$t('tool_tip.repeat')"
- />
- {{ ' ' }}
- <small>{{ $t('notifications.repeated_you') }}</small>
- </span>
- <span v-if="notification.type === 'follow'">
- <FAIcon
- class="type-icon"
- icon="user-plus"
- />
- {{ ' ' }}
- <small>{{ $t('notifications.followed_you') }}</small>
- </span>
- <span v-if="notification.type === 'follow_request'">
- <FAIcon
- class="type-icon"
- icon="user"
- />
- {{ ' ' }}
- <small>{{ $t('notifications.follow_request') }}</small>
- </span>
- <span v-if="notification.type === 'move'">
- <FAIcon
- class="type-icon"
- icon="suitcase-rolling"
- />
- {{ ' ' }}
- <small>{{ $t('notifications.migrated_to') }}</small>
- </span>
- <span v-if="notification.type === 'pleroma:emoji_reaction'">
- <small>
- <i18n-t
- scope="global"
- keypath="notifications.reacted_with"
+ <small>{{ $t('notifications.favorited_you') }}</small>
+ </span>
+ <span v-if="notification.type === 'repeat'">
+ <FAIcon
+ class="type-icon"
+ icon="retweet"
+ :title="$t('tool_tip.repeat')"
+ />
+ {{ ' ' }}
+ <small>{{ $t('notifications.repeated_you') }}</small>
+ </span>
+ <span v-if="notification.type === 'follow'">
+ <FAIcon
+ class="type-icon"
+ icon="user-plus"
+ />
+ {{ ' ' }}
+ <small>{{ $t('notifications.followed_you') }}</small>
+ </span>
+ <span v-if="notification.type === 'follow_request'">
+ <FAIcon
+ class="type-icon"
+ icon="user"
+ />
+ {{ ' ' }}
+ <small>{{ $t('notifications.follow_request') }}</small>
+ </span>
+ <span v-if="notification.type === 'move'">
+ <FAIcon
+ class="type-icon"
+ icon="suitcase-rolling"
+ />
+ {{ ' ' }}
+ <small>{{ $t('notifications.migrated_to') }}</small>
+ </span>
+ <span v-if="notification.type === 'pleroma:emoji_reaction'">
+ <small>
+ <i18n-t
+ scope="global"
+ keypath="notifications.reacted_with"
+ >
+ <img
+ v-if="notification.emoji_url"
+ class="emoji-reaction-emoji emoji-reaction-emoji-image"
+ :src="notification.emoji_url"
+ :alt="notification.emoji"
+ :title="notification.emoji"
+ :class="{ ['-wide']: allowNonSquareEmoji }"
>
- <img
- v-if="notification.emoji_url"
- class="emoji-reaction-emoji emoji-reaction-emoji-image"
- :src="notification.emoji_url"
- :alt="notification.emoji"
- :title="notification.emoji"
- :class="{ ['-wide']: allowNonSquareEmoji }"
- >
- <span
- v-else
- class="emoji-reaction-emoji"
- >{{ notification.emoji }}</span>
- </i18n-t>
- </small>
- </span>
- <span v-if="notification.type === 'pleroma:report'">
- <small>{{ $t('notifications.submitted_report') }}</small>
- </span>
- <span v-if="notification.type === 'poll'">
- <FAIcon
- class="type-icon"
- icon="poll-h"
- />
- {{ ' ' }}
- <small>{{ $t('notifications.poll_ended') }}</small>
- </span>
- </div>
- <div
- v-if="isStatusNotification"
- class="timeago"
- >
- <router-link
- v-if="notification.status"
- :to="{ name: 'conversation', params: { statusId: notification.status.id } }"
- class="timeago-link faint"
- :aria-label="$t('status.open_in_thread_view')"
- >
- <Timeago
- :time="notification.created_at"
- :auto-update="240"
- />
- </router-link>
- <button
- class="button-unstyled expand-icon"
- :title="$t('tool_tip.toggle_expand')"
- :aria-expanded="statusExpanded"
- @click.prevent="toggleStatusExpanded"
- >
- <FAIcon
- class="fa-scale-110"
- fixed-width
- :icon="statusExpanded ? 'compress-alt' : 'expand-alt'"
- />
- </button>
- </div>
- <div
- v-else
- class="timeago"
- >
- <span class="faint">
- <Timeago
- :time="notification.created_at"
- :auto-update="240"
- />
- </span>
- </div>
- <button
- v-if="needMute"
- class="button-unstyled"
- :title="$t('tool_tip.toggle_mute')"
- :aria-expanded="!unmuted"
- @click.prevent="toggleMute"
- >
+ <span
+ v-else
+ class="emoji-reaction-emoji"
+ >{{ notification.emoji }}</span>
+ </i18n-t>
+ </small>
+ </span>
+ <span v-if="notification.type === 'pleroma:report'">
+ <small>{{ $t('notifications.submitted_report') }}</small>
+ </span>
+ <span v-if="notification.type === 'poll'">
<FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="eye-slash"
+ class="type-icon"
+ icon="poll-h"
/>
- </button>
- </span>
+ {{ ' ' }}
+ <small>{{ $t('notifications.poll_ended') }}</small>
+ </span>
+ </div>
<div
v-if="notification.type === 'follow' || notification.type === 'follow_request'"
class="follow-text"
>
<UserLink
class="follow-name"
:user="notification.from_profile"
/>
<div
v-if="notification.type === 'follow_request'"
style="white-space: nowrap;"
>
<button
class="button-unstyled"
:title="$t('tool_tip.accept_follow_request')"
@click="() => approve(user.id)"
>
<FAIcon
icon="check"
class="fa-scale-110 fa-old-padding follow-request-accept"
/>
</button>
<button
class="button-unstyled"
:title="$t('tool_tip.reject_follow_request')"
@click="() => deny(user.id)"
>
<FAIcon
icon="times"
class="fa-scale-110 fa-old-padding follow-request-reject"
/>
</button>
</div>
</div>
<div
v-else-if="notification.type === 'move'"
class="move-text"
>
<UserLink
:user="notification.target"
/>
</div>
<Report
v-else-if="notification.type === 'pleroma:report'"
:report-id="notification.report.id"
/>
<template v-else>
<StatusContent
class="status-content"
:compact="!statusExpanded"
:status="status"
:collapse="!statusExpanded"
@click="onContentClick"
/>
</template>
</div>
</div>
</article>
</template>
<script src="./notification.js"></script>
<style src="./notification.scss" lang="scss"></style>
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 7219da6de5..3774a33f18 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,650 +1,634 @@
import { defineAsyncComponent } from 'vue'
import AvatarList from 'src/components/avatar_list/avatar_list.vue'
import EmojiReactions from 'src/components/emoji_reactions/emoji_reactions.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
+import StatusHeader from 'src/components/status_header/status_header.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import StatusPopover from 'src/components/status_popover/status_popover.vue'
-import Timeago from 'src/components/timeago/timeago.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import UserListPopover from 'src/components/user_list_popover/user_list_popover.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { muteFilterHits } from '../../services/status_parser/status_parser.js'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useScrobblesStore } from 'src/stores/scrobbles.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { useUsersStore } from 'src/stores/users.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleRight,
faChevronDown,
faChevronUp,
faEllipsisH,
faEnvelope,
faEye,
faEyeSlash,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faPlay,
faPlusSquare,
faReply,
faRetweet,
faSmileBeam,
faStar,
faThumbtack,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEnvelope,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faTimes,
faRetweet,
faReply,
faPlusSquare,
faStar,
faSmileBeam,
faEllipsisH,
faEyeSlash,
faEye,
faThumbtack,
faChevronUp,
faChevronDown,
faAngleDoubleRight,
faPlay,
)
const Status = {
name: 'Status',
components: {
PostStatusForm,
UserAvatar,
AvatarList,
- Timeago,
StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent,
+ StatusHeader,
MentionLink,
MentionsLine,
UserPopover,
UserLink,
Quote: defineAsyncComponent(() => import('src/components/quote/quote.vue')),
StatusActionButtons,
},
props: {
statusId: String,
statusoid: Object,
replies: Set,
focused: Boolean,
compact: Boolean,
isPreview: Boolean,
noHeading: Boolean,
inQuote: Boolean,
ignoreMute: Boolean,
threadDisplayState: String,
conversationRank: {
type: String,
default: 'linear',
},
},
emits: [
- 'goto',
'dive',
'toggleExpanded',
'toggleThreadDisplay',
'suspendableStateChange',
'heightChange',
],
inject: {
profileUserId: {
default: null,
},
isPage: {
default: false,
},
isExpanded: {
default: false,
},
expandable: {
default: false,
},
},
provide: {
expandable: false, // for quotes
},
data() {
return {
resizeObserver: new ResizeObserver(this.updateVirtualHeight),
replying: false,
unmuted: false,
mediaPlaying: new Set(),
error: null,
headTailLinks: null,
}
},
created() {
useScrobblesStore().getLatestScrobble(this.status.user.id)
},
computed: {
headerLevel() {
return [
this.isPage ? 0 : 1,
this.isExpanded ? 1 : 0,
].reduce((acc, item) => acc + item, 1)
},
rootClasses() {
return [
{
'-focused': this.focused,
'-conversation': !this.isPage && this.isExpanded,
},
`-conversation-rank-${this.conversationRank}`,
]
},
// Whatever we're given to work with
status() {
return this.statusoid ?? useStatusesStore().allStatuses.get(this.statusId)
},
inConversation() {
return this.isExpanded
},
inProfile() {
return this.profileUserId != null
},
// Status repeated
repeatedStatus() {
if (this.status.retweeted_status === undefined) return undefined
return useStatusesStore().allStatuses.get(this.status.retweeted_status.id)
},
// THE repeat
repeatStatus() {
if (this.isRepeat) {
return this.status
} else {
return null
}
},
mainStatus() {
if (this.isRepeat) {
return this.repeatedStatus
} else {
return this.status
}
},
repeater() {
return useUsersStore().findUser(this.status.user.id)
},
user() {
return useUsersStore().findUser(this.mainStatus.user.id)
},
isTreeView() {
return this.mergedConfig.conversationDisplay === 'tree'
},
- simpleTree() {
- return !this.mergedConfig.conversationTreeAdvanced
- },
showOtherRepliesInside() {
return this.mergedConfig.conversationOtherRepliesButton === 'inside'
},
showOtherRepliesBelow() {
return this.mergedConfig.conversationOtherRepliesButton === 'below'
},
showReasonMutedThread() {
return (
(this.mainStatus.thread_muted || this.repeatStatus?.thread_muted) &&
!this.inConversation
)
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
repeaterClass() {
return highlightClass(this.repeater)
},
userClass() {
return highlightClass(this.user)
},
isDeleted() {
return this.status.deleted
},
repeaterStyle() {
return highlightStyle(
useUserHighlightStore().get(this.repeater.screen_name),
)
},
favoritedBy() {
return useStatusesStore().favs.get(this.mainStatus.id) ?? new Set()
},
repeatedBy() {
return useStatusesStore().repeats.get(this.mainStatus.id) ?? new Set()
},
userStyle() {
if (this.noHeading) return
return highlightStyle(useUserHighlightStore().get(this.user.screen_name))
},
userProfileLink() {
return this.generateUserProfileLink(this.user.id, this.user.screen_name)
},
replyProfileLink() {
if (this.isReply) {
const user = useUsersStore().findUser(
this.mainStatus.in_reply_to_user_id,
)
// User referenced in post might not be yet present in store
// since their data is not included in status data, just the id
return user?.statusnet_profile_url
}
},
isRepeat() {
return !!this.repeatedStatus
},
repeaterName() {
return this.status.user.name || this.status.user.screen_name_ui
},
repeaterHtml() {
return this.status.user.name
},
repeaterProfileLink() {
return this.generateUserProfileLink(
this.repeater.id,
this.repeater.screen_name,
)
},
loggedIn() {
return !!this.currentUser
},
muteFilterHits() {
return muteFilterHits(
Object.values(
useSyncConfigStore().prefsStorage.simple.muteFilters || {},
),
this.status,
)
},
botStatus() {
return this.status.user.actor_type === 'Service'
},
sensitiveStatus() {
return this.status.nsfw
},
mentionsLine() {
if (!this.headTailLinks) return []
const writtenSet = new Set(
this.headTailLinks.writtenMentions.map((_) => _.url),
)
return this.mainStatus.attentions
.filter((attn) => {
// no reply user
return (
attn.id !== this.mainStatus.in_reply_to_user_id &&
// no self-replies
attn.statusnet_profile_url !==
this.mainStatus.user.statusnet_profile_url &&
// don't include if mentions is written
!writtenSet.has(attn.statusnet_profile_url)
)
})
.map((attn) => ({
url: attn.statusnet_profile_url,
content: attn.screen_name,
userId: attn.id,
}))
},
hasMentionsLine() {
return this.mentionsLine.length > 0
},
// TODO move muting logic into some store
muteReasons() {
return [
this.userIsMuted ? 'user' : null,
this.mainStatus.thread_muted ? 'thread' : null,
this.muteFilterHits.length > 0 ? 'filtered' : null,
this.muteBotStatuses && this.botStatus ? 'bot' : null,
this.muteSensitiveStatuses && this.sensitiveStatus ? 'nsfw' : null,
].filter(Boolean)
},
muteLocalized() {
if (this.muteReasons.length === 0) return null
const mainReason = () => {
switch (this.muteReasons[0]) {
case 'user':
return this.$t('status.muted_user')
case 'thread':
return this.$t('status.thread_muted')
case 'filtered':
return this.$t(
'status.muted_filters',
{
name: this.muteFilterHits[0].name,
filterMore: this.muteFilterHits.length - 1,
},
this.muteFilterHits.length,
)
case 'bot':
return this.$t('status.bot_muted')
case 'nsfw':
return this.$t('status.sensitive_muted')
}
}
if (this.muteReasons.length > 1) {
return this.$t(
'status.multi_reason_mute',
{
main: mainReason(),
numReasonsMore: this.muteReasons.length - 1,
},
this.muteReasons.length - 1,
)
} else {
return mainReason()
}
},
muted() {
if (this.ignoreMute) return false
if (this.status.user.id === this.currentUser?.id) return false
return !this.unmuted && !this.shouldNotMute && this.muteReasons.length > 0
},
userIsMuted() {
if (!this.currentUser) return false
if (this.user === this.currentUser) return false
if (this.repeater === this.currentUser) return false
const relationship = useUsersStore().relationship(this.user.id)
const relationshipRepeat = useUsersStore().relationship(this.repeater?.id)
return (
(this.status.muted && !this.status.thread_muted) ||
// Reprööt of a muted post according to BE
(this.repeatedStatus?.muted && !this.repeatedStatus.thread_muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprööt
relationshipRepeat?.muting
)
},
shouldNotMute() {
if (this.ignoreMute) return true
if (this.focused) return true
const { reblog } = this.mainStatus
return (
((this.inProfile &&
// Don't mute user's posts on user timeline (except reblogs)
((!reblog && this.mainStatus.user.id === this.profileUserId) ||
// Same as above but also allow self-reblogs
reblog?.user.id === this.profileUserId)) ||
// Don't mute statuses in muted conversation when said conversation is opened
(this.inConversation && this.mainStatus.thread_muted)) &&
// No excuses if post has muted words
!this.muteFilterHits.length > 0
)
},
hideMutedUsers() {
return this.mergedConfig.hideMutedPosts
},
hideMutedThreads() {
return this.mergedConfig.hideMutedThreads
},
hideFilteredStatuses() {
return this.mergedConfig.hideFilteredStatuses
},
hideWordFilteredPosts() {
return this.mergedConfig.hideWordFilteredPosts
},
hideStatus() {
return (
!this.shouldNotMute &&
((this.muted && this.hideFilteredStatuses) ||
(this.userIsMuted && this.hideMutedUsers) ||
(this.status.thread_muted && this.hideMutedThreads) ||
(this.muteFilterHits.length > 0 && this.hideWordFilteredPosts) ||
this.muteFilterHits.some((x) => x.hide))
)
},
isReply() {
return !!(
this.mainStatus.in_reply_to_status_id &&
this.mainStatus.in_reply_to_user_id
)
},
replyToName() {
if (this.mainStatus.in_reply_to_screen_name) {
return this.mainStatus.in_reply_to_screen_name
} else {
const user = useUsersStore().findUser(
this.mainStatus.in_reply_to_user_id,
)
return user?.screen_name_ui
}
},
combinedFavsAndRepeatsUsers() {
return new Set([...this.favoritedBy, ...this.repeatedBy])
},
tags() {
return [...this.status.tags]
.filter((tagObj) => Object.hasOwn(tagObj, 'name'))
.map((tagObj) => tagObj.name)
.join(' ')
},
hidePostStats() {
return this.mergedConfig.hidePostStats
},
shouldDisplayFavsAndRepeats() {
return (
!this.hidePostStats &&
this.focused &&
(this.combinedFavsAndRepeatsUsers.size > 0 ||
this.mainStatus.quotes_count)
)
},
muteBotStatuses() {
return this.mergedConfig.muteBotStatuses
},
muteSensitiveStatuses() {
return this.mergedConfig.muteSensitiveStatuses
},
hideBotIndication() {
return this.mergedConfig.hideBotIndication
},
currentUser() {
return useUsersStore().currentUser
},
mergedConfig() {
return useMergedConfigStore().mergedConfig
},
isSuspendable() {
return !this.replying && this.mediaPlaying.size === 0
},
- inThreadForest() {
- return !!this.threadDisplayState
- },
- threadShowing() {
- return this.threadDisplayState === 'showing'
- },
visibilityLocalized() {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
},
isEdited() {
return this.mainStatus.edited_at !== null
},
editingAvailable() {
return useInstanceCapabilitiesStore().editingAvailable
},
quoteId() {
return this.mainStatus.quote_id
},
quoteUrl() {
return this.mainStatus.quote_url
},
quoteVisible() {
return this.mainStatus.quote_visible
},
quoteExpanded() {
return !this.inQuote
},
scrobblePresent() {
if (this.mergedConfig.hideScrobbles) return false
if (!this.status.user?.latestScrobble) return false
const value = this.mergedConfig.hideScrobblesAfter.match(/\d+/gs)[0]
const unit = this.mergedConfig.hideScrobblesAfter.match(/\D+/gs)[0]
let multiplier = 60 * 1000 // minutes is smallest unit
switch (unit) {
case 'm':
break
case 'h':
multiplier *= 60 // hour
break
case 'd':
multiplier *= 60 // hour
multiplier *= 24 // day
break
}
const maxAge = Number(value) * multiplier
const createdAt = Date.parse(this.status.user.latestScrobble.created_at)
const age = Date.now() - createdAt
if (age > maxAge) return false
return this.status.user.latestScrobble.artist
},
scrobble() {
return this.status.user?.latestScrobble
},
},
methods: {
visibilityIcon(visibility) {
switch (visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
},
showError(error) {
this.error = error
},
clearError() {
this.error = undefined
},
toggleReplyForm() {
if (this.replying) {
// This emits 'close-accepted' if successful
// which in turn callse closeReply()
this.$refs.postStatusForm.requestClose()
} else {
this.replying = true
}
},
closeReplyForm() {
this.replying = false
},
- toggleExpanded() {
- this.$emit('toggleExpanded')
- },
toggleMute() {
this.unmuted = !this.unmuted
},
generateUserProfileLink(id, name) {
return generateProfileLink(
id,
name,
useInstanceStore().restrictedNicknames,
)
},
addMediaPlaying(id) {
this.mediaPlaying.add(id)
},
removeMediaPlaying(id) {
this.mediaPlaying.delete(id)
},
setHeadTailLinks(headTailLinks) {
this.headTailLinks = headTailLinks
},
- toggleThreadDisplay() {
- this.$emit('toggleThreadDisplay')
- },
updateVirtualHeight(e) {
const [entry] = e
this.$emit('heightChange', {
id: this.status.id,
height: entry.contentRect.height + 1,
element: this.$el,
})
},
},
mounted() {
if (this.$refs.root) {
this.resizeObserver.observe(this.$refs.root)
this.updateVirtualHeight([
{
contentRect: this.$refs.root.getBoundingClientRect(),
},
])
}
},
unmounted() {
this.resizeObserver.disconnect()
},
watch: {
hideStatus: {
handler() {
if (this.$refs.root) {
this.resizeObserver.observe(this.$refs.root)
} else {
this.resizeObserver.disconnect()
}
},
flush: 'post',
},
'mainStatus.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (this.focused && this.repeatedBy.size !== num) {
useStatusesStore().fetchRepeats(this.mainStatus.id)
}
},
'mainStatus.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (this.focused && this.favoritedBy.size !== num) {
useStatusesStore().fetchFavs(this.mainStatus.id)
}
},
isSuspendable: function (suspendable) {
this.$emit('suspendableStateChange', { id: this.status.id, suspendable })
},
},
}
export default Status
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index c9741efc38..267fc958b3 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -1,364 +1,320 @@
.Status {
min-width: 0;
white-space: normal;
overflow-wrap: break-word;
text-wrap: pretty;
display: flex;
flex-direction: column;
&:hover {
--_still-image-img-visibility: visible;
--_still-image-canvas-visibility: hidden;
--_still-image-label-visibility: hidden;
}
.gravestone {
padding: var(--status-margin);
display: flex;
.deleted-text {
margin: 0.5em 0;
align-items: center;
}
}
.status-container {
display: flex;
- padding: var(--status-margin);
+ padding-top: var(--status-margin);
+ padding-left: var(--status-margin);
+ padding-right: var(--status-margin);
+ /* action buttons have HUGE height so we don't need as much margin at bottom */
+ padding-bottom: calc(var(--status-margin) / 2);
gap: var(--status-margin);
> * {
min-width: 0;
}
}
.pin {
display: flex;
align-items: center;
justify-content: flex-end;
margin-right: 0.5em;
}
._misclick-prevention & {
pointer-events: none;
.attachments {
pointer-events: initial;
cursor: initial;
}
}
.left-side {
flex: 0 0 auto;
}
.right-side {
flex: 1 1 auto;
display: flex;
flex-direction: column;
- gap: calc(var(--status-margin) / 2);
- }
-
- .usercard {
- margin-bottom: var(--status-margin);
+ gap: calc(var(--status-margin) / 4);
}
.status-username {
white-space: nowrap;
overflow: hidden;
max-width: 85%;
font-weight: bold;
flex-shrink: 1;
margin-right: 0.4em;
text-overflow: ellipsis;
--_still_image-label-scale: 0.25;
--emoji-size: 1em;
}
- .status-favicon {
- height: 1.2em;
- width: 1.2em;
- margin-right: 0.4em;
- object-fit: contain;
- }
-
- .heading-name-row {
- display: flex;
- justify-content: space-between;
- line-height: 1.3;
-
- a {
- display: inline-block;
- white-space: nowrap;
- text-overflow: ellipsis;
- overflow-x: hidden;
- width: 100%
- }
- }
-
- .account-name {
- display: inline-block;
- min-width: 1em;
- margin-right: 0.4em;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- flex: 1 1 0;
-
- &.unknown {
- min-width: 8em;
- }
+ .status-content {
+ margin-top: calc(var(--status-margin));
+ /* action buttons have HUGE height so we don't need as much margin at bottom */
+ margin-bottom: calc(var(--status-margin) / 2);
}
- .heading-left {
- display: flex;
- min-width: 0;
- }
-
- .heading-right {
- display: flex;
- flex-shrink: 0;
- align-self: baseline;
- word-break: keep-all;
- margin-right: calc(var(--status-margin) / -2);
-
- .visibility-icon,
- .button-unstyled {
- margin: calc(var(--status-margin) / -2) 0;
- padding: calc(var(--status-margin) / 2);
- }
+ .usercard {
+ margin-bottom: var(--status-margin);
}
.glued-label {
display: inline-flex;
white-space: nowrap;
}
& .heading-reply-row,
& .heading-edited-row {
position: relative;
align-content: baseline;
font-size: 0.85em;
line-height: 130%;
max-width: 100%;
align-items: stretch;
}
& .reply-to-popover,
& .reply-to-no-popover,
& .mentions {
min-width: 0;
margin-right: 0.4em;
flex-shrink: 0;
}
.reply-glued-label {
margin-right: 0.5em;
}
.reply-to-popover {
.faint-link:hover {
// override default
text-decoration: none;
}
&.-strikethrough {
.reply-to::after {
content: "";
display: block;
position: absolute;
top: 50%;
width: 100%;
border-bottom: 1px solid var(--faint);
pointer-events: none;
}
}
}
& .mentions,
& .reply-to {
white-space: nowrap;
position: relative;
}
& .mentions-text,
& .reply-to-text {
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mentions-line {
display: inline;
}
.replies {
margin-top: 0.25em;
line-height: 1.3;
font-size: 0.85em;
display: flex;
flex-wrap: wrap;
& > * {
margin-right: 0.4em;
}
}
.reply-link {
height: 17px;
}
.repeat-info {
display: flex;
align-items: center;
padding: 0.4em var(--status-margin);
.repeater-avatar {
flex: 0 0 1.5em;
border-radius: var(--roundness);
margin-left: 2em; // 3.5 (poster avatar size) - 1.5 (repeater avatar size)
width: 1.5em;
height: 1.5em;
}
.right-side {
display: flex;
flex-direction: row;
flex: 1 1 auto;
overflow-x: hidden;
text-overflow: ellipsis;
margin-right: 0;
.repeater-name {
flex: 0 1 auto;
margin: 0;
}
.repeat-label {
white-space: nowrap;
flex: 0 0 auto;
.repeat-icon {
vertical-align: middle;
color: var(--cGreen);
}
}
.emoji {
width: 1em;
height: 1em;
vertical-align: middle;
object-fit: contain;
}
}
}
.status-actions {
position: relative;
width: 100%;
display: grid;
grid-template-columns: 1fr;
grid-auto-columns: 1fr;
grid-auto-flow: column;
margin-top: var(--status-margin);
}
.muted {
padding: 0.25em 0.6em;
height: 1.2em;
line-height: 1.2em;
text-overflow: ellipsis;
overflow: hidden;
display: flex;
flex-wrap: nowrap;
gap: 1ex;
& .status-username,
& .mute-reason {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.status-username {
font-weight: normal;
flex: 0 1 auto;
margin-right: 0.2em;
font-size: smaller;
display: flex;
}
.unmute {
flex: 0 0 auto;
margin-left: auto;
display: block;
}
}
.reply-form {
padding-top: 0;
padding-bottom: 0;
}
.reply-body {
flex: 1;
}
.favs-repeated-users {
margin-top: var(--status-margin);
}
.stats {
width: 100%;
display: flex;
line-height: 1em;
}
.avatar-row {
flex: 1;
position: relative;
display: flex;
align-items: center;
overflow: hidden;
&::before {
content: "";
position: absolute;
height: 100%;
width: 1px;
left: 0;
background-color: var(--textFaint);
}
}
.stat-count {
margin-right: var(--status-margin);
user-select: none;
.stat-title {
color: var(--textFaint);
font-size: 0.85em;
text-transform: uppercase;
position: relative;
}
.stat-number {
font-weight: bolder;
font-size: 1.1em;
line-height: 1em;
color: var(--text);
}
&:hover .stat-title {
text-decoration: underline;
}
}
.thread-tree-show-replies-button {
display: block;
padding: var(--status-margin);
padding-left: var(--status-margin);
}
}
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index cbcae7dc38..14b7dd80a6 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,580 +1,470 @@
<template>
<article
v-if="!hideStatus"
ref="root"
class="Status"
:class="rootClasses"
:aria-label="mainStatus.summary || $t('status.unsubjected')"
>
<div
v-if="error"
class="alert error"
>
{{ error }}
<button
class="fa-scale-110 fa-old-padding"
type="button"
@click="clearError"
>
<FAIcon icon="times" />
</button>
</div>
<template v-if="muted && !isPreview">
<div class="status-container muted">
<small class="status-username">
<FAIcon
v-if="muted && isRepeat"
class="fa-scale-110 fa-old-padding repeat-icon"
icon="retweet"
/>
<UserLink
:user="repeater"
:at="false"
/>
</small>
<small class="mute-reason">
{{ muteLocalized }}
</small>
<button
class="unmute button-unstyled"
@click.prevent="toggleMute"
:title="$t('status.show_muted_status')"
>
<FAIcon
icon="eye-slash"
class="fa-scale-110 fa-old-padding"
/>
</button>
</div>
</template>
<template v-else>
<div
v-if="isRepeat && !noHeading && !inConversation"
:class="[repeaterClass, { highlighted: repeaterStyle }]"
:style="[repeaterStyle]"
class="status-container repeat-info"
>
<UserAvatar
class="left-side repeater-avatar"
:user-id="repeater.id"
/>
<div class="right-side faint">
<bdi
class="status-username repeater-name"
:title="repeaterName"
>
<router-link
v-if="repeaterHtml"
:to="repeaterProfileLink"
>
<RichContent
:html="repeaterHtml"
:emoji="repeater.emoji"
:allow-non-square-emoji="allowNonSquareEmoji"
:pause-mfm="pauseMfm"
:scale-mfm="scaleMfm"
:is-local="repeater.is_local"
/>
</router-link>
<router-link
v-else
:to="repeaterProfileLink"
>{{ repeaterName }}</router-link>
</bdi>
<div class="repeat-label">
<FAIcon
icon="retweet"
class="repeat-icon"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
</div>
</div>
</div>
<div
v-if="!isDeleted"
:class="[userClass, { highlighted: userStyle, '-repeat': isRepeat && !inConversation }]"
:style="[ userStyle ]"
class="status-container"
:data-tags="tags"
>
<div
v-if="!noHeading"
class="left-side"
>
<a
v-if="user.name"
:href="$router.resolve(userProfileLink).href"
@click.prevent
>
<UserPopover
:user-id="user.id"
:overlay-centers="true"
>
<UserAvatar
class="post-avatar"
:compact="compact"
:user-id="user.id"
/>
</UserPopover>
</a>
<UserAvatar
v-else
:user-id="user.id"
class="post-avatar"
:compact="compact"
:title="$t('status.unknown_user_info')"
/>
</div>
<div class="right-side">
+ <StatusHeader
+ :status-id="mainStatus.id"
+ :user-id="user.id"
+ :unmuted="unmuted"
+ :time="mainStatus.created_at"
+ :replies="replies"
+
+ :expandable="expandable"
+ :expanded="isExpanded"
+ :thread-display-state="threadDisplayState"
+
+ @toggle-expanded="$emit('toggleExpanded')"
+ @toggle-thread-display="$emit('toggleThreadDisplay')"
+ @dive="$emit('dive')"
+ />
<div
- v-if="!noHeading"
- class="status-heading"
+ v-if="scrobblePresent"
+ class="status-rich-presence"
>
- <div class="heading-name-row">
- <div
- v-if="user"
- class="heading-left"
- >
- <div
- v-if="user.name_html"
- class="status-username"
- :title="user.name"
- >
- <RichContent
- :html="user.name"
- :emoji="user.emoji"
- :allow-non-square-emoji="allowNonSquareEmoji"
- :is-local="user.is_local"
- />
- </div>
- <div
- v-else
- class="status-username"
- :title="user.name"
- >
- {{ user.name }}
- </div>
- <UserLink
- class="account-name"
- :title="user.screen_name_ui"
- :user="user"
- :at="false"
- />
- <img
- v-if="!!(user && user.favicon)"
- class="status-favicon"
- :title="$t('status.favicon')"
- :src="user.favicon"
- >
- </div>
-
- <span class="heading-right">
- <span
- v-if="mainStatus.pinned"
- class="pin"
- >
- <FAIcon
- icon="thumbtack"
- class="faint"
- />
- <span class="faint">{{ $t('status.pinned') }}</span>
- </span>
- <router-link
- class="timeago faint"
- :to="{ name: 'conversation', params: { statusId: status.id } }"
- :aria-label="$t('status.open_in_thread_view')"
- >
- <Timeago
- :time="mainStatus.created_at"
- :auto-update="60"
- />
- </router-link>
- <span
- v-if="mainStatus.visibility"
- class="visibility-icon"
- :title="visibilityLocalized"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110"
- :icon="visibilityIcon(status.visibility)"
- />
- </span>
- <button
- v-if="expandable && !isExpanded && !isPreview"
- class="button-unstyled"
- :title="$t('status.expand')"
- @click.prevent="toggleExpanded"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110"
- icon="plus-square"
- />
- </button>
- <button
- v-if="unmuted"
- class="button-unstyled"
- @click.prevent="toggleMute"
- >
- <FAIcon
- fixed-width
- icon="eye-slash"
- class="fa-scale-110"
- />
- </button>
- <button
- v-if="inThreadForest && replies?.size && !simpleTree"
- class="button-unstyled"
- :title="threadShowing ? $t('status.thread_hide') : $t('status.thread_show')"
- :aria-expanded="threadShowing ? 'true' : 'false'"
- @click.prevent="toggleThreadDisplay"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110"
- :icon="threadShowing ? 'chevron-up' : 'chevron-down'"
- />
- </button>
- <button
- v-if="isExpanded && isTreeView && !simpleTree"
- class="button-unstyled"
- :title="$t('status.show_only_conversation_under_this')"
- @click.prevent="$emit('dive')"
- >
- <FAIcon
- fixed-width
- class="fa-scale-110"
- :icon="'angle-double-right'"
- />
- </button>
- </span>
- </div>
- <div
- v-if="scrobblePresent"
- class="status-rich-presence"
+ <a
+ v-if="scrobble.externalLink"
+ :href="scrobble.externalLink"
+ target="_blank"
>
- <a
- v-if="scrobble.externalLink"
- :href="scrobble.externalLink"
- target="_blank"
- >
- {{ scrobble.artist }} — {{ scrobble.title }}
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="play"
- />
- <span class="status-rich-presence-time">
- <Timeago
- template-key="time.in_past"
- :time="scrobble.created_at"
- :auto-update="60"
- />
- </span>
- </a>
- <span v-if="!scrobble.externalLink">
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="music"
+ {{ scrobble.artist }} — {{ scrobble.title }}
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ icon="play"
+ />
+ <span class="status-rich-presence-time">
+ <Timeago
+ template-key="time.in_past"
+ :time="scrobble.created_at"
+ :auto-update="60"
/>
- {{ scrobble.artist }} — {{ scrobble.title }}
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="play"
+ </span>
+ </a>
+ <span v-if="!scrobble.externalLink">
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ icon="music"
+ />
+ {{ scrobble.artist }} — {{ scrobble.title }}
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ icon="play"
+ />
+ <span class="status-rich-presence-time">
+ <Timeago
+ template-key="time.in_past"
+ :time="scrobble.created_at"
+ :auto-update="60"
/>
- <span class="status-rich-presence-time">
- <Timeago
- template-key="time.in_past"
- :time="scrobble.created_at"
- :auto-update="60"
- />
- </span>
</span>
- </div>
- <div
- v-if="isReply || hasMentionsLine"
- class="heading-reply-row"
+ </span>
+ </div>
+ <div
+ v-if="isReply || hasMentionsLine"
+ class="heading-reply-row"
+ >
+ <span
+ v-if="isReply"
+ class="glued-label reply-glued-label"
>
- <span
- v-if="isReply"
- class="glued-label reply-glued-label"
+ <i18n-t
+ keypath="status.reply_to_with_arg"
+ scope="global"
>
- <i18n-t
- keypath="status.reply_to_with_arg"
- scope="global"
- >
- <template #replyToWithIcon>
- <StatusPopover
- v-if="!isPreview"
- :status-id="mainStatus.parent_visible && mainStatus.in_reply_to_status_id"
- class="reply-to-popover"
- style="min-width: 0;"
- :class="{ '-strikethrough': !mainStatus.parent_visible }"
- :aria-label="$t('tool_tip.reply')"
- >
- <i18n-t
- keypath="status.reply_to_with_icon"
- scope="global"
+ <template #replyToWithIcon>
+ <StatusPopover
+ v-if="!isPreview"
+ :status-id="mainStatus.parent_visible && mainStatus.in_reply_to_status_id"
+ class="reply-to-popover"
+ style="min-width: 0;"
+ :class="{ '-strikethrough': !mainStatus.parent_visible }"
+ :aria-label="$t('tool_tip.reply')"
+ >
+ <i18n-t
+ keypath="status.reply_to_with_icon"
+ scope="global"
+ >
+ <template #icon>
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ icon="reply"
+ flip="horizontal"
+ />
+ </template>
+ <template #replyTo>
+ <span
+ class="reply-to-text"
>
- <template #icon>
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="reply"
- flip="horizontal"
- />
- </template>
- <template #replyTo>
- <span
- class="reply-to-text"
- >
- {{ $t('status.reply_to') }}
- </span>
- </template>
- </i18n-t>
- </StatusPopover>
+ {{ $t('status.reply_to') }}
+ </span>
+ </template>
+ </i18n-t>
+ </StatusPopover>
- <span
- v-else
- class="reply-to-no-popover"
- >
- <span class="reply-to-text">{{ $t('status.reply_to') }}</span>
- </span>
- </template>
- <template #user>
- <MentionLink
- :content="replyToName"
- :url="replyProfileLink"
- :user-id="status.in_reply_to_user_id"
- :user-screen-name="status.in_reply_to_screen_name"
- />
- </template>
- </i18n-t>
- </span>
+ <span
+ v-else
+ class="reply-to-no-popover"
+ >
+ <span class="reply-to-text">{{ $t('status.reply_to') }}</span>
+ </span>
+ </template>
+ <template #user>
+ <MentionLink
+ :content="replyToName"
+ :url="replyProfileLink"
+ :user-id="status.in_reply_to_user_id"
+ :user-screen-name="status.in_reply_to_screen_name"
+ />
+ </template>
+ </i18n-t>
+ </span>
- <!-- This little wrapper is made for sole purpose of "gluing" -->
- <!-- "Mentions" label to the first mention -->
+ <!-- This little wrapper is made for sole purpose of "gluing" -->
+ <!-- "Mentions" label to the first mention -->
+ <span
+ v-if="hasMentionsLine"
+ class="glued-label"
+ >
<span
- v-if="hasMentionsLine"
- class="glued-label"
+ class="mentions"
+ :aria-label="$t('tool_tip.mentions')"
+ @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<span
- class="mentions"
- :aria-label="$t('tool_tip.mentions')"
- @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
+ class="mentions-text"
>
- <span
- class="mentions-text"
- >
- {{ $t('status.mentions') }}
- </span>
+ {{ $t('status.mentions') }}
</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"
+ :mentions="mentionsLine.slice(0, 1)"
+ class="mentions-line-first"
/>
- </div>
- <div
- v-if="isEdited && editingAvailable && !isPreview"
- class="heading-edited-row"
+ </span>
+ {{ ' ' }}
+ <MentionsLine
+ v-if="hasMentionsLine"
+ :mentions="mentionsLine.slice(1)"
+ class="mentions-line"
+ />
+ </div>
+ <div
+ v-if="isEdited && editingAvailable && !isPreview"
+ class="heading-edited-row"
+ >
+ <i18n-t
+ scope="global"
+ keypath="status.edited_at"
+ tag="span"
>
- <i18n-t
- scope="global"
- keypath="status.edited_at"
- tag="span"
- >
- <template #time>
- <Timeago
- template-key="time.in_past"
- :time="mainStatus.edited_at"
- :auto-update="60"
- :long-format="true"
- />
- </template>
- </i18n-t>
- </div>
+ <template #time>
+ <Timeago
+ template-key="time.in_past"
+ :time="mainStatus.edited_at"
+ :auto-update="60"
+ :long-format="true"
+ />
+ </template>
+ </i18n-t>
</div>
-
<StatusContent
ref="content"
+ class="status-content"
:status="mainStatus"
:focused="focused"
:in-conversation="inConversation"
@mediaplay="addMediaPlaying($event)"
@mediapause="removeMediaPlaying($event)"
@parse-ready="setHeadTailLinks"
/>
<Quote
:status-id="quoteId"
:status-url="quoteUrl"
:status-visible="quoteVisible"
:initially-expanded="quoteExpanded"
/>
<div
v-if="inConversation && !isPreview && replies?.size"
class="replies"
>
<button
v-if="showOtherRepliesInside && replies.size > 1"
class="button-unstyled -link"
:title="$t('status.ancestor_follow', { numReplies: replies.size - 1 }, replies.size - 1)"
@click.prevent="$emit('dive')"
>
{{ $t('status.replies_list_with_others', { numReplies: replies.size - 1 }, replies.size - 1) }}
</button>
<span
v-else
role="header"
:aria-level="headerLevel"
class="faint"
>
{{ $t('status.replies_list') }}
</span>
<span role="list" :aria-label="$t('status.replies_list')">
<span
v-for="reply in replies.values()"
:key="reply.id"
role="listitem"
:aria-label="reply.name"
>
<StatusPopover
:status-id="reply.id"
class="reply-link"
>
{{ reply.name }}
</StatusPopover>
</span>
</span>
</div>
<Transition name="fade">
<div
v-if="shouldDisplayFavsAndRepeats"
class="favs-repeated-users"
>
<div class="stats">
<UserListPopover
v-if="repeatedBy.size > 0"
:user-ids="repeatedBy"
>
<div class="stat-count">
<a class="stat-title">{{ $t('status.repeats') }}</a>
<div class="stat-number">
{{ repeatedBy.size }}
</div>
</div>
</UserListPopover>
<UserListPopover
v-if="favoritedBy.size > 0"
:user-ids="favoritedBy"
>
<div
class="stat-count"
>
<a class="stat-title">{{ $t('status.favorites') }}</a>
<div class="stat-number">
{{ favoritedBy.size }}
</div>
</div>
</UserListPopover>
<router-link
v-if="mainStatus.quotes_count > 0"
:to="{ name: 'quotes', params: { id: status.id } }"
>
<div
class="stat-count"
>
<a class="stat-title">{{ $t('status.quotes') }}</a>
<div class="stat-number">
{{ mainStatus.quotes_count }}
</div>
</div>
</router-link>
<div class="avatar-row">
<AvatarList :user-ids="combinedFavsAndRepeatsUsers" />
</div>
</div>
</div>
</Transition>
<EmojiReactions
v-if="(mergedConfig.emojiReactionsOnTimeline || focused) && (!noHeading && !isPreview)"
:status="mainStatus"
/>
<StatusActionButtons
v-if="!noHeading && !isPreview"
class="status-action-buttons"
:status="mainStatus"
:replying="replying"
@toggle-replying="toggleReplyForm"
/>
</div>
</div>
<div
v-else
class="gravestone"
>
<div class="left-side">
<UserAvatar
class="post-avatar"
:compact="compact"
/>
</div>
<div class="right-side">
<div class="deleted-text">
{{ $t('status.status_deleted') }}
</div>
</div>
</div>
<div
v-if="replying"
class="status-container reply-form"
>
<PostStatusForm
ref="postStatusForm"
class="reply-body"
:closeable="true"
:replied-status="mainStatus"
@posted="closeReplyForm"
@draft-done="closeReplyForm"
@close-accepted="closeReplyForm"
/>
</div>
<i18n-t
v-if="inConversation && conversationRank === 'ancestor' && !isPreview && showOtherRepliesBelow && replies?.size > 1"
tag="button"
scope="global"
keypath="status.ancestor_follow_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="$emit('dive')"
>
<template #icon>
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
{{ $t('status.ancestor_follow', { numReplies: replies.size - 1 }) }}
</span>
</template>
</i18n-t>
</template>
</article>
</template>
<script src="./status.js"></script>
<style src="./status.scss" lang="scss"></style>
diff --git a/src/components/status_header/status_header.js b/src/components/status_header/status_header.js
new file mode 100644
index 0000000000..32ebd3a1eb
--- /dev/null
+++ b/src/components/status_header/status_header.js
@@ -0,0 +1,136 @@
+import Timeago from 'src/components/timeago/timeago.vue'
+import UserLink from 'src/components/user_link/user_link.vue'
+import { muteFilterHits } from '../../services/status_parser/status_parser.js'
+import {
+ highlightClass,
+ highlightStyle,
+} from '../../services/user_highlighter/user_highlighter.js'
+
+import { useInstanceStore } from 'src/stores/instance.js'
+import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useScrobblesStore } from 'src/stores/scrobbles.js'
+import { useStatusesStore } from 'src/stores/statuses.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
+import { useUserHighlightStore } from 'src/stores/user_highlight.js'
+import { useUsersStore } from 'src/stores/users.js'
+
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faAngleDoubleRight,
+ faChevronDown,
+ faChevronUp,
+ faEllipsisH,
+ faEnvelope,
+ faEye,
+ faEyeSlash,
+ faGlobe,
+ faIgloo,
+ faLock,
+ faLockOpen,
+ faPlay,
+ faPlusSquare,
+ faReply,
+ faRetweet,
+ faSmileBeam,
+ faStar,
+ faThumbtack,
+ faTimes,
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faEnvelope,
+ faGlobe,
+ faIgloo,
+ faLock,
+ faLockOpen,
+ faTimes,
+ faRetweet,
+ faReply,
+ faPlusSquare,
+ faStar,
+ faSmileBeam,
+ faEllipsisH,
+ faEyeSlash,
+ faEye,
+ faThumbtack,
+ faChevronUp,
+ faChevronDown,
+ faAngleDoubleRight,
+ faPlay,
+)
+
+const Status = {
+ name: 'Status',
+ components: {
+ Timeago,
+ UserLink,
+ },
+ props: {
+ statusId: String,
+ statusoid: Object,
+ userId: String,
+ userObject: Object,
+ replies: Set,
+ expandable: Boolean,
+ expanded: Boolean,
+ time: Date,
+ threadDisplayState: String,
+ unmuted: Boolean,
+ // for notifications
+ nonMention: Boolean,
+ },
+ emits: [
+ 'dive',
+ 'toggleExpanded',
+ 'toggleThreadDisplay',
+ ],
+ computed: {
+ // Whatever we're given to work with
+ status() {
+ return this.statusoid ?? useStatusesStore().allStatuses.get(this.statusId)
+ },
+ user() {
+ return this.userObject ?? useUsersStore().findUser(this.userId)
+ },
+ isTreeView() {
+ return this.mergedConfig.conversationDisplay === 'tree'
+ },
+ simpleTree() {
+ return !this.mergedConfig.conversationTreeAdvanced
+ },
+ allowNonSquareEmoji() {
+ return this.mergedConfig.nonSquareEmoji
+ },
+ mergedConfig() {
+ return useMergedConfigStore().mergedConfig
+ },
+ inThreadForest() {
+ return !!this.threadDisplayState
+ },
+ threadShowing() {
+ return this.threadDisplayState === 'showing'
+ },
+ visibilityLocalized() {
+ return this.$i18n.t('general.scope_in_timeline.' + this.status?.visibility)
+ },
+ visibilityIcon() {
+ switch (this.status?.visibility) {
+ case 'private':
+ return 'lock'
+ case 'unlisted':
+ return 'lock-open'
+ case 'direct':
+ return 'envelope'
+ case 'local':
+ return 'igloo'
+ default:
+ return 'globe'
+ }
+ },
+ },
+}
+
+export default Status
diff --git a/src/components/status_header/status_header.scss b/src/components/status_header/status_header.scss
new file mode 100644
index 0000000000..72d9b7ed5f
--- /dev/null
+++ b/src/components/status_header/status_header.scss
@@ -0,0 +1,68 @@
+.StatusHeader {
+ .status-username {
+ white-space: nowrap;
+ overflow: hidden;
+ max-width: 85%;
+ font-weight: bold;
+ flex-shrink: 1;
+ margin-right: 0.4em;
+ text-overflow: ellipsis;
+
+ --_still_image-label-scale: 0.25;
+ --emoji-size: 1em;
+ }
+
+ .status-favicon {
+ height: 1.2em;
+ width: 1.2em;
+ margin-right: 0.4em;
+ object-fit: contain;
+ }
+
+ .heading-name-row {
+ display: flex;
+ justify-content: space-between;
+ line-height: 1;
+
+ a {
+ display: inline-block;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow-x: hidden;
+ width: 100%
+ }
+ }
+
+ .account-name {
+ display: inline-block;
+ min-width: 1em;
+ margin-right: 0.4em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1 1 0;
+
+ &.unknown {
+ min-width: 8em;
+ }
+ }
+
+ .heading-left {
+ display: flex;
+ min-width: 0;
+ }
+
+ .heading-right {
+ display: flex;
+ flex-shrink: 0;
+ align-self: baseline;
+ word-break: keep-all;
+ margin-right: calc(var(--status-margin) / -2);
+
+ .visibility-icon,
+ .button-unstyled {
+ margin: calc(var(--status-margin) / -2) 0;
+ padding: calc(var(--status-margin) / 2);
+ }
+ }
+}
diff --git a/src/components/status_header/status_header.vue b/src/components/status_header/status_header.vue
new file mode 100644
index 0000000000..af5a5360fa
--- /dev/null
+++ b/src/components/status_header/status_header.vue
@@ -0,0 +1,143 @@
+
+<template>
+ <div
+ v-if="!noHeading"
+ class="StatusHeader"
+ >
+ <div class="heading-name-row">
+ <div
+ v-if="user"
+ class="heading-left"
+ >
+ <div
+ v-if="user.name_html"
+ class="status-username"
+ :title="user.name"
+ >
+ <RichContent
+ :html="user.name"
+ :emoji="user.emoji"
+ :allow-non-square-emoji="allowNonSquareEmoji"
+ :is-local="user.is_local"
+ />
+ </div>
+ <div
+ v-else
+ class="status-username"
+ :title="user.name"
+ >
+ {{ user.name }}
+ </div>
+ <UserLink
+ class="account-name"
+ :title="user.screen_name_ui"
+ :user="user"
+ :at="false"
+ />
+ <img
+ v-if="!!(user && user.favicon)"
+ class="status-favicon"
+ :title="$t('status.favicon')"
+ :src="user.favicon"
+ >
+ </div>
+
+ <span class="heading-right">
+ <span
+ v-if="!nonMention && status?.pinned"
+ class="pin"
+ >
+ <FAIcon
+ icon="thumbtack"
+ class="faint"
+ />
+ <span class="faint">{{ $t('status.pinned') }}</span>
+ </span>
+ <router-link
+ class="timeago faint"
+ :to="{ name: 'conversation', params: { statusId: status.id } }"
+ :aria-label="$t('status.open_in_thread_view')"
+ >
+ <Timeago
+ :time="time"
+ :auto-update="60"
+ />
+ </router-link>
+ <span
+ v-if="!nonMention && status.visibility"
+ class="visibility-icon"
+ :title="visibilityLocalized"
+ >
+ <FAIcon
+ fixed-width
+ class="fa-scale-110"
+ :icon="visibilityIcon"
+ />
+ </span>
+ <button
+ v-if="!nonMention && expandable && !expanded"
+ class="button-unstyled"
+ :title="$t('status.expand')"
+ @click.prevent="$emit('toggleExpanded')"
+ >
+ <FAIcon
+ fixed-width
+ class="fa-scale-110"
+ icon="plus-square"
+ />
+ </button>
+ <button
+ v-if="unmuted"
+ class="button-unstyled"
+ @click.prevent="toggleMute"
+ >
+ <FAIcon
+ fixed-width
+ icon="eye-slash"
+ class="fa-scale-110"
+ />
+ </button>
+ <button
+ v-if="status && inThreadForest && replies?.size && !simpleTree"
+ class="button-unstyled"
+ :title="threadShowing ? $t('status.thread_hide') : $t('status.thread_show')"
+ :aria-expanded="threadShowing ? 'true' : 'false'"
+ @click.prevent="$emit('toggleThreadDisplay')"
+ >
+ <FAIcon
+ fixed-width
+ class="fa-scale-110"
+ :icon="threadShowing ? 'chevron-up' : 'chevron-down'"
+ />
+ </button>
+ <button
+ v-if="!nonMention && expanded && isTreeView && !simpleTree"
+ class="button-unstyled"
+ :title="$t('status.show_only_conversation_under_this')"
+ @click.prevent="$emit('dive')"
+ >
+ <FAIcon
+ fixed-width
+ class="fa-scale-110"
+ :icon="'angle-double-right'"
+ />
+ </button>
+ <button
+ v-if="nonMention && status"
+ class="button-unstyled expand-icon"
+ :title="$t('tool_tip.toggle_expand')"
+ :aria-expanded="statusExpanded"
+ @click.prevent="$emit('toggleExpanded')"
+ >
+ <FAIcon
+ class="fa-scale-110"
+ fixed-width
+ :icon="expanded ? 'compress-alt' : 'expand-alt'"
+ />
+ </button>
+ </span>
+ </div>
+ </div>
+</template>
+<script src="./status_header.js"></script>
+<style src="./status_header.scss"></style>
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Oct 10, 4:35 AM (1 d, 12 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1785034
Default Alt Text
(80 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment