From ec6d224d7124ef93f0ec1fd3d09a2ed96855eed7 Mon Sep 17 00:00:00 2001 From: pa Date: Tue, 17 Feb 2026 21:26:38 +0900 Subject: [PATCH] feat add notification center --- src/localization/en.json | 10 + src/plugin/dayjs.js | 2 + src/stores/notification.js | 208 +++++++++++- src/views/Notifications/Notification.vue | 180 +--------- src/views/Sidebar/Sidebar.vue | 18 +- .../components/NotificationCenterSheet.vue | 128 +++++++ .../Sidebar/components/NotificationItem.vue | 317 ++++++++++++++++++ .../Sidebar/components/NotificationList.vue | 83 +++++ 8 files changed, 774 insertions(+), 172 deletions(-) create mode 100644 src/views/Sidebar/components/NotificationCenterSheet.vue create mode 100644 src/views/Sidebar/components/NotificationItem.vue create mode 100644 src/views/Sidebar/components/NotificationList.vue diff --git a/src/localization/en.json b/src/localization/en.json index 354503c8..4665298a 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -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": { diff --git a/src/plugin/dayjs.js b/src/plugin/dayjs.js index 6975cb21..3d943d52 100644 --- a/src/plugin/dayjs.js +++ b/src/plugin/dayjs.js @@ -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); } diff --git a/src/stores/notification.js b/src/stores/notification.js index 5bce77fe..56725fa0 100644 --- a/src/stores/notification.js +++ b/src/stores/notification.js @@ -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 }; }); diff --git a/src/views/Notifications/Notification.vue b/src/views/Notifications/Notification.vue index ec3a6b34..4e67efa7 100644 --- a/src/views/Notifications/Notification.vue +++ b/src/views/Notifications/Notification.vue @@ -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(() => {}); - }