refactor: migrate to nuxt compatibilityVersion: 4 (#3298)
This commit is contained in:
parent
46e4433e1c
commit
a3fbc056a9
342 changed files with 1200 additions and 2932 deletions
21
app/components/status/StatusAccountDetails.vue
Normal file
21
app/components/status/StatusAccountDetails.vue
Normal 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>
|
87
app/components/status/StatusActionButton.vue
Normal file
87
app/components/status/StatusActionButton.vue
Normal 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>
|
114
app/components/status/StatusActions.vue
Normal file
114
app/components/status/StatusActions.vue
Normal 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>
|
356
app/components/status/StatusActionsMore.vue
Normal file
356
app/components/status/StatusActionsMore.vue
Normal 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>
|
306
app/components/status/StatusAttachment.vue
Normal file
306
app/components/status/StatusAttachment.vue
Normal 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>
|
55
app/components/status/StatusBody.vue
Normal file
55
app/components/status/StatusBody.vue
Normal 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>
|
217
app/components/status/StatusCard.vue
Normal file
217
app/components/status/StatusCard.vue
Normal 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>
|
15
app/components/status/StatusCardSkeleton.vue
Normal file
15
app/components/status/StatusCardSkeleton.vue
Normal 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>
|
75
app/components/status/StatusContent.vue
Normal file
75
app/components/status/StatusContent.vue
Normal 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>
|
69
app/components/status/StatusDetails.vue
Normal file
69
app/components/status/StatusDetails.vue
Normal 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">
|
||||
·
|
||||
</div>
|
||||
<StatusVisibilityIndicator :status="status" />
|
||||
<div v-if="status.application?.name" aria-hidden="true">
|
||||
·
|
||||
</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>
|
105
app/components/status/StatusEmbeddedMedia.vue
Normal file
105
app/components/status/StatusEmbeddedMedia.vue
Normal 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>
|
59
app/components/status/StatusFavouritedBoostedBy.vue
Normal file
59
app/components/status/StatusFavouritedBoostedBy.vue
Normal 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>
|
49
app/components/status/StatusLink.vue
Normal file
49
app/components/status/StatusLink.vue
Normal 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>
|
46
app/components/status/StatusMedia.vue
Normal file
46
app/components/status/StatusMedia.vue
Normal 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>
|
30
app/components/status/StatusNotFound.vue
Normal file
30
app/components/status/StatusNotFound.vue
Normal 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>
|
117
app/components/status/StatusPoll.vue
Normal file
117
app/components/status/StatusPoll.vue
Normal 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>
|
||||
·
|
||||
<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>
|
21
app/components/status/StatusPreviewCard.vue
Normal file
21
app/components/status/StatusPreviewCard.vue
Normal 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>
|
36
app/components/status/StatusPreviewCardInfo.vue
Normal file
36
app/components/status/StatusPreviewCardInfo.vue
Normal 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>
|
20
app/components/status/StatusPreviewCardMoreFromAuthor.vue
Normal file
20
app/components/status/StatusPreviewCardMoreFromAuthor.vue
Normal 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>
|
124
app/components/status/StatusPreviewCardNormal.vue
Normal file
124
app/components/status/StatusPreviewCardNormal.vue
Normal 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>
|
46
app/components/status/StatusPreviewCardSkeleton.vue
Normal file
46
app/components/status/StatusPreviewCardSkeleton.vue
Normal 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>
|
134
app/components/status/StatusPreviewGitHub.vue
Normal file
134
app/components/status/StatusPreviewGitHub.vue
Normal 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>
|
100
app/components/status/StatusPreviewStackBlitz.vue
Normal file
100
app/components/status/StatusPreviewStackBlitz.vue
Normal 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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/`/g, '`')
|
||||
|
||||
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>
|
77
app/components/status/StatusReplyingTo.vue
Normal file
77
app/components/status/StatusReplyingTo.vue
Normal 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>
|
46
app/components/status/StatusSpoiler.vue
Normal file
46
app/components/status/StatusSpoiler.vue
Normal 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>
|
48
app/components/status/StatusTranslation.vue
Normal file
48
app/components/status/StatusTranslation.vue
Normal 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>
|
15
app/components/status/StatusVisibilityIndicator.vue
Normal file
15
app/components/status/StatusVisibilityIndicator.vue
Normal 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>
|
50
app/components/status/edit/StatusEditHistory.vue
Normal file
50
app/components/status/edit/StatusEditHistory.vue
Normal 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>
|
3
app/components/status/edit/StatusEditHistorySkeleton.vue
Normal file
3
app/components/status/edit/StatusEditHistorySkeleton.vue
Normal file
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<div class="skeleton-loading-bg" h-5 w-full rounded my2 />
|
||||
</template>
|
38
app/components/status/edit/StatusEditIndicator.vue
Normal file
38
app/components/status/edit/StatusEditIndicator.vue
Normal 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])">
|
||||
 
|
||||
<time
|
||||
:title="editedAt"
|
||||
:datetime="editedAt"
|
||||
font-bold underline decoration-dashed
|
||||
text-secondary
|
||||
> * </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>
|
28
app/components/status/edit/StatusEditPreview.vue
Normal file
28
app/components/status/edit/StatusEditPreview.vue
Normal 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>
|
Loading…
Add table
Add a link
Reference in a new issue