feat add notification center

This commit is contained in:
pa
2026-02-17 21:26:38 +09:00
parent 5d36163eef
commit ec6d224d71
8 changed files with 774 additions and 172 deletions

View File

@@ -94,6 +94,16 @@
"sort_tertiary": "Then by",
"favorite_groups": "Favorite Groups",
"favorite_groups_placeholder": "All Groups"
},
"notifications": "Notifications",
"notification_center": {
"title": "Notification Center",
"view_more": "View More",
"tab_friend": "Friend",
"tab_group": "Group",
"tab_other": "Other",
"past_notifications": "Past",
"no_notifications": "No notifications"
}
},
"view": {

View File

@@ -3,6 +3,7 @@ import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import relativeTime from 'dayjs/plugin/relativeTime';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
@@ -13,4 +14,5 @@ export function initDayjs() {
dayjs.extend(isSameOrAfter);
dayjs.extend(localizedFormat);
dayjs.extend(customParseFormat);
dayjs.extend(relativeTime);
}

View File

@@ -1,11 +1,14 @@
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n';
import Noty from 'noty';
import {
checkCanInvite,
displayLocation,
escapeTag,
extractFileId,
extractFileVersion,
getUserMemo,
@@ -14,6 +17,7 @@ import {
replaceBioSymbols
} from '../shared/utils';
import {
friendRequest,
instanceRequest,
notificationRequest,
userRequest,
@@ -29,6 +33,7 @@ import { useGameStore } from './game';
import { useGeneralSettingsStore } from './settings/general';
import { useInstanceStore } from './instance';
import { useLocationStore } from './location';
import { useModalStore } from './modal';
import { useNotificationsSettingsStore } from './settings/notifications';
import { useSharedFeedStore } from './sharedFeed';
import { useUiStore } from './ui';
@@ -39,6 +44,7 @@ import { watchState } from '../service/watchState';
import configRepository from '../service/config';
export const useNotificationStore = defineStore('Notification', () => {
const { t } = useI18n();
const generalSettingsStore = useGeneralSettingsStore();
const locationStore = useLocationStore();
const favoriteStore = useFavoriteStore();
@@ -52,6 +58,7 @@ export const useNotificationStore = defineStore('Notification', () => {
const gameStore = useGameStore();
const sharedFeedStore = useSharedFeedStore();
const instanceStore = useInstanceStore();
const modalStore = useModalStore();
const notificationInitStatus = ref(false);
const notificationTable = ref({
@@ -74,6 +81,49 @@ export const useNotificationStore = defineStore('Notification', () => {
});
const unseenNotifications = ref([]);
const isNotificationsLoading = ref(false);
const isNotificationCenterOpen = ref(false);
const FRIEND_TYPES = new Set([
'friendRequest',
'ignoredFriendRequest',
'invite',
'requestInvite',
'inviteResponse',
'requestInviteResponse',
'boop'
]);
const GROUP_TYPES_PREFIX = ['group.', 'moderation.'];
const GROUP_EXACT_TYPES = new Set(['groupChange', 'event.announcement']);
function getNotificationCategory(type) {
if (!type) return 'other';
if (FRIEND_TYPES.has(type)) return 'friend';
if (
GROUP_EXACT_TYPES.has(type) ||
GROUP_TYPES_PREFIX.some((p) => type.startsWith(p))
)
return 'group';
return 'other';
}
const friendNotifications = computed(() =>
notificationTable.value.data.filter(
(n) => getNotificationCategory(n.type) === 'friend'
)
);
const groupNotifications = computed(() =>
notificationTable.value.data.filter(
(n) => getNotificationCategory(n.type) === 'group'
)
);
const otherNotifications = computed(() =>
notificationTable.value.data.filter(
(n) => getNotificationCategory(n.type) === 'other'
)
);
const hasUnseenNotifications = computed(
() => unseenNotifications.value.length > 0
);
const notyMap = {};
@@ -2374,6 +2424,144 @@ export const useNotificationStore = defineStore('Notification', () => {
});
}
function acceptFriendRequestNotification(row) {
modalStore
.confirm({
description: t('confirm.accept_friend_request'),
title: t('confirm.title')
})
.then(({ ok }) => {
if (!ok) return;
notificationRequest.acceptFriendRequestNotification({
notificationId: row.id
});
})
.catch(() => {});
}
async function hideNotification(row) {
if (row.type === 'ignoredFriendRequest') {
const args = await friendRequest.deleteHiddenFriendRequest(
{ notificationId: row.id },
row.senderUserId
);
handleNotificationHide(args);
} else {
notificationRequest.hideNotification({
notificationId: row.id
});
}
}
function hideNotificationPrompt(row) {
modalStore
.confirm({
description: t('confirm.decline_type', { type: row.type }),
title: t('confirm.title')
})
.then(({ ok }) => {
if (ok) hideNotification(row);
})
.catch(() => {});
}
function acceptRequestInvite(row) {
modalStore
.confirm({
description: t('confirm.send_invite'),
title: t('confirm.title')
})
.then(({ ok }) => {
if (!ok) return;
let currentLocation = locationStore.lastLocation.location;
if (locationStore.lastLocation.location === 'traveling') {
currentLocation = locationStore.lastLocationDestination;
}
if (!currentLocation) {
currentLocation = userStore.currentUser?.$locationTag;
}
const L = parseLocation(currentLocation);
worldRequest
.getCachedWorld({ worldId: L.worldId })
.then((args) => {
notificationRequest
.sendInvite(
{
instanceId: L.tag,
worldId: L.tag,
worldName: args.ref.name,
rsvp: true
},
row.senderUserId
)
.then((_args) => {
toast(t('message.invite.sent'));
notificationRequest.hideNotification({
notificationId: row.id
});
return _args;
});
});
})
.catch(() => {});
}
function sendNotificationResponse(notificationId, responses, responseType) {
if (!Array.isArray(responses) || responses.length === 0) return;
let responseData = '';
for (let i = 0; i < responses.length; i++) {
if (responses[i].type === responseType) {
responseData = responses[i].data;
break;
}
}
const params = { notificationId, responseType, responseData };
notificationRequest
.sendNotificationResponse(params)
.then((json) => {
if (!json) return;
const args = { json, params };
handleNotificationHide(args);
new Noty({
type: 'success',
text: escapeTag(args.json)
}).show();
})
.catch((err) => {
handleNotificationHide({ params });
notificationRequest.hideNotificationV2(params.notificationId);
console.error('Notification response failed', err);
toast.error('Error');
});
}
function deleteNotificationLog(row) {
const idx = notificationTable.value.data.findIndex(
(e) => e.id === row.id
);
if (idx !== -1) {
notificationTable.value.data.splice(idx, 1);
}
if (
row.type !== 'friendRequest' &&
row.type !== 'ignoredFriendRequest'
) {
database.deleteNotification(row.id);
}
}
function deleteNotificationLogPrompt(row) {
modalStore
.confirm({
description: t('confirm.delete_type', { type: row.type }),
title: t('confirm.title')
})
.then(({ ok }) => {
if (ok) deleteNotificationLog(row);
})
.catch(() => {});
}
return {
notificationInitStatus,
notificationTable,
@@ -2396,6 +2584,22 @@ export const useNotificationStore = defineStore('Notification', () => {
handleNotificationHide,
handleNotification,
handleNotificationV2,
testNotification
testNotification,
// Notification actions
acceptFriendRequestNotification,
hideNotification,
hideNotificationPrompt,
acceptRequestInvite,
sendNotificationResponse,
deleteNotificationLog,
deleteNotificationLogPrompt,
isNotificationCenterOpen,
friendNotifications,
groupNotifications,
otherNotifications,
hasUnseenNotifications,
getNotificationCategory
};
});

View File

@@ -92,7 +92,6 @@
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n';
import Noty from 'noty';
import dayjs from 'dayjs';
import {
@@ -100,17 +99,13 @@
useGalleryStore,
useGroupStore,
useInviteStore,
useLocationStore,
useModalStore,
useNotificationStore,
useUserStore,
useVrcxStore
} from '../../stores';
import { convertFileUrlToImageUrl, escapeTag, parseLocation } from '../../shared/utils';
import { friendRequest, notificationRequest, worldRequest } from '../../api';
import { DataTableLayout } from '../../components/ui/data-table';
import { convertFileUrlToImageUrl } from '../../shared/utils';
import { createColumns } from './columns.jsx';
import { database } from '../../service/database';
import { useDataTableScrollHeight } from '../../composables/useDataTableScrollHeight';
import { useVrcxVueTable } from '../../lib/table/useVrcxVueTable';
@@ -120,16 +115,22 @@
const { showUserDialog } = useUserStore();
const { showGroupDialog } = useGroupStore();
const { lastLocation, lastLocationDestination } = storeToRefs(useLocationStore());
const { refreshInviteMessageTableData } = useInviteStore();
const { clearInviteImageUpload } = useGalleryStore();
const { notificationTable, isNotificationsLoading } = storeToRefs(useNotificationStore());
const { refreshNotifications, handleNotificationHide } = useNotificationStore();
const {
refreshNotifications,
acceptFriendRequestNotification,
hideNotification,
hideNotificationPrompt,
acceptRequestInvite,
sendNotificationResponse,
deleteNotificationLog,
deleteNotificationLogPrompt
} = useNotificationStore();
const { showFullscreenImageDialog } = useGalleryStore();
const { currentUser } = storeToRefs(useUserStore());
const appearanceSettingsStore = useAppearanceSettingsStore();
const vrcxStore = useVrcxStore();
const modalStore = useModalStore();
const { t } = useI18n();
@@ -329,24 +330,6 @@
return convertFileUrlToImageUrl(url);
}
function acceptFriendRequestNotification(row) {
// FIXME: 메시지 수정
modalStore
.confirm({
description: t('confirm.accept_friend_request'),
title: t('confirm.title')
})
.then(({ ok }) => {
if (!ok) {
return;
}
notificationRequest.acceptFriendRequestNotification({
notificationId: row.id
});
})
.catch(() => {});
}
function showSendInviteResponseDialog(invite) {
sendInviteResponseDialog.value.invite = invite;
sendInviteResponseDialog.value.messageSlot = {};
@@ -355,52 +338,6 @@
sendInviteResponseDialogVisible.value = true;
}
function acceptRequestInvite(row) {
modalStore
.confirm({
description: t('confirm.send_invite'),
title: t('confirm.title')
})
.then(({ ok }) => {
if (!ok) {
return;
}
let currentLocation = lastLocation.value.location;
if (lastLocation.value.location === 'traveling') {
currentLocation = lastLocationDestination.value;
}
if (!currentLocation) {
// game log disabled, use API location
currentLocation = currentUser.$locationTag;
}
const L = parseLocation(currentLocation);
worldRequest
.getCachedWorld({
worldId: L.worldId
})
.then((args) => {
notificationRequest
.sendInvite(
{
instanceId: L.tag,
worldId: L.tag,
worldName: args.ref.name,
rsvp: true
},
row.senderUserId
)
.then((_args) => {
toast(t('message.invite.sent'));
notificationRequest.hideNotification({
notificationId: row.id
});
return _args;
});
});
})
.catch(() => {});
}
function showSendInviteRequestResponseDialog(invite) {
sendInviteResponseDialog.value.invite = invite;
sendInviteResponseDialog.value.messageSlot = {};
@@ -408,101 +345,6 @@
clearInviteImageUpload();
sendInviteRequestResponseDialogVisible.value = true;
}
function sendNotificationResponse(notificationId, responses, responseType) {
if (!Array.isArray(responses) || responses.length === 0) {
return;
}
let responseData = '';
for (let i = 0; i < responses.length; i++) {
if (responses[i].type === responseType) {
responseData = responses[i].data;
break;
}
}
const params = {
notificationId,
responseType,
responseData
};
notificationRequest
.sendNotificationResponse(params)
.then((json) => {
if (!json) {
return;
}
const args = {
json,
params
};
handleNotificationHide(args);
new Noty({
type: 'success',
text: escapeTag(args.json)
}).show();
console.log('NOTIFICATION:RESPONSE', args);
})
.catch((err) => {
handleNotificationHide({ params });
notificationRequest.hideNotificationV2(params.notificationId);
console.error('Notification response failed', err);
toast.error('Error');
});
}
async function hideNotification(row) {
if (row.type === 'ignoredFriendRequest') {
const args = await friendRequest.deleteHiddenFriendRequest(
{
notificationId: row.id
},
row.senderUserId
);
useNotificationStore().handleNotificationHide(args);
} else {
notificationRequest.hideNotification({
notificationId: row.id
});
}
}
function hideNotificationPrompt(row) {
modalStore
.confirm({
description: t('confirm.decline_type', { type: row.type }),
title: t('confirm.title')
})
.then(({ ok }) => {
if (ok) {
hideNotification(row);
}
})
.catch(() => {});
}
function deleteNotificationLog(row) {
const idx = notificationTable.value.data.findIndex((e) => e.id === row.id);
if (idx !== -1) {
notificationTable.value.data.splice(idx, 1);
}
if (row.type !== 'friendRequest' && row.type !== 'ignoredFriendRequest') {
database.deleteNotification(row.id);
}
}
function deleteNotificationLogPrompt(row) {
modalStore
.confirm({
description: t('confirm.delete_type', { type: row.type }),
title: t('confirm.title')
})
.then(({ ok }) => {
if (ok) {
deleteNotificationLog(row);
}
})
.catch(() => {});
}
</script>
<style scoped>

View File

@@ -74,6 +74,18 @@
<RefreshCw v-else />
</Button>
</TooltipWrapper>
<TooltipWrapper side="bottom" :content="t('side_panel.notifications')">
<Button
class="rounded-full relative"
variant="ghost"
size="icon-sm"
@click="isNotificationCenterOpen = !isNotificationCenterOpen">
<Bell />
<span
v-if="hasUnseenNotifications"
class="absolute top-0.5 right-0.5 size-2 rounded-full bg-red-500" />
</Button>
</TooltipWrapper>
<Popover>
<PopoverTrigger as-child>
<Button class="rounded-full" variant="ghost" size="icon-sm">
@@ -235,6 +247,7 @@
</div>
</template>
</TabsUnderline>
<NotificationCenterSheet />
</div>
</template>
@@ -248,9 +261,9 @@
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Bell, RefreshCw, Settings } from 'lucide-vue-next';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { computed, ref, watch } from 'vue';
import { RefreshCw, Settings } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import { DataTableEmpty } from '@/components/ui/data-table';
import { Input } from '@/components/ui/input';
@@ -266,18 +279,21 @@
useFavoriteStore,
useFriendStore,
useGroupStore,
useNotificationStore,
useSearchStore
} from '../../stores';
import { debounce, userImage } from '../../shared/utils';
import FriendsSidebar from './components/FriendsSidebar.vue';
import GroupsSidebar from './components/GroupsSidebar.vue';
import NotificationCenterSheet from './components/NotificationCenterSheet.vue';
const { friends, isRefreshFriendsLoading, onlineFriendCount } = storeToRefs(useFriendStore());
const { refreshFriendsList } = useFriendStore();
const { quickSearchRemoteMethod, quickSearchChange } = useSearchStore();
const { quickSearchItems } = storeToRefs(useSearchStore());
const { groupInstances } = storeToRefs(useGroupStore());
const { isNotificationCenterOpen, hasUnseenNotifications } = storeToRefs(useNotificationStore());
const { t } = useI18n();
const appearanceSettingsStore = useAppearanceSettingsStore();

View File

@@ -0,0 +1,128 @@
<template>
<Sheet v-model:open="isNotificationCenterOpen">
<SheetContent side="right" class="flex flex-col p-0 sm:max-w-md px-1" @open-auto-focus.prevent>
<SheetHeader class="border-b px-4 pt-4 pb-3">
<div class="flex items-center pr-6">
<SheetTitle>{{ t('side_panel.notification_center.title') }}</SheetTitle>
</div>
</SheetHeader>
<Tabs v-model="activeTab" class="flex min-h-0 flex-1 flex-col">
<TabsList class="mr-4 ml-2 mt-2 grid w-auto grid-cols-3">
<TabsTrigger value="friend">
{{ t('side_panel.notification_center.tab_friend') }}
<span v-if="activeCount.friend" class="ml-1 text-xs text-muted-foreground">
({{ activeCount.friend }})
</span>
</TabsTrigger>
<TabsTrigger value="group">
{{ t('side_panel.notification_center.tab_group') }}
<span v-if="activeCount.group" class="ml-1 text-xs text-muted-foreground">
({{ activeCount.group }})
</span>
</TabsTrigger>
<TabsTrigger value="other">
{{ t('side_panel.notification_center.tab_other') }}
<span v-if="activeCount.other" class="ml-1 text-xs text-muted-foreground">
({{ activeCount.other }})
</span>
</TabsTrigger>
</TabsList>
<TabsContent value="friend" class="mt-0 min-h-0 flex-1 overflow-hidden">
<NotificationList
:notifications="friendNotifications"
:unseen-ids="unseenNotifications"
@show-invite-response="showSendInviteResponseDialog"
@show-invite-request-response="showSendInviteRequestResponseDialog"
@navigate-to-table="navigateToTable" />
</TabsContent>
<TabsContent value="group" class="mt-0 min-h-0 flex-1 overflow-hidden">
<NotificationList
:notifications="groupNotifications"
:unseen-ids="unseenNotifications"
@show-invite-response="showSendInviteResponseDialog"
@show-invite-request-response="showSendInviteRequestResponseDialog"
@navigate-to-table="navigateToTable" />
</TabsContent>
<TabsContent value="other" class="mt-0 min-h-0 flex-1 overflow-hidden">
<NotificationList
:notifications="otherNotifications"
:unseen-ids="unseenNotifications"
@show-invite-response="showSendInviteResponseDialog"
@show-invite-request-response="showSendInviteRequestResponseDialog"
@navigate-to-table="navigateToTable" />
</TabsContent>
</Tabs>
</SheetContent>
</Sheet>
<SendInviteResponseDialog
v-model:send-invite-response-dialog="sendInviteResponseDialog"
v-model:sendInviteResponseDialogVisible="sendInviteResponseDialogVisible" />
<SendInviteRequestResponseDialog
v-model:send-invite-response-dialog="sendInviteResponseDialog"
v-model:sendInviteRequestResponseDialogVisible="sendInviteRequestResponseDialogVisible" />
</template>
<script setup>
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useGalleryStore, useInviteStore, useNotificationStore } from '../../../stores';
import NotificationList from './NotificationList.vue';
import SendInviteRequestResponseDialog from '../../Notifications/dialogs/SendInviteRequestResponseDialog.vue';
import SendInviteResponseDialog from '../../Notifications/dialogs/SendInviteResponseDialog.vue';
const { t } = useI18n();
const router = useRouter();
const { refreshInviteMessageTableData } = useInviteStore();
const { clearInviteImageUpload } = useGalleryStore();
const {
isNotificationCenterOpen,
friendNotifications,
groupNotifications,
otherNotifications,
unseenNotifications
} = storeToRefs(useNotificationStore());
const activeTab = ref('friend');
const activeCount = computed(() => ({
friend: friendNotifications.value.filter((n) => !n.$isExpired).length,
group: groupNotifications.value.filter((n) => !n.$isExpired).length,
other: otherNotifications.value.filter((n) => !n.$isExpired).length
}));
// Dialog state
const sendInviteResponseDialog = ref({
messageSlot: {},
invite: {}
});
const sendInviteResponseDialogVisible = ref(false);
const sendInviteRequestResponseDialogVisible = ref(false);
function navigateToTable() {
isNotificationCenterOpen.value = false;
router.push({ name: 'notification' });
}
function showSendInviteResponseDialog(invite) {
sendInviteResponseDialog.value.invite = invite;
sendInviteResponseDialog.value.messageSlot = {};
refreshInviteMessageTableData('response');
clearInviteImageUpload();
sendInviteResponseDialogVisible.value = true;
}
function showSendInviteRequestResponseDialog(invite) {
sendInviteResponseDialog.value.invite = invite;
sendInviteResponseDialog.value.messageSlot = {};
refreshInviteMessageTableData('requestResponse');
clearInviteImageUpload();
sendInviteRequestResponseDialogVisible.value = true;
}
</script>

View File

@@ -0,0 +1,317 @@
<template>
<Item
size="sm"
:variant="notification.$isExpired ? 'default' : 'muted'"
:class="[{ 'opacity-50': notification.$isExpired }, 'mb-1.5']">
<ItemMedia variant="image" class="cursor-pointer" @click.stop="openSender">
<Avatar class="size-full">
<AvatarImage v-if="avatarUrl" :src="avatarUrl" />
<AvatarFallback class="text-muted-foreground">
<component :is="typeIcon" class="size-4" />
</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent class="min-w-0">
<ItemTitle class="min-w-0">
<span class="truncate cursor-pointer" @click.stop="openSender">{{ senderName }}</span>
<Badge variant="secondary" class="shrink-0 text-muted-foreground text-[10px]">
{{ typeLabel }}
</Badge>
<span
v-if="!notification.$isExpired && isUnseen"
class="ml-auto size-2 shrink-0 rounded-full bg-blue-500" />
</ItemTitle>
<TooltipWrapper v-if="displayMessage" side="top" :content="displayMessage" :delay-duration="600">
<ItemDescription class="text-xs select-none line-clamp-3">
{{ displayMessage }}
</ItemDescription>
</TooltipWrapper>
</ItemContent>
<div class="flex shrink-0 flex-col items-end gap-1">
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
{{ relativeTime }}
</span>
<div class="flex items-center gap-1">
<template v-if="!notification.$isExpired">
<TooltipWrapper
v-if="notification.type === 'friendRequest'"
side="top"
:content="t('view.notification.actions.accept')">
<button
type="button"
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
@click.stop="notificationStore.acceptFriendRequestNotification(notification)">
<Check class="size-3" />
</button>
</TooltipWrapper>
<TooltipWrapper
v-if="notification.type === 'invite'"
side="top"
:content="t('view.notification.actions.decline_with_message')">
<button
type="button"
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
@click.stop="$emit('show-invite-response', notification)">
<MessageCircle class="size-3" />
</button>
</TooltipWrapper>
<template v-if="notification.type === 'requestInvite'">
<TooltipWrapper v-if="canInvite" side="top" :content="t('view.notification.actions.invite')">
<button
type="button"
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
@click.stop="notificationStore.acceptRequestInvite(notification)">
<Check class="size-3" />
</button>
</TooltipWrapper>
<TooltipWrapper side="top" :content="t('view.notification.actions.decline_with_message')">
<button
type="button"
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
@click.stop="$emit('show-invite-request-response', notification)">
<MessageCircle class="size-3" />
</button>
</TooltipWrapper>
</template>
<template v-if="hasResponses">
<TooltipWrapper
v-for="response in notification.responses"
:key="`${response.text}:${response.type}`"
side="top"
:content="response.text">
<button
type="button"
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
@click.stop="handleResponse(response)">
<component :is="getResponseIcon(response)" class="size-3" />
</button>
</TooltipWrapper>
</template>
<TooltipWrapper v-if="showDecline" side="top" :content="t('view.notification.actions.decline')">
<button
type="button"
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-muted"
@click.stop="notificationStore.hideNotificationPrompt(notification)">
<X class="size-3" />
</button>
</TooltipWrapper>
</template>
<TooltipWrapper v-if="showDeleteLog" side="top" :content="t('view.notification.actions.delete_log')">
<button
type="button"
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-muted"
@click.stop="notificationStore.deleteNotificationLogPrompt(notification)">
<Trash2 class="size-3" />
</button>
</TooltipWrapper>
</div>
</div>
</Item>
</template>
<script setup>
import {
Ban,
Bell,
BellOff,
Check,
Link,
Mail,
MessageCircle,
Reply,
Send,
Tag,
Trash2,
UserPlus,
Users,
X
} from 'lucide-vue-next';
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { TooltipWrapper } from '@/components/ui/tooltip';
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs';
import { useGameStore, useGroupStore, useLocationStore, useNotificationStore, useUserStore } from '../../../stores';
import { checkCanInvite, userImage } from '../../../shared/utils';
const props = defineProps({
notification: { type: Object, required: true },
isUnseen: { type: Boolean, default: false }
});
defineEmits(['show-invite-response', 'show-invite-request-response']);
const { t, te } = useI18n();
const userStore = useUserStore();
const groupStore = useGroupStore();
const notificationStore = useNotificationStore();
const { lastLocation } = storeToRefs(useLocationStore());
const { isGameRunning } = storeToRefs(useGameStore());
const senderName = computed(() => {
const n = props.notification;
return n.senderUsername || n.data?.groupName || n.groupName || n.details?.groupName || '';
});
const avatarUrl = computed(() => {
const n = props.notification;
const userId = n.senderUserId;
// Group notifications: use details.imageUrl or imageUrl
if (userId?.startsWith('grp_') || n.type?.startsWith('group.') || n.type === 'groupChange') {
return n.details?.imageUrl || n.imageUrl || n.senderUserIcon || null;
}
// User notifications: try cached user first, then fallback
if (userId) {
const user = userStore.cachedUsers.get(userId);
if (user) {
return userImage(user);
}
}
return n.senderUserIcon || null;
});
const typeLabel = computed(() => {
const typeKey = `view.notification.filters.${props.notification.type}`;
return te(typeKey) ? t(typeKey) : props.notification.type;
});
const TYPE_ICON_MAP = {
friendRequest: UserPlus,
ignoredFriendRequest: UserPlus,
invite: Send,
requestInvite: Send,
inviteResponse: Send,
requestInviteResponse: Send,
boop: MessageCircle,
message: Mail
};
const typeIcon = computed(() => {
const type = props.notification.type;
if (TYPE_ICON_MAP[type]) return TYPE_ICON_MAP[type];
if (type?.startsWith('group.') || type === 'groupChange') return Users;
return Bell;
});
const displayMessage = computed(() => {
const n = props.notification;
if (n.message) return n.message;
if (n.details?.inviteMessage) return n.details.inviteMessage;
if (n.details?.requestMessage) return n.details.requestMessage;
if (n.details?.responseMessage) return n.details.responseMessage;
if (n.details?.worldName) return n.details.worldName;
return '';
});
const relativeTime = computed(() => {
const createdAt = props.notification.created_at || props.notification.createdAt;
if (!createdAt) return '';
return dayjs(createdAt).fromNow(true);
});
const showDecline = computed(() => {
const type = props.notification.type;
return (
type !== 'requestInviteResponse' &&
type !== 'inviteResponse' &&
type !== 'message' &&
type !== 'boop' &&
type !== 'groupChange' &&
!type?.includes('group.') &&
!type?.includes('moderation.') &&
!type?.includes('instance.')
);
});
const hasResponses = computed(() => Array.isArray(props.notification.responses));
const showDeleteLog = computed(() => {
const n = props.notification;
const type = n.type;
if (type === 'friendRequest' || type === 'ignoredFriendRequest') return false;
if (type?.includes('group.') || type?.includes('moderation.') || type?.includes('instance.')) return false;
if (n.link?.startsWith('economy.')) return false;
// For active notifications, group.queueReady is handled separately
if (!n.$isExpired && type === 'group.queueReady') return false;
return true;
});
const canInvite = computed(() => {
const location = lastLocation.value?.location;
return Boolean(location) && isGameRunning.value && checkCanInvite(location);
});
function getResponseIcon(response) {
if (response?.type === 'link') return Link;
switch (response?.icon) {
case 'check':
return Check;
case 'cancel':
return X;
case 'ban':
return Ban;
case 'bell-slash':
return BellOff;
case 'reply':
return props.notification.type === 'boop' ? MessageCircle : Reply;
default:
return Tag;
}
}
function handleResponse(response) {
if (response.type === 'link') {
openNotificationLink(response.data);
return;
}
if (response.icon === 'reply' && props.notification.type === 'boop') {
userStore.showSendBoopDialog(props.notification.senderUserId);
return;
}
notificationStore.sendNotificationResponse(props.notification.id, props.notification.responses, response.type);
}
function openNotificationLink(link) {
if (!link) return;
const data = link.split(':');
if (!data.length) return;
switch (data[0]) {
case 'group':
groupStore.showGroupDialog(data[1]);
break;
case 'user':
userStore.showUserDialog(data[1]);
break;
case 'event': {
const ids = data[1].split(',');
if (ids.length >= 2) {
groupStore.showGroupDialog(ids[0]);
}
break;
}
}
}
function openSender() {
const userId = props.notification.senderUserId;
if (!userId) return;
if (userId.startsWith('grp_')) {
groupStore.showGroupDialog(userId);
} else {
userStore.showUserDialog(userId);
}
}
</script>

View File

@@ -0,0 +1,83 @@
<template>
<div class="flex h-full flex-col overflow-hidden">
<div class="flex-1 overflow-y-auto">
<div v-if="activeNotifications.length" class="flex flex-col gap-0.5 p-2">
<NotificationItem
v-for="n in activeNotifications"
:key="n.id || n.type + n.created_at"
:notification="n"
:is-unseen="unseenIds.includes(n.id)"
@show-invite-response="$emit('show-invite-response', $event)"
@show-invite-request-response="$emit('show-invite-request-response', $event)" />
</div>
<div v-else class="flex items-center justify-center p-8 text-sm text-muted-foreground">
{{ t('side_panel.notification_center.no_notifications') }}
</div>
<template v-if="expiredNotifications.length">
<div class="flex items-center gap-2 px-4 py-2">
<Separator class="flex-1" />
<span class="shrink-0 text-[10px] text-muted-foreground uppercase tracking-wider">
{{ t('side_panel.notification_center.past_notifications') }}
</span>
<Separator class="flex-1" />
</div>
<div class="flex flex-col gap-0.5 px-2 pb-2">
<NotificationItem
v-for="n in expiredNotifications"
:key="n.id || n.type + n.created_at"
:notification="n"
:is-unseen="false" />
</div>
</template>
<div class="flex justify-center py-3">
<Button
variant="ghost"
size="sm"
class="text-xs text-muted-foreground"
@click="$emit('navigate-to-table')">
{{ t('side_panel.notification_center.view_more') }}
</Button>
</div>
</div>
</div>
</template>
<script setup>
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs';
import NotificationItem from './NotificationItem.vue';
const props = defineProps({
notifications: { type: Array, required: true },
unseenIds: { type: Array, default: () => [] }
});
defineEmits(['show-invite-response', 'show-invite-request-response', 'navigate-to-table']);
const { t } = useI18n();
function getTs(n) {
const raw = n?.created_at ?? n?.createdAt;
if (typeof raw === 'number') {
return raw > 1_000_000_000_000 ? raw : raw * 1000;
}
const ts = dayjs(raw).valueOf();
return Number.isFinite(ts) ? ts : 0;
}
const sortedNotifications = computed(() => [...props.notifications].sort((a, b) => getTs(b) - getTs(a)));
const activeNotifications = computed(() => sortedNotifications.value.filter((n) => !n.$isExpired));
const MAX_EXPIRED = 20;
const expiredNotifications = computed(() =>
sortedNotifications.value.filter((n) => n.$isExpired).slice(0, MAX_EXPIRED)
);
</script>