mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-11 10:53:52 +02:00
feat add notification center
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
128
src/views/Sidebar/components/NotificationCenterSheet.vue
Normal file
128
src/views/Sidebar/components/NotificationCenterSheet.vue
Normal 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>
|
||||
317
src/views/Sidebar/components/NotificationItem.vue
Normal file
317
src/views/Sidebar/components/NotificationItem.vue
Normal 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>
|
||||
83
src/views/Sidebar/components/NotificationList.vue
Normal file
83
src/views/Sidebar/components/NotificationList.vue
Normal 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>
|
||||
Reference in New Issue
Block a user