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
154
app/components/notification/NotificationCard.vue
Normal file
154
app/components/notification/NotificationCard.vue
Normal file
|
@ -0,0 +1,154 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
// Add undocumented 'annual_report' type introduced in v4.3
|
||||
// ref. https://github.com/mastodon/documentation/issues/1211#:~:text=api/v1/annual_reports
|
||||
type NotificationType = mastodon.v1.Notification['type'] | 'annual_report'
|
||||
type Notification = Omit<mastodon.v1.Notification, 'type'> & { type: NotificationType }
|
||||
|
||||
const { notification } = defineProps<{
|
||||
notification: Notification
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// list of notification types Elk currently implemented
|
||||
// type 'favourite' and 'reblog' should always rendered by NotificationGroupedLikes
|
||||
const supportedNotificationTypes: NotificationType[] = [
|
||||
'follow',
|
||||
'admin.sign_up',
|
||||
'admin.report',
|
||||
'follow_request',
|
||||
'update',
|
||||
'mention',
|
||||
'poll',
|
||||
'update',
|
||||
'status',
|
||||
'annual_report',
|
||||
]
|
||||
|
||||
// well-known emoji reactions types Elk does not support yet
|
||||
const unsupportedEmojiReactionTypes = ['pleroma:emoji_reaction', 'reaction']
|
||||
|
||||
if (unsupportedEmojiReactionTypes.includes(notification.type) || !supportedNotificationTypes.includes(notification.type)) {
|
||||
console.warn(`[DEV] ${t('notification.missing_type')} '${notification.type}' (notification.id: ${notification.id})`)
|
||||
}
|
||||
|
||||
const timeAgoOptions = useTimeAgoOptions(true)
|
||||
const timeAgo = useTimeAgo(() => notification.createdAt, timeAgoOptions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article flex flex-col relative>
|
||||
<template v-if="notification.type === 'follow'">
|
||||
<NuxtLink :to="getAccountRoute(notification.account)">
|
||||
<div
|
||||
flex items-center absolute
|
||||
ps-3 pe-4 inset-is-0
|
||||
rounded-ie-be-3
|
||||
py-3 bg-base top-0
|
||||
>
|
||||
<div i-ri-user-3-line text-xl me-3 color-blue />
|
||||
<AccountDisplayName :account="notification.account" text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all />
|
||||
<span ws-nowrap>
|
||||
{{ $t('notification.followed_you') }}
|
||||
<time text-secondary :datetime="notification.createdAt">
|
||||
・{{ timeAgo }}
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
<AccountBigCard
|
||||
ms10
|
||||
:account="notification.account"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'admin.sign_up'">
|
||||
<NuxtLink :to="getAccountRoute(notification.account)">
|
||||
<div flex p4 items-center bg-shaded>
|
||||
<div i-ri:user-add-line text-xl me-2 color-purple />
|
||||
<AccountDisplayName
|
||||
:account="notification.account"
|
||||
text-purple me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||
/>
|
||||
<span>{{ $t("notification.signed_up") }}
|
||||
<time text-secondary :datetime="notification.createdAt">
|
||||
・{{ timeAgo }}
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'admin.report'">
|
||||
<NuxtLink :to="getReportRoute(notification.report?.id!)">
|
||||
<div flex p4 items-center bg-shaded>
|
||||
<div i-ri:flag-line text-xl me-2 color-purple />
|
||||
<i18n-t keypath="notification.reported">
|
||||
<AccountDisplayName
|
||||
:account="notification.account"
|
||||
text-purple me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||
/>
|
||||
<AccountDisplayName
|
||||
:account="notification.report?.targetAccount!"
|
||||
text-purple ms-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||
/>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'follow_request'">
|
||||
<div flex px-3 py-2>
|
||||
<div i-ri-user-shared-line text-xl me-3 color-blue />
|
||||
<AccountDisplayName
|
||||
:account="notification.account"
|
||||
text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||
/>
|
||||
<span me-1 ws-nowrap>
|
||||
{{ $t('notification.request_to_follow') }}
|
||||
<time text-secondary :datetime="notification.createdAt">
|
||||
・{{ timeAgo }}
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
<AccountCard p="s-2 e-4 b-2" hover-card :account="notification.account">
|
||||
<AccountFollowRequestButton :account="notification.account" />
|
||||
</AccountCard>
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'update'">
|
||||
<StatusCard :status="notification.status!" :in-notification="true" :actions="false">
|
||||
<template #meta>
|
||||
<div flex="~" gap-1 items-center mt1>
|
||||
<div i-ri:edit-2-fill text-xl me-1 text-secondary />
|
||||
<AccountInlineInfo :account="notification.account" me1 />
|
||||
<span ws-nowrap>
|
||||
{{ $t('notification.update_status') }}
|
||||
<time text-secondary :datetime="notification.createdAt">
|
||||
・{{ timeAgo }}
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</StatusCard>
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'mention' || notification.type === 'poll' || notification.type === 'status'">
|
||||
<StatusCard :status="notification.status!" />
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'annual_report'">
|
||||
<div flex p4 items-center bg-shaded>
|
||||
<div i-mdi:party-popper text-xl me-4 color-purple />
|
||||
<div class="content-rich">
|
||||
<p>
|
||||
Your 2024 <NuxtLink to="/tags/Wrapstodon">
|
||||
#Wrapstodon
|
||||
</NuxtLink> awaits! Unveil your year's highlights and memorable moments on Mastodon!
|
||||
</p>
|
||||
<p>
|
||||
<NuxtLink :to="`https://${currentServer}/notifications`" target="_blank">
|
||||
View #Wrapstodon on Mastodon
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
</template>
|
|
@ -0,0 +1,77 @@
|
|||
<script setup lang="ts">
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
|
||||
defineProps<{
|
||||
closeableHeader?: boolean
|
||||
busy?: boolean
|
||||
animate?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits(['hide', 'subscribe'])
|
||||
|
||||
defineSlots<{
|
||||
error: (props: object) => void
|
||||
}>()
|
||||
|
||||
const xl = useMediaQuery('(min-width: 1280px)')
|
||||
|
||||
const isLegacyAccount = computed(() => !currentUser.value?.vapidKey)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
flex="~ col"
|
||||
gap-y-2
|
||||
role="alert"
|
||||
aria-labelledby="notifications-warning"
|
||||
:class="closeableHeader ? 'border-b border-base' : 'px6 px4'"
|
||||
>
|
||||
<header flex items-center pb-2>
|
||||
<h2 id="notifications-warning" text-md font-bold w-full>
|
||||
{{ $t('settings.notifications.push_notifications.warning.enable_title') }}
|
||||
</h2>
|
||||
<button
|
||||
v-if="closeableHeader"
|
||||
flex rounded-4
|
||||
type="button"
|
||||
:title="$t('settings.notifications.push_notifications.warning.enable_close')"
|
||||
hover:bg-active cursor-pointer transition-100
|
||||
:disabled="busy"
|
||||
@click="$emit('hide')"
|
||||
>
|
||||
<span aria-hidden="true" i-ri:close-line />
|
||||
</button>
|
||||
</header>
|
||||
<template v-if="closeableHeader">
|
||||
<p xl:hidden>
|
||||
{{ $t('settings.notifications.push_notifications.warning.enable_description') }}
|
||||
</p>
|
||||
<p xl:hidden>
|
||||
{{ $t('settings.notifications.push_notifications.warning.enable_description_mobile') }}
|
||||
</p>
|
||||
<p :class="xl ? null : 'hidden'">
|
||||
{{ $t('settings.notifications.push_notifications.warning.enable_description_desktop') }}
|
||||
</p>
|
||||
</template>
|
||||
<p v-else>
|
||||
{{ $t('settings.notifications.push_notifications.warning.enable_description_settings') }}
|
||||
</p>
|
||||
<p v-if="isLegacyAccount">
|
||||
{{ $t('settings.notifications.push_notifications.warning.re_auth') }}
|
||||
</p>
|
||||
<button
|
||||
btn-outline rounded-full font-bold py4 flex="~ gap2 center" m5
|
||||
type="button"
|
||||
:class="busy || isLegacyAccount ? 'border-transparent' : null"
|
||||
:disabled="busy || isLegacyAccount"
|
||||
@click="$emit('subscribe')"
|
||||
>
|
||||
<span v-if="busy && animate" aria-hidden="true" block animate-spin preserve-3d>
|
||||
<span block i-ri:loader-2-fill aria-hidden="true" />
|
||||
</span>
|
||||
<span v-else aria-hidden="true" block i-ri:check-line />
|
||||
<span>{{ $t('settings.notifications.push_notifications.warning.enable_desktop') }}</span>
|
||||
</button>
|
||||
<slot name="error" />
|
||||
</div>
|
||||
</template>
|
102
app/components/notification/NotificationGroupedFollow.vue
Normal file
102
app/components/notification/NotificationGroupedFollow.vue
Normal file
|
@ -0,0 +1,102 @@
|
|||
<script setup lang="ts">
|
||||
import type { GroupedNotifications } from '#shared/types'
|
||||
|
||||
const { items } = defineProps<{
|
||||
items: GroupedNotifications
|
||||
}>()
|
||||
|
||||
const maxVisibleFollows = 5
|
||||
const follows = computed(() => items.items)
|
||||
const visibleFollows = computed(() => follows.value.slice(0, maxVisibleFollows))
|
||||
const count = computed(() => follows.value.length)
|
||||
const countPlus = computed(() => Math.max(count.value - maxVisibleFollows, 0))
|
||||
const isExpanded = ref(false)
|
||||
const lang = computed(() => {
|
||||
return (count.value > 1 || count.value === 0) ? undefined : items.items[0].status?.language
|
||||
})
|
||||
|
||||
const timeAgoOptions = useTimeAgoOptions(true)
|
||||
const timeAgoCreatedAt = computed(() => follows.value[0].createdAt)
|
||||
const timeAgo = useTimeAgo(() => timeAgoCreatedAt.value, timeAgoOptions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article flex flex-col relative :lang="lang ?? undefined">
|
||||
<div flex items-center top-0 left-2 pt-2 px-3>
|
||||
<div :class="count > 1 ? 'i-ri-group-line' : 'i-ri-user-3-line'" me-3 color-blue text-xl aria-hidden="true" />
|
||||
<template v-if="count > 1">
|
||||
<AccountHoverWrapper
|
||||
:account="follows[0].account"
|
||||
>
|
||||
<NuxtLink :to="getAccountRoute(follows[0].account)">
|
||||
<AccountDisplayName
|
||||
:account="follows[0].account"
|
||||
text-primary font-bold line-clamp-1 ws-pre-wrap break-all hover:underline
|
||||
/>
|
||||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
{{ $t('notification.and') }}
|
||||
<CommonLocalizedNumber
|
||||
keypath="notification.others"
|
||||
:count="count - 1"
|
||||
text-primary font-bold line-clamp-1 ws-pre-wrap break-all
|
||||
/>
|
||||
{{ $t('notification.followed_you') }}
|
||||
<time text-secondary :datetime="timeAgoCreatedAt">
|
||||
・{{ timeAgo }}
|
||||
</time>
|
||||
</template>
|
||||
<template v-else-if="count === 1">
|
||||
<NuxtLink :to="getAccountRoute(follows[0].account)">
|
||||
<AccountDisplayName
|
||||
:account="follows[0].account"
|
||||
text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all hover:underline
|
||||
/>
|
||||
</NuxtLink>
|
||||
<span me-1 ws-nowrap>
|
||||
{{ $t('notification.followed_you') }}
|
||||
<time text-secondary :datetime="timeAgoCreatedAt">
|
||||
・{{ timeAgo }}
|
||||
</time>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div pb-2 ps8>
|
||||
<div
|
||||
v-if="!isExpanded && count > 1"
|
||||
flex="~ wrap gap-1.75" p4 items-center cursor-pointer
|
||||
@click="isExpanded = !isExpanded"
|
||||
>
|
||||
<AccountHoverWrapper
|
||||
v-for="follow in visibleFollows"
|
||||
:key="follow.id"
|
||||
:account="follow.account"
|
||||
>
|
||||
<NuxtLink :to="getAccountRoute(follow.account)">
|
||||
<AccountAvatar :account="follow.account" w-12 h-12 />
|
||||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
<div flex="~ 1" items-center>
|
||||
<span v-if="countPlus > 0" ps-2 text="base lg">+{{ countPlus }}</span>
|
||||
<div i-ri:arrow-down-s-line mx-1 text-secondary text-xl aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="count > 1" flex p-4 pb-2 cursor-pointer @click="isExpanded = !isExpanded">
|
||||
<div i-ri:arrow-up-s-line ms-2 text-secondary text-xl aria-hidden="true" />
|
||||
<span ps-2 text-base>Hide</span>
|
||||
</div>
|
||||
<AccountHoverWrapper
|
||||
v-for="follow in follows"
|
||||
:key="follow.id"
|
||||
:account="follow.account"
|
||||
>
|
||||
<NuxtLink :to="getAccountRoute(follow.account)" flex gap-4 px-4 py-2>
|
||||
<AccountAvatar :account="follow.account" w-12 h-12 />
|
||||
<StatusAccountDetails :account="follow.account" />
|
||||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
74
app/components/notification/NotificationGroupedLikes.vue
Normal file
74
app/components/notification/NotificationGroupedLikes.vue
Normal file
|
@ -0,0 +1,74 @@
|
|||
<script setup lang="ts">
|
||||
import type { GroupedLikeNotifications } from '#shared/types'
|
||||
|
||||
const { group } = defineProps<{
|
||||
group: GroupedLikeNotifications
|
||||
}>()
|
||||
const useStarFavoriteIcon = usePreferences('useStarFavoriteIcon')
|
||||
|
||||
const reblogs = computed(() => group.likes.filter(i => i.reblog))
|
||||
const likes = computed(() => group.likes.filter(i => i.favourite && !i.reblog))
|
||||
|
||||
const timeAgoOptions = useTimeAgoOptions(true)
|
||||
const reblogsTimeAgoCreatedAt = computed(() => reblogs.value[0].reblog?.createdAt)
|
||||
const reblogsTimeAgo = useTimeAgo(() => reblogsTimeAgoCreatedAt.value ?? '', timeAgoOptions)
|
||||
const likesTimeAgoCreatedAt = computed(() => likes.value[0].favourite?.createdAt)
|
||||
const likesTimeAgo = useTimeAgo(() => likesTimeAgoCreatedAt.value ?? '', timeAgoOptions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article flex flex-col relative>
|
||||
<StatusLink :status="group.status!" pb4 pt5>
|
||||
<div flex flex-col gap-3>
|
||||
<div v-if="reblogs.length" flex="~ gap-1">
|
||||
<div i-ri:repeat-fill text-xl me-2 color-green />
|
||||
<template v-for="i, idx of reblogs" :key="idx">
|
||||
<AccountHoverWrapper :account="i.account">
|
||||
<NuxtLink :to="getAccountRoute(i.account)">
|
||||
<AccountAvatar text-primary font-bold :account="i.account" class="h-1.5em w-1.5em" />
|
||||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
</template>
|
||||
<div ml1>
|
||||
{{ $t('notification.reblogged_post') }}
|
||||
<time text-secondary :datetime="reblogsTimeAgoCreatedAt">
|
||||
・{{ reblogsTimeAgo }}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="likes.length" flex="~ gap-1 wrap">
|
||||
<div :class="useStarFavoriteIcon ? 'i-ri:star-line color-yellow' : 'i-ri:heart-line color-red'" text-xl me-2 />
|
||||
<template v-for="i, idx of likes" :key="idx">
|
||||
<AccountHoverWrapper :account="i.account" relative me--4 border="2 bg-base" rounded-full hover:z-1 focus-within:z-1>
|
||||
<NuxtLink :to="getAccountRoute(i.account)">
|
||||
<AccountAvatar text-primary font-bold :account="i.account" class="h-1.5em w-1.5em" />
|
||||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
</template>
|
||||
<div ms-4>
|
||||
{{ $t('notification.favourited_post') }}
|
||||
<time text-secondary :datetime="likesTimeAgoCreatedAt">
|
||||
・{{ likesTimeAgo }}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ps9 mt-1>
|
||||
<StatusBody :status="group.status!" text-secondary />
|
||||
<!-- When no text content is presented, we show media instead -->
|
||||
<template v-if="!group.status!.content">
|
||||
<StatusMedia
|
||||
v-if="group.status!.mediaAttachments?.length"
|
||||
:status="group.status!"
|
||||
:is-preview="false"
|
||||
pointer-events-none
|
||||
/>
|
||||
<StatusPoll
|
||||
v-else-if="group.status!.poll"
|
||||
:status="group.status!"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</StatusLink>
|
||||
</article>
|
||||
</template>
|
224
app/components/notification/NotificationPaginator.vue
Normal file
224
app/components/notification/NotificationPaginator.vue
Normal file
|
@ -0,0 +1,224 @@
|
|||
<script setup lang="ts">
|
||||
import type { GroupedAccountLike, NotificationSlot } from '#shared/types'
|
||||
import type { mastodon } from 'masto'
|
||||
// @ts-expect-error missing types
|
||||
import { DynamicScrollerItem } from 'vue-virtual-scroller'
|
||||
|
||||
defineProps<{
|
||||
paginator: mastodon.Paginator<mastodon.v1.Notification[], mastodon.rest.v1.ListNotificationsParams>
|
||||
stream?: mastodon.streaming.Subscription
|
||||
}>()
|
||||
|
||||
const virtualScroller = false // TODO: fix flickering issue with virtual scroll
|
||||
|
||||
const groupCapacity = Number.MAX_VALUE // No limit
|
||||
|
||||
const includeNotificationTypes: mastodon.v1.NotificationType[] = ['update', 'mention', 'poll', 'status']
|
||||
|
||||
let id = 0
|
||||
|
||||
function includeNotificationsForStatusCard({ type, status }: mastodon.v1.Notification) {
|
||||
// Exclude update, mention, pool and status notifications without the status entry:
|
||||
// no makes sense to include them
|
||||
// Those notifications will be shown using StatusCard SFC:
|
||||
// check NotificationCard SFC L68 and L81 => :status="notification.status!"
|
||||
return status || !includeNotificationTypes.includes(type)
|
||||
}
|
||||
|
||||
// Group by type (and status when applicable)
|
||||
function groupId(item: mastodon.v1.Notification): string {
|
||||
// If the update is related to a status, group notifications from the same account (boost + favorite the same status)
|
||||
const id = item.status
|
||||
? {
|
||||
status: item.status?.id,
|
||||
type: (item.type === 'reblog' || item.type === 'favourite') ? 'like' : item.type,
|
||||
}
|
||||
: {
|
||||
type: item.type,
|
||||
}
|
||||
return JSON.stringify(id)
|
||||
}
|
||||
|
||||
function hasHeader(account: mastodon.v1.Account) {
|
||||
return !account.header.endsWith('/original/missing.png')
|
||||
}
|
||||
|
||||
function groupItems(items: mastodon.v1.Notification[]): NotificationSlot[] {
|
||||
const results: NotificationSlot[] = []
|
||||
|
||||
let currentGroupId = ''
|
||||
let currentGroup: mastodon.v1.Notification[] = []
|
||||
const processGroup = () => {
|
||||
if (currentGroup.length === 0)
|
||||
return
|
||||
|
||||
const group = currentGroup
|
||||
currentGroup = []
|
||||
|
||||
// Only group follow notifications when there are too many in a row
|
||||
// This normally happens when you transfer an account, if not, show
|
||||
// a big profile card for each follow
|
||||
if (group[0].type === 'follow') {
|
||||
// Order group by followers count
|
||||
const processedGroup = [...group]
|
||||
processedGroup.sort((a, b) => {
|
||||
const aHasHeader = hasHeader(a.account)
|
||||
const bHasHeader = hasHeader(b.account)
|
||||
if (bHasHeader && !aHasHeader)
|
||||
return 1
|
||||
if (aHasHeader && !bHasHeader)
|
||||
return -1
|
||||
return b.account.followersCount - a.account.followersCount
|
||||
})
|
||||
|
||||
if (processedGroup.length > 0 && hasHeader(processedGroup[0].account))
|
||||
results.push(processedGroup.shift()!)
|
||||
|
||||
if (processedGroup.length === 1 && hasHeader(processedGroup[0].account))
|
||||
results.push(processedGroup.shift()!)
|
||||
|
||||
if (processedGroup.length > 0) {
|
||||
results.push({
|
||||
id: `grouped-${id++}`,
|
||||
type: 'grouped-follow',
|
||||
items: processedGroup,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
else if (group.length && (group[0].type === 'reblog' || group[0].type === 'favourite')) {
|
||||
if (!group[0].status) {
|
||||
// Ignore favourite or reblog if status is null, sometimes the API is sending these
|
||||
// notifications
|
||||
return
|
||||
}
|
||||
// All notifications in these group are reblogs or favourites of the same status
|
||||
const likes: GroupedAccountLike[] = []
|
||||
for (const notification of group) {
|
||||
let like = likes.find(like => like.account.id === notification.account.id)
|
||||
if (!like) {
|
||||
like = { account: notification.account }
|
||||
likes.push(like)
|
||||
}
|
||||
like[notification.type === 'reblog' ? 'reblog' : 'favourite'] = notification
|
||||
}
|
||||
likes.sort((a, b) => a.reblog
|
||||
? (!b.reblog || (a.favourite && !b.favourite))
|
||||
? -1
|
||||
: 0
|
||||
: 0)
|
||||
results.push({
|
||||
id: `grouped-${id++}`,
|
||||
type: 'grouped-reblogs-and-favourites',
|
||||
status: group[0].status,
|
||||
likes,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
results.push(...group)
|
||||
}
|
||||
|
||||
for (const item of items.filter(includeNotificationsForStatusCard)) {
|
||||
const itemId = groupId(item)
|
||||
// Finalize the group if it already has too many notifications
|
||||
if (currentGroupId !== itemId || currentGroup.length >= groupCapacity)
|
||||
processGroup()
|
||||
|
||||
currentGroup.push(item)
|
||||
currentGroupId = itemId
|
||||
}
|
||||
// Finalize remaining groups
|
||||
processGroup()
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function removeFiltered(items: mastodon.v1.Notification[]): mastodon.v1.Notification[] {
|
||||
return items.filter(item => !item.status?.filtered?.find(
|
||||
filter => filter.filter.filterAction === 'hide' && filter.filter.context.includes('notifications'),
|
||||
))
|
||||
}
|
||||
|
||||
function preprocess(items: NotificationSlot[]): NotificationSlot[] {
|
||||
const flattenedNotifications: mastodon.v1.Notification[] = []
|
||||
for (const item of items) {
|
||||
if (item.type === 'grouped-reblogs-and-favourites') {
|
||||
const group = item
|
||||
for (const like of group.likes) {
|
||||
if (like.reblog)
|
||||
flattenedNotifications.push(like.reblog)
|
||||
if (like.favourite)
|
||||
flattenedNotifications.push(like.favourite)
|
||||
}
|
||||
}
|
||||
else if (item.type === 'grouped-follow') {
|
||||
flattenedNotifications.push(...item.items)
|
||||
}
|
||||
else {
|
||||
flattenedNotifications.push(item)
|
||||
}
|
||||
}
|
||||
return groupItems(removeFiltered(flattenedNotifications))
|
||||
}
|
||||
|
||||
const { clearNotifications } = useNotifications()
|
||||
const { formatNumber } = useHumanReadableNumber()
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/attribute-hyphenation -->
|
||||
<template>
|
||||
<CommonPaginator
|
||||
:paginator="paginator"
|
||||
:preprocess="preprocess"
|
||||
:stream="stream"
|
||||
eventType="notification"
|
||||
:virtualScroller="virtualScroller"
|
||||
>
|
||||
<template #updater="{ number, update }">
|
||||
<button id="elk_show_new_items" py-4 border="b base" flex="~ col" p-3 w-full text-primary font-bold @click="() => { update(); clearNotifications() }">
|
||||
{{ $t('timeline.show_new_items', number, { named: { v: formatNumber(number) } }) }}
|
||||
</button>
|
||||
</template>
|
||||
<template #default="{ item, active }">
|
||||
<template v-if="virtualScroller">
|
||||
<DynamicScrollerItem :item="item" :active="active" tag="div">
|
||||
<NotificationGroupedFollow
|
||||
v-if="item.type === 'grouped-follow'"
|
||||
:items="item"
|
||||
border="b base"
|
||||
/>
|
||||
<NotificationGroupedLikes
|
||||
v-else-if="item.type === 'grouped-reblogs-and-favourites'"
|
||||
:group="item"
|
||||
border="b base"
|
||||
/>
|
||||
<NotificationCard
|
||||
v-else
|
||||
:notification="item"
|
||||
hover:bg-active
|
||||
border="b base"
|
||||
/>
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NotificationGroupedFollow
|
||||
v-if="item.type === 'grouped-follow'"
|
||||
:items="item"
|
||||
border="b base"
|
||||
/>
|
||||
<NotificationGroupedLikes
|
||||
v-else-if="item.type === 'grouped-reblogs-and-favourites'"
|
||||
:group="item"
|
||||
border="b base"
|
||||
/>
|
||||
<NotificationCard
|
||||
v-else
|
||||
:notification="item"
|
||||
hover:bg-active
|
||||
border="b base"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</CommonPaginator>
|
||||
</template>
|
226
app/components/notification/NotificationPreferences.client.vue
Normal file
226
app/components/notification/NotificationPreferences.client.vue
Normal file
|
@ -0,0 +1,226 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{ show?: boolean }>()
|
||||
|
||||
const {
|
||||
pushNotificationData,
|
||||
saveEnabled,
|
||||
undoChanges,
|
||||
hiddenNotification,
|
||||
isSubscribed,
|
||||
isSupported,
|
||||
notificationPermission,
|
||||
updateSubscription,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
} = usePushManager()
|
||||
const { t } = useI18n()
|
||||
|
||||
const pwaEnabled = useAppConfig().pwaEnabled
|
||||
|
||||
const busy = ref<boolean>(false)
|
||||
const animateSave = ref<boolean>(false)
|
||||
const animateSubscription = ref<boolean>(false)
|
||||
const animateRemoveSubscription = ref<boolean>(false)
|
||||
const subscribeError = ref<string>('')
|
||||
const showSubscribeError = ref<boolean>(false)
|
||||
|
||||
function hideNotification() {
|
||||
const key = currentUser.value?.account?.acct
|
||||
if (key)
|
||||
hiddenNotification.value[key] = true
|
||||
}
|
||||
|
||||
const showWarning = computed(() => {
|
||||
if (!pwaEnabled)
|
||||
return false
|
||||
|
||||
return isSupported
|
||||
&& (!isSubscribed.value || !notificationPermission.value || notificationPermission.value === 'prompt')
|
||||
&& !(hiddenNotification.value[currentUser.value?.account?.acct ?? ''])
|
||||
})
|
||||
|
||||
async function saveSettings() {
|
||||
if (busy.value)
|
||||
return
|
||||
|
||||
busy.value = true
|
||||
await nextTick()
|
||||
animateSave.value = true
|
||||
|
||||
try {
|
||||
await updateSubscription()
|
||||
}
|
||||
catch (err) {
|
||||
// todo: handle error
|
||||
console.error(err)
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
animateSave.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doSubscribe() {
|
||||
if (busy.value)
|
||||
return
|
||||
|
||||
busy.value = true
|
||||
await nextTick()
|
||||
animateSubscription.value = true
|
||||
|
||||
try {
|
||||
const result = await subscribe()
|
||||
if (result !== 'subscribed') {
|
||||
subscribeError.value = t(`settings.notifications.push_notifications.subscription_error.${result === 'notification-denied' ? 'permission_denied' : 'request_error'}`)
|
||||
showSubscribeError.value = true
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof PushSubscriptionError) {
|
||||
subscribeError.value = t(`settings.notifications.push_notifications.subscription_error.${err.code}`)
|
||||
}
|
||||
else {
|
||||
console.error(err)
|
||||
subscribeError.value = t('settings.notifications.push_notifications.subscription_error.request_error')
|
||||
}
|
||||
showSubscribeError.value = true
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
animateSubscription.value = false
|
||||
}
|
||||
}
|
||||
async function removeSubscription() {
|
||||
if (busy.value)
|
||||
return
|
||||
|
||||
busy.value = true
|
||||
await nextTick()
|
||||
animateRemoveSubscription.value = true
|
||||
try {
|
||||
await unsubscribe()
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
animateRemoveSubscription.value = false
|
||||
}
|
||||
}
|
||||
onActivated(() => (busy.value = false))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="pwaEnabled && (showWarning || show)" aria-labelledby="pn-s">
|
||||
<Transition name="slide-down">
|
||||
<div v-if="show" flex="~ col" border="b base">
|
||||
<h3 id="pn-settings" px6 py4 mt2 font-bold text-xl flex="~ gap-1" items-center>
|
||||
{{ $t('settings.notifications.push_notifications.label') }}
|
||||
</h3>
|
||||
<template v-if="isSupported">
|
||||
<div v-if="isSubscribed" flex="~ col">
|
||||
<form flex="~ col" gap-y-2 px6 pb4 @submit.prevent="saveSettings">
|
||||
<p id="pn-instructions" text-sm pb2 aria-hidden="true">
|
||||
{{ $t('settings.notifications.push_notifications.instructions') }}
|
||||
</p>
|
||||
<fieldset flex="~ col" gap-y-1 py-1>
|
||||
<legend>{{ $t('settings.notifications.push_notifications.alerts.title') }}</legend>
|
||||
<CommonCheckbox v-model="pushNotificationData.follow" hover :label="$t('settings.notifications.push_notifications.alerts.follow')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.favourite" hover :label="$t('settings.notifications.push_notifications.alerts.favourite')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.reblog" hover :label="$t('settings.notifications.push_notifications.alerts.reblog')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.mention" hover :label="$t('settings.notifications.push_notifications.alerts.mention')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.poll" hover :label="$t('settings.notifications.push_notifications.alerts.poll')" />
|
||||
</fieldset>
|
||||
<fieldset flex="~ col" gap-y-1 py-1>
|
||||
<legend>{{ $t('settings.notifications.push_notifications.policy.title') }}</legend>
|
||||
<CommonRadio v-model="pushNotificationData.policy" hover value="all" :label="$t('settings.notifications.push_notifications.policy.all')" />
|
||||
<CommonRadio v-model="pushNotificationData.policy" hover value="followed" :label="$t('settings.notifications.push_notifications.policy.followed')" />
|
||||
<CommonRadio v-model="pushNotificationData.policy" hover value="follower" :label="$t('settings.notifications.push_notifications.policy.follower')" />
|
||||
<CommonRadio v-model="pushNotificationData.policy" hover value="none" :label="$t('settings.notifications.push_notifications.policy.none')" />
|
||||
</fieldset>
|
||||
<div flex="~ col" gap-y-4 gap-x-2 py-1 sm="~ justify-between flex-row">
|
||||
<button
|
||||
btn-solid font-bold py2 full-w sm-wa flex="~ gap2 center"
|
||||
:class="busy || !saveEnabled ? 'border-transparent' : null"
|
||||
:disabled="busy || !saveEnabled"
|
||||
>
|
||||
<span v-if="busy && animateSave" aria-hidden="true" block animate-spin preserve-3d>
|
||||
<span block i-ri:loader-2-fill aria-hidden="true" />
|
||||
</span>
|
||||
<span v-else block aria-hidden="true" i-ri:save-2-fill />
|
||||
{{ $t('settings.notifications.push_notifications.save_settings') }}
|
||||
</button>
|
||||
<button
|
||||
btn-outline font-bold py2 full-w sm-wa flex="~ gap2 center"
|
||||
type="button"
|
||||
:class="busy || !saveEnabled ? 'border-transparent' : null"
|
||||
:disabled="busy || !saveEnabled"
|
||||
@click="undoChanges"
|
||||
>
|
||||
<span aria-hidden="true" class="block i-material-symbols:undo-rounded" />
|
||||
{{ $t('settings.notifications.push_notifications.undo_settings') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form flex="~ col" mt-4 @submit.prevent="removeSubscription">
|
||||
<span border="b base 2px" class="bg-$c-text-secondary" />
|
||||
<button
|
||||
btn-outline rounded-full font-bold py-4 flex="~ gap2 center" m5
|
||||
:class="busy ? 'border-transparent' : null"
|
||||
:disabled="busy"
|
||||
>
|
||||
<span v-if="busy && animateRemoveSubscription" aria-hidden="true" block animate-spin preserve-3d>
|
||||
<span block i-ri:loader-2-fill aria-hidden="true" />
|
||||
</span>
|
||||
<span v-else block aria-hidden="true" i-material-symbols:cancel-rounded />
|
||||
{{ $t('settings.notifications.push_notifications.unsubscribe') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<template v-else>
|
||||
<NotificationEnablePushNotification
|
||||
:animate="animateSubscription"
|
||||
:busy="busy"
|
||||
@hide="hideNotification"
|
||||
@subscribe="doSubscribe"
|
||||
>
|
||||
<template #error>
|
||||
<Transition name="slide-down">
|
||||
<NotificationSubscribePushNotificationError
|
||||
v-model="showSubscribeError"
|
||||
:message="subscribeError"
|
||||
/>
|
||||
</transition>
|
||||
</template>
|
||||
</NotificationEnablePushNotification>
|
||||
</template>
|
||||
</template>
|
||||
<div v-else px6 pb4 role="alert" aria-labelledby="n-unsupported">
|
||||
<p id="n-unsupported">
|
||||
{{ $t('settings.notifications.push_notifications.unsupported') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<NotificationEnablePushNotification
|
||||
v-if="showWarning && !show"
|
||||
closeable-header
|
||||
px5
|
||||
py4
|
||||
:animate="animateSubscription"
|
||||
:busy="busy"
|
||||
@hide="hideNotification"
|
||||
@subscribe="doSubscribe"
|
||||
>
|
||||
<template #error>
|
||||
<Transition name="slide-down">
|
||||
<NotificationSubscribePushNotificationError
|
||||
v-model="showSubscribeError"
|
||||
:message="subscribeError"
|
||||
/>
|
||||
</Transition>
|
||||
</template>
|
||||
</NotificationEnablePushNotification>
|
||||
</section>
|
||||
</template>
|
|
@ -0,0 +1,52 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title?: string
|
||||
message: string
|
||||
}>()
|
||||
const modelValue = defineModel<boolean>({ required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="modelValue"
|
||||
role="alert"
|
||||
aria-describedby="notification-failed"
|
||||
flex="~ col"
|
||||
gap-1 text-sm
|
||||
pt-1 ps-2 pe-1 pb-2
|
||||
text-red-600 dark:text-red-400
|
||||
border="~ base rounded red-600 dark:red-400"
|
||||
>
|
||||
<header id="notification-failed" flex justify-between>
|
||||
<div flex items-center gap-x-2 font-bold>
|
||||
<div aria-hidden="true" i-ri:error-warning-fill />
|
||||
<p>{{ title ?? $t('settings.notifications.push_notifications.subscription_error.title') }}</p>
|
||||
</div>
|
||||
<CommonTooltip placement="bottom" :content="$t('settings.notifications.push_notifications.subscription_error.clear_error')">
|
||||
<button
|
||||
flex rounded-4 p1
|
||||
hover:bg-active cursor-pointer transition-100
|
||||
:aria-label="$t('settings.notifications.push_notifications.subscription_error.clear_error')"
|
||||
@click="modelValue = false"
|
||||
>
|
||||
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
</header>
|
||||
<p>{{ message }}</p>
|
||||
<p py-2>
|
||||
<i18n-t keypath="settings.notifications.push_notifications.subscription_error.error_hint">
|
||||
<NuxtLink font-bold href="https://docs.elk.zone/pwa#faq" target="_blank" inline-flex="~ row" items-center gap-x-2>
|
||||
https://docs.elk.zone/pwa#faq
|
||||
<span inline-block aria-hidden="true" i-ri:external-link-line class="rtl-flip" />
|
||||
</NuxtLink>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<p py-2>
|
||||
<NuxtLink font-bold text-primary href="https://github.com/elk-zone/elk" target="_blank" flex="~ row" items-center gap-x-2>
|
||||
{{ $t('settings.notifications.push_notifications.subscription_error.repo_link') }}
|
||||
<span inline-block aria-hidden="true" i-ri:external-link-line class="rtl-flip" />
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
Loading…
Add table
Add a link
Reference in a new issue