refactor: migrate to nuxt compatibilityVersion: 4 (#3298)

This commit is contained in:
Daniel Roe 2025-05-20 15:05:01 +01:00 committed by GitHub
parent 46e4433e1c
commit a3fbc056a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
342 changed files with 1200 additions and 2932 deletions

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { link = true } = defineProps<{
account: mastodon.v1.Account
link?: boolean
}>()
const userSettings = useUserSettings()
</script>
<template>
<NuxtLink
:to="link ? getAccountRoute(account) : undefined"
flex="~ col" min-w-0 md:flex="~ row gap-2" md:items-center
text-link-rounded
>
<AccountDisplayName :account="account" :hide-emojis="getPreferences(userSettings, 'hideUsernameEmojis')" font-bold line-clamp-1 ws-pre-wrap break-all />
<AccountHandle :account="account" class="zen-none" />
</NuxtLink>
</template>

View file

@ -0,0 +1,87 @@
<script setup lang="ts">
defineOptions({
inheritAttrs: false,
})
const { as = 'button', command, disabled, content, icon } = defineProps<{
text?: string | number
content: string
color: string
icon: string
activeIcon?: string
inactiveIcon?: string
hover: string
elkGroupHover: string
active?: boolean
disabled?: boolean
as?: string
command?: boolean
}>()
defineSlots<{
text: (props: object) => void
}>()
const el = ref<HTMLDivElement>()
useCommand({
scope: 'Actions',
order: -2,
visible: () => command && !disabled,
name: () => content,
icon: () => icon,
onActivate() {
if (!checkLogin())
return
const clickEvent = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true,
})
el.value?.dispatchEvent(clickEvent)
},
})
</script>
<template>
<component
:is="as"
v-bind="$attrs" ref="el"
w-fit flex gap-1 items-center transition-all select-none
rounded group
:hover=" !disabled ? hover : undefined"
focus:outline-none
:focus-visible="hover"
:class="active ? color : (disabled ? 'op25 cursor-not-allowed' : 'text-secondary')"
:aria-label="content"
:disabled="disabled"
:aria-disabled="disabled"
>
<CommonTooltip placement="bottom" :content="content">
<div
rounded-full p2
v-bind="disabled ? {} : {
'elk-group-hover': elkGroupHover,
'group-focus-visible': elkGroupHover,
'group-focus-visible:ring': '2 current',
}"
>
<div :class="active && activeIcon ? activeIcon : (disabled && inactiveIcon ? inactiveIcon : icon)" />
</div>
</CommonTooltip>
<CommonAnimateNumber v-if="text !== undefined || $slots.text" :increased="active" text-sm>
<span text-secondary-light>
<slot name="text">{{ text }}</slot>
</span>
<template #next>
<span :class="[color]">
<slot name="text">{{ text }}</slot>
</span>
</template>
</CommonAnimateNumber>
</component>
</template>

View file

@ -0,0 +1,114 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { details, command, ...props } = defineProps<{
status: mastodon.v1.Status
details?: boolean
command?: boolean
}>()
const focusEditor = inject<typeof noop>('focus-editor', noop)
const userSettings = useUserSettings()
const useStarFavoriteIcon = usePreferences('useStarFavoriteIcon')
const {
status,
isLoading,
canReblog,
toggleBookmark,
toggleFavourite,
toggleReblog,
} = useStatusActions({ status: props.status })
function reply() {
if (!checkLogin())
return
if (details)
focusEditor()
else
navigateToStatus({ status: status.value, focusReply: true })
}
</script>
<template>
<div flex justify-between items-center class="status-actions">
<div flex-1>
<StatusActionButton
:content="$t('action.reply')"
:text="!getPreferences(userSettings, 'hideReplyCount') && status.repliesCount || ''"
color="text-blue" hover="text-blue" elk-group-hover="bg-blue/10"
icon="i-ri:chat-1-line"
:command="command"
@click="reply"
>
<template v-if="status.repliesCount && !getPreferences(userSettings, 'hideReplyCount')" #text>
<CommonLocalizedNumber
keypath="action.reply_count"
:count="status.repliesCount"
/>
</template>
</StatusActionButton>
</div>
<div flex-1>
<StatusActionButton
:content="$t(status.reblogged ? 'action.boosted' : 'action.boost')"
:text="!getPreferences(userSettings, 'hideBoostCount') && status.reblogsCount ? status.reblogsCount : ''"
color="text-green" hover="text-green" elk-group-hover="bg-green/10"
icon="i-ri:repeat-line"
active-icon="i-ri:repeat-fill"
inactive-icon="i-tabler:repeat-off"
:active="!!status.reblogged"
:disabled="isLoading.reblogged || !canReblog"
:command="command"
@click="toggleReblog()"
>
<template v-if="status.reblogsCount && !getPreferences(userSettings, 'hideBoostCount')" #text>
<CommonLocalizedNumber
keypath="action.boost_count"
:count="status.reblogsCount"
/>
</template>
</StatusActionButton>
</div>
<div flex-1>
<StatusActionButton
:content="$t(status.favourited ? 'action.favourited' : 'action.favourite')"
:text="!getPreferences(userSettings, 'hideFavoriteCount') && status.favouritesCount ? status.favouritesCount : ''"
:color="useStarFavoriteIcon ? 'text-yellow' : 'text-rose'"
:hover="useStarFavoriteIcon ? 'text-yellow' : 'text-rose'"
:elk-group-hover="useStarFavoriteIcon ? 'bg-yellow/10' : 'bg-rose/10'"
:icon="useStarFavoriteIcon ? 'i-ri:star-line' : 'i-ri:heart-3-line'"
:active-icon="useStarFavoriteIcon ? 'i-ri:star-fill' : 'i-ri:heart-3-fill'"
:active="!!status.favourited"
:disabled="isLoading.favourited"
:command="command"
@click="toggleFavourite()"
>
<template v-if="status.favouritesCount && !getPreferences(userSettings, 'hideFavoriteCount')" #text>
<CommonLocalizedNumber
keypath="action.favourite_count"
:count="status.favouritesCount"
/>
</template>
</StatusActionButton>
</div>
<div flex-none>
<StatusActionButton
:content="$t(status.bookmarked ? 'action.bookmarked' : 'action.bookmark')"
:color="useStarFavoriteIcon ? 'text-rose' : 'text-yellow'"
:hover="useStarFavoriteIcon ? 'text-rose' : 'text-yellow'"
:elk-group-hover="useStarFavoriteIcon ? 'bg-rose/10' : 'bg-yellow/10' "
icon="i-ri:bookmark-line"
active-icon="i-ri:bookmark-fill"
:active="!!status.bookmarked"
:disabled="isLoading.bookmarked"
:command="command"
@click="toggleBookmark()"
/>
</div>
</div>
</template>

View file

@ -0,0 +1,356 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
import { toggleBlockAccount, toggleMuteAccount, useRelationship } from '~/composables/masto/relationship'
const { details, ...props } = defineProps<{
status: mastodon.v1.Status
details?: boolean
command?: boolean
}>()
const emit = defineEmits<{
(event: 'afterEdit'): void
}>()
const focusEditor = inject<typeof noop>('focus-editor', noop)
const {
status,
isLoading,
toggleBookmark,
toggleFavourite,
togglePin,
toggleReblog,
toggleMute,
} = useStatusActions({ status: props.status })
const clipboard = useClipboard()
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const userSettings = useUserSettings()
const useStarFavoriteIcon = usePreferences('useStarFavoriteIcon')
const isAuthor = computed(() => status.value.account.id === currentUser.value?.account.id)
const { client } = useMasto()
function getPermalinkUrl(status: mastodon.v1.Status) {
const url = getStatusPermalinkRoute(status)
if (url)
return `${location.origin}/${url}`
return null
}
async function copyLink(status: mastodon.v1.Status) {
const url = getPermalinkUrl(status)
if (url)
await clipboard.copy(url)
}
async function copyOriginalLink(status: mastodon.v1.Status) {
const url = status.url
if (url)
await clipboard.copy(url)
}
const { share, isSupported: isShareSupported } = useShare()
async function shareLink(status: mastodon.v1.Status) {
const url = getPermalinkUrl(status)
if (url)
await share({ url })
}
async function deleteStatus() {
const confirmDelete = await openConfirmDialog({
title: t('confirm.delete_posts.title'),
description: t('confirm.delete_posts.description'),
confirm: t('confirm.delete_posts.confirm'),
cancel: t('confirm.delete_posts.cancel'),
})
if (confirmDelete.choice !== 'confirm')
return
removeCachedStatus(status.value.id)
await client.value.v1.statuses.$select(status.value.id).remove()
if (route.name === 'status')
router.back()
// TODO when timeline, remove this item
}
async function deleteAndRedraft() {
const confirmDelete = await openConfirmDialog({
title: t('confirm.delete_posts.title'),
description: t('confirm.delete_posts.description'),
confirm: t('confirm.delete_posts.confirm'),
cancel: t('confirm.delete_posts.cancel'),
})
if (confirmDelete.choice !== 'confirm')
return
if (import.meta.dev) {
// eslint-disable-next-line no-alert
const result = confirm('[DEV] Are you sure you want to delete and re-draft this post?')
if (!result)
return
}
removeCachedStatus(status.value.id)
await client.value.v1.statuses.$select(status.value.id).remove()
await openPublishDialog('dialog', await getDraftFromStatus(status.value), true)
// Go to the new status, if the page is the old status
if (lastPublishDialogStatus.value && route.name === 'status')
router.push(getStatusRoute(lastPublishDialogStatus.value))
}
function reply() {
if (!checkLogin())
return
if (details) {
focusEditor()
}
else {
const { key, draft } = getReplyDraft(status.value)
openPublishDialog(key, draft())
}
}
async function editStatus() {
await openPublishDialog(`edit-${status.value.id}`, {
...await getDraftFromStatus(status.value),
editingStatus: status.value,
}, true)
emit('afterEdit')
}
function showFavoritedAndBoostedBy() {
openFavoridedBoostedByDialog(status.value.id)
}
</script>
<template>
<CommonDropdown flex-none ms3 placement="bottom" :eager-mount="command">
<StatusActionButton
:content="$t('action.more')"
color="text-primary"
hover="text-primary"
elk-group-hover="bg-primary-light"
icon="i-ri:more-line"
my--2
/>
<template #popper>
<div flex="~ col">
<template v-if="getPreferences(userSettings, 'zenMode') && !details">
<CommonDropdownItem
is="button"
:text="$t('action.reply')"
icon="i-ri:chat-1-line"
:command="command"
@click="reply()"
/>
<CommonDropdownItem
is="button"
:text="status.reblogged ? $t('action.boosted') : $t('action.boost')"
icon="i-ri:repeat-fill"
:class="status.reblogged ? 'text-green' : ''"
:command="command"
:disabled="isLoading.reblogged"
@click="toggleReblog()"
/>
<CommonDropdownItem
is="button"
:text="status.favourited ? $t('action.favourited') : $t('action.favourite')"
:icon="useStarFavoriteIcon
? status.favourited ? 'i-ri:star-fill' : 'i-ri:star-line'
: status.favourited ? 'i-ri:heart-3-fill' : 'i-ri:heart-3-line'"
:class="status.favourited
? useStarFavoriteIcon ? 'text-yellow' : 'text-rose'
: ''
"
:command="command"
:disabled="isLoading.favourited"
@click="toggleFavourite()"
/>
<CommonDropdownItem
is="button"
:text="status.bookmarked ? $t('action.bookmarked') : $t('action.bookmark')"
:icon="status.bookmarked ? 'i-ri:bookmark-fill' : 'i-ri:bookmark-line'"
:class="status.bookmarked
? useStarFavoriteIcon ? 'text-rose' : 'text-yellow'
: ''
"
:command="command"
:disabled="isLoading.bookmarked"
@click="toggleBookmark()"
/>
</template>
<CommonDropdownItem
is="button"
:text="$t('menu.show_favourited_and_boosted_by')"
icon="i-ri:hearts-line"
:command="command"
@click="showFavoritedAndBoostedBy()"
/>
<CommonDropdownItem
is="button"
:text="$t('menu.copy_link_to_post')"
icon="i-ri:link"
:command="command"
@click="copyLink(status)"
/>
<CommonDropdownItem
is="button"
:text="$t('menu.copy_original_link_to_post')"
icon="i-ri:links-fill"
:command="command"
@click="copyOriginalLink(status)"
/>
<CommonDropdownItem
is="button"
v-if="isShareSupported"
:text="$t('menu.share_post')"
icon="i-ri:share-line"
:command="command"
@click="shareLink(status)"
/>
<CommonDropdownItem
is="button"
v-if="currentUser && (status.account.id === currentUser.account.id || status.mentions.some(m => m.id === currentUser!.account.id))"
:text="status.muted ? $t('menu.unmute_conversation') : $t('menu.mute_conversation')"
:icon="status.muted ? 'i-ri:eye-line' : 'i-ri:eye-off-line'"
:command="command"
:disabled="isLoading.muted"
@click="toggleMute()"
/>
<NuxtLink v-if="status.url" :to="status.url" external target="_blank">
<CommonDropdownItem
:text="$t('menu.open_in_original_site')"
icon="i-ri:arrow-right-up-line"
:command="command"
/>
</NuxtLink>
<template v-if="isHydrated && currentUser">
<template v-if="isAuthor">
<CommonDropdownItem
is="button"
:text="status.pinned ? $t('menu.unpin_on_profile') : $t('menu.pin_on_profile')"
icon="i-ri:pushpin-line"
:command="command"
@click="togglePin"
/>
<CommonDropdownItem
is="button"
:text="$t('menu.edit')"
icon="i-ri:edit-line"
:command="command"
@click="editStatus"
/>
<CommonDropdownItem
is="button"
:text="$t('menu.delete')"
icon="i-ri:delete-bin-line"
text-red-600
:command="command"
@click="deleteStatus"
/>
<CommonDropdownItem
is="button"
:text="$t('menu.delete_and_redraft')"
icon="i-ri:eraser-line"
text-red-600
:command="command"
@click="deleteAndRedraft"
/>
</template>
<template v-else>
<CommonDropdownItem
is="button"
:text="$t('menu.mention_account', [`@${status.account.acct}`])"
icon="i-ri:at-line"
:command="command"
@click="mentionUser(status.account)"
/>
<CommonDropdownItem
is="button"
v-if="!useRelationship(status.account).value?.muting"
:text="$t('menu.mute_account', [`@${status.account.acct}`])"
icon="i-ri:volume-mute-line"
:command="command"
@click="toggleMuteAccount(useRelationship(status.account).value!, status.account)"
/>
<CommonDropdownItem
is="button"
v-else
:text="$t('menu.unmute_account', [`@${status.account.acct}`])"
icon="i-ri:volume-up-fill"
:command="command"
@click="toggleMuteAccount(useRelationship(status.account).value!, status.account)"
/>
<CommonDropdownItem
is="button"
v-if="!useRelationship(status.account).value?.blocking"
:text="$t('menu.block_account', [`@${status.account.acct}`])"
icon="i-ri:forbid-2-line"
:command="command"
@click="toggleBlockAccount(useRelationship(status.account).value!, status.account)"
/>
<CommonDropdownItem
is="button"
v-else
:text="$t('menu.unblock_account', [`@${status.account.acct}`])"
icon="i-ri:checkbox-circle-line"
:command="command"
@click="toggleBlockAccount(useRelationship(status.account).value!, status.account)"
/>
<template v-if="getServerName(status.account) && getServerName(status.account) !== currentServer">
<CommonDropdownItem
is="button"
v-if="!useRelationship(status.account).value?.domainBlocking"
:text="$t('menu.block_domain', [getServerName(status.account)])"
icon="i-ri:shut-down-line"
:command="command"
@click="toggleBlockDomain(useRelationship(status.account).value!, status.account)"
/>
<CommonDropdownItem
is="button"
v-else
:text="$t('menu.unblock_domain', [getServerName(status.account)])"
icon="i-ri:restart-line"
:command="command"
@click="toggleBlockDomain(useRelationship(status.account).value!, status.account)"
/>
</template>
<CommonDropdownItem
is="button"
:text="$t('menu.report_account', [`@${status.account.acct}`])"
icon="i-ri:flag-2-line"
:command="command"
@click="openReportDialog(status.account, status)"
/>
</template>
</template>
</div>
</template>
</CommonDropdown>
</template>

View file

@ -0,0 +1,306 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
import { clamp } from '@vueuse/core'
import { decode } from 'blurhash'
const {
attachment,
fullSize = false,
isPreview = false,
} = defineProps<{
attachment: mastodon.v1.MediaAttachment
attachments?: mastodon.v1.MediaAttachment[]
fullSize?: boolean
isPreview?: boolean
}>()
const src = computed(() => attachment.previewUrl || attachment.url || attachment.remoteUrl!)
const srcset = computed(() => [
[attachment.url, attachment.meta?.original?.width],
[attachment.remoteUrl, attachment.meta?.original?.width],
[attachment.previewUrl, attachment.meta?.small?.width],
].filter(([url]) => url).map(([url, size]) => `${url} ${size}w`).join(', '))
const rawAspectRatio = computed(() => {
if (attachment.meta?.original?.aspect)
return attachment.meta.original.aspect
if (attachment.meta?.small?.aspect)
return attachment.meta.small.aspect
return undefined
})
const aspectRatio = computed(() => {
if (fullSize)
return rawAspectRatio.value
if (rawAspectRatio.value)
return clamp(rawAspectRatio.value, 0.8, 6)
return undefined
})
const objectPosition = computed(() => {
const focusX = attachment.meta?.focus?.x || 0
const focusY = attachment.meta?.focus?.y || 0
const x = ((focusX / 2) + 0.5) * 100
const y = ((focusY / -2) + 0.5) * 100
return `${x}% ${y}%`
})
const typeExtsMap = {
video: ['mp4', 'webm', 'mov', 'avi', 'mkv', 'flv', 'wmv', 'mpg', 'mpeg'],
audio: ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma'],
image: ['jpg', 'jpeg', 'png', 'svg', 'webp', 'bmp'],
gifv: ['gifv', 'gif'],
}
const type = computed(() => {
if (attachment.type && attachment.type !== 'unknown')
return attachment.type
// some server returns unknown type, we need to guess it based on file extension
for (const [type, exts] of Object.entries(typeExtsMap)) {
if (exts.some(ext => src.value?.toLowerCase().endsWith(`.${ext}`)))
return type
}
return 'unknown'
})
const video = ref<HTMLVideoElement | undefined>()
const prefersReducedMotion = usePreferredReducedMotion()
const isAudio = computed(() => attachment.type === 'audio')
const isVideo = computed(() => attachment.type === 'video')
const isGif = computed(() => attachment.type === 'gifv')
const enableAutoplay = usePreferences('enableAutoplay')
const unmuteVideos = usePreferences('unmuteVideos')
useIntersectionObserver(video, (entries) => {
const ready = video.value?.dataset.ready === 'true'
if (prefersReducedMotion.value === 'reduce' || !enableAutoplay.value) {
if (ready && !video.value?.paused)
video.value?.pause()
return
}
entries.forEach((entry) => {
if (entry.intersectionRatio <= 0.75) {
if (ready && !video.value?.paused)
video.value?.pause()
}
else {
video.value?.play().then(() => {
video.value!.dataset.ready = 'true'
}).catch(noop)
}
})
}, { threshold: 0.75 })
const userSettings = useUserSettings()
const shouldLoadAttachment = ref(isPreview || !getPreferences(userSettings.value, 'enableDataSaving'))
function loadAttachment() {
shouldLoadAttachment.value = true
}
const blurHashSrc = computed(() => {
if (!attachment.blurhash)
return ''
const pixels = decode(attachment.blurhash, 32, 32)
return getDataUrlFromArr(pixels, 32, 32)
})
const videoThumbnail = ref(shouldLoadAttachment.value
? attachment.previewUrl
: blurHashSrc.value)
watch(shouldLoadAttachment, () => {
videoThumbnail.value = shouldLoadAttachment.value
? attachment.previewUrl
: blurHashSrc.value
})
</script>
<template>
<div relative ma flex :gap="isAudio ? '2' : ''">
<template v-if="type === 'video'">
<button
type="button"
relative
@click="!shouldLoadAttachment ? loadAttachment() : null"
>
<video
ref="video"
preload="none"
:poster="videoThumbnail"
:muted="!unmuteVideos"
loop
playsinline
:controls="shouldLoadAttachment"
rounded-lg
object-cover
fullscreen:object-contain
:width="attachment.meta?.original?.width"
:height="attachment.meta?.original?.height"
:style="{
aspectRatio,
objectPosition,
}"
:class="!shouldLoadAttachment ? 'brightness-60 hover:brightness-70 transition-filter' : ''"
>
<source :src="attachment.url || attachment.previewUrl" type="video/mp4">
</video>
<span
v-if="!shouldLoadAttachment"
class="status-attachment-load"
absolute
text-sm
text-white
flex flex-col justify-center items-center
gap-3 w-6 h-6
pointer-events-none
i-ri:video-download-line
/>
</button>
</template>
<template v-else-if="type === 'gifv'">
<button
type="button"
relative
@click="!shouldLoadAttachment ? loadAttachment() : openMediaPreview(attachments ? attachments : [attachment], attachments?.indexOf(attachment) || 0)"
>
<video
ref="video"
preload="none"
:poster="videoThumbnail"
:muted="!unmuteVideos"
loop
playsinline
rounded-lg
object-cover
:width="attachment.meta?.original?.width"
:height="attachment.meta?.original?.height"
:style="{
aspectRatio,
objectPosition,
}"
>
<source :src="attachment.url || attachment.previewUrl" type="video/mp4">
</video>
<span
v-if="!shouldLoadAttachment"
class="status-attachment-load"
absolute
text-sm
text-white
flex flex-col justify-center items-center
gap-3 w-6 h-6
pointer-events-none
i-ri:video-download-line
/>
</button>
</template>
<template v-else-if="type === 'audio'">
<audio controls h-15>
<source :src="attachment.url || attachment.previewUrl" type="audio/mp3">
</audio>
</template>
<template v-else>
<button
type="button"
focus:outline-none
focus:ring="2 primary inset"
rounded-lg
h-full
w-full
:aria-label="$t('action.open_image_preview_dialog')"
relative
@click="!shouldLoadAttachment ? loadAttachment() : openMediaPreview(attachments ? attachments : [attachment], attachments?.indexOf(attachment) || 0)"
>
<CommonBlurhash
:blurhash="attachment.blurhash || ''"
class="status-attachment-image"
:src="src"
:srcset="srcset"
:width="attachment.meta?.original?.width"
:height="attachment.meta?.original?.height"
:alt="attachment.description ?? 'Image'"
:style="{
aspectRatio,
objectPosition,
}"
:should-load-image="shouldLoadAttachment"
rounded-lg
h-full
w-full
object-cover
:draggable="shouldLoadAttachment"
:class="!shouldLoadAttachment ? 'brightness-60 hover:brightness-70 transition-filter' : ''"
/>
<span
v-if="!shouldLoadAttachment"
class="status-attachment-load"
absolute
text-sm
text-white
flex flex-col justify-center items-center
gap-3 w-6 h-6
pointer-events-none
i-ri:file-download-line
/>
</button>
</template>
<div
:class="isAudio ? [] : [
'absolute left-2',
isVideo ? 'top-2' : 'bottom-2',
]"
flex gap-col-2
>
<VDropdown v-if="attachment.description && !getPreferences(userSettings, 'hideAltIndicatorOnPosts')" :distance="6" placement="bottom-start">
<button
font-bold text-sm
:class="isAudio
? 'rounded-full h-15 w-15 btn-outline border-base text-secondary hover:bg-active hover:text-active'
: 'rounded-1 bg-black/65 text-white hover:bg-black px1.2 py0.2'"
>
<div hidden>
{{ $t('status.img_alt.read', [attachment.type]) }}
</div>
{{ $t('status.img_alt.ALT') }}
</button>
<template #popper>
<div p4 flex flex-col gap-2 max-w-130>
<div flex justify-between>
<h2 font-bold text-xl text-secondary>
{{ $t('status.img_alt.desc') }}
</h2>
<button v-close-popper text-sm btn-outline py0 px2 text-secondary border-base>
{{ $t('status.img_alt.dismiss') }}
</button>
</div>
<p whitespace-pre-wrap>
{{ attachment.description }}
</p>
</div>
</template>
</VDropdown>
<div v-if="isGif && !getPreferences(userSettings, 'hideGifIndicatorOnPosts')">
<button
aria-hidden font-bold text-sm
rounded-1 bg-black:65 text-white px1.2 py0.2 pointer-events-none
>
{{ $t('status.gif') }}
</button>
</div>
</div>
</div>
</template>
<style lang="postcss">
.status-attachment-load {
left: 50%;
top: 50%;
translate: -50% -50%;
}
</style>

View file

@ -0,0 +1,55 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const {
status,
newer,
withAction = true,
} = defineProps<{
status: mastodon.v1.Status | mastodon.v1.StatusEdit
newer?: mastodon.v1.Status
withAction?: boolean
}>()
const { translation } = useTranslation(status, getLanguageCode())
const emojisObject = useEmojisFallback(() => status.emojis)
const vnode = computed(() => {
if (!status.content)
return null
return contentToVNode(status.content, {
emojis: emojisObject.value,
mentions: 'mentions' in status ? status.mentions : undefined,
markdown: true,
collapseMentionLink: !!('inReplyToId' in status && status.inReplyToId),
status: 'id' in status ? status : undefined,
inReplyToStatus: newer,
})
})
</script>
<template>
<div class="status-body" whitespace-pre-wrap break-words :class="{ 'with-action': withAction }" relative>
<span
v-if="status.content"
class="content-rich line-compact" dir="auto"
:lang="('language' in status && status.language) || undefined"
>
<component :is="vnode" v-if="vnode" />
</span>
<div v-else />
<template v-if="translation.visible">
<div my2 h-px border="b base" bg-base />
<ContentRich v-if="translation.success" class="line-compact" :content="translation.text" :emojis="status.emojis" />
<div v-else text-red-4>
Error: {{ translation.error }}
</div>
</template>
</div>
</template>
<style>
.status-body.with-action p {
cursor: pointer;
}
</style>

View file

@ -0,0 +1,217 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { actions = true, older, newer, hasOlder, hasNewer, main, ...props } = defineProps<{
status: mastodon.v1.Status
followedTag?: string | null
actions?: boolean
context?: mastodon.v2.FilterContext
hover?: boolean
inNotification?: boolean
isPreview?: boolean
// If we know the prev and next status in the timeline, we can simplify the card
older?: mastodon.v1.Status
newer?: mastodon.v1.Status
// Manual overrides
hasOlder?: boolean
hasNewer?: boolean
// When looking into a detailed view of a post, we can simplify the replying badges
// to the main expanded post
main?: mastodon.v1.Status
}>()
const userSettings = useUserSettings()
const status = computed(() => {
if (props.status.reblog && (!props.status.content || props.status.content === props.status.reblog.content))
return props.status.reblog
return props.status
})
// Use original status, avoid connecting a reblog
const directReply = computed(() => hasNewer || (!!status.value.inReplyToId && (status.value.inReplyToId === newer?.id || status.value.inReplyToId === newer?.reblog?.id)))
// Use reblogged status, connect it to further replies
const connectReply = computed(() => hasOlder || status.value.id === older?.inReplyToId || status.value.id === older?.reblog?.inReplyToId)
// Open a detailed status, the replies directly to it
const replyToMain = computed(() => main && main.id === status.value.inReplyToId)
const rebloggedBy = computed(() => props.status.reblog ? props.status.account : null)
const statusRoute = computed(() => getStatusRoute(status.value))
const router = useRouter()
function go(evt: MouseEvent | KeyboardEvent) {
if (evt.metaKey || evt.ctrlKey) {
window.open(statusRoute.value.href)
}
else {
cacheStatus(status.value)
router.push(statusRoute.value)
}
}
const createdAt = useFormattedDateTime(status.value.createdAt)
const timeAgoOptions = useTimeAgoOptions(true)
const timeago = useTimeAgo(() => status.value.createdAt, timeAgoOptions)
const isSelfReply = computed(() => status.value.inReplyToAccountId === status.value.account.id)
const collapseRebloggedBy = computed(() => rebloggedBy.value?.id === status.value.account.id)
const isDM = computed(() => status.value.visibility === 'direct')
const isPinned = computed(() => status.value.pinned)
const showUpperBorder = computed(() => newer && !directReply.value)
const showReplyTo = computed(() => !replyToMain.value && !directReply.value)
const forceShow = ref(false)
</script>
<template>
<StatusLink :status="status" :hover="hover">
<!-- Upper border -->
<div :h="showUpperBorder ? '1px' : '0'" w-auto bg-border mb-1 z--1 />
<slot name="meta">
<!-- followed hashtag badge -->
<div flex="~ col" justify-between>
<div
v-if="!!followedTag && followedTag !== ''"
flex="~ gap2" items-center h-auto text-sm text-orange
m="is-5" p="t-1 is-5"
relative text-secondary ws-nowrap
>
<div i-ri:hashtag />
<!-- show first hit followed tag -->
<span>{{ followedTag }}</span>
</div>
</div>
<!-- Pinned status -->
<div flex="~ col" justify-between>
<div
v-if="isPinned"
flex="~ gap2" items-center h-auto text-sm text-orange
m="is-5" p="t-1 is-5"
relative text-secondary ws-nowrap
>
<div i-ri:pushpin-line />
<span>{{ $t('status.pinned') }}</span>
</div>
</div>
<!-- Line connecting to previous status -->
<template v-if="status.inReplyToAccountId">
<StatusReplyingTo
v-if="showReplyTo"
m="is-5" p="t-1 is-5"
:status="status"
:is-self-reply="isSelfReply"
:class="inNotification ? 'text-secondary-light' : ''"
/>
<div flex="~ col gap-1" items-center pos="absolute top-0 inset-is-0" w="77px" z--1>
<template v-if="showReplyTo">
<div w="1px" h="0.5" border="x base" mt-3 />
<div w="1px" h="0.5" border="x base" />
<div w="1px" h="0.5" border="x base" />
</template>
<div w="1px" h-10 border="x base" />
</div>
</template>
<!-- Reblog status -->
<div flex="~ col" justify-between>
<div
v-if="rebloggedBy && !collapseRebloggedBy"
flex="~" items-center
p="t-1 b-0.5 x-1px"
relative text-secondary ws-nowrap
>
<div i-ri:repeat-fill me-46px text-green w-16px h-16px class="status-boosted" />
<div absolute top-1 ms-24px w-32px h-32px rounded-full>
<AccountHoverWrapper :account="rebloggedBy">
<NuxtLink :to="getAccountRoute(rebloggedBy)">
<AccountAvatar :account="rebloggedBy" />
</NuxtLink>
</AccountHoverWrapper>
</div>
<AccountInlineInfo font-bold :account="rebloggedBy" :avatar="false" text-sm />
</div>
</div>
</slot>
<div flex gap-3 :class="{ 'text-secondary': inNotification }">
<template v-if="status.account.suspended && !forceShow">
<div flex="~col 1" min-w-0>
<p italic>
{{ $t('status.account.suspended_message') }}
</p>
<div>
<button p-0 flex="~ center" gap-2 text-sm btn-text @click="forceShow = true">
<div i-ri:eye-line />
<span>{{ $t('status.account.suspended_show') }}</span>
</button>
</div>
</div>
</template>
<template v-else>
<!-- Avatar -->
<div relative>
<div v-if="collapseRebloggedBy" absolute flex items-center justify-center top--6px px-2px py-3px rounded-full bg-base>
<div i-ri:repeat-fill text-green w-16px h-16px />
</div>
<AccountHoverWrapper :account="status.account">
<NuxtLink :to="getAccountRoute(status.account)" rounded-full>
<AccountBigAvatar :account="status.account" />
</NuxtLink>
</AccountHoverWrapper>
<div v-if="connectReply" w-full h-full flex mt--3px justify-center>
<div w-1px border="x base" mb-9 />
</div>
</div>
<!-- Main -->
<div flex="~ col 1" min-w-0>
<!-- Account Info -->
<div flex items-center space-x-1>
<AccountHoverWrapper :account="status.account">
<StatusAccountDetails :account="status.account" />
</AccountHoverWrapper>
<div flex-auto />
<div v-show="!getPreferences(userSettings, 'zenMode')" text-sm text-secondary flex="~ row nowrap" hover:underline whitespace-nowrap>
<AccountLockIndicator v-if="status.account.locked" me-2 />
<AccountBotIndicator v-if="status.account.bot" me-2 />
<div flex="~ gap1" items-center>
<StatusVisibilityIndicator v-if="status.visibility !== 'public'" :status="status" />
<div flex>
<CommonTooltip :content="createdAt">
<NuxtLink :title="status.createdAt" :href="statusRoute.href" @click.prevent="go($event)">
<time text-sm ws-nowrap hover:underline :datetime="status.createdAt">
{{ timeago }}
</time>
</NuxtLink>
</CommonTooltip>
<StatusEditIndicator :status="status" inline />
</div>
</div>
</div>
<StatusActionsMore v-if="actions !== false" :status="status" me--2 />
</div>
<!-- Content -->
<StatusContent
:status="status"
:newer="newer"
:context="context"
:is-preview="isPreview"
:in-notification="inNotification"
mb2 :class="{ 'mt-2 mb1': isDM }"
/>
<StatusActions v-if="actions !== false" v-show="!getPreferences(userSettings, 'zenMode')" :status="status" />
</div>
</template>
</div>
</StatusLink>
</template>

View file

@ -0,0 +1,15 @@
<template>
<div flex flex-col gap-2 px-4 py-3>
<div flex gap-4>
<div>
<div w-12 h-12 rounded-full class="skeleton-loading-bg" />
</div>
<div flex="~ col 1 gap-2" pb2 min-w-0>
<div flex class="skeleton-loading-bg" h-5 w-20 rounded />
<div flex class="skeleton-loading-bg" h-4 w-full rounded />
<div flex class="skeleton-loading-bg" h-4 w="4/5" rounded />
<div flex class="skeleton-loading-bg" h-4 w="2/5" rounded />
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,75 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { status, context } = defineProps<{
status: mastodon.v1.Status
newer?: mastodon.v1.Status
context?: mastodon.v2.FilterContext | 'details'
isPreview?: boolean
inNotification?: boolean
}>()
const isDM = computed(() => status.visibility === 'direct')
const isDetails = computed(() => context === 'details')
// Content Filter logic
const filterResult = computed(() => status.filtered?.length ? status.filtered[0] : null)
const filter = computed(() => filterResult.value?.filter)
const filterPhrase = computed(() => filter.value?.title)
const isFiltered = computed(() => status.account.id !== currentUser.value?.account.id && filterPhrase && context && context !== 'details' && !!filter.value?.context.includes(context))
// check spoiler text or media attachment
// needed to handle accounts that mark all their posts as sensitive
const spoilerTextPresent = computed(() => !!status.spoilerText && status.spoilerText.trim().length > 0)
const hasSpoilerOrSensitiveMedia = computed(() => spoilerTextPresent.value || (status.sensitive && !!status.mediaAttachments.length))
const isSensitiveNonSpoiler = computed(() => status.sensitive && !status.spoilerText && !!status.mediaAttachments.length)
const hideAllMedia = computed(
() => {
return currentUser.value ? (getHideMediaByDefault(currentUser.value.account) && (!!status.mediaAttachments.length || !!status.card?.html)) : false
},
)
const embeddedMediaPreference = usePreferences('experimentalEmbeddedMedia')
const allowEmbeddedMedia = computed(() => status.card?.html && embeddedMediaPreference.value)
</script>
<template>
<div
space-y-3
:class="{
'py2 px3.5 bg-dm rounded-4 me--1': isDM,
'ms--3.5 mt--1 ms--1': isDM && context !== 'details',
}"
>
<StatusBody v-if="(!isFiltered && isSensitiveNonSpoiler) || hideAllMedia" :status="status" :newer="newer" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
<StatusSpoiler :enabled="hasSpoilerOrSensitiveMedia || isFiltered" :filter="isFiltered" :sensitive-non-spoiler="isSensitiveNonSpoiler || hideAllMedia" :is-d-m="isDM">
<template v-if="spoilerTextPresent" #spoiler>
<p>
<ContentRich :content="status.spoilerText" :emojis="status.emojis" :markdown="false" />
</p>
</template>
<template v-else-if="filterPhrase" #spoiler>
<p>{{ `${$t('status.filter_hidden_phrase')}: ${filterPhrase}` }}</p>
</template>
<StatusBody v-if="!(isSensitiveNonSpoiler || hideAllMedia)" :status="status" :newer="newer" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
<StatusTranslation :status="status" />
<StatusPoll v-if="status.poll" :status="status" />
<StatusMedia
v-if="status.mediaAttachments?.length"
:status="status"
:is-preview="isPreview"
/>
<StatusPreviewCard
v-if="status.card && !allowEmbeddedMedia"
:card="status.card"
:small-picture-only="status.mediaAttachments?.length > 0"
/>
<StatusEmbeddedMedia v-if="allowEmbeddedMedia" :status="status" />
<StatusCard
v-if="status.reblog"
:status="status.reblog" border="~ rounded"
:actions="false"
/>
</StatusSpoiler>
</div>
</template>

View file

@ -0,0 +1,69 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { actions = true, ...props } = defineProps<{
status: mastodon.v1.Status
newer?: mastodon.v1.Status
command?: boolean
actions?: boolean
}>()
defineEmits<{
(event: 'refetchStatus'): void
}>()
const status = computed(() => {
if (props.status.reblog && props.status.reblog)
return props.status.reblog
return props.status
})
const createdAt = useFormattedDateTime(status.value.createdAt)
const { t } = useI18n()
useHydratedHead({
title: () => `${getDisplayName(status.value.account)} ${t('common.in')} ${t('app_name')}: "${removeHTMLTags(status.value.content) || ''}"`,
})
</script>
<template>
<div :id="`status-${status.id}`" flex flex-col gap-2 pt2 pb1 ps-3 pe-4 relative :lang="status.language ?? undefined" aria-roledescription="status-details">
<StatusActionsMore :status="status" :details="true" absolute inset-ie-2 top-2 @after-edit="$emit('refetchStatus')" />
<NuxtLink :to="getAccountRoute(status.account)" rounded-full hover:bg-active transition-100 pe5 me-a>
<AccountHoverWrapper :account="status.account">
<AccountInfo :account="status.account" />
</AccountHoverWrapper>
</NuxtLink>
<StatusContent :status="status" :newer="newer" context="details" />
<div flex="~ gap-1" items-center text-secondary text-sm>
<div flex>
<div>{{ createdAt }}</div>
<StatusEditIndicator
:status="status"
:inline="false"
>
<span ms1 font-bold cursor-pointer>{{ $t('state.edited') }}</span>
</StatusEditIndicator>
</div>
<div aria-hidden="true">
&middot;
</div>
<StatusVisibilityIndicator :status="status" />
<div v-if="status.application?.name" aria-hidden="true">
&middot;
</div>
<div v-if="status.application?.website && status.application.name">
<NuxtLink :to="status.application.website">
{{ status.application.name }}
</NuxtLink>
</div>
<div v-else-if="status.application?.name">
{{ status.application?.name }}
</div>
</div>
<div border="t base" py-2>
<StatusActions v-if="actions" :status="status" details :command="command" />
</div>
</div>
</template>

View file

@ -0,0 +1,105 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { status } = defineProps<{
status: mastodon.v1.Status
}>()
const vnode = computed(() => {
if (!status.card?.html)
return null
const node = sanitizeEmbeddedIframe(status.card?.html)?.children[0]
return node ? nodeToVNode(node) : null
})
const overlayToggle = ref(true)
const card = ref(status.card)
</script>
<template>
<div v-if="card">
<div
v-if="overlayToggle"
h-80
cursor-pointer
relative
>
<div
p-3
absolute
w-full
h-full
z-10
rounded-lg
style="background: linear-gradient(black, rgba(0,0,0,0.5), transparent, transparent, rgba(0,0,0,0.20))"
>
<NuxtLink flex flex-col gap-1 hover:underline text-xs text-light font-light target="_blank" :href="card?.url">
<div flex gap-0.5>
<p flex-row line-clamp-1>
{{ card?.providerName }}<span v-if="card?.authorName"> {{ card?.authorName }}</span>
</p>
<span
flex-row
w-4 h-4
pointer-events-none
i-ri:arrow-right-up-line
/>
</div>
<p font-bold line-clamp-1 text-size-base>
{{ card?.title }}
</p>
<p line-clamp-1>
{{ $t('status.embedded_warning') }}
</p>
</NuxtLink>
<div
flex
h-50
mt-1
justify-center
flex-items-center
>
<button
absolute
bg-primary
opacity-85
rounded-full
hover:bg-primary-active
hover:opacity-95
transition-all
box-shadow-outline
@click.stop.prevent="() => overlayToggle = !overlayToggle"
>
<span
text-light
flex flex-col
gap-3
w-27 h-27
pointer-events-none
i-ri:play-circle-line
/>
</button>
</div>
</div>
<CommonBlurhash
v-if="card?.image"
:blurhash="card.blurhash"
:src="card.image"
w-full
h-full
object-cover
rounded-lg
/>
</div>
<div v-else>
<!-- this inserts the iframe -->
<component :is="vnode" v-if="vnode" rounded-lg h-80 />
</div>
</div>
</template>
<style>
iframe {
width: 100%;
height: 100%;
}
</style>

View file

@ -0,0 +1,59 @@
<script setup lang="ts">
import { favouritedBoostedByStatusId } from '~/composables/dialog'
const type = ref<'favourited-by' | 'boosted-by'>('favourited-by')
const { client } = useMasto()
function load() {
return client.value.v1.statuses.$select(favouritedBoostedByStatusId.value!)[type.value === 'favourited-by' ? 'favouritedBy' : 'rebloggedBy'].list()
}
const paginator = computed(() => load())
function showFavouritedBy() {
type.value = 'favourited-by'
}
function showRebloggedBy() {
type.value = 'boosted-by'
}
const { t } = useI18n()
const tabs = [
{
name: 'favourited-by',
display: t('status.favourited_by'),
onClick: showFavouritedBy,
},
{
name: 'boosted-by',
display: t('status.boosted_by'),
onClick: showRebloggedBy,
},
]
</script>
<template>
<div flex w-full items-center lg:text-lg of-x-auto scrollbar-hide>
<template
v-for="option in tabs"
:key="option.name"
>
<div
relative flex flex-auto cursor-pointer sm:px6 px2 rounded transition-all
tabindex="0"
hover:bg-active transition-100
@click="option.onClick"
>
<span
ws-nowrap mxa sm:px2 sm:py3 xl:pb4 xl:pt5 py2 text-center border-b-3
:class="option.name === type ? 'border-primary op100 text-base' : 'border-transparent text-secondary-light hover:text-secondary op50'"
>{{
option.display
}}</span>
</div>
</template>
</div>
<AccountPaginator :key="`paginator-${type}`" :paginator="paginator" />
</template>

View file

@ -0,0 +1,49 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { status } = defineProps<{
status: mastodon.v1.Status
hover?: boolean
}>()
const el = ref<HTMLElement>()
const router = useRouter()
const statusRoute = computed(() => getStatusRoute(status))
function onclick(evt: MouseEvent | KeyboardEvent) {
const path = evt.composedPath() as HTMLElement[]
const el = path.find(el => ['A', 'BUTTON', 'IMG', 'VIDEO'].includes(el.tagName?.toUpperCase()))
const text = window.getSelection()?.toString()
const isCustomEmoji = el?.parentElement?.classList.contains('custom-emoji')
if ((!el && !text) || isCustomEmoji)
go(evt)
}
function go(evt: MouseEvent | KeyboardEvent) {
if (evt.metaKey || evt.ctrlKey) {
window.open(statusRoute.value.href)
}
else {
cacheStatus(status)
router.push(statusRoute.value)
}
}
</script>
<template>
<div
:id="`status-${status.id}`"
ref="el"
relative flex="~ col gap1"
p="b-2 is-3 ie-4"
:class="{ 'hover:bg-active': hover }"
tabindex="0"
focus:outline-none focus-visible:ring="2 primary inset"
aria-roledescription="status-card"
:lang="status.language ?? undefined"
@click="onclick"
@keydown.enter="onclick"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,46 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { status, isPreview = false } = defineProps<{
status: mastodon.v1.Status | mastodon.v1.StatusEdit
fullSize?: boolean
isPreview?: boolean
}>()
const gridColumnNumber = computed(() => {
const num = status.mediaAttachments.length
if (num <= 1)
return 1
else if (num <= 4)
return 2
else
return 3
})
</script>
<template>
<div class="status-media-container">
<template v-for="attachment of status.mediaAttachments" :key="attachment.id">
<StatusAttachment
:attachment="attachment"
:attachments="status.mediaAttachments"
:full-size="fullSize"
w-full
h-full
:is-preview="isPreview"
/>
</template>
</div>
</template>
<style lang="postcss">
.status-media-container {
--grid-cols: v-bind(gridColumnNumber);
display: grid;
grid-template-columns: repeat(var(--grid-cols, 1), 1fr);
--at-apply: gap-2;
position: relative;
width: 100%;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,30 @@
<script setup lang="ts">
const { account, status } = defineProps<{
account: string
status: string
}>()
const originalUrl = computed(() => {
const [handle, _server] = account.split('@')
const server = _server || currentUser.value?.server
if (!server)
return null
return `https://${server}/@${handle}/${status}`
})
</script>
<template>
<CommonNotFound>
<div flex="~ col center gap2">
<div>{{ $t('error.status_not_found') }}</div>
<NuxtLink v-if="originalUrl" :to="originalUrl" external target="_blank">
<button btn-solid flex="~ center gap-2" text-sm px2 py1>
<div i-ri:arrow-right-up-line />
{{ $t('status.try_original_site') }}
</button>
</NuxtLink>
</div>
</CommonNotFound>
</template>

View file

@ -0,0 +1,117 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { status } = defineProps<{
status: mastodon.v1.Status
}>()
const poll = reactive({ ...status.poll! })
function toPercentage(num: number) {
const percentage = 100 * num
return `${percentage.toFixed(1).replace(/\.?0+$/, '')}%`
}
const timeAgoOptions = useTimeAgoOptions()
const expiredTimeAgo = useTimeAgo(poll.expiresAt!, timeAgoOptions)
const expiredTimeFormatted = useFormattedDateTime(poll.expiresAt!)
const { formatPercentage } = useHumanReadableNumber()
const loading = ref(false)
const { client } = useMasto()
async function vote(e: Event) {
const formData = new FormData(e.target as HTMLFormElement)
const choices = formData.getAll('choices').map(i => +i) as number[]
// Update the poll optimistically
for (const [index, option] of poll.options.entries()) {
if (choices.includes(index))
option.votesCount = (option.votesCount || 0) + 1
}
poll.voted = true
poll.votesCount++
if (!poll.votersCount && poll.votesCount)
poll.votesCount = poll.votesCount + 1
else
poll.votersCount = (poll.votersCount || 0) + 1
cacheStatus({ ...status, poll }, undefined, true)
await client.value.v1.polls.$select(poll.id).votes.create({ choices })
}
async function refresh() {
if (loading.value) {
return
}
loading.value = true
try {
const newPoll = await client.value.v1.polls.$select(poll.id).fetch()
Object.assign(poll, newPoll)
cacheStatus({ ...status, poll: newPoll }, undefined, true)
}
catch (e) {
console.error(e)
}
finally {
loading.value = false
}
}
const votersCount = computed(() => poll.votersCount ?? poll.votesCount ?? 0)
</script>
<template>
<div flex flex-col w-full items-stretch gap-2 py3 dir="auto" class="poll-wrapper">
<form v-if="!poll.voted && !poll.expired" flex="~ col gap3" accent-primary @click.stop="noop" @submit.prevent="vote">
<label v-for="(option, index) of poll.options" :key="index" flex="~ gap2" items-center>
<input name="choices" :value="index" :type="poll.multiple ? 'checkbox' : 'radio'" cursor-pointer>
{{ option.title }}
</label>
<button btn-solid mt-1>
{{ $t('action.vote') }}
</button>
</form>
<template v-else>
<div
v-for="(option, index) of poll.options"
:key="index" py-1 relative
:style="{ '--bar-width': toPercentage(votersCount === 0 ? 0 : (option.votesCount ?? 0) / votersCount) }"
>
<div flex justify-between pb-2 w-full>
<span inline-flex align-items>
{{ option.title }}
<span v-if="poll.voted && poll.ownVotes?.includes(index)" ms-2 mt-1 inline-block i-ri:checkbox-circle-line />
</span>
<span text-primary-active> {{ formatPercentage(votersCount > 0 ? (option.votesCount || 0) / votersCount : 0) }}</span>
</div>
<div class="bg-gray/40" rounded-l-sm rounded-r-lg h-5px w-full>
<div bg-primary-active h-full min-w="1%" class="w-[var(--bar-width)]" />
</div>
</div>
</template>
<div text-sm text-secondary flex justify-between items-center gap-3>
<div flex gap-x-1 flex-wrap>
<div inline-block>
<CommonLocalizedNumber
keypath="status.poll.count"
:count="poll.votesCount"
/>
</div>
&middot;
<div inline-block>
<CommonTooltip v-if="poll.expiresAt" :content="expiredTimeFormatted" class="inline-block" placement="right">
<time :datetime="poll.expiresAt!">{{ $t(poll.expired ? 'status.poll.finished' : 'status.poll.ends', [expiredTimeAgo]) }}</time>
</CommonTooltip>
</div>
</div>
<div v-if="!poll.expired">
<button whitespace-nowrap flex gap-1 items-center hover:text-primary @click="refresh">
<div text-xs :class="loading ? 'animate-spin' : ''" i-ri:loop-right-line />
{{ $t('status.poll.update') }}
</button>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { card } = defineProps<{
card: mastodon.v1.PreviewCard
/** For the preview image, only the small image mode is displayed */
smallPictureOnly?: boolean
/** When it is root card in the list, not appear as a child card */
root?: boolean
}>()
const providerName = card.providerName
const gitHubCards = usePreferences('experimentalGitHubCards')
</script>
<template>
<LazyStatusPreviewGitHub v-if="gitHubCards && providerName === 'GitHub'" :card="card" />
<LazyStatusPreviewStackBlitz v-else-if="gitHubCards && providerName === 'StackBlitz'" :card="card" :small-picture-only="smallPictureOnly" :root="root" />
<StatusPreviewCardNormal v-else :card="card" :small-picture-only="smallPictureOnly" :root="root" />
</template>

View file

@ -0,0 +1,36 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
defineProps<{
card: mastodon.v1.PreviewCard
/** When it is root card in the list, not appear as a child card */
root?: boolean
/** For the preview image, only the small image mode is displayed */
provider?: string
}>()
</script>
<template>
<div
max-h-2xl
flex flex-col
my-auto
:class="[
root ? 'flex-gap-1' : 'justify-center sm:justify-start',
]"
>
<p text-secondary break-all line-clamp-1>
{{ provider }}
</p>
<strong
v-if="card.title" font-normal sm:font-medium line-clamp-1
break-all
>{{ card.title }}</strong>
<p
v-if="card.description"
line-clamp-1 break-all sm:break-words text-secondary :class="[root ? 'sm:line-clamp-2' : '']"
>
{{ card.description }}
</p>
</div>
</template>

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
defineProps<{
account: mastodon.v1.Account
}>()
</script>
<template>
<div
max-h-2xl
flex gap-2
my-auto
p-4 py-2
light:bg-gray-3 dark:bg-gray-8
>
<span z-0>More from</span>
<AccountInlineInfo :account="account" hover:bg-inherit ps-0 ms-0 />
</div>
</template>

View file

@ -0,0 +1,124 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { card, smallPictureOnly } = defineProps<{
card: mastodon.v1.PreviewCard
/** For the preview image, only the small image mode is displayed */
smallPictureOnly?: boolean
/** When it is root card in the list, not appear as a child card */
root?: boolean
}>()
// mastodon's default max og image width
const ogImageWidth = 400
const alt = computed(() => `${card.title} - ${card.title}`)
const isSquare = computed(() => (
smallPictureOnly
|| card.width === card.height
|| Number(card.width || 0) < ogImageWidth
|| Number(card.height || 0) < ogImageWidth / 2
))
const providerName = computed(() => card.providerName ? card.providerName : new URL(card.url).hostname)
// TODO: handle card.type: 'photo' | 'video' | 'rich';
const cardTypeIconMap: Record<mastodon.v1.PreviewCardType, string> = {
link: 'i-ri:profile-line',
photo: 'i-ri:image-line',
video: 'i-ri:play-line',
rich: 'i-ri:profile-line',
}
const userSettings = useUserSettings()
const shouldLoadAttachment = ref(!getPreferences(userSettings.value, 'enableDataSaving'))
function loadAttachment() {
shouldLoadAttachment.value = true
}
</script>
<template>
<NuxtLink
block
of-hidden
:to="card.url"
bg-card
hover:bg-active
:class="{
'flex flex-col': isSquare,
'p-4': root,
'rounded-lg': !root,
}"
target="_blank"
external
>
<div :class="isSquare ? 'flex' : ''">
<!-- image -->
<div
v-if="card.image"
flex flex-col
display-block of-hidden
:class="{
'sm:(min-w-32 w-32 h-32) min-w-24 w-24 h-24': isSquare,
'w-full aspect-[1.91]': !isSquare,
'rounded-lg': root,
}"
relative
>
<CommonBlurhash
:blurhash="card.blurhash"
:src="card.image"
:width="card.width"
:height="card.height"
:alt="alt"
:should-load-image="shouldLoadAttachment"
w-full h-full object-cover
:class="!shouldLoadAttachment ? 'brightness-60' : ''"
/>
<button
v-if="!shouldLoadAttachment"
type="button"
absolute
class="status-preview-card-load bg-black/64"
p-2
transition
rounded
hover:bg-black
cursor-pointer
@click.stop.prevent="!shouldLoadAttachment ? loadAttachment() : null"
>
<span
text-sm
text-white
flex flex-col justify-center items-center
gap-3 w-6 h-6
i-ri:file-download-line
/>
</button>
</div>
<div
v-else
min-w-24 w-24 h-24 sm="min-w-32 w-32 h-32" bg="slate-500/10" flex justify-center items-center
:class="[
root ? 'rounded-lg' : '',
]"
>
<div :class="cardTypeIconMap[card.type]" w="30%" h="30%" text-secondary />
</div>
<!-- description -->
<StatusPreviewCardInfo :p="isSquare ? 'x-4' : '4'" :root="root" :card="card" :provider="providerName" />
</div>
<StatusPreviewCardMoreFromAuthor
v-if="card?.authors?.[0]?.account"
:account="card.authors[0].account"
/>
</NuxtLink>
</template>
<style lang="postcss">
.status-preview-card-load {
left: 50%;
top: 50%;
translate: -50% -50%;
}
</style>

View file

@ -0,0 +1,46 @@
<script setup lang="ts">
defineProps<{
/** For the preview image, only the small image mode is displayed */
square?: boolean
/** When it is root card in the list, not appear as a child card */
root?: boolean
}>()
</script>
<template>
<div
of-hidden
:class="{
'flex': square,
'p-4': root,
'rounded-lg border border-base': !root,
}"
>
<div
flex flex-col
display-block of-hidden
border="base"
:class="{
'sm:(min-w-32 w-32 h-32) min-w-22 w-22 h-22 border-r': square,
'w-full aspect-[1.91] border-b': !square,
'rounded-lg': root,
}"
>
<div w-full h-full class="skeleton-loading-bg" />
</div>
<div
px3 max-h-2xl
flex-1 flex flex-col flex-gap-2 sm:flex-gap-3
:class="[
root ? 'py2.5 sm:py3' : 'py3 justify-center sm:justify-start',
]"
>
<div flex class="skeleton-loading-bg" h-4 w-30 rounded :class="root ? '' : 'hidden sm:block'" />
<div flex class="skeleton-loading-bg" h-5 w="4/5" rounded />
<div flex="~ col gap-2">
<div flex class="skeleton-loading-bg" h-4 w-full rounded />
<div sm:flex hidden class="skeleton-loading-bg" h-4 w="2/5" rounded />
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,134 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
import reservedNames from 'github-reserved-names'
const { card } = defineProps<{
card: mastodon.v1.PreviewCard
}>()
type UrlType = 'user' | 'repo' | 'issue' | 'pull'
interface Meta {
type: UrlType
user?: string
titleUrl: string
avatar: string
details: string
repo?: string
number?: string
author?: {
avatar: string
user: string
}
}
// Supported paths
// /user
// /user/repo
// /user/repo/issues/number
// /user/repo/pull/number
// /sponsors/user
const supportedReservedRoutes = ['sponsors']
const meta = computed(() => {
const { url } = card
const path = url.split('https://github.com/')[1]
const [firstName, secondName] = path?.split('/') || []
if (!firstName || (reservedNames.check(firstName) && !supportedReservedRoutes.includes(firstName)))
return undefined
const firstIsUser = firstName && !supportedReservedRoutes.includes(firstName)
const user = firstIsUser ? firstName : secondName
const repo = firstIsUser ? secondName : undefined
let type: UrlType = repo ? 'repo' : 'user'
let number: string | undefined
let details = (card.title ?? '').replace('GitHub - ', '').split(' · ')[0]
if (repo) {
const repoPath = `${user}/${repo}`
details = details.replace(`${repoPath}: `, '')
const inRepoPath = path.split(`${repoPath}/`)?.[1]
if (inRepoPath) {
number = inRepoPath.match(/issues\/(\d+)/)?.[1]
if (number) {
type = 'issue'
}
else {
number = inRepoPath.match(/pull\/(\d+)/)?.[1]
if (number)
type = 'pull'
}
}
}
const avatar = `https://github.com/${user}.png?size=256`
const author = card.authorName
return {
type,
user,
titleUrl: `https://github.com/${user}${repo ? `/${repo}` : ''}`,
details,
repo,
number,
avatar,
author: author
? {
avatar: `https://github.com/${author}.png?size=64`,
user: author,
}
: undefined,
} satisfies Meta
})
</script>
<template>
<div
v-if="card.image && meta"
flex flex-col
display-block of-hidden
bg-card
relative
w-full min-h-50 md:min-h-60
justify-center
rounded-lg
>
<div p4 sm:px-8 flex flex-col justify-between min-h-50 md:min-h-60 h-full>
<div flex justify-between items-center gap-2 sm:gap-6 h-full mb-2 min-h-35 md:min-h-45>
<div flex flex-col gap-2>
<NuxtLink flex gap-1 text-xl sm:text-3xl flex-wrap leading-none :href="meta.titleUrl" target="_blank" external>
<template v-if="meta.repo">
<span>{{ meta.user }}</span><span text-secondary-light>/</span><span text-primary font-bold>{{ meta.repo }}</span>
</template>
<span v-else>{{ meta.user }}</span>
</NuxtLink>
<NuxtLink sm:text-lg :href="card.url" target="_blank" external>
<span v-if="meta.type === 'issue'" text-secondary-light me-2>
#{{ meta.number }}
</span>
<span v-if="meta.type === 'pull'" text-secondary-light me-2>
PR #{{ meta.number }}
</span>
<span text-secondary leading-tight>{{ meta.details }}</span>
</NuxtLink>
</div>
<div shrink-0 w-18 sm:w-30>
<NuxtLink :href="meta.titleUrl" target="_blank" external>
<img w-full aspect-square width="112" height="112" rounded-2 :src="meta.avatar">
</NuxtLink>
</div>
</div>
<div flex justify-between>
<div v-if="meta.author" flex class="gap-2.5" items-center>
<div>
<img w-8 aspect-square width="25" height="25" rounded-full :src="meta.author?.avatar">
</div>
<span text-lg text-primary>@{{ meta.author?.user }}</span>
</div>
<div v-else />
<div text-3xl i-ri:github-fill text-secondary />
</div>
</div>
</div>
<StatusPreviewCardNormal v-else :card="card" />
</template>

View file

@ -0,0 +1,100 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { card } = defineProps<{
card: mastodon.v1.PreviewCard
/** For the preview image, only the small image mode is displayed */
smallPictureOnly?: boolean
/** When it is root card in the list, not appear as a child card */
root?: boolean
}>()
interface Meta {
code?: string
file?: string
lines?: string
project?: string
}
// Protect against long code snippets
const maxLines = 20
const meta = computed(() => {
const { description } = card
const meta = description.match(/.*Code Snippet from (.+), lines (\S+)\n\n(.+)/s)
const file = meta?.[1]
const lines = meta?.[2]
const code = meta?.[3].split('\n').slice(0, maxLines).join('\n')
const project = card.title?.replace(' - StackBlitz', '')
return {
file,
lines,
code,
project,
} satisfies Meta
})
const vnodeCode = computed(() => {
if (!meta.value.code)
return null
const code = meta.value.code
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/`/g, '&#96;')
const vnode = contentToVNode(`<p>\`\`\`${meta.value.file?.split('.')?.[1] ?? ''}\n${code}\n\`\`\`\</p>`, {
markdown: true,
})
return vnode
})
</script>
<template>
<div
v-if="meta.code"
flex flex-col gap-1
display-block of-hidden
w-full
rounded-lg
overflow-hidden
pb-2
>
<div whitespace-pre-wrap break-words>
<span v-if="vnodeCode" class="content-rich line-compact" dir="auto">
<component :is="vnodeCode" />
</span>
</div>
<div
flex
justify-between
display-block of-hidden
bg-card
w-full
p-3
pb-4
>
<div flex flex-col>
<p flex gap-1>
<span>{{ $t('custom_cards.stackblitz.snippet_from', [meta.file]) }}</span><span text-secondary>{{ `- ${$t('custom_cards.stackblitz.lines', [meta.lines])}` }}</span>
</p>
<div flex font-bold gap-2>
<span text-primary>{{ meta.project }}</span><span flex text-secondary><span flex items-center><svg h-5 width="22.27" height="32" viewBox="0 0 256 368"><path fill="currentColor" d="M109.586 217.013H0L200.34 0l-53.926 150.233H256L55.645 367.246l53.927-150.233z" /></svg></span><span>StackBlitz</span></span>
</div>
</div>
<NuxtLink external target="_blank" btn-solid pt-0 pb-1 px-2 h-fit :to="card.url">
{{ $t('custom_cards.stackblitz.open') }}
</NuxtLink>
</div>
</div>
<StatusPreviewCardNormal v-else :card="card" :small-picture-only="smallPictureOnly" :root="root" />
</template>
<style scoped>
.content-rich p {
margin-top: 0;
}
.code-block {
margin-top: 0;
border-radius: 0;
}
</style>

View file

@ -0,0 +1,77 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
import { fetchAccountById } from '~/composables/cache'
type WatcherType = [status?: mastodon.v1.Status, v?: boolean]
const { status } = defineProps<{
status: mastodon.v1.Status
isSelfReply: boolean
}>()
const link = ref()
const targetIsVisible = ref(false)
const isSelf = computed(() => status.inReplyToAccountId === status.account.id)
const account = ref<mastodon.v1.Account | null | undefined>(isSelf.value ? status.account : undefined)
useIntersectionObserver(
link,
([{ intersectionRatio }]) => {
targetIsVisible.value = intersectionRatio > 0.1
},
)
watch(
() => [status, targetIsVisible.value] satisfies WatcherType,
([newStatus, newVisible]) => {
if (newStatus.account && newStatus.inReplyToAccountId === newStatus.account.id) {
account.value = newStatus.account
return
}
if (!newVisible)
return
const newId = newStatus.inReplyToAccountId
if (newId) {
fetchAccountById(newStatus.inReplyToAccountId).then((acc) => {
if (newId === status.inReplyToAccountId)
account.value = acc
})
return
}
account.value = undefined
},
{ immediate: true, flush: 'post' },
)
</script>
<template>
<NuxtLink
v-if="status.inReplyToId"
ref="link"
flex="~ gap2" items-center h-auto text-sm text-secondary
:to="getStatusInReplyToRoute(status)"
:title="$t('status.replying_to', [account ? getDisplayName(account) : $t('status.someone')])"
text-blue saturate-50 hover:saturate-100
>
<template v-if="isSelfReply">
<div i-ri-discuss-line text-blue />
<span>{{ $t('status.show_full_thread') }}</span>
</template>
<template v-else>
<div i-ri-chat-1-line text-blue />
<div ws-nowrap flex>
<i18n-t keypath="status.replying_to">
<template v-if="account">
<AccountInlineInfo :account="account" :link="false" m-inline-2 />
</template>
<template v-else>
{{ $t('status.someone') }}
</template>
</i18n-t>
</div>
</template>
</NuxtLink>
</template>

View file

@ -0,0 +1,46 @@
<script setup lang="ts">
const { enabled, filter, sensitiveNonSpoiler } = defineProps<{
enabled?: boolean
filter?: boolean
isDM?: boolean
sensitiveNonSpoiler?: boolean
}>()
const expandSpoilers = computed(() => {
const expandCW = currentUser.value ? getExpandSpoilersByDefault(currentUser.value.account) : false
const expandMedia = currentUser.value ? getExpandMediaByDefault(currentUser.value.account) : false
return !filter // always prevent expansion if filtered
&& ((sensitiveNonSpoiler && expandMedia)
|| (!sensitiveNonSpoiler && expandCW))
})
const hideContent = enabled || sensitiveNonSpoiler
const showContent = ref(expandSpoilers.value ? true : !hideContent)
const toggleContent = useToggle(showContent)
watchEffect(() => {
showContent.value = expandSpoilers.value ? true : !hideContent
})
function getToggleText() {
if (sensitiveNonSpoiler)
return 'status.spoiler_media_hidden'
return filter ? 'status.filter_show_anyway' : 'status.spoiler_show_more'
}
</script>
<template>
<div v-if="hideContent" flex flex-col items-start>
<div class="content-rich" p="x-4 b-2.5" text-center text-secondary w-full border="~ base" border-0 border-b-dotted border-b-3 mt-2>
<slot name="spoiler" />
</div>
<div flex="~ gap-1 center" w-full :mb="isDM && !showContent ? '4' : ''" mt="-4.5">
<button btn-text px-2 py-1 rounded-lg :bg="isDM ? 'transparent' : 'base'" flex="~ center gap-2" :class="showContent ? '' : 'filter-saturate-0 hover:filter-saturate-100'" :aria-expanded="showContent" @click="toggleContent()">
<div v-if="showContent" i-ri:eye-line />
<div v-else i-ri:eye-close-line />
{{ showContent ? $t('status.spoiler_show_less') : $t(getToggleText()) }}
</button>
</div>
</div>
<slot v-if="!hideContent || showContent" />
</template>

View file

@ -0,0 +1,48 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { status } = defineProps<{
status: mastodon.v1.Status
}>()
const {
toggle: _toggleTranslation,
translation,
enabled: isTranslationEnabled,
} = useTranslation(status, getLanguageCode())
const preferenceHideTranslation = usePreferences('hideTranslation')
const showButton = computed(() =>
!preferenceHideTranslation.value
&& isTranslationEnabled
&& status.content.trim().length,
)
const translating = ref(false)
async function toggleTranslation() {
translating.value = true
try {
await _toggleTranslation()
}
finally {
translating.value = false
}
}
</script>
<template>
<div v-if="showButton">
<button
p-0 flex="~ center" gap-2 text-sm
:disabled="translating" disabled-bg-transparent btn-text class="disabled-text-$c-text-btn-disabled-deeper" @click="toggleTranslation"
>
<span v-if="translating" block animate-spin preserve-3d>
<span block i-ri:loader-2-fill />
</span>
<div v-else i-ri:translate />
{{ translation.visible ? $t('menu.show_untranslated') : $t('menu.translate_post') }}
</button>
</div>
</template>
<style scoped></style>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { status } = defineProps<{
status: mastodon.v1.Status
}>()
const visibility = computed(() => statusVisibilities.find(v => v.value === status.visibility)!)
</script>
<template>
<CommonTooltip :content="$t(`visibility.${visibility.value}`)" placement="bottom">
<div :class="visibility.icon" :aria-label="$t(`visibility.${visibility.value}`)" />
</CommonTooltip>
</template>

View file

@ -0,0 +1,50 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
import { formatTimeAgo } from '@vueuse/core'
const { status } = defineProps<{
status: mastodon.v1.Status
}>()
const paginator = useMastoClient().v1.statuses.$select(status.id).history.list()
function showHistory(edit: mastodon.v1.StatusEdit) {
openEditHistoryDialog(edit)
}
const timeAgoOptions = useTimeAgoOptions()
// TODO: rework, this is only reversing the first page of edits
function reverseHistory(items: mastodon.v1.StatusEdit[]) {
return [...items].reverse()
}
</script>
<template>
<CommonPaginator :paginator="paginator" key-prop="createdAt" :preprocess="reverseHistory">
<template #default="{ items, item, index }">
<CommonDropdownItem
px="0.5"
@click="showHistory(item)"
>
{{ getDisplayName(item.account) }}
<template v-if="index === items.length - 1">
<i18n-t keypath="status_history.created">
{{ formatTimeAgo(new Date(item.createdAt), timeAgoOptions) }}
</i18n-t>
</template>
<i18n-t v-else keypath="status_history.edited">
{{ formatTimeAgo(new Date(item.createdAt), timeAgoOptions) }}
</i18n-t>
</CommonDropdownItem>
</template>
<template #loading>
<StatusEditHistorySkeleton />
<StatusEditHistorySkeleton op50 />
<StatusEditHistorySkeleton op25 />
</template>
<template #done>
<span />
</template>
</CommonPaginator>
</template>

View file

@ -0,0 +1,3 @@
<template>
<div class="skeleton-loading-bg" h-5 w-full rounded my2 />
</template>

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { status } = defineProps<{
status: mastodon.v1.Status
inline: boolean
}>()
const editedAt = computed(() => status.editedAt)
const formatted = useFormattedDateTime(editedAt)
</script>
<template>
<template v-if="editedAt">
<CommonTooltip v-if="inline" :content="$t('status.edited', [formatted])">
&#160;
<time
:title="editedAt"
:datetime="editedAt"
font-bold underline decoration-dashed
text-secondary
>&#160;*&#160;</time>
</CommonTooltip>
<CommonDropdown v-else>
<slot />
<template #popper>
<div text-sm p2>
<div text-center mb1>
{{ $t('status.edited', [formatted]) }}
</div>
<StatusEditHistory :status="status" />
</div>
</template>
</CommonDropdown>
</template>
</template>

View file

@ -0,0 +1,28 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
defineProps<{
edit: mastodon.v1.StatusEdit
}>()
</script>
<template>
<div px3 py-4 flex="~ col">
<div text-center flex="~ row gap-1 wrap">
<AccountInlineInfo :account="edit.account" />
<span>
{{ $t('status_history.edited', [useFormattedDateTime(edit.createdAt).value]) }}
</span>
</div>
<div h1px bg="gray/20" my2 />
<StatusSpoiler :enabled="edit.sensitive">
<template #spoiler>
{{ edit.spoilerText }}
</template>
<StatusBody :status="edit" />
<StatusMedia v-if="edit.mediaAttachments.length" :status="edit" />
</StatusSpoiler>
</div>
</template>