Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
82 KB
Referenced Files
None
Subscribers
None
diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index c44f2b7d3e..88e691741f 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -1,183 +1,175 @@
import { mapState } from 'pinia'
import UnicodeDomainIndicator from 'src/components/unicode_domain_indicator/unicode_domain_indicator.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_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 { faAt } from '@fortawesome/free-solid-svg-icons'
library.add(faAt)
const MentionLink = {
name: 'MentionLink',
components: {
UserAvatar,
UnicodeDomainIndicator,
UserPopover,
},
props: {
url: {
required: false,
type: String,
},
content: {
required: true,
type: String,
},
userId: {
required: false,
type: String,
},
userScreenName: {
required: false,
type: String,
},
},
data() {
return {
hasSelection: false,
}
},
methods: {
- onClick(e, navigate) {
- // Mouse, Pen, Touch all have respective pointerType, other
- // AT possibly might have vendor-specific strings but
- // interacting with Tab and Enter however gives empty string
- if (!this.shouldShowTooltip || e.pointerType === '') {
- // Don't open popover as we're about to navigate
- e.stopPropagation()
- navigate()
- }
+ onClick() {
+ if (this.shouldShowTooltip) return
+ const link = generateProfileLink(
+ this.userId || this.user.id,
+ this.userScreenName || this.user.screen_name,
+ )
+ this.$router.push(link)
},
handleSelection() {
if (this.$refs.full) {
this.hasSelection = document
.getSelection()
.containsNode(this.$refs.full, true)
}
},
},
mounted() {
document.addEventListener('selectionchange', this.handleSelection)
},
unmounted() {
document.removeEventListener('selectionchange', this.handleSelection)
},
computed: {
user() {
return this.url ? useUsersStore().findUserByUrl(this.url) : null
},
- link() {
- return generateProfileLink(
- this.userId || this.user.id,
- this.userScreenName || this.user.screen_name,
- )
- },
isYou() {
if (!this.currentUser) return false
return this.user === this.currentUser
},
userName() {
return this.user && this.userNameFullUi.split('@')[0]
},
serverName() {
// XXX assumed that domain does not contain @
return (
this.user &&
(this.userNameFullUi.split('@')[1] || useInstanceStore().instanceDomain)
)
},
userNameFull() {
return this.user?.screen_name
},
userNameFullUi() {
return this.user?.screen_name_ui
},
highlightData() {
return this.highlight[this.user?.screen_name]
},
highlightType() {
return this.highlightData && '-' + this.highlightData.type
},
highlightClass() {
return this.highlightData && highlightClass(this.user)
},
style() {
if (this.highlightData) {
const {
backgroundColor,
backgroundPosition,
backgroundImage,
...rest
} = highlightStyle(this.highlightData)
return rest
}
},
classnames() {
return [
{
'-you': this.isYou && this.shouldBoldenYou,
'-highlighted': !!this.highlightData,
'-has-selection': this.hasSelection,
'-with-tooltip': this.shouldShowTooltip,
},
'short',
'new',
this.highlightType,
]
},
popoverAttrs() {
return {
href: this.url,
style: this.style,
class: this.classnames,
}
},
isRemote() {
return this.userName !== this.userNameFull
},
shouldShowFullUserName() {
const conf = this.mergedConfig.mentionLinkDisplay
if (conf === 'short') {
return false
} else if (conf === 'full') {
return true
} else {
// full_for_remote
return this.isRemote
}
},
shouldShowTooltip() {
return this.mergedConfig.mentionLinkShowTooltip
},
shouldShowAvatar() {
return this.mergedConfig.mentionLinkShowAvatar
},
shouldShowYous() {
return this.mergedConfig.mentionLinkShowYous
},
shouldBoldenYou() {
return this.mergedConfig.mentionLinkBoldenYou
},
shouldFadeDomain() {
return this.mergedConfig.mentionLinkFadeDomain
},
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useUserHighlightStore, ['highlight']),
...mapState(useUsersStore, ['currentUser']),
},
}
export default MentionLink
diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 4ff0398950..3aa7816be3 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -1,117 +1,110 @@
.MentionLink {
position: relative;
white-space: normal;
display: inline;
word-break: normal;
- .new,
- .original {
+ & .new,
+ & .original {
display: inline;
}
- .new {
- /* Let <a> styles take over */
- &, &:hover, &:focus, &:focus-visible {
- text-decoration: none;
- }
- }
-
.mention-avatar {
border-radius: var(--roundness);
width: 1.5em;
height: 1.5em;
vertical-align: middle;
user-select: none;
margin-right: 0.2em;
}
.full {
position: absolute;
display: inline-block;
pointer-events: none;
opacity: 0;
top: 100%;
left: 0;
height: 100%;
white-space: nowrap;
transition: opacity 0.2s ease;
z-index: 1;
margin-top: 0.25em;
padding: 0.5em;
user-select: all;
}
& .short.-with-tooltip,
& .you {
user-select: none;
}
& .short,
& .full {
white-space: nowrap;
}
.shortName {
white-space: normal;
}
.new {
&.-you {
.shortName {
font-weight: 600;
}
}
&.-has-selection {
--color: var(--selectionText);
--link: var(--selectionText);
background-color: var(--selectionBackground);
}
.at {
color: var(--link);
opacity: 0.8;
display: inline-block;
line-height: 1;
padding: 0 0.1em;
vertical-align: -25%;
margin: 0;
}
&.-striped {
& .shortName {
background-image:
repeating-linear-gradient(
135deg,
var(--____highlight-tintColor),
var(--____highlight-tintColor) 5px,
var(--____highlight-tintColor2) 5px,
var(--____highlight-tintColor2) 10px
);
}
}
&.-solid {
.shortName {
background-image: linear-gradient(var(--____highlight-tintColor2), var(--____highlight-tintColor2));
}
}
&.-side {
.shortName {
box-shadow: 0 -5px 3px -4px inset var(--____highlight-solidColor);
}
}
}
.serverName.-faded {
color: var(--linkFaint);
}
}
.mention-link-popover {
max-width: 70ch;
max-height: 20rem;
overflow: hidden;
}
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index c624211608..029a5c846b 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -1,71 +1,60 @@
<template>
<span
class="MentionLink"
>
<a
v-if="!user"
:href="url"
class="original"
target="_blank"
v-html="content"
/>
<UserPopover
v-else
- class="new"
- :class="{ '-no-link-click': shouldShowTooltip }"
:user-id="user.id"
:disabled="!shouldShowTooltip"
v-bind="popoverAttrs"
+ link
+ @click="onClick"
>
- <router-link
- :to="link"
- custom
- v-slot="{ href, navigate }"
- >
- <a
- :href="href"
- @click.left.exact.prevent="e => onClick(e, navigate)"
- :aria-label="$t('user_card.open_profile')"
- >
- <UserAvatar
- v-if="shouldShowAvatar"
- class="mention-avatar"
- :user-id="user.id"
- /><span
- class="shortName"
- >@<span
- class="userName"
- v-html="userName"
- /><span
- v-if="shouldShowFullUserName"
- class="serverName"
- :class="{ '-faded': shouldFadeDomain }"
- v-html="'@' + serverName"
- /><UnicodeDomainIndicator
- v-if="shouldShowFullUserName"
- :user="user"
- />
- </span>
- <span
- v-if="isYou && shouldShowYous"
- :class="{ '-you': shouldBoldenYou }"
- > {{ ' ' + $t('status.you') }}</span>
- </a>
- </router-link>
+ <UserAvatar
+ v-if="shouldShowAvatar"
+ class="mention-avatar"
+ :user-id="user.id"
+ /><span
+ class="shortName"
+ >@<span
+ class="userName"
+ v-html="userName"
+ /><span
+ v-if="shouldShowFullUserName"
+ class="serverName"
+ :class="{ '-faded': shouldFadeDomain }"
+ v-html="'@' + serverName"
+ /><UnicodeDomainIndicator
+ v-if="shouldShowFullUserName"
+ :user="user"
+ />
+ </span>
+ <span
+ v-if="isYou && shouldShowYous"
+ :class="{ '-you': shouldBoldenYou }"
+ > {{ ' ' + $t('status.you') }}</span>
+ <!-- eslint-enable vue/no-v-html -->
</UserPopover>
<span
v-if="user"
ref="full"
class="full"
aria-hidden="true"
>
<!-- eslint-disable vue/no-v-html -->
@<span v-html="userName" /><span v-html="'@' + serverName" />
<!-- eslint-enable vue/no-v-html -->
</span>
</span>
</template>
<script src="./mention_link.js" />
<style lang="scss" src="./mention_link.scss" />
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index f2c2a39342..3b094ee5df 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -1,210 +1,211 @@
<template>
<Status
v-if="notification.type === 'mention' || notification.type === 'status'"
ref="root"
class="Notification panel-body"
:compact="true"
:status-id="notification.status?.id"
@click="interacted"
width-efficient
/>
<article
v-else
ref="root"
class="NotificationParent panel-body"
:class="{ '-expandable': expandable }"
>
<div
v-if="needMute && !unmuted"
:id="'notif-' +notification.id"
:aria-expanded="statusExpanded ? 'true' : 'false'"
:aria-controls="'notif-' +notification.id"
class="Notification container -muted"
>
<small>
<UserLink
:user="notification.from_profile"
:at="false"
/>
</small>
<button
class="button-unstyled unmute"
:title="$t('status.show_muted_status')"
@click.prevent="toggleMute"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="eye-slash"
/>
</button>
</div>
<div
v-else
class="Notification -non-mention activity-layout -width-efficient -compact"
:class="[userClass, { highlighted: userStyle }, '-type--' + notification.type]"
:style="[ userStyle ]"
>
<div class="activity-avatar">
<UserPopover
:user-id="notification.from_profile.id"
overlay-centers
+ link
>
<UserAvatar
class="post-avatar"
:compact="true"
:user-id="notification.from_profile.id"
/>
</UserPopover>
</div>
<ActivityHeader
class="activity-header"
:statusoid="status"
:time="notification.created_at"
:user-id="notification.from_profile.id"
:unmuted="unmuted"
:expanded="statusExpanded"
non-mention
multiline
@toggle-muted="toggleMute"
@toggle-expanded="toggleStatusExpanded"
/>
<div class="activity-subheader">
<span v-if="notification.type === 'like'">
<FAIcon
class="type-icon"
icon="star"
/>
{{ ' ' }}
{{ $t('notifications.favorited_you') }}
</span>
<span v-if="notification.type === 'repeat'">
<FAIcon
class="type-icon"
icon="retweet"
:title="$t('tool_tip.repeat')"
/>
{{ ' ' }}
{{ $t('notifications.repeated_you') }}
</span>
<span v-if="notification.type === 'follow'">
<FAIcon
class="type-icon"
icon="user-plus"
/>
{{ ' ' }}
{{ $t('notifications.followed_you') }}
</span>
<span v-if="notification.type === 'follow_request'">
<FAIcon
class="type-icon"
icon="user"
/>
{{ ' ' }}
{{ $t('notifications.follow_request') }}
</span>
<span v-if="notification.type === 'move'">
<FAIcon
class="type-icon"
icon="suitcase-rolling"
/>
{{ ' ' }}
{{ $t('notifications.migrated_to') }}
</span>
<span v-if="notification.type === 'pleroma:emoji_reaction'">
<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 }"
>
<span
v-else
class="emoji-reaction-emoji"
>{{ notification.emoji }}</span>
</i18n-t>
</span>
<span v-if="notification.type === 'pleroma:report'">
{{ $t('notifications.submitted_report') }}
</span>
<span v-if="notification.type === 'poll'">
<FAIcon
class="type-icon"
icon="poll-h"
/>
{{ ' ' }}
{{ $t('notifications.poll_ended') }}
</span>
</div>
<div class="activity-content">
<div
v-if="notification.type === 'follow' || notification.type === 'follow_request'"
class="follow-text activity-content"
>
<UserLink
class="follow-name"
:user="notification.from_profile"
/>
</div>
<div
v-else-if="notification.type === 'move'"
class="move-text activity-content"
>
<UserLink
:user="notification.target"
/>
</div>
<Report
class="avatar-container"
v-else-if="notification.type === 'pleroma:report'"
:report-id="notification.report.id"
/>
<small>
<StatusContent
v-if="status"
class="activity-container"
:compact="!statusExpanded"
:status="status"
:collapse="!statusExpanded"
@click="onContentClick"
/>
</small>
</div>
<div
v-if="notification.type === 'follow_request'"
style="white-space: nowrap;"
class="activity-actions"
>
<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>
</article>
</template>
<script src="./notification.js"></script>
<style src="./notification.scss" lang="scss"></style>
<style src="/src/components/status/activity-layout.scss" lang="scss"></style>
diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js
index 3beb5c16d4..fb54422975 100644
--- a/src/components/popover/popover.js
+++ b/src/components/popover/popover.js
@@ -1,528 +1,532 @@
import { useId } from 'vue'
const Popover = {
name: 'Popover',
inheritAttrs: false,
props: {
// Action to trigger popover: either 'hover' or 'click'
trigger: String,
// Accessible label
label: String,
// 'top', 'bottom', 'left', 'right'
placement: String,
// Takes object with properties 'x' and 'y', values of these can be
// 'container' for using offsetParent as boundaries for either axis
// or 'viewport'
boundTo: Object,
// Takes a selector to use as a replacement for the parent container
// for getting boundaries for x an y axis
boundToSelector: String,
// Takes a top/bottom/left/right object, how much space to leave
// between boundary and popover element
margin: Object,
// Takes a x/y object and tells how many pixels to offset from
// anchor point on either axis
offset: Object,
// Replaces the classes you may want for the popover container.
// Use 'popover-default' in addition to get the default popover
// styles with your custom class.
popoverClass: String,
// If true, subtract padding when calculating position for the popover,
// use it when popover offset looks to be different on top vs bottom.
removePadding: Boolean,
// self-explanatory (i hope)
disabled: Boolean,
// Instead of putting popover next to anchor, overlay popover's center on top of anchor's center
overlayCenters: Boolean,
// What selector (witin popover!) to use for determining center of popover
overlayCentersSelector: String,
// Lets hover popover stay when clicking inside of it
stayOnClick: Boolean,
// Use styled button (to avoid nested buttons)
normalButton: Boolean,
+ // Use <a> instead of <button>
+ link: Boolean,
+
// Don't put button classes on trigger
menuitem: Boolean,
// Whether to hide the trigger totally
hideTrigger: Boolean,
// Used to set or unset aria-haspopup (attribute not set for tooltips)
tooltip: Boolean,
},
emits: ['show', 'close', 'forwardTabContentFocus', 'reverseTabContentFocus'],
inject: {
// override popover z layer
popoversZLayer: {
default: '',
},
},
setup() {
const uniqueId = useId()
return { uniqueId }
},
data() {
return {
// lockReEntry is a flag that is set when mouse cursor is leaving the popover's content
// so that if mouse goes back into popover it won't be re-shown again to prevent annoyance
// with popovers refusing to be hidden when user wants to interact with something in below popover
anchorEl: null,
// There's an issue where having teleport enabled by default causes things just...
// not render at all, i.e. main post status form and its emoji inputs
teleport: false,
lockReEntry: false,
hidden: true,
nextFocus: null,
redirectFocus: false,
styles: {},
oldSize: { width: 0, height: 0 },
scrollable: null,
// used to avoid blinking if hovered onto popover
graceTimeout: null,
parentPopover: null,
disableClickOutside: false,
childrenShown: new Set(),
}
},
computed: {
idPrefix() {
return `popover-${this.uniqueId}-`
},
allTriggerAttrs() {
if (process.env.NODE_ENV === 'development') {
if ('aria-hidden' in this.$attrs) {
throw new Error(
'Do not use aria-hidden in attrs. Instead set hideTrigger to true',
)
}
}
const allAttrs = {
...this.$attrs,
}
const {
'aria-hidden': _unused0,
'aria-label': _unused1,
...rest
} = allAttrs
const attrs = { ...rest }
if (this.hideTrigger) {
attrs['aria-hidden'] = true
attrs.tabindex = -1
}
return attrs
},
ariaHasPopup() {
if (this.tooltip || this.disabled) {
return undefined
} else {
return this.$attrs['aria-haspopup'] ?? 'menu'
}
},
triggerClass() {
- if (this.menuitem) {
+ if (this.link || this.menuitem) {
return ''
} else if (this.normalButton) {
return 'button-default btn'
} else {
return 'button-unstyled'
}
},
},
methods: {
setAnchorEl(el) {
this.anchorEl = el
this.updateStyles()
},
containerBoundingClientRect() {
const container = this.boundToSelector
? this.$el.closest(this.boundToSelector)
: this.$el.offsetParent
return container.getBoundingClientRect()
},
updateStyles() {
if (this.hidden) {
this.styles = {}
return
}
// Popover will be anchored around this element, trigger ref is the container, so
// its children are what are inside the slot. Expect only one v-slot:trigger.
const anchorEl =
this.anchorEl || this.$refs.trigger?.children[0] || this.$refs.trigger
// SVGs don't have offsetWidth/Height, use fallback
const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
const anchorScreenBox = anchorEl.getBoundingClientRect()
const anchorStyle = getComputedStyle(anchorEl)
const topPadding = Number.parseFloat(anchorStyle.paddingTop)
const bottomPadding = Number.parseFloat(anchorStyle.paddingBottom)
const rightPadding = Number.parseFloat(anchorStyle.paddingRight)
const leftPadding = Number.parseFloat(anchorStyle.paddingLeft)
// Screen position of the origin point for popover = center of the anchor
const origin = {
x: anchorScreenBox.left + anchorWidth * 0.5,
y: anchorScreenBox.top + anchorHeight * 0.5,
}
const content = this.$refs.content
const overlayCenter = this.overlayCenters
? this.$refs.content.querySelector(this.overlayCentersSelector)
: null
// Minor optimization, don't call a slow reflow call if we don't have to
const parentScreenBox =
(this.boundTo?.x === 'container' || this.boundTo?.y === 'container') &&
this.containerBoundingClientRect()
const margin = this.margin || {}
// What are the screen bounds for the popover? Viewport vs container
// when using viewport, using default margin values to dodge the navbar
const xBounds =
this.boundTo?.x === 'container'
? {
min: parentScreenBox.left + (margin.left || 0),
max: parentScreenBox.right - (margin.right || 0),
}
: {
min: 0 + (margin.left || 10),
max: window.innerWidth - (margin.right || 10),
}
const yBounds =
this.boundTo?.y === 'container'
? {
min: parentScreenBox.top + (margin.top || 0),
max: parentScreenBox.bottom - (margin.bottom || 0),
}
: {
min: 0 + (margin.top || 50),
max: window.innerHeight - (margin.bottom || 5),
}
let horizOffset = 0
let vertOffset = 0
if (overlayCenter) {
const box = content.getBoundingClientRect()
const overlayCenterScreenBox = overlayCenter.getBoundingClientRect()
const leftInnerOffset = overlayCenterScreenBox.left - box.left
const topInnerOffset = overlayCenterScreenBox.top - box.top
horizOffset = -leftInnerOffset - overlayCenter.offsetWidth * 0.5
vertOffset = -topInnerOffset - overlayCenter.offsetHeight * 0.5
} else {
horizOffset = content.offsetWidth * -0.5
vertOffset = content.offsetHeight * -0.5
}
const leftBorder = origin.x + horizOffset
const rightBorder = leftBorder + content.offsetWidth
const topBorder = origin.y + vertOffset
const bottomBorder = topBorder + content.offsetHeight
// If overflowing from left, move it so that it doesn't
if (leftBorder < xBounds.min) {
horizOffset += xBounds.min - leftBorder
}
// If overflowing from right, move it so that it doesn't
if (rightBorder > xBounds.max) {
horizOffset -= rightBorder - xBounds.max
}
// If overflowing from top, move it so that it doesn't
if (topBorder < yBounds.min) {
vertOffset += yBounds.min - topBorder
}
// If overflowing from bottom, move it so that it doesn't
if (bottomBorder > yBounds.max) {
vertOffset -= bottomBorder - yBounds.max
}
let translateX = 0
let translateY = 0
if (overlayCenter) {
translateX = origin.x + horizOffset
translateY = origin.y + vertOffset
} else if (this.placement !== 'right' && this.placement !== 'left') {
// Default to whatever user wished with placement prop
let usingTop = this.placement !== 'bottom'
// Handle special cases, first force to displaying on top if there's no space on bottom,
// regardless of what placement value was. Then check if there's no space on top, and
// force to bottom, again regardless of what placement value was.
const topBoundary =
origin.y - anchorHeight * 0.5 + (this.removePadding ? topPadding : 0)
const bottomBoundary =
origin.y +
anchorHeight * 0.5 -
(this.removePadding ? bottomPadding : 0)
if (bottomBoundary + content.offsetHeight > yBounds.max) usingTop = true
if (topBoundary - content.offsetHeight < yBounds.min) usingTop = false
const yOffset = this.offset?.y || 0
translateY = usingTop
? topBoundary - yOffset - content.offsetHeight
: bottomBoundary + yOffset
const xOffset = this.offset?.x || 0
translateX = origin.x + horizOffset + xOffset
} else {
// Default to whatever user wished with placement prop
let usingLeft = this.placement !== 'right'
// Handle special cases, first force to displaying on left if there's no space on right,
// regardless of what placement value was. Then check if there's no space on right, and
// force to left, again regardless of what placement value was.
const leftBoundary =
origin.x - anchorWidth * 0.5 + (this.removePadding ? leftPadding : 0)
const rightBoundary =
origin.x + anchorWidth * 0.5 - (this.removePadding ? rightPadding : 0)
if (rightBoundary + content.offsetWidth > xBounds.max) usingLeft = true
if (leftBoundary - content.offsetWidth < xBounds.min) usingLeft = false
const xOffset = this.offset?.x || 0
translateX = usingLeft
? leftBoundary - xOffset - content.offsetWidth
: rightBoundary + xOffset
const yOffset = this.offset?.y || 0
translateY = origin.y + vertOffset + yOffset
}
this.styles = {
left: `${Math.round(translateX)}px`,
top: `${Math.round(translateY)}px`,
}
if (this.popoversZLayer) {
this.styles['--ZI_popover_override'] =
`var(--ZI_${this.popoversZLayer}_popovers)`
}
if (parentScreenBox) {
this.styles.maxWidth = `${Math.round(parentScreenBox.width)}px`
}
},
showPopover() {
if (this.disabled) return
this.disableClickOutside = true
setTimeout(() => {
this.disableClickOutside = false
}, 0)
const wasHidden = this.hidden
this.hidden = false
this.parentPopover?.onChildPopoverState(this, true)
if (this.trigger === 'click' || this.stayOnClick) {
document.addEventListener('click', this.onClickOutside)
}
this.scrollable.addEventListener('scroll', this.onScroll)
this.scrollable.addEventListener('resize', this.onResize)
// My assumption is that upon showing popover initially has different size
// as its contents are getting populating, so logic uses those incorrect
// sizes as basis
setTimeout(() => {
if (wasHidden) this.$emit('show')
this.updateStyles()
}, 1)
},
hidePopover() {
if (this.disabled) return
if (!this.hidden) this.$emit('close')
this.redirectFocus = false
this.nextFocus = null
this.hidden = true
this.parentPopover?.onChildPopoverState(this, false)
if (this.trigger === 'click') {
document.removeEventListener('click', this.onClickOutside)
}
this.scrollable?.removeEventListener('scroll', this.onScroll)
this.scrollable?.removeEventListener('resize', this.onResize)
},
resizePopover() {
setTimeout(() => {
this.updateStyles()
}, 1)
},
onMouseenter() {
if (this.trigger === 'hover') {
this.lockReEntry = false
clearTimeout(this.graceTimeout)
this.graceTimeout = null
this.showPopover()
}
},
onMouseleave() {
if (this.trigger === 'hover' && this.childrenShown.size === 0) {
this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
}
},
onMouseenterContent() {
if (this.trigger === 'hover' && !this.lockReEntry) {
this.lockReEntry = true
clearTimeout(this.graceTimeout)
this.graceTimeout = null
this.showPopover()
}
},
onMouseleaveContent() {
if (this.trigger === 'hover' && this.childrenShown.size === 0) {
this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
}
},
// # Focus management.
//
// Since our content is far away in separate #modals or #popovers container
// the native tabindex flow is broken, but we still want user to be able to
// focus popover contents. To do so we do a bit of focus management.
//
// We do a little focus trapping and redirecting. First, we must establish
// our order. Opening a popover (including auto-open by hover/focus) and
// hitting tab should select content's first focusable element. Shift-tab
// away from popover trigger should hide popover and continue on as normal.
// While inside content, shift-tabbing from first focusable element should
// focus trigger again, and tabbing beyond last focusable element should
// focus element next in line relative to trigger.
//
// We use two tricks here:
// 1. we add two bogus ".focus-trap" elements before and after popover
// contents. Those are aria-hidden="1" and empty spans so they don't
// interfere with the UI or accessibility. We use them to detect when
// user tab-navigated beyond first and last focusable elements and act
// accordingly
// 2. when we open up a popover we listen for "Tab" keypress and engage
// "redirectFocus", so that when trigger loses focus (immideately after)
// we a) focus content's first focusable element and b) save reference
// to the next intended focus target.
//
// There is a blind spot in this approach. If user opened a popover and
// navigated in it with a mouse we never get to know the "next intended
// focus target" so focus order ends up being broken.
//
// There's probably a better way to figure out this "next target" but for
// now this will do.
onFocus(e) {
if (this.trigger === 'hover') {
this.showPopover()
}
},
onBlur(e) {
if (this.redirectFocus) {
const firstFocusable = this.$refs.content
?.querySelector('*:not(.focus-trap)')
?.querySelector('a[href], button, [tabindex="0"], input')
if (firstFocusable) {
firstFocusable?.focus()
this.nextFocus = e.relatedTarget
} else {
this.hidePopover()
}
}
this.redirectFocus = false
},
onTab(e) {
this.redirectFocus = true
},
onForwardTabContentFocus() {
this.nextFocus?.focus()
this.hidePopover()
this.$emit('forwardTabContentFocus')
},
onReverseTabContentFocus() {
const triggerer = this.anchorEl ?? this.$refs.trigger
triggerer.focus()
this.$emit('reverseTabContentFocus')
},
onClick(e) {
if (this.trigger === 'click') {
if (e.ctrlKey) return
if (e.shiftKey) return
if (e.metaKey) return
if (e.button > 0) return
+ e.preventDefault()
if (this.hidden) {
this.showPopover()
} else {
this.hidePopover()
}
}
},
onClickOutside(e) {
if (this.disableClickOutside) return
if (this.hidden) return
if (this.$refs.content?.contains(e.target)) return
if (this.$el.contains(e.target)) return
if (this.childrenShown.size > 0) return
this.hidePopover()
if (this.parentPopover) this.parentPopover.onClickOutside(e)
},
onScroll() {
this.updateStyles()
},
onResize() {
const content = this.$refs.content
if (!content) return
if (
this.oldSize.width !== content.offsetWidth ||
this.oldSize.height !== content.offsetHeight
) {
this.updateStyles()
this.oldSize = {
width: content.offsetWidth,
height: content.offsetHeight,
}
}
},
onChildPopoverState(childRef, state) {
if (state) {
this.childrenShown.add(childRef)
} else {
this.childrenShown.delete(childRef)
}
},
},
updated() {
// Monitor changes to content size, update styles only when content sizes have changed,
// that should be the only time we need to move the popover box if we don't care about scroll
// or resize
this.onResize()
},
mounted() {
this.teleport = true
let scrollable =
this.$refs.trigger.closest('.column.-scrollable') ||
this.$refs.trigger.closest('.mobile-notifications')
if (!scrollable) scrollable = window
this.scrollable = scrollable
let parent = this.$parent
while (parent && parent.$.type.name !== 'Popover') {
parent = parent.$parent
}
this.parentPopover = parent
},
beforeUnmount() {
this.hidePopover()
},
}
export default Popover
diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue
index 5b7eaa4424..f6f2643570 100644
--- a/src/components/popover/popover.vue
+++ b/src/components/popover/popover.vue
@@ -1,66 +1,67 @@
<template>
- <button
+ <component
ref="trigger"
- type="button"
+ :is="link ? 'a' : 'button'"
+ :type="link ? undefined : 'button'"
class="popover-trigger-button"
:class="triggerClass"
:aria-haspopup="ariaHasPopup"
:aria-owns="hidden ? undefined : `${idPrefix}content`"
:aria-describedby="tooltip ? `${idPrefix}content` : undefined"
:aria-expanded="!hidden ? 'true' : 'false'"
:aria-label="label ?? $attrs['aria-label']"
@mouseenter="onMouseenter"
@mouseleave="onMouseleave"
@click="onClick"
@focus="onFocus"
@blur="onBlur"
@keydown.tab.exact="onTab"
@keydown.tab.shift.exact="hidePopover"
v-bind="allTriggerAttrs"
>
<slot name="trigger" />
<teleport
:disabled="!teleport"
to="#popovers"
>
<transition name="fade">
<div
v-if="!hidden"
:id="($attrs.id ?? idPrefix) + 'content'"
ref="content"
:style="styles"
class="popover"
:role="tooltip ? 'tooltip' : 'dialog'"
:aria-label="$attrs['aria-label']"
:class="popoverClass || 'popover-default'"
@mouseenter="onMouseenterContent"
@mouseleave="onMouseleaveContent"
@click="onClickContent"
>
<span
class="focus-trap -before"
aria-hidden="1"
tabindex="0"
@focus="onReverseTabContentFocus"
/>
<slot
name="content"
class="popover-inner"
:close="hidePopover"
:resize="resizePopover"
/>
<span
class="focus-trap -after"
aria-hidden="1"
tabindex="0"
@focus="onForwardTabContentFocus"
/>
</div>
</transition>
</teleport>
- </button>
+ </component>
</template>
<script src="./popover.js" />
<style src="./popover.scss" lang="scss"></style>
diff --git a/src/components/status/status.js b/src/components/status/status.js
index e1fa557f46..0059bab3df 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,630 +1,630 @@
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import ActivityHeader from 'src/components/activity_header/activity_header.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 StatusContent from 'src/components/status_content/status_content.vue'
-import StatusLink from 'src/components/status_link/status_link.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 { useInterfaceStore } from 'src/stores/interface.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,
- StatusLink,
+ StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent,
ActivityHeader,
MentionLink,
MentionsLine,
UserPopover,
UserLink,
Timeago,
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,
widthEfficient: Boolean,
threadDisplayState: String,
conversationRank: {
type: String,
default: 'linear',
},
},
emits: [
'requestFocus',
'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: {
shouldBeWidthEfficient() {
return (
this.widthEfficient ||
this.layoutType === 'mobile' ||
this.mergedConfig.widthEfficientPostLayout
)
},
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'
},
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))
},
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
},
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
},
...mapState(useInterfaceStore, ['layoutType']),
},
methods: {
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
},
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
},
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.vue b/src/components/status/status.vue
index 1b4b337a39..281ddc2730 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,469 +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="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 && !inConversation"
:class="[repeaterClass, { highlighted: repeaterStyle }]"
:style="[repeaterStyle]"
class="repeat-info"
>
<UserAvatar
class="repeater-avatar"
:user-id="repeater.id"
/>
<bdi
class="status-username repeater-name"
:title="repeaterName"
>
<router-link
class="button-unstyled"
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>
{{ ' ' }}
<span class="repeat-label faint">
<FAIcon
icon="retweet"
class="repeat-icon"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
</span>
</div>
<div
v-if="!isDeleted"
:class="[userClass, { highlighted: userStyle, '-repeat': isRepeat && !inConversation, '-width-efficient': shouldBeWidthEfficient, '-compact': compact }]"
:style="[ userStyle ]"
class="activity-layout"
:data-tags="tags"
>
<UserPopover
v-if="user.name"
class="activity-avatar"
:user-id="user.id"
overlay-centers
+ link
>
<UserAvatar
class="post-avatar"
:compact="compact"
:user-id="user.id"
/>
</UserPopover>
<UserAvatar
v-else
:user-id="user.id"
class="activity-avatar"
:compact="compact"
:title="$t('status.unknown_user_info')"
/>
<ActivityHeader
class="activity-header"
:status-id="mainStatus.id"
:user-id="user.id"
:unmuted="unmuted"
:time="mainStatus.created_at"
:replies="replies"
:multiline="shouldBeWidthEfficient"
:compact="compact"
:expandable="expandable"
:expanded="isExpanded"
:thread-display-state="threadDisplayState"
@request-focus="$emit('requestFocus', mainStatus.id)"
@toggle-muted="toggleMute"
@toggle-expanded="$emit('toggleExpanded')"
@toggle-thread-display="$emit('toggleThreadDisplay')"
/>
<div class="activity-subheader">
<div
v-if="scrobblePresent"
class="status-rich-presence"
>
<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"
/>
</span>
</span>
</div>
<div
v-if="isReply || hasMentionsLine"
class="heading-reply-row"
>
<span
v-if="isReply"
class="glued-label reply-glued-label"
>
<i18n-t
keypath="status.reply_to_with_arg"
scope="global"
>
<template #replyToWithIcon>
- <StatusLink
+ <StatusPopover
v-if="!isPreview"
class="reply-to-popover"
:class="{ '-strikethrough': !mainStatus.parent_visible }"
:status-id="mainStatus.parent_visible && mainStatus.in_reply_to_status_id"
:link="isPage"
@navigate="$emit('requestFocus', mainStatus.parent_visible && mainStatus.in_reply_to_status_id)"
>
- <i18n-t
- keypath="status.reply_to_with_icon"
- scope="global"
- >
+ <i18n-t
+ keypath="status.reply_to_with_icon"
+ scope="global"
+ >
<template #icon>
<FAIcon
class="fa-scale-110"
icon="reply"
flip="horizontal"
/>
</template>
<template #replyTo>
<span
class="reply-to-text"
>
{{ $t('status.reply_to') }}
</span>
</template>
</i18n-t>
- </StatusLink>
+ </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>
<!-- This little wrapper is made for sole purpose of "gluing" -->
<!-- "Mentions" label to the first mention -->
<span
v-if="hasMentionsLine"
class="glued-label"
>
<span
class="mentions"
:aria-label="$t('tool_tip.mentions')"
@click.prevent="$emit('requestFocus', status.in_reply_to_status_id)"
>
<span class="mentions-text">
{{ $t('status.mentions') }}
</span>
</span>
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(0, 1)"
class="mentions-line-first"
/>
</span>
{{ ' ' }}
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(1)"
class="mentions-line"
/>
</div>
<div
v-if="isEdited && editingAvailable && !isPreview"
class="heading-edited-row"
>
<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>
</div>
<div
class="activity-content"
>
<StatusContent
ref="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('requestFocus', mainStatus.id)"
>
{{ $t('status.replies_list_with_others', { numReplies: replies.size - 1 }, replies.size - 1) }}
</button>
<span
v-else
role="heading"
:aria-level="headerLevel"
class="faint"
>
{{ $t('status.replies_list') }}
</span>
{{ ' ' }}
<ol
class="replies-list"
:aria-label="$t('status.replies_list')"
>
<li
v-for="reply in replies.values()"
:key="reply.id"
:aria-label="reply.name"
>
- <StatusLink
+ <StatusPopover
:status-id="reply.id"
:link="isPage"
class="reply-link"
@navigate="$emit('requestFocus', reply.id)"
>
{{ reply.name }}
- </StatusLink>
+ </StatusPopover>
{{ ' ' }}
</li>
</ol>
</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) && (!isPreview)"
:status="mainStatus"
/>
</div>
<StatusActionButtons
v-if="!isPreview"
class="activity-actions"
:status="mainStatus"
:replying="replying"
@toggle-replying="toggleReplyForm"
/>
</div>
<div
v-else
class="gravestone"
>
<UserAvatar
class="post-avatar"
:compact="compact"
/>
<div class="deleted-text">
{{ $t('status.status_deleted') }}
</div>
</div>
<div
v-if="replying"
class="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('requestFocus', mainStatus.id)"
>
<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>
<style src="./activity-layout.scss" lang="scss"></style>
diff --git a/src/components/status_link/status_link.js b/src/components/status_link/status_link.js
deleted file mode 100644
index 5a795ac843..0000000000
--- a/src/components/status_link/status_link.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import StatusPopover from 'src/components/status_popover/status_popover.vue'
-
-const StatusLink = {
- name: 'StatusLink',
- components: {
- StatusPopover,
- },
- props: {
- statusId: {
- required: true,
- type: String,
- },
- link: Boolean,
- chat: Boolean,
- },
- emits: ['navigate'],
- methods: {
- onLinkClick(e, navigate) {
- // Mouse, Pen, Touch all have respective pointerType, other
- // AT possibly might have vendor-specific strings but
- // interacting with Tab and Enter however gives empty string
- if (this.link || e.pointerType === '') {
- // Don't open popover as we're about to navigate
- e.stopPropagation()
- navigate()
- }
- },
- onPopoverClick(e) {
- if (!this.link) {
- this.$emit('navigate', this.statusId)
- }
- },
- },
- computed: {
- destination() {
- return {
- name: this.chat ? 'conversation2' : 'conversation',
- params: { statusId: this.statusId },
- }
- },
- },
-}
-
-export default StatusLink
diff --git a/src/components/status_link/status_link.scss b/src/components/status_link/status_link.scss
deleted file mode 100644
index 63128e7c64..0000000000
--- a/src/components/status_link/status_link.scss
+++ /dev/null
@@ -1,8 +0,0 @@
-.StatusLink {
- &.-link {
- /* Let <a> styles take over */
- &, &:hover, &:focus, &:focus-visible {
- text-decoration: none;
- }
- }
-}
diff --git a/src/components/status_link/status_link.vue b/src/components/status_link/status_link.vue
deleted file mode 100644
index 9ae33d546d..0000000000
--- a/src/components/status_link/status_link.vue
+++ /dev/null
@@ -1,28 +0,0 @@
-<template>
- <StatusPopover
- class="StatusLink"
- :class="{ '-link': link }"
- :status-id="statusId"
- @click.left.exact="onPopoverClick"
- >
- <template v-if="link">
- <router-link
- :to="destination"
- custom
- v-slot="{ href, navigate }"
- >
- <a
- :href="href"
- @click.left.exact.prevent="e => onLinkClick(e, navigate)"
- >
- <slot />
- </a>
- </router-link>
- </template>
- <template v-else>
- <slot />
- </template>
- </StatusPopover>
-</template>
-<script src="./status_link.js" />
-<style src="./status_link.scss" />
diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js
index fb252454a8..a8699f0ba3 100644
--- a/src/components/status_popover/status_popover.js
+++ b/src/components/status_popover/status_popover.js
@@ -1,55 +1,69 @@
import Popover from 'src/components/popover/popover.vue'
import { useStatusesStore } from 'src/stores/statuses.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
const StatusPopover = {
name: 'StatusPopover',
props: {
statusId: String,
+ link: Boolean,
},
- emits: ['click'],
+ emits: ['navigate'],
data() {
return {
error: false,
}
},
computed: {
status() {
return useStatusesStore().allStatuses.get(this.statusId)
},
+ statusLink() {
+ return this.$router.resolve({
+ name: 'conversation',
+ params: { statusId: this.statusId },
+ }).href
+ },
},
components: {
Popover,
},
methods: {
- onClick(e) {
- this.$emit('click', e)
- },
enter() {
if (!this.status) {
if (!this.statusId) {
this.error = true
return
}
useStatusesStore()
.fetchStatus(this.statusId)
.then(() => (this.error = false))
.catch(() => (this.error = true))
}
},
+ navigate() {
+ if (this.link) {
+ this.$router.push({
+ name: 'conversation',
+ params: { statusId: this.statusId },
+ })
+ } else {
+ this.$emit('navigate', this.statusId)
+ }
+ },
},
watch: {
status(newStatus, oldStatus) {
if (newStatus !== oldStatus) {
this.$nextTick(() => this.$refs.popover.updateStyles())
}
},
},
}
export default StatusPopover
diff --git a/src/components/status_popover/status_popover.vue b/src/components/status_popover/status_popover.vue
index f09c9cf885..43e9507e91 100644
--- a/src/components/status_popover/status_popover.vue
+++ b/src/components/status_popover/status_popover.vue
@@ -1,71 +1,73 @@
<template>
<Popover
ref="popover"
popover-class="popover-default status-popover"
trigger="hover"
+ :link="link"
+ :href="link ? statusLink : undefined"
:stay-on-click="true"
:bound-to="{ x: 'container' }"
aria-haspopup="dialog"
@show="enter"
- @click="onClick"
+ @click.prevent="navigate"
v-bind="$attrs"
>
<template #trigger>
<slot />
</template>
<template #content>
<Status
v-if="status"
:is-preview="true"
:status-id="statusId"
:compact="true"
:ignore-mute="true"
/>
<div
v-else-if="error"
class="status-preview-no-content faint"
>
{{ $t('status.status_unavailable') }}
</div>
<div
v-else
class="status-preview-no-content"
>
<FAIcon
icon="circle-notch"
spin
size="2x"
/>
</div>
</template>
</Popover>
</template>
<script src="./status_popover.js"></script>
<style lang="scss">
/* popover styles load on-demand, so we need to override */
.status-popover.popover {
font-size: 1rem;
min-width: 15em;
max-width: 95%;
border-color: var(--border);
border-style: solid;
border-width: 1px;
/* TODO cleanup this */
.Status.Status {
border: none;
}
.status-preview-no-content {
padding: 1em;
text-align: center;
i {
font-size: 2em;
}
}
}
</style>
diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue
index 16af7fa56e..81ccc6dd8b 100644
--- a/src/components/user_avatar/user_avatar.vue
+++ b/src/components/user_avatar/user_avatar.vue
@@ -1,91 +1,92 @@
<template>
<span
class="Avatar"
:class="{ '-compact': compact }"
>
<StillImage
v-if="user"
class="avatar"
- :alt="`${$t('user_card.avatar')} (${user.screen_name_ui})`"
+ :alt="user.screen_name_ui"
+ :title="user.screen_name_ui"
:src="url ? url : imgSrc(user.profile_image_url_original)"
:image-load-error="imageLoadError"
:class="{ '-compact': compact, '-better-shadow': betterShadow }"
/>
<div
v-else
class="avatar -placeholder"
:class="{ '-compact': compact }"
/>
<FAIcon
v-if="showActorTypeIndicator && user?.actor_type === 'Service'"
icon="robot"
class="actor-type-indicator"
/>
<FAIcon
v-if="showActorTypeIndicator && user?.actor_type === 'Group'"
icon="people-group"
class="actor-type-indicator"
/>
</span>
</template>
<script src="./user_avatar.js"></script>
<style lang="scss">
.Avatar {
--_avatarShadowBox: var(--shadow);
--_avatarShadowFilter: var(--shadowFilter);
--_avatarShadowInset: var(--shadowInset);
--_still-image-label-visibility: hidden;
display: inline-block;
position: relative;
width: var(--_avatar-size, 3.5em);
height: var(--_avatar-size, 3.5em);
&.-compact {
width: var(--_avatar-size, 2.2em);
height: var(--_avatar-size, 2.2em);
border-radius: var(--roundness);
}
.avatar {
width: 100%;
height: 100%;
box-shadow: var(--_avatarShadowBox);
border-radius: var(--roundness);
&.-better-shadow {
box-shadow: var(--_avatarShadowInset);
filter: var(--_avatarShadowFilter);
}
&.-animated::before {
display: none;
}
&.-compact {
border-radius: var(--roundness);
}
&.-placeholder {
background-color: var(--background);
}
}
img {
width: 100%;
height: 100%;
}
.actor-type-indicator {
position: absolute;
bottom: 0;
right: 0;
margin: -0.2em;
padding: 0.2em;
background: rgb(127 127 127 / 50%);
color: #fff;
border-radius: var(--roundness);
}
}
</style>
diff --git a/src/components/user_popover/user_popover.js b/src/components/user_popover/user_popover.js
index 59042f110f..59be2c0c57 100644
--- a/src/components/user_popover/user_popover.js
+++ b/src/components/user_popover/user_popover.js
@@ -1,34 +1,47 @@
import { mapState } from 'pinia'
import Popover from 'src/components/popover/popover.vue'
import UserCard from 'src/components/user_card/user_card.vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useUsersStore } from 'src/stores/users.js'
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+
const UserPopover = {
name: 'UserPopover',
props: {
userId: String,
userObject: Object,
overlayCenters: Boolean,
disabled: Boolean,
+ link: Boolean,
overlayCentersSelector: String,
},
components: {
UserCard,
Popover,
},
computed: {
user() {
return this.userObject ?? useUsersStore().findUser(this.userId)
},
+ href() {
+ if (!this.user) return ''
+ return this.$router.resolve(
+ generateProfileLink(
+ this.user.id,
+ this.user.screen_name,
+ useInstanceStore().restrictedNicknames,
+ ),
+ ).href
+ },
...mapState(useMergedConfigStore, {
userPopoverAvatarAction: (state) =>
state.mergedConfig.userPopoverAvatarAction,
userPopoverOverlay: (state) => state.mergedConfig.userPopoverOverlay,
}),
},
}
export default UserPopover
diff --git a/src/components/user_popover/user_popover.vue b/src/components/user_popover/user_popover.vue
index 75379c1d6c..3ab339e0cf 100644
--- a/src/components/user_popover/user_popover.vue
+++ b/src/components/user_popover/user_popover.vue
@@ -1,54 +1,56 @@
<template>
<Popover
trigger="click"
popover-class="popover-default user-popover"
:overlay-centers-selector="overlayCentersSelector || '.user-info .Avatar'"
:overlay-centers="overlayCenters && userPopoverOverlay"
+ :link="link"
+ :href="link ? href : undefined"
aria-haspopup="dialog"
v-bind="$attrs"
>
<template #trigger>
<slot />
</template>
<template #content="{close}">
<UserCard
class="user-popover"
:compact="true"
:show-close="true"
:show-expand="true"
:user-id="userId"
:hide-bio="true"
:avatar-action="userPopoverAvatarAction == 'close' ? close : userPopoverAvatarAction"
@close="close"
/>
</template>
</Popover>
</template>
<script src="./user_popover.js"></script>
<style lang="scss">
/* popover styles load on-demand, so we need to override */
/* stylelint-disable block-no-empty */
.user-popover {
margin: 0;
.user-info {
width: 100%;
}
.user-identity {
min-width: calc(min(30em, 98vw));
}
.user-card-inner {
display: flex;
}
&.popover {
overflow: hidden;
}
}
/* stylelint-enable block-no-empty */
</style>

File Metadata

Mime Type
text/x-diff
Expires
Fri, Oct 30, 3:02 PM (20 h, 18 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1800230
Default Alt Text
(82 KB)

Event Timeline