notifications v2 table

This commit is contained in:
Natsumi
2026-02-20 17:19:06 +11:00
parent aa6ae21033
commit 5fe2f8ddf5
15 changed files with 488 additions and 259 deletions
+66 -4
View File
@@ -1,4 +1,5 @@
import { useGalleryStore, useNotificationStore } from '../stores'; import { useGalleryStore, useNotificationStore } from '../stores';
import { notificationRequest } from '.';
import { request } from '../service/request'; import { request } from '../service/request';
/** /**
@@ -8,6 +9,10 @@ function getGalleryStore() {
return useGalleryStore(); return useGalleryStore();
} }
function getNotificationStore() {
return useNotificationStore();
}
const notificationReq = { const notificationReq = {
/** @typedef {{ /** @typedef {{
* n: number, * n: number,
@@ -201,14 +206,20 @@ const notificationReq = {
json, json,
params params
}; };
useNotificationStore().handleNotificationAccept(args); getNotificationStore().handleNotificationAccept(args);
return args; return args;
}) })
.catch((err) => { .catch((err) => {
// if friend request could not be found, delete it // if friend request could not be found, delete it
if (err && err.message && err.message.includes('404')) { if (err && err.message && err.message.includes('404')) {
useNotificationStore().handleNotificationHide({ params }); getNotificationStore().handleNotificationHide(
params.notificationId
);
} }
return {
json: null,
params
};
}); });
}, },
@@ -227,7 +238,41 @@ const notificationReq = {
json, json,
params params
}; };
useNotificationStore().handleNotificationHide(args); getNotificationStore().handleNotificationHide(
params.notificationId
);
return args;
});
},
/**
* @param {{ notificationId: string }} params
* @return { Promise<{json: any, params}> }
*/
seeNotification(params) {
return request(`auth/user/notifications/${params.notificationId}/see`, {
method: 'PUT'
}).then((json) => {
const args = {
json,
params
};
return args;
});
},
/**
* @param {{ notificationId: string }} params
* @return { Promise<{json: any, params}> }
*/
seeNotificationV2(params) {
return request(`notifications/${params.notificationId}/see`, {
method: 'POST'
}).then((json) => {
const args = {
json,
params
};
return args; return args;
}); });
}, },
@@ -244,7 +289,24 @@ const notificationReq = {
return request(`notifications/${params.notificationId}/respond`, { return request(`notifications/${params.notificationId}/respond`, {
method: 'POST', method: 'POST',
params params
}); })
.then((json) => {
const args = {
json,
params
};
return args;
})
.catch(() => {
getNotificationStore().handleNotificationV2Hide(
params.notificationId
);
notificationRequest.hideNotificationV2(params.notificationId);
return {
json: null,
params
};
});
}, },
hideNotificationV2(notificationId) { hideNotificationV2(notificationId) {
@@ -1,43 +1,45 @@
<template> <template>
<Dialog <Dialog
:open="changeAvatarImageDialogVisible" :open="changeAvatarImageDialogVisible"
@update:open="(open) => { @update:open="
if (!open) closeDialog(); (open) => {
}"> if (!open) closeDialog();
}
">
<DialogContent class="x-dialog sm:max-w-212.5"> <DialogContent class="x-dialog sm:max-w-212.5">
<DialogHeader> <DialogHeader>
<DialogTitle>{{ t('dialog.change_content_image.avatar') }}</DialogTitle> <DialogTitle>{{ t('dialog.change_content_image.avatar') }}</DialogTitle>
</DialogHeader> </DialogHeader>
<div> <div>
<input <input
id="AvatarImageUploadButton" id="AvatarImageUploadButton"
type="file" type="file"
accept="image/*" accept="image/*"
style="display: none" style="display: none"
@change="onFileChangeAvatarImage" /> @change="onFileChangeAvatarImage" />
<span>{{ t('dialog.change_content_image.description') }}</span> <span>{{ t('dialog.change_content_image.description') }}</span>
<br /> <br />
<Button <Button
variant="outline" variant="outline"
size="icon-sm" size="sm"
:disabled="changeAvatarImageDialogLoading" :disabled="changeAvatarImageDialogLoading"
@click="uploadAvatarImage"> @click="uploadAvatarImage">
<Upload /> <Upload />
{{ t('dialog.change_content_image.upload') }} {{ t('dialog.change_content_image.upload') }}
</Button> </Button>
<br /> <br />
<div class="inline-block p-1 pb-0 hover:rounded-sm"> <div class="inline-block p-1 pb-0 hover:rounded-sm">
<img :src="previousImageUrl" class="img-size" loading="lazy" /> <img :src="previousImageUrl" class="img-size" loading="lazy" />
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Upload } from 'lucide-vue-next'; import { Upload } from 'lucide-vue-next';
import { ref } from 'vue'; import { ref } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
+5 -6
View File
@@ -94,6 +94,7 @@
const { showGalleryPage, refreshEmojiTable } = useGalleryStore(); const { showGalleryPage, refreshEmojiTable } = useGalleryStore();
const { emojiTable } = storeToRefs(useGalleryStore()); const { emojiTable } = storeToRefs(useGalleryStore());
const { isLocalUserVrcPlusSupporter } = storeToRefs(useUserStore()); const { isLocalUserVrcPlusSupporter } = storeToRefs(useUserStore());
const { isNotificationExpired, handleNotificationV2Hide } = useNotificationStore();
const fileId = ref(''); const fileId = ref('');
const displayName = ref(''); const displayName = ref('');
@@ -161,14 +162,12 @@
const array = notificationTable.value.data; const array = notificationTable.value.data;
for (let i = array.length - 1; i >= 0; i--) { for (let i = array.length - 1; i >= 0; i--) {
const ref = array[i]; const ref = array[i];
if (ref.type !== 'boop' || ref.$isExpired || ref.senderUserId !== userId) { if (ref.type !== 'boop' || isNotificationExpired(ref) || ref.link !== `user:${userId}`) {
continue; continue;
} }
notificationRequest.sendNotificationResponse({ console.log('Dismissing boop notification with id', ref.id);
notificationId: ref.id, handleNotificationV2Hide(ref.id);
responseType: 'delete', notificationRequest.hideNotificationV2(ref.id);
responseData: ''
});
} }
} }
</script> </script>
+3 -2
View File
@@ -103,7 +103,7 @@
"tab_group": "Group", "tab_group": "Group",
"tab_other": "Other", "tab_other": "Other",
"past_notifications": "Past", "past_notifications": "Past",
"no_notifications": "No notifications" "no_new_notifications": "No new notifications"
} }
}, },
"view": { "view": {
@@ -334,7 +334,8 @@
}, },
"report": { "report": {
"closed": "Moderation Report Closed" "closed": "Moderation Report Closed"
} },
"contentrestriction": "Moderation Content Restriction"
}, },
"instance": { "instance": {
"closed": "Instance Closed" "closed": "Instance Closed"
+3
View File
@@ -77,6 +77,9 @@ const database = {
await sqliteService.executeNonQuery( await sqliteService.executeNonQuery(
`CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_notifications (id TEXT PRIMARY KEY, created_at TEXT, type TEXT, sender_user_id TEXT, sender_username TEXT, receiver_user_id TEXT, message TEXT, world_id TEXT, world_name TEXT, image_url TEXT, invite_message TEXT, request_message TEXT, response_message TEXT, expired INTEGER)` `CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_notifications (id TEXT PRIMARY KEY, created_at TEXT, type TEXT, sender_user_id TEXT, sender_username TEXT, receiver_user_id TEXT, message TEXT, world_id TEXT, world_name TEXT, image_url TEXT, invite_message TEXT, request_message TEXT, response_message TEXT, expired INTEGER)`
); );
await sqliteService.executeNonQuery(
`CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_notifications_v2 (id TEXT PRIMARY KEY, created_at TEXT, updated_at TEXT, expires_at TEXT, type TEXT, link TEXT, link_text TEXT, message TEXT, title TEXT, image_url TEXT, seen INTEGER, sender_user_id TEXT, sender_username TEXT, data TEXT, responses TEXT, details TEXT)`
);
await sqliteService.executeNonQuery( await sqliteService.executeNonQuery(
`CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_moderation (user_id TEXT PRIMARY KEY, updated_at TEXT, display_name TEXT, block INTEGER, mute INTEGER)` `CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_moderation (user_id TEXT PRIMARY KEY, updated_at TEXT, display_name TEXT, block INTEGER, mute INTEGER)`
); );
+83
View File
@@ -151,6 +151,89 @@ const notifications = {
'@expired': expired '@expired': expired
} }
); );
},
// notifications v2
async getNotificationsV2() {
const notifications = [];
await sqliteService.execute((dbRow) => {
const row = {
id: dbRow[0],
createdAt: dbRow[1],
updatedAt: dbRow[2],
expiresAt: dbRow[3],
type: dbRow[4],
link: dbRow[5],
linkText: dbRow[6],
message: dbRow[7],
title: dbRow[8],
imageUrl: dbRow[9],
seen: dbRow[10] === 1,
senderUserId: dbRow[11],
senderUsername: dbRow[12],
data: JSON.parse(dbRow[13] || '{}'),
responses: JSON.parse(dbRow[14] || '[]'),
details: JSON.parse(dbRow[15] || '{}')
};
// for UI table
row.created_at = row.createdAt;
row.version = 2;
notifications.unshift(row);
}, `SELECT * FROM ${dbVars.userPrefix}_notifications_v2 ORDER BY created_at DESC LIMIT ${dbVars.maxTableSize}`);
return notifications;
},
addNotificationV2ToDatabase(entry) {
sqliteService.executeNonQuery(
`INSERT OR REPLACE INTO ${dbVars.userPrefix}_notifications_v2 (id, created_at, updated_at, expires_at, type, link, link_text, message, title, image_url, seen, sender_user_id, sender_username, data, responses, details) VALUES (@id, @created_at, @updated_at, @expires_at, @type, @link, @link_text, @message, @title, @image_url, @seen, @sender_user_id, @sender_username, @data, @responses, @details)`,
{
'@id': entry.id,
'@created_at': entry.createdAt,
'@updated_at': entry.updatedAt,
'@expires_at': entry.expiresAt,
'@type': entry.type,
'@link': entry.link,
'@link_text': entry.linkText,
'@message': entry.message,
'@title': entry.title,
'@image_url': entry.imageUrl,
'@seen': entry.seen ? 1 : 0,
'@sender_user_id': entry.senderUserId,
'@sender_username': entry.senderUsername,
'@data': JSON.stringify(entry.data || {}),
'@responses': JSON.stringify(entry.responses || []),
'@details': JSON.stringify(entry.details || {})
}
);
},
expireNotificationV2(id) {
sqliteService.executeNonQuery(
`UPDATE ${dbVars.userPrefix}_notifications_v2 SET expires_at = @expires_at, seen = 1 WHERE id = @id`,
{
'@id': id,
'@expires_at': new Date().toJSON()
}
);
},
seenNotificationV2(id) {
sqliteService.executeNonQuery(
`UPDATE ${dbVars.userPrefix}_notifications_v2 SET seen = 1 WHERE id = @id`,
{
'@id': id
}
);
},
deleteNotificationV2(id) {
sqliteService.executeNonQuery(
`DELETE FROM ${dbVars.userPrefix}_notifications_v2 WHERE id = @id`,
{
'@id': id
}
);
} }
}; };
+7
View File
@@ -121,6 +121,13 @@ export function request(endpoint, options) {
if (AppDebug.debugWebRequests) { if (AppDebug.debugWebRequests) {
console.log(init, 'parsed data', response.data); console.log(init, 'parsed data', response.data);
} }
if (response.data.error) {
$throw(
response.data.error.status_code || 0,
response.data.error.message,
endpoint
);
}
return response; return response;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
+7 -35
View File
@@ -215,16 +215,8 @@ function handlePipeline(args) {
case 'notification-v2-delete': case 'notification-v2-delete':
console.log('notification-v2-delete', content); console.log('notification-v2-delete', content);
for (var id of content.ids) { for (var id of content.ids) {
notificationStore.handleNotificationHide({ notificationStore.handleNotificationV2Hide(id);
params: { notificationStore.handleNotificationSee(id);
notificationId: id
}
});
notificationStore.handleNotificationSee({
params: {
notificationId: id
}
});
} }
break; break;
@@ -239,37 +231,17 @@ function handlePipeline(args) {
break; break;
case 'see-notification': case 'see-notification':
notificationStore.handleNotificationSee({ notificationStore.handleNotificationSee(content);
params: {
notificationId: content
}
});
break; break;
case 'hide-notification': case 'hide-notification':
notificationStore.handleNotificationHide({ notificationStore.handleNotificationHide(content);
params: { notificationStore.handleNotificationSee(content);
notificationId: content
}
});
notificationStore.handleNotificationSee({
params: {
notificationId: content
}
});
break; break;
case 'response-notification': case 'response-notification':
notificationStore.handleNotificationHide({ notificationStore.handleNotificationHide(content.notificationId);
params: { notificationStore.handleNotificationSee(content.notificationId);
notificationId: content.notificationId
}
});
notificationStore.handleNotificationSee({
params: {
notificationId: content.notificationId
}
});
break; break;
case 'friend-add': case 'friend-add':
+194 -99
View File
@@ -4,6 +4,7 @@ import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Noty from 'noty'; import Noty from 'noty';
import dayjs from 'dayjs';
import { import {
checkCanInvite, checkCanInvite,
@@ -23,8 +24,8 @@ import {
userRequest, userRequest,
worldRequest worldRequest
} from '../api'; } from '../api';
import { database, dbVars } from '../service/database';
import { AppDebug } from '../service/appConfig'; import { AppDebug } from '../service/appConfig';
import { database } from '../service/database';
import { useAdvancedSettingsStore } from './settings/advanced'; import { useAdvancedSettingsStore } from './settings/advanced';
import { useAppearanceSettingsStore } from './settings/appearance'; import { useAppearanceSettingsStore } from './settings/appearance';
import { useFavoriteStore } from './favorite'; import { useFavoriteStore } from './favorite';
@@ -155,6 +156,11 @@ export const useNotificationStore = defineStore('Notification', () => {
const { ref } = args; const { ref } = args;
const array = notificationTable.value.data; const array = notificationTable.value.data;
const { length } = array; const { length } = array;
if (ref.seen) {
removeFromArray(unseenNotifications.value, ref.id);
} else if (!unseenNotifications.value.includes(ref.id)) {
unseenNotifications.value.push(ref.id);
}
for (let i = 0; i < length; ++i) { for (let i = 0; i < length; ++i) {
if (array[i].id === ref.id) { if (array[i].id === ref.id) {
array[i] = ref; array[i] = ref;
@@ -189,7 +195,6 @@ export const useNotificationStore = defineStore('Notification', () => {
) { ) {
uiStore.notifyMenu('notification'); uiStore.notifyMenu('notification');
} }
unseenNotifications.value.push(ref.id);
queueNotificationNoty(ref); queueNotificationNoty(ref);
sharedFeedStore.addEntry(ref); sharedFeedStore.addEntry(ref);
} }
@@ -206,30 +211,19 @@ export const useNotificationStore = defineStore('Notification', () => {
D.incomingRequest = true; D.incomingRequest = true;
} }
function handleNotificationHide(args) { function handleNotificationHide(notificationId) {
let ref; const ref = notificationTable.value.data.find(
const array = notificationTable.value.data; (n) => n.id === notificationId
for (let i = array.length - 1; i >= 0; i--) { );
if (array[i].id === args.params.notificationId) {
ref = array[i];
break;
}
}
if (typeof ref === 'undefined') { if (typeof ref === 'undefined') {
return; return;
} }
args.ref = ref;
if ( if (
ref.type === 'friendRequest' || ref.type === 'friendRequest' ||
ref.type === 'ignoredFriendRequest' || ref.type === 'ignoredFriendRequest' ||
ref.type.includes('.') ref.type.includes('.')
) { ) {
for (let i = array.length - 1; i >= 0; i--) { removeFromArray(notificationTable.value.data, ref);
if (array[i].id === ref.id) {
array.splice(i, 1);
break;
}
}
} else { } else {
ref.$isExpired = true; ref.$isExpired = true;
database.updateNotificationExpired(ref); database.updateNotificationExpired(ref);
@@ -242,28 +236,6 @@ export const useNotificationStore = defineStore('Notification', () => {
}); });
} }
function handleNotificationV2Update(args) {
const notificationId = args.params.notificationId;
const json = args.json;
if (!json) {
return;
}
json.id = notificationId;
handleNotification({
json,
params: {
notificationId
}
});
if (json.seen) {
handleNotificationSee({
params: {
notificationId
}
});
}
}
function handlePipelineNotification(args) { function handlePipelineNotification(args) {
const ref = args.json; const ref = args.json;
if ( if (
@@ -374,12 +346,18 @@ export const useNotificationStore = defineStore('Notification', () => {
}); });
} }
function handleNotificationSee(args) { function handleNotificationSee(notificationId) {
const { notificationId } = args.params;
removeFromArray(unseenNotifications.value, notificationId); removeFromArray(unseenNotifications.value, notificationId);
if (unseenNotifications.value.length === 0) { if (unseenNotifications.value.length === 0) {
uiStore.removeNotify('notification'); uiStore.removeNotify('notification');
} }
const ref = notificationTable.value.data.find(
(n) => n.id === notificationId
);
if (ref) {
ref.seen = true;
}
database.seenNotificationV2(ref);
} }
function handleNotificationAccept(args) { function handleNotificationAccept(args) {
@@ -435,10 +413,11 @@ export const useNotificationStore = defineStore('Notification', () => {
/** /**
* *
* @param {object} json * @param {object} data
* @returns {object} * @returns {object}
*/ */
function applyNotification(json) { function applyNotification(data) {
const json = { ...data };
if (json.message) { if (json.message) {
json.message = replaceBioSymbols(json.message); json.message = replaceBioSymbols(json.message);
} }
@@ -489,25 +468,117 @@ export const useNotificationStore = defineStore('Notification', () => {
} }
ref.details = details; ref.details = details;
} }
if (ref.type === 'boop') { return ref;
}
function applyNotificationV2(data) {
const json = { ...data };
// delete any null in json
for (const key in json) {
if (json[key] === null || typeof json[key] === 'undefined') {
delete json[key];
}
}
let ref = notificationTable.value.data.find((n) => n.id === json.id);
if (typeof ref === 'undefined') {
ref = {
id: '',
createdAt: '',
updatedAt: '',
expiresAt: '',
type: '',
link: '',
linkText: '',
message: '',
title: '',
imageUrl: '',
seen: false,
data: {},
responses: [],
version: 2,
...json
};
} else {
Object.assign(ref, json);
}
ref.created_at = ref.createdAt; // for table
// legacy handling of boops
if (ref.type === 'boop' && ref.title) {
ref.message = ref.title; ref.message = ref.title;
ref.title = '';
if (ref.details?.emojiId?.startsWith('default_')) { if (ref.details?.emojiId?.startsWith('default_')) {
ref.details.imageUrl = ref.details.emojiId; ref.imageUrl = ref.details.emojiId;
ref.message += ` ${ref.details.emojiId.replace('default_', '')}`; ref.message += ` ${ref.details.emojiId.replace('default_', '')}`;
} else { } else {
ref.details.imageUrl = `${AppDebug.endpointDomain}/file/${ref.details.emojiId}/${ref.details.emojiVersion}`; ref.imageUrl = `${AppDebug.endpointDomain}/file/${ref.details.emojiId}/${ref.details.emojiVersion}`;
} }
} }
return ref; return ref;
} }
function handleNotificationV2(args) {
const ref = applyNotificationV2(args.json);
if (ref.seen) {
removeFromArray(unseenNotifications.value, ref.id);
} else if (!unseenNotifications.value.includes(ref.id)) {
unseenNotifications.value.push(ref.id);
}
const existingNotification = notificationTable.value.data.find(
(n) => n.id === ref.id
);
if (existingNotification) {
Object.assign(existingNotification, ref);
database.addNotificationV2ToDatabase(existingNotification); // update
return;
}
if (
notificationTable.value.filters[0].value.length === 0 ||
notificationTable.value.filters[0].value.includes(ref.type)
) {
uiStore.notifyMenu('notification');
}
database.addNotificationV2ToDatabase(ref);
notificationTable.value.data.push(ref);
queueNotificationNoty(ref);
sharedFeedStore.addEntry(ref);
}
function handleNotificationV2Update(args) {
const notificationId = args.params.notificationId;
const json = { ...args.json };
if (!json) {
return;
}
json.id = notificationId;
handleNotificationV2({
json,
params: {
notificationId
}
});
if (json.seen) {
handleNotificationSee(notificationId);
}
}
function handleNotificationV2Hide(notificationId) {
database.expireNotificationV2(notificationId);
const ref = notificationTable.value.data.find(
(n) => n.id === notificationId
);
if (ref) {
ref.expiresAt = new Date().toJSON();
ref.seen = true;
}
}
function expireFriendRequestNotifications() { function expireFriendRequestNotifications() {
const array = notificationTable.value.data; const array = notificationTable.value.data;
for (let i = array.length - 1; i >= 0; i--) { for (let i = array.length - 1; i >= 0; i--) {
if ( if (
array[i].type === 'friendRequest' || array[i].type === 'friendRequest' ||
array[i].type === 'ignoredFriendRequest' || array[i].type === 'ignoredFriendRequest'
array[i].type.includes('.')
) { ) {
array.splice(i, 1); array.splice(i, 1);
} }
@@ -540,22 +611,6 @@ export const useNotificationStore = defineStore('Notification', () => {
}); });
} }
function handleNotificationV2(args) {
const json = args.json;
json.created_at = json.createdAt;
if (json.title && json.message) {
json.message = `${json.title}, ${json.message}`;
} else if (json.title) {
json.message = json.title;
}
handleNotification({
json,
params: {
notificationId: json.id
}
});
}
/** /**
* *
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -581,7 +636,6 @@ export const useNotificationStore = defineStore('Notification', () => {
} }
}); });
} }
unseenNotifications.value = [];
params.offset += 100; params.offset += 100;
if (args.json.length < 100) { if (args.json.length < 100) {
break; break;
@@ -595,23 +649,14 @@ export const useNotificationStore = defineStore('Notification', () => {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const args = const args =
await notificationRequest.getNotificationsV2(params); await notificationRequest.getNotificationsV2(params);
for (const json of args.json) { for (const json of args.json) {
json.created_at = json.createdAt; handleNotificationV2({
if (json.title && json.message) {
json.message = `${json.title}, ${json.message}`;
} else if (json.title) {
json.message = json.title;
}
handleNotification({
json, json,
params: { params: {
notificationId: json.id notificationId: json.id
} }
}); });
} }
unseenNotifications.value = [];
params.offset += 100; params.offset += 100;
if (args.json.length < 100) { if (args.json.length < 100) {
break; break;
@@ -634,7 +679,6 @@ export const useNotificationStore = defineStore('Notification', () => {
} }
}); });
} }
unseenNotifications.value = [];
params.offset += 100; params.offset += 100;
if (args.json.length < 100) { if (args.json.length < 100) {
break; break;
@@ -2412,7 +2456,16 @@ export const useNotificationStore = defineStore('Notification', () => {
async function initNotifications() { async function initNotifications() {
notificationInitStatus.value = false; notificationInitStatus.value = false;
notificationTable.value.data = await database.getNotifications(); let tableData = await database.getNotificationsV2();
let notifications = await database.getNotifications();
tableData = tableData.concat(
notifications.filter((n) => !tableData.some((t) => t.id === n.id))
);
tableData.sort(
(a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)
);
tableData.splice(dbVars.maxTableSize);
notificationTable.value.data = tableData;
refreshNotifications(); refreshNotifications();
} }
@@ -2441,11 +2494,11 @@ export const useNotificationStore = defineStore('Notification', () => {
async function hideNotification(row) { async function hideNotification(row) {
if (row.type === 'ignoredFriendRequest') { if (row.type === 'ignoredFriendRequest') {
const args = await friendRequest.deleteHiddenFriendRequest( await friendRequest.deleteHiddenFriendRequest(
{ notificationId: row.id }, { notificationId: row.id },
row.senderUserId row.senderUserId
); );
handleNotificationHide(args); handleNotificationHide(row.id);
} else { } else {
notificationRequest.hideNotification({ notificationRequest.hideNotification({
notificationId: row.id notificationId: row.id
@@ -2516,23 +2569,15 @@ export const useNotificationStore = defineStore('Notification', () => {
} }
} }
const params = { notificationId, responseType, responseData }; const params = { notificationId, responseType, responseData };
notificationRequest notificationRequest.sendNotificationResponse(params).then((args) => {
.sendNotificationResponse(params) console.log('Notification response', args);
.then((json) => { if (!args.json) return;
if (!json) return; handleNotificationV2Hide(notificationId);
const args = { json, params }; new Noty({
handleNotificationHide(args); type: 'success',
new Noty({ text: escapeTag(args.json)
type: 'success', }).show();
text: escapeTag(args.json) });
}).show();
})
.catch((err) => {
handleNotificationHide({ params });
notificationRequest.hideNotificationV2(params.notificationId);
console.error('Notification response failed', err);
toast.error(t('message.error'));
});
} }
function deleteNotificationLog(row) { function deleteNotificationLog(row) {
@@ -2546,7 +2591,11 @@ export const useNotificationStore = defineStore('Notification', () => {
row.type !== 'friendRequest' && row.type !== 'friendRequest' &&
row.type !== 'ignoredFriendRequest' row.type !== 'ignoredFriendRequest'
) { ) {
database.deleteNotification(row.id); if (!row.version || row.version < 2) {
database.deleteNotification(row.id);
} else {
database.deleteNotificationV2(row.id);
}
} }
} }
@@ -2562,6 +2611,49 @@ export const useNotificationStore = defineStore('Notification', () => {
.catch(() => {}); .catch(() => {});
} }
function isNotificationExpired(notification) {
if (notification.$isExpired !== undefined) {
return notification.$isExpired;
}
if (!notification.expiresAt) {
return false;
}
const expiresAt = dayjs(notification.expiresAt);
return expiresAt.isValid() && dayjs().isSameOrAfter(expiresAt);
}
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) {
console.error('Invalid event notification link:', data[1]);
return;
}
groupStore.showGroupDialog(ids[0]);
// ids[1] cal_ is the event id
break;
case 'openNotificationLink':
default:
toast.error('Unsupported notification link type');
break;
}
}
return { return {
notificationInitStatus, notificationInitStatus,
notificationTable, notificationTable,
@@ -2582,6 +2674,7 @@ export const useNotificationStore = defineStore('Notification', () => {
handlePipelineNotification, handlePipelineNotification,
handleNotificationV2Update, handleNotificationV2Update,
handleNotificationHide, handleNotificationHide,
handleNotificationV2Hide,
handleNotification, handleNotification,
handleNotificationV2, handleNotificationV2,
testNotification, testNotification,
@@ -2600,6 +2693,8 @@ export const useNotificationStore = defineStore('Notification', () => {
groupNotifications, groupNotifications,
otherNotifications, otherNotifications,
hasUnseenNotifications, hasUnseenNotifications,
getNotificationCategory getNotificationCategory,
isNotificationExpired,
openNotificationLink
}; };
}); });
+3 -3
View File
@@ -93,9 +93,9 @@ export const useSharedFeedStore = defineStore('SharedFeed', () => {
); );
watch( watch(
() => watchState.isLoggedIn, () => [watchState.isFriendsLoaded, watchState.isFavoritesLoaded],
(isLoggedIn) => { ([isFriendsLoaded, isFavoritesLoaded]) => {
if (isLoggedIn) { if (isFriendsLoaded && isFavoritesLoaded) {
sharedFeedData.value = []; sharedFeedData.value = [];
loadSharedFeed(); loadSharedFeed();
} }
+5 -39
View File
@@ -42,7 +42,9 @@
'group.queueReady', 'group.queueReady',
'moderation.warning.group', 'moderation.warning.group',
'moderation.report.closed', 'moderation.report.closed',
'instance.closed' 'moderation.contentrestriction',
'instance.closed',
'economy.alert'
]" ]"
:key="type" :key="type"
:value="type"> :value="type">
@@ -89,7 +91,6 @@
import { RefreshCw } from 'lucide-vue-next'; import { RefreshCw } from 'lucide-vue-next';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -97,10 +98,8 @@
import { import {
useAppearanceSettingsStore, useAppearanceSettingsStore,
useGalleryStore, useGalleryStore,
useGroupStore,
useInviteStore, useInviteStore,
useNotificationStore, useNotificationStore,
useUserStore,
useVrcxStore useVrcxStore
} from '../../stores'; } from '../../stores';
import { DataTableLayout } from '../../components/ui/data-table'; import { DataTableLayout } from '../../components/ui/data-table';
@@ -113,8 +112,6 @@
import SendInviteResponseDialog from './dialogs/SendInviteResponseDialog.vue'; import SendInviteResponseDialog from './dialogs/SendInviteResponseDialog.vue';
import configRepository from '../../service/config'; import configRepository from '../../service/config';
const { showUserDialog } = useUserStore();
const { showGroupDialog } = useGroupStore();
const { refreshInviteMessageTableData } = useInviteStore(); const { refreshInviteMessageTableData } = useInviteStore();
const { clearInviteImageUpload } = useGalleryStore(); const { clearInviteImageUpload } = useGalleryStore();
const { notificationTable, isNotificationsLoading } = storeToRefs(useNotificationStore()); const { notificationTable, isNotificationsLoading } = storeToRefs(useNotificationStore());
@@ -126,7 +123,8 @@
acceptRequestInvite, acceptRequestInvite,
sendNotificationResponse, sendNotificationResponse,
deleteNotificationLog, deleteNotificationLog,
deleteNotificationLogPrompt deleteNotificationLogPrompt,
openNotificationLink
} = useNotificationStore(); } = useNotificationStore();
const { showFullscreenImageDialog } = useGalleryStore(); const { showFullscreenImageDialog } = useGalleryStore();
const appearanceSettingsStore = useAppearanceSettingsStore(); const appearanceSettingsStore = useAppearanceSettingsStore();
@@ -294,38 +292,6 @@
saveTableFilters(); saveTableFilters();
} }
function openNotificationLink(link) {
if (!link) {
return;
}
const data = link.split(':');
if (!data.length) {
return;
}
switch (data[0]) {
case 'group':
showGroupDialog(data[1]);
break;
case 'user':
showUserDialog(data[1]);
break;
case 'event':
const ids = data[1].split(',');
if (ids.length < 2) {
console.error('Invalid event notification link:', data[1]);
return;
}
showGroupDialog(ids[0]);
// ids[1] cal_ is the event id
break;
case 'openNotificationLink':
default:
toast.error('Unsupported notification link type');
break;
}
}
function getSmallThumbnailUrl(url) { function getSmallThumbnailUrl(url) {
return convertFileUrlToImageUrl(url); return convertFileUrlToImageUrl(url);
} }
+28 -8
View File
@@ -29,7 +29,8 @@ import {
useLocationStore, useLocationStore,
useUiStore, useUiStore,
useUserStore, useUserStore,
useWorldStore useWorldStore,
useNotificationStore
} from '../../stores'; } from '../../stores';
import Emoji from '../../components/Emoji.vue'; import Emoji from '../../components/Emoji.vue';
@@ -61,6 +62,7 @@ export const createColumns = ({
const { currentUser } = storeToRefs(useUserStore()); const { currentUser } = storeToRefs(useUserStore());
const { lastLocation } = storeToRefs(useLocationStore()); const { lastLocation } = storeToRefs(useLocationStore());
const { isGameRunning } = storeToRefs(useGameStore()); const { isGameRunning } = storeToRefs(useGameStore());
const { isNotificationExpired } = useNotificationStore();
const canInvite = () => { const canInvite = () => {
const location = lastLocation.value?.location; const location = lastLocation.value?.location;
@@ -385,7 +387,8 @@ export const createColumns = ({
cell: ({ row }) => { cell: ({ row }) => {
const original = row.original; const original = row.original;
if (original.type === 'boop') { if (original.type === 'boop') {
const imageUrl = original.details?.imageUrl; const imageUrl =
original.details?.imageUrl || original.imageUrl;
if (!imageUrl || imageUrl.startsWith('default_')) { if (!imageUrl || imageUrl.startsWith('default_')) {
return null; return null;
} }
@@ -455,7 +458,28 @@ export const createColumns = ({
/> />
</div> </div>
) : null} ) : null}
{original.message && original.title ? (
<TooltipWrapper
content={`${original.title}, ${original.message}`}
delayDuration={500}
>
<span class="block w-full min-w-0 truncate">
{`${original.title}, ${original.message}`}
</span>
</TooltipWrapper>
) : null}
{!original.message && original.title ? (
<TooltipWrapper
content={original.title}
delayDuration={500}
>
<span class="block w-full min-w-0 truncate">
{original.title}
</span>
</TooltipWrapper>
) : null}
{original.message && {original.message &&
!original.title &&
original.message !== original.message !==
`This is a generated invite to ${original.details?.worldName}` ? ( `This is a generated invite to ${original.details?.worldName}` ? (
<TooltipWrapper <TooltipWrapper
@@ -529,16 +553,12 @@ export const createColumns = ({
!original.link?.startsWith('economy.'); !original.link?.startsWith('economy.');
const showDeleteLog = const showDeleteLog =
original.type !== 'friendRequest' && original.type !== 'friendRequest' &&
original.type !== 'ignoredFriendRequest' && original.type !== 'ignoredFriendRequest';
!original.type?.includes('group.') &&
!original.type?.includes('moderation.') &&
!original.type?.includes('instance.') &&
!original.link?.startsWith('economy.');
return ( return (
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
{original.senderUserId !== currentUser.value?.id && {original.senderUserId !== currentUser.value?.id &&
!original.$isExpired ? ( !isNotificationExpired(original) ? (
<span class="inline-flex items-center gap-2"> <span class="inline-flex items-center gap-2">
{original.type === 'friendRequest' ? ( {original.type === 'friendRequest' ? (
<Tooltip> <Tooltip>
@@ -92,9 +92,9 @@
const activeTab = ref('friend'); const activeTab = ref('friend');
const activeCount = computed(() => ({ const activeCount = computed(() => ({
friend: friendNotifications.value.filter((n) => !n.$isExpired).length, friend: friendNotifications.value.length,
group: groupNotifications.value.filter((n) => !n.$isExpired).length, group: groupNotifications.value.length,
other: otherNotifications.value.filter((n) => !n.$isExpired).length other: otherNotifications.value.length
})); }));
// Dialog state // Dialog state
@@ -1,8 +1,5 @@
<template> <template>
<Item <Item size="sm" variant="muted" class="mb-1.5">
size="sm"
:variant="notification.$isExpired ? 'default' : 'muted'"
:class="[{ 'opacity-50': notification.$isExpired }, 'mb-1.5']">
<ItemMedia variant="image" class="cursor-pointer" @click.stop="openSender"> <ItemMedia variant="image" class="cursor-pointer" @click.stop="openSender">
<Avatar class="size-full"> <Avatar class="size-full">
<AvatarImage v-if="avatarUrl" :src="avatarUrl" /> <AvatarImage v-if="avatarUrl" :src="avatarUrl" />
@@ -18,7 +15,7 @@
{{ typeLabel }} {{ typeLabel }}
</Badge> </Badge>
<span <span
v-if="!notification.$isExpired && isUnseen" v-if="!isNotificationExpired(notification) && !isSeen"
class="ml-auto size-2 shrink-0 rounded-full bg-blue-500" /> class="ml-auto size-2 shrink-0 rounded-full bg-blue-500" />
</ItemTitle> </ItemTitle>
<TooltipWrapper v-if="displayMessage" side="top" :content="displayMessage" :delay-duration="600"> <TooltipWrapper v-if="displayMessage" side="top" :content="displayMessage" :delay-duration="600">
@@ -33,7 +30,7 @@
{{ relativeTime }} {{ relativeTime }}
</span> </span>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<template v-if="!notification.$isExpired"> <template v-if="!isNotificationExpired(notification)">
<TooltipWrapper <TooltipWrapper
v-if="notification.type === 'friendRequest'" v-if="notification.type === 'friendRequest'"
side="top" side="top"
@@ -134,9 +131,10 @@
} from 'lucide-vue-next'; } from 'lucide-vue-next';
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item'; import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { computed, onMounted } from 'vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { TooltipWrapper } from '@/components/ui/tooltip'; import { TooltipWrapper } from '@/components/ui/tooltip';
import { computed } from 'vue'; import { notificationRequest } from '@/api';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@@ -158,6 +156,7 @@
const notificationStore = useNotificationStore(); const notificationStore = useNotificationStore();
const { lastLocation } = storeToRefs(useLocationStore()); const { lastLocation } = storeToRefs(useLocationStore());
const { isGameRunning } = storeToRefs(useGameStore()); const { isGameRunning } = storeToRefs(useGameStore());
const { openNotificationLink, isNotificationExpired, handleNotificationV2Hide } = useNotificationStore();
const senderName = computed(() => { const senderName = computed(() => {
const n = props.notification; const n = props.notification;
@@ -224,6 +223,7 @@
const showDecline = computed(() => { const showDecline = computed(() => {
const type = props.notification.type; const type = props.notification.type;
const link = props.notification.link;
return ( return (
type !== 'requestInviteResponse' && type !== 'requestInviteResponse' &&
type !== 'inviteResponse' && type !== 'inviteResponse' &&
@@ -232,7 +232,8 @@
type !== 'groupChange' && type !== 'groupChange' &&
!type?.includes('group.') && !type?.includes('group.') &&
!type?.includes('moderation.') && !type?.includes('moderation.') &&
!type?.includes('instance.') !type?.includes('instance.') &&
!link?.startsWith('economy.')
); );
}); });
@@ -242,13 +243,18 @@
const n = props.notification; const n = props.notification;
const type = n.type; const type = n.type;
if (type === 'friendRequest' || type === 'ignoredFriendRequest') return false; 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; return true;
}); });
const isSeen = computed(() => {
const n = props.notification;
if (typeof n.seen === 'boolean') {
return n.seen;
}
// Fallback for v1 notifications without seen property
return !props.isUnseen;
});
const canInvite = computed(() => { const canInvite = computed(() => {
const location = lastLocation.value?.location; const location = lastLocation.value?.location;
return Boolean(location) && isGameRunning.value && checkCanInvite(location); return Boolean(location) && isGameRunning.value && checkCanInvite(location);
@@ -284,27 +290,6 @@
notificationStore.sendNotificationResponse(props.notification.id, props.notification.responses, response.type); 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() { function openSender() {
const userId = props.notification.senderUserId; const userId = props.notification.senderUserId;
if (!userId) return; if (!userId) return;
@@ -314,4 +299,36 @@
userStore.showUserDialog(userId); userStore.showUserDialog(userId);
} }
} }
onMounted(() => {
// Mark as seen
if (isNotificationExpired(props.notification) || isSeen.value) {
return;
}
const params = { notificationId: props.notification.id };
if (!props.notification.version || props.notification.version < 2) {
notificationRequest.seeNotification({ notificationId: props.notification.id }).then((args) => {
console.log('Marked notification-v1 as seen:', args.json);
notificationStore.handleNotificationSee(props.notification.id);
});
return;
}
notificationRequest
.seeNotificationV2(params)
.then((args) => {
console.log('Marked notification-v2 as seen:', args.json);
const newArgs = {
params,
json: {
...args.json,
seen: true
}
};
notificationStore.handleNotificationV2Update(newArgs);
})
.catch((err) => {
console.error('Failed to mark notification-v2 as seen:', err);
handleNotificationV2Hide(props.notification.id);
});
});
</script> </script>
@@ -11,7 +11,7 @@
@show-invite-request-response="$emit('show-invite-request-response', $event)" /> @show-invite-request-response="$emit('show-invite-request-response', $event)" />
</div> </div>
<div v-else class="flex items-center justify-center p-8 text-sm text-muted-foreground"> <div v-else class="flex items-center justify-center p-8 text-sm text-muted-foreground">
{{ t('side_panel.notification_center.no_notifications') }} {{ t('side_panel.notification_center.no_new_notifications') }}
</div> </div>
<template v-if="expiredNotifications.length"> <template v-if="expiredNotifications.length">
@@ -73,11 +73,13 @@
const sortedNotifications = computed(() => [...props.notifications].sort((a, b) => getTs(b) - getTs(a))); const sortedNotifications = computed(() => [...props.notifications].sort((a, b) => getTs(b) - getTs(a)));
const activeNotifications = computed(() => sortedNotifications.value.filter((n) => !n.$isExpired)); const activeNotifications = computed(() =>
sortedNotifications.value.filter((n) => getTs(n) > dayjs().subtract(1, 'week').valueOf())
);
const MAX_EXPIRED = 20; const MAX_EXPIRED = 20;
const expiredNotifications = computed(() => const expiredNotifications = computed(() =>
sortedNotifications.value.filter((n) => n.$isExpired).slice(0, MAX_EXPIRED) sortedNotifications.value.filter((n) => getTs(n) <= dayjs().subtract(1, 'week').valueOf()).slice(0, MAX_EXPIRED)
); );
</script> </script>