feat: notification improvements (#396)
parent
a26cedbdd4
commit
33b0f295f6
|
@ -13,7 +13,7 @@ const { link = true, avatar = true } = defineProps<{
|
|||
<NuxtLink
|
||||
:to="link ? getAccountRoute(account) : undefined"
|
||||
:class="link ? 'text-link-rounded ml-0 pl-0' : ''"
|
||||
min-w-0 flex gap-1 items-center
|
||||
min-w-0 flex gap-2 items-center
|
||||
>
|
||||
<AccountAvatar v-if="avatar" :account="account" w-5 h-5 />
|
||||
<ContentRich
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div flex="~" gap-1 items-center absolute top-0 pt-2 left-0 px-3>
|
||||
<div flex="~" gap-1 items-center absolute top-0 left-0 py-3 px-4>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -9,47 +9,55 @@ const { notification } = defineProps<{
|
|||
<template>
|
||||
<article flex flex-col relative>
|
||||
<template v-if="notification.type === 'follow'">
|
||||
<div flex ml-4 items-center absolute class="-top-2.5" right-2 px-2>
|
||||
<div flex items-center absolute px-3 py-3 bg-base rounded-br-3 top-0 left-0>
|
||||
<div i-ri:user-follow-fill mr-1 color-primary />
|
||||
<AccountInlineInfo :account="notification.account" mr1 />
|
||||
<ContentRich
|
||||
text-primary mr-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||
:content="getDisplayName(notification.account, { rich: true })"
|
||||
:emojis="notification.account.emojis"
|
||||
/>
|
||||
<span ws-nowrap>
|
||||
{{ $t('notification.followed_you') }}
|
||||
</span>
|
||||
</div>
|
||||
<AccountCard :account="notification.account" />
|
||||
<AccountBigCard :account="notification.account" />
|
||||
</template>
|
||||
<template v-if="notification.type === 'admin.sign_up'">
|
||||
<div flex p2 items-center gap-2>
|
||||
<template v-else-if="notification.type === 'admin.sign_up'">
|
||||
<div flex p3 items-center bg-shaded>
|
||||
<div i-ri:admin-fill mr-1 color-purple />
|
||||
<span>New Sign Up</span>
|
||||
<ContentRich
|
||||
text-purple mr-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||
:content="getDisplayName(notification.account, { rich: true })"
|
||||
:emojis="notification.account.emojis"
|
||||
/>
|
||||
<span>signed up</span>
|
||||
</div>
|
||||
<AccountCard :account="notification.account" px2 pb2 />
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'follow_request'">
|
||||
<div flex ml-4 items-center class="-top-2.5" absolute right-2 px-2>
|
||||
<div i-ri:user-follow-fill mr-1 />
|
||||
<div i-ri:user-follow-fill text-xl mr-1 />
|
||||
<AccountInlineInfo :account="notification.account" mr1 />
|
||||
</div>
|
||||
<!-- TODO: accept request -->
|
||||
<AccountCard :account="notification.account" />
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'favourite'">
|
||||
<CommonMetaWrapper>
|
||||
<div i-ri:heart-fill mr-1 color-red />
|
||||
<AccountInlineInfo :account="notification.account" mr1 />
|
||||
<CommonMetaWrapper z-1>
|
||||
<div i-ri:heart-fill text-xl mr-1 color-red />
|
||||
<AccountInlineInfo text-primary font-bold :account="notification.account" mr1 />
|
||||
</CommonMetaWrapper>
|
||||
<StatusCard :status="notification.status!" :decorated="true" />
|
||||
<StatusCard op50 hover:op100 :status="notification.status!" :decorated="true" />
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'reblog'">
|
||||
<CommonMetaWrapper>
|
||||
<div i-ri:repeat-fill mr-1 color-green />
|
||||
<AccountInlineInfo :account="notification.account" mr1 />
|
||||
<CommonMetaWrapper z-1>
|
||||
<div i-ri:repeat-fill text-xl mr-1 color-green />
|
||||
<AccountInlineInfo text-primary font-bold :account="notification.account" mr1 />
|
||||
</CommonMetaWrapper>
|
||||
<StatusCard :status="notification.status!" :decorated="true" />
|
||||
<StatusCard op50 hover:op100 :status="notification.status!" :decorated="true" />
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'update'">
|
||||
<CommonMetaWrapper>
|
||||
<div i-ri:edit-2-fill mr-1 text-secondary />
|
||||
<CommonMetaWrapper z-1>
|
||||
<div i-ri:edit-2-fill text-xl mr-1 text-secondary />
|
||||
<AccountInlineInfo :account="notification.account" mr1 />
|
||||
<span ws-nowrap>
|
||||
{{ $t('notification.update_status') }}
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { GroupedLikeNotifications } from '~/types'
|
||||
|
||||
const { group } = defineProps<{
|
||||
group: GroupedLikeNotifications
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article flex flex-col relative>
|
||||
<div flex flex-col class="-mb-12" py-3>
|
||||
<div v-for="like of group.likes" :key="like.account.id" flex px-3 py-1>
|
||||
<div v-if="like.reblog" i-ri:repeat-fill text-xl mr-2 color-green />
|
||||
<div v-if="like.favourite && !like.reblog" i-ri:heart-fill text-xl mr-2 color-red />
|
||||
<AccountInlineInfo text-primary font-bold :account="like.account" mr2 />
|
||||
<div v-if="like.favourite && like.reblog" i-ri:heart-fill text-xl mr-2 color-red />
|
||||
</div>
|
||||
</div>
|
||||
<StatusCard op50 hover:op100 :status="group.status!" :decorated="true" />
|
||||
</article>
|
||||
</template>
|
|
@ -1,45 +1,90 @@
|
|||
<script setup lang="ts">
|
||||
import type { Notification, Paginator, WsEvents } from 'masto'
|
||||
import type { GroupedNotifications } from '~/types'
|
||||
import type { GroupedAccountLike, NotificationSlot } from '~/types'
|
||||
|
||||
const { paginator, stream } = defineProps<{
|
||||
paginator: Paginator<any, Notification[]>
|
||||
stream?: WsEvents
|
||||
}>()
|
||||
|
||||
function groupItems(items: Notification[]): (Notification | GroupedNotifications)[] {
|
||||
const results: (Notification | GroupedNotifications)[] = []
|
||||
const groupCapacity = Number.MAX_VALUE // No limit
|
||||
const minFollowGroupSize = 5 // Below this limit, show a profile card for each follow
|
||||
|
||||
// Group by type (and status when applicable)
|
||||
const groupId = (item: Notification): string => {
|
||||
// If the update is related to an 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 groupItems(items: Notification[]): NotificationSlot[] {
|
||||
const results: NotificationSlot[] = []
|
||||
|
||||
let id = 0
|
||||
let followGroup: Notification[] = []
|
||||
let currentGroupId = ''
|
||||
let currentGroup: Notification[] = []
|
||||
const processGroup = () => {
|
||||
if (currentGroup.length === 0)
|
||||
return
|
||||
|
||||
const bump = () => {
|
||||
const alwaysGroup = true
|
||||
if (!alwaysGroup && followGroup.length === 1) {
|
||||
results.push(followGroup[0])
|
||||
followGroup = []
|
||||
}
|
||||
else if (followGroup.length > 0) {
|
||||
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' && group.length > minFollowGroupSize) {
|
||||
results.push({
|
||||
id: `grouped-${id++}`,
|
||||
type: 'grouped-follow',
|
||||
items: followGroup,
|
||||
type: `grouped-${group[0].type}`,
|
||||
items: group,
|
||||
})
|
||||
followGroup = []
|
||||
return
|
||||
}
|
||||
|
||||
const { status } = group[0]
|
||||
if (status && group.length > 1 && (group[0].type === 'reblog' || group[0].type === 'favourite')) {
|
||||
// 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) => b.reblog && !a.reblog ? 1 : -1)
|
||||
results.push({
|
||||
id: `grouped-${id++}`,
|
||||
type: 'grouped-reblogs-and-favourites',
|
||||
status,
|
||||
likes,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
results.push(...group)
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === 'follow') {
|
||||
followGroup.push(item)
|
||||
}
|
||||
else {
|
||||
bump()
|
||||
results.push(item)
|
||||
}
|
||||
}
|
||||
const itemId = groupId(item)
|
||||
// Finalize group if it already has too many notifications
|
||||
if (currentGroupId !== itemId || currentGroup.length >= groupCapacity)
|
||||
processGroup()
|
||||
|
||||
bump()
|
||||
currentGroup.push(item)
|
||||
currentGroupId = itemId
|
||||
}
|
||||
// Finalize remaining groups
|
||||
processGroup()
|
||||
|
||||
return results
|
||||
}
|
||||
|
@ -48,7 +93,7 @@ const { clearNotifications } = useNotifications()
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<CommonPaginator :paginator="paginator" :stream="stream" event-type="notification">
|
||||
<CommonPaginator :paginator="paginator" :stream="stream" :eager="3" event-type="notification">
|
||||
<template #updater="{ number, update }">
|
||||
<button py-4 border="b base" flex="~ col" p-3 w-full text-primary font-bold @click="() => { update(); clearNotifications() }">
|
||||
{{ $t('timeline.show_new_items', [number]) }}
|
||||
|
@ -61,6 +106,11 @@ const { clearNotifications } = useNotifications()
|
|||
: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"
|
||||
|
|
|
@ -66,7 +66,7 @@ const avatarOnAvatar = $(computedEager(() => useFeatureFlags().experimentalAvata
|
|||
<div i-ri:repeat-fill mr-1 text-primary />
|
||||
<AccountInlineInfo font-bold :account="rebloggedBy" :avatar="!avatarOnAvatar" />
|
||||
</CommonMetaWrapper>
|
||||
<div v-if="decorated || rebloggedBy || (showReplyTo && status.inReplyToAccountId)" h-4 />
|
||||
<div v-if="decorated || rebloggedBy || (showReplyTo && status.inReplyToAccountId)" h-6 />
|
||||
<div flex gap-4>
|
||||
<div relative>
|
||||
<AccountHoverWrapper :account="status.account" :class="rebloggedBy && avatarOnAvatar ? 'mt-4' : 'mt-1'">
|
||||
|
@ -74,7 +74,7 @@ const avatarOnAvatar = $(computedEager(() => useFeatureFlags().experimentalAvata
|
|||
<AccountAvatar w-12 h-12 :account="status.account" />
|
||||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
<div v-if="(rebloggedBy && avatarOnAvatar && rebloggedBy.id !== status.account.id)" absolute class="-top-1 -left-2" w-8 h-8 border-bg-base border-3 rounded-full>
|
||||
<div v-if="(rebloggedBy && avatarOnAvatar && rebloggedBy.id !== status.account.id)" absolute class="-top-2 -left-2" w-9 h-9 border-bg-base border-3 rounded-full>
|
||||
<AccountAvatar :account="rebloggedBy" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -9,7 +9,7 @@ const account = useAccountById(status.inReplyToAccountId)
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="status.inReplyToAccountId" absolute top-0 pt-2 right-0 px-4 flex="~ wrap" gap-1>
|
||||
<div v-if="status.inReplyToAccountId" absolute top-0 right-0 px-4 py-3 flex="~ wrap" gap-1>
|
||||
<NuxtLink
|
||||
v-if="status.inReplyToId"
|
||||
flex="~" items-center font-bold text-sm text-secondary gap-1
|
||||
|
|
|
@ -5,8 +5,9 @@ definePageMeta({
|
|||
|
||||
const { t } = useI18n()
|
||||
|
||||
const paginatorAll = useMasto().notifications.iterate()
|
||||
const paginatorMention = useMasto().notifications.iterate({ types: ['mention'] })
|
||||
// Default limit is 20 notifications, and servers are normally caped to 30
|
||||
const paginatorAll = useMasto().notifications.iterate({ limit: 30 })
|
||||
const paginatorMention = useMasto().notifications.iterate({ limit: 30, types: ['mention'] })
|
||||
|
||||
const { clearNotifications } = useNotifications()
|
||||
onActivated(clearNotifications)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { AccountCredentials, Emoji, Instance, Notification } from 'masto'
|
||||
import type { Account, AccountCredentials, Emoji, Instance, Notification, Status } from 'masto'
|
||||
|
||||
export interface AppInfo {
|
||||
id: string
|
||||
|
@ -26,8 +26,23 @@ export interface ServerInfo extends Instance {
|
|||
|
||||
export interface GroupedNotifications {
|
||||
id: string
|
||||
type: string
|
||||
type: Exclude<string, 'grouped-reblogs-and-favourites'>
|
||||
items: Notification[]
|
||||
}
|
||||
|
||||
export interface GroupedAccountLike {
|
||||
account: Account
|
||||
favourite?: Notification
|
||||
reblog?: Notification
|
||||
}
|
||||
|
||||
export interface GroupedLikeNotifications {
|
||||
id: string
|
||||
type: 'grouped-reblogs-and-favourites'
|
||||
status: Status
|
||||
likes: GroupedAccountLike[]
|
||||
}
|
||||
|
||||
export type NotificationSlot = GroupedNotifications | GroupedLikeNotifications | Notification
|
||||
|
||||
export type TranslateFn = ReturnType<typeof useI18n>['t']
|
||||
|
|
Loading…
Reference in New Issue