rewrite notification table

This commit is contained in:
pa
2026-01-06 21:48:56 +09:00
committed by Natsumi
parent 25c4ad8d2f
commit 5424762d5c
6 changed files with 1065 additions and 544 deletions

View File

@@ -6,7 +6,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger TooltipTrigger
} from '../../components/ui/tooltip'; } from '../../components/ui/tooltip';
import { ArrowRight, ArrowUpDown, X } from 'lucide-vue-next'; import { ArrowRight, ArrowUpDown } from 'lucide-vue-next';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { formatDateFilter } from '../../shared/utils'; import { formatDateFilter } from '../../shared/utils';
@@ -123,29 +123,24 @@ export const createColumns = ({ onDelete, onDeletePrompt }) => {
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const original = row.original; const original = row.original;
if (shiftHeld.value) {
return (
<div class="flex justify-end">
<Button
variant="ghost"
size="sm"
class="h-6 text-destructive"
onClick={() => onDelete(original)}
>
<X />
</Button>
</div>
);
}
return ( return (
<div class="flex justify-end"> <div class="flex justify-end">
<button <button
type="button" type="button"
class="inline-flex h-6 items-center justify-center text-muted-foreground hover:text-foreground" class="inline-flex h-6 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() => onDeletePrompt(original)} onClick={() =>
shiftHeld.value
? onDelete(original)
: onDeletePrompt(original)
}
> >
<i class="ri-delete-bin-line" /> <i
class={
shiftHeld.value
? 'ri-close-line text-red-600'
: 'ri-delete-bin-line'
}
/>
</button> </button>
</div> </div>
); );

View File

@@ -1,12 +1,6 @@
import Location from '../../components/Location.vue'; import Location from '../../components/Location.vue';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '../../components/ui/dropdown-menu';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -42,14 +36,6 @@ export const createColumns = ({ getCreatedAt, onDelete, onDeletePrompt }) => {
const { gameLogIsFriend, gameLogIsFavorite } = useGameLogStore(); const { gameLogIsFriend, gameLogIsFavorite } = useGameLogStore();
const { shiftHeld } = storeToRefs(useUiStore()); const { shiftHeld } = storeToRefs(useUiStore());
const handleDelete = (row) => {
if (shiftHeld.value) {
onDelete(row);
return;
}
onDeletePrompt(row);
};
return [ return [
{ {
id: 'spacer', id: 'spacer',
@@ -274,38 +260,52 @@ export const createColumns = ({ getCreatedAt, onDelete, onDeletePrompt }) => {
} }
return ( return (
<div class="flex justify-end"> <div class="flex items-center justify-end gap-2">
<DropdownMenu> {canDelete ? (
<DropdownMenuTrigger asChild> <button
<Button type="button"
variant="ghost" class="inline-flex h-6 items-center justify-center text-muted-foreground hover:text-foreground"
size="sm" onClick={() =>
class="h-7 px-2 text-xs" shiftHeld.value
> ? onDelete(original)
... : onDeletePrompt(original)
</Button> }
</DropdownMenuTrigger> >
<DropdownMenuContent align="end"> <i
{canDelete ? ( class={
<DropdownMenuItem shiftHeld.value
onSelect={() => handleDelete(original)} ? 'ri-close-line text-red-600'
> : 'ri-delete-bin-line'
Delete }
</DropdownMenuItem> />
) : null} </button>
{canShowPrevious ? ( ) : null}
<DropdownMenuItem {canShowPrevious ? (
onSelect={() => <TooltipProvider>
showPreviousInstancesInfoDialog( <Tooltip>
original.location <TooltipTrigger asChild>
) <button
} type="button"
> class="inline-flex h-6 items-center justify-center text-muted-foreground hover:text-foreground"
{t('dialog.previous_instances.info')} onClick={() =>
</DropdownMenuItem> showPreviousInstancesInfoDialog(
) : null} original.location
</DropdownMenuContent> )
</DropdownMenu> }
>
<i class="ri-file-list-2-line" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>
{t(
'dialog.previous_instances.info'
)}
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div> </div>
); );
} }

View File

@@ -39,8 +39,6 @@
</template> </template>
<script setup> <script setup>
import { Refresh } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
import { import {
getCoreRowModel, getCoreRowModel,
getFilteredRowModel, getFilteredRowModel,
@@ -49,6 +47,8 @@
useVueTable useVueTable
} from '@tanstack/vue-table'; } from '@tanstack/vue-table';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import { Refresh } from '@element-plus/icons-vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';

View File

@@ -6,7 +6,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger TooltipTrigger
} from '../../components/ui/tooltip'; } from '../../components/ui/tooltip';
import { ArrowUpDown, X } from 'lucide-vue-next'; import { ArrowUpDown } from 'lucide-vue-next';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { formatDateFilter } from '../../shared/utils'; import { formatDateFilter } from '../../shared/utils';
@@ -129,29 +129,24 @@ export const createColumns = ({ onDelete, onDeletePrompt }) => {
return null; return null;
} }
if (shiftHeld.value) {
return (
<div class="flex justify-end">
<Button
variant="ghost"
size="sm"
class="h-6 text-destructive"
onClick={() => onDelete(original)}
>
<X />
</Button>
</div>
);
}
return ( return (
<div class="flex justify-end"> <div class="flex justify-end">
<button <button
type="button" type="button"
class="inline-flex h-6 items-center justify-center text-muted-foreground hover:text-foreground" class="inline-flex h-6 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() => onDeletePrompt(original)} onClick={() =>
shiftHeld.value
? onDelete(original)
: onDeletePrompt(original)
}
> >
<i class="ri-delete-bin-line" /> <i
class={
shiftHeld.value
? 'ri-close-line text-red-600'
: 'ri-delete-bin-line'
}
/>
</button> </button>
</div> </div>
); );

View File

@@ -1,423 +1,65 @@
<template> <template>
<div v-loading="isNotificationsLoading" class="x-container" ref="notificationsRef"> <div class="x-container" ref="notificationsRef">
<div style="margin: 0 0 10px; display: flex; align-items: center"> <DataTableLayout
<el-select :table="table"
v-model="notificationTable.filters[0].value" :loading="isNotificationsLoading"
multiple :table-style="tableHeightStyle"
clearable :page-sizes="pageSizes"
style="flex: 1" :total-items="totalItems"
:placeholder="t('view.notification.filter_placeholder')" :on-page-size-change="handlePageSizeChange">
@change="saveTableFilters"> <template #toolbar>
<el-option <div style="margin: 0 0 10px; display: flex; align-items: center">
v-for="type in [ <el-select
'requestInvite', v-model="notificationTable.filters[0].value"
'invite', multiple
'requestInviteResponse', clearable
'inviteResponse', style="flex: 1"
'friendRequest', :placeholder="t('view.notification.filter_placeholder')"
'ignoredFriendRequest', @change="saveTableFilters">
'message', <el-option
'boop', v-for="type in [
'event.announcement', 'requestInvite',
'groupChange', 'invite',
'group.announcement', 'requestInviteResponse',
'group.informative', 'inviteResponse',
'group.invite', 'friendRequest',
'group.joinRequest', 'ignoredFriendRequest',
'group.transfer', 'message',
'group.queueReady', 'boop',
'moderation.warning.group', 'event.announcement',
'moderation.report.closed', 'groupChange',
'instance.closed' 'group.announcement',
]" 'group.informative',
:key="type" 'group.invite',
:label="t('view.notification.filters.' + type)" 'group.joinRequest',
:value="type" /> 'group.transfer',
</el-select> 'group.queueReady',
<el-input 'moderation.warning.group',
v-model="notificationTable.filters[1].value" 'moderation.report.closed',
:placeholder="t('view.notification.search_placeholder')" 'instance.closed'
style="flex: none; width: 150px; margin: 0 10px" /> ]"
<el-tooltip placement="bottom" :content="t('view.notification.refresh_tooltip')"> :key="type"
<el-button :label="t('view.notification.filters.' + type)"
type="default" :value="type" />
:loading="isNotificationsLoading" </el-select>
:icon="Refresh" <el-input
circle v-model="notificationTable.filters[1].value"
style="flex: none" :placeholder="t('view.notification.search_placeholder')"
@click="refreshNotifications()" /> clearable
</el-tooltip> class="flex-[0.4]"
</div> style="margin: 0 10px" />
<el-tooltip placement="bottom" :content="t('view.notification.refresh_tooltip')">
<DataTable <el-button
v-bind="notificationTable" type="default"
:data="notificationDisplayData" :loading="isNotificationsLoading"
ref="notificationTableRef" :icon="Refresh"
class="notification-table"> circle
<el-table-column :label="t('table.notification.date')" prop="created_at" width="130"> style="flex: none"
<template #default="scope"> @click="refreshNotifications()" />
<el-tooltip placement="right">
<template #content>
<span>{{ formatDateFilter(scope.row.created_at, 'long') }}</span>
</template>
<span>{{ formatDateFilter(scope.row.created_at, 'short') }}</span>
</el-tooltip> </el-tooltip>
</template> </div>
</el-table-column> </template>
</DataTableLayout>
<el-table-column :label="t('table.notification.type')" prop="type" width="180">
<template #default="scope">
<el-tag type="info" effect="plain" size="small">
<span
v-if="scope.row.type === 'invite'"
v-text="t('view.notification.filters.' + scope.row.type)"></span>
<el-tooltip
v-else-if="scope.row.type === 'group.queueReady' || scope.row.type === 'instance.closed'"
placement="top">
<template #content>
<Location
v-if="scope.row.location"
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"
:link="false" />
</template>
<span
class="x-link"
@click="showWorldDialog(scope.row.location)"
v-text="t('view.notification.filters.' + scope.row.type)"></span>
</el-tooltip>
<el-tooltip v-else-if="scope.row.link" placement="top" :content="scope.row.linkText">
<span
class="x-link"
@click="openNotificationLink(scope.row.link)"
v-text="t('view.notification.filters.' + scope.row.type)"></span>
</el-tooltip>
<span v-else v-text="t('view.notification.filters.' + scope.row.type)"></span>
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('table.notification.user')" prop="senderUsername" width="150">
<template #default="scope">
<div class="table-user-text">
<template v-if="scope.row.senderUserId && !isGroupId(scope.row.senderUserId)">
<span
class="x-link"
@click="showUserDialog(scope.row.senderUserId)"
v-text="scope.row.senderUsername"></span>
</template>
<template v-else-if="scope.row.link?.startsWith('user:')">
<span
class="x-link"
@click="openNotificationLink(scope.row.link)"
v-text="scope.row.linkText || scope.row.senderUsername"></span>
</template>
<template v-else-if="scope.row.senderUsername && !isGroupId(scope.row.senderUserId)">
<span v-text="scope.row.senderUsername"></span>
</template>
</div>
</template>
</el-table-column>
<el-table-column :label="t('table.notification.group')" prop="groupName" width="150">
<template #default="scope">
<div class="table-user-text">
<template
v-if="
scope.row.senderUserId &&
(scope.row.type === 'groupChange' || isGroupId(scope.row.senderUserId))
">
<span
class="x-link"
@click="showGroupDialog(scope.row.senderUserId)"
v-text="scope.row.senderUsername || scope.row.groupName"></span>
</template>
<template v-else-if="scope.row.type === 'groupChange' && scope.row.senderUsername">
<span v-text="scope.row.senderUsername"></span>
</template>
<template v-else-if="scope.row.link?.startsWith('group:')">
<span
class="x-link"
@click="openNotificationLink(scope.row.link)"
v-text="scope.row.data?.groupName || scope.row.linkText"></span>
</template>
<template v-else-if="scope.row.link?.startsWith('event:')">
<span
class="x-link"
@click="openNotificationLink(scope.row.link)"
v-text="scope.row.data?.groupName || scope.row.groupName || scope.row.linkText"></span>
</template>
<template v-else-if="scope.row.data?.groupName">
<span v-text="scope.row.data.groupName"></span>
</template>
<template v-else-if="scope.row.details?.groupName">
<span v-text="scope.row.details.groupName"></span>
</template>
<template v-else-if="scope.row.groupName">
<span v-text="scope.row.groupName"></span>
</template>
</div>
</template>
</el-table-column>
<el-table-column :label="t('table.notification.photo')" width="80" prop="photo">
<template #default="scope">
<template v-if="scope.row.type === 'boop'">
<Emoji
class="x-link notification-image"
@click="showFullscreenImageDialog(scope.row.details.imageUrl)"
v-if="scope.row.details?.imageUrl && !scope.row.details.imageUrl.startsWith('default_')"
:imageUrl="scope.row.details.imageUrl"
:size="30"></Emoji>
</template>
<template v-else-if="scope.row.details && scope.row.details.imageUrl">
<img
class="x-link notification-image"
:src="getSmallThumbnailUrl(scope.row.details.imageUrl)"
@click="showFullscreenImageDialog(scope.row.details.imageUrl)"
loading="lazy" />
</template>
<template v-else-if="scope.row.imageUrl">
<img
class="x-link notification-image"
:src="getSmallThumbnailUrl(scope.row.imageUrl)"
@click="showFullscreenImageDialog(scope.row.imageUrl)"
loading="lazy" />
</template>
</template>
</el-table-column>
<el-table-column :label="t('table.notification.message')" prop="message">
<template #default="scope">
<span v-if="scope.row.type === 'invite'" style="display: flex">
<Location
v-if="scope.row.details"
:location="scope.row.details.worldId"
:hint="scope.row.details.worldName"
:grouphint="scope.row.details.groupName"
:link="true" />
<br v-if="scope.row.details" />
</span>
<div
v-if="
scope.row.message &&
scope.row.message !== `This is a generated invite to ${scope.row.details?.worldName}`
"
v-text="scope.row.message"></div>
<span
v-else-if="scope.row.details && scope.row.details.inviteMessage"
v-text="scope.row.details.inviteMessage"></span>
<span
v-else-if="scope.row.details && scope.row.details.requestMessage"
v-text="scope.row.details.requestMessage"></span>
<span
v-else-if="scope.row.details && scope.row.details.responseMessage"
v-text="scope.row.details.responseMessage"></span>
</template>
</el-table-column>
<el-table-column :label="t('table.notification.action')" width="100" align="right">
<template #default="scope">
<template v-if="scope.row.senderUserId !== currentUser.id && !scope.row.$isExpired">
<template v-if="scope.row.type === 'friendRequest'">
<el-tooltip placement="top" content="Accept">
<el-button
text
:icon="Check"
style="color: var(--el-color-success)"
size="small"
class="button-pd-0"
@click="acceptFriendRequestNotification(scope.row)" />
</el-tooltip>
</template>
<template v-else-if="scope.row.type === 'invite'">
<el-tooltip placement="top" content="Decline with message">
<el-button
text
:icon="ChatLineSquare"
size="small"
class="button-pd-0"
@click="showSendInviteResponseDialog(scope.row)" />
</el-tooltip>
</template>
<template v-else-if="scope.row.type === 'requestInvite'">
<template
v-if="lastLocation.location && isGameRunning && checkCanInvite(lastLocation.location)">
<el-tooltip placement="top" content="Invite">
<el-button
text
:icon="Check"
style="color: var(--el-color-success)"
size="small"
class="button-pd-0"
@click="acceptRequestInvite(scope.row)" />
</el-tooltip>
</template>
<el-tooltip placement="top" content="Decline with message">
<el-button
text
:icon="ChatLineSquare"
size="small"
:class="['button-pd-0', 'ml-5']"
@click="showSendInviteRequestResponseDialog(scope.row)" />
</el-tooltip>
</template>
<template v-if="scope.row.responses">
<template v-for="response in scope.row.responses" :key="response.text">
<el-tooltip placement="top" :content="response.text">
<el-button
v-if="response.type === 'link'"
text
:icon="Link"
size="small"
:class="['button-pd-0', 'ml-5']"
@click="openNotificationLink(response.data)" />
<el-button
v-else-if="response.icon === 'check'"
text
:icon="Check"
size="small"
:class="['button-pd-0', 'ml-5']"
@click="
sendNotificationResponse(scope.row.id, scope.row.responses, response.type)
" />
<el-button
v-else-if="response.icon === 'cancel'"
text
:icon="Close"
size="small"
:class="['button-pd-0', 'ml-5']"
@click="
sendNotificationResponse(scope.row.id, scope.row.responses, response.type)
" />
<el-button
v-else-if="response.icon === 'ban'"
text
:icon="CircleClose"
size="small"
:class="['button-pd-0', 'ml-5']"
@click="
sendNotificationResponse(scope.row.id, scope.row.responses, response.type)
" />
<el-button
v-else-if="response.icon === 'bell-slash'"
text
:icon="Bell"
size="small"
:class="['button-pd-0', 'ml-5']"
@click="
sendNotificationResponse(scope.row.id, scope.row.responses, response.type)
" />
<el-button
v-else-if="response.icon === 'reply' && scope.row.type === 'boop'"
text
:icon="ChatLineSquare"
size="small"
:class="['button-pd-0', 'ml-5']"
@click="showSendBoopDialog(scope.row.senderUserId)" />
<el-button
v-else-if="response.icon === 'reply'"
text
:icon="ChatLineSquare"
size="small"
:class="['button-pd-0', 'ml-5']"
@click="
sendNotificationResponse(scope.row.id, scope.row.responses, response.type)
" />
<el-button
v-else
text
:icon="CollectionTag"
size="small"
:class="['button-pd-0', 'ml-5']"
@click="
sendNotificationResponse(scope.row.id, scope.row.responses, response.type)
" />
</el-tooltip>
</template>
</template>
<template
v-if="
scope.row.type !== 'requestInviteResponse' &&
scope.row.type !== 'inviteResponse' &&
scope.row.type !== 'message' &&
scope.row.type !== 'boop' &&
scope.row.type !== 'groupChange' &&
!scope.row.type.includes('group.') &&
!scope.row.type.includes('moderation.') &&
!scope.row.type.includes('instance.')
">
<el-tooltip placement="top" content="Decline">
<el-button
v-if="shiftHeld"
style="color: var(--el-color-danger)"
text
:icon="Close"
size="small"
:class="['button-pd-0', 'ml-5']"
@click="hideNotification(scope.row)" />
<el-button
v-else
text
:icon="Close"
size="small"
:class="['button-pd-0', 'ml-5']"
@click="hideNotificationPrompt(scope.row)" />
</el-tooltip>
</template>
</template>
<template v-if="scope.row.type === 'group.queueReady'">
<el-tooltip placement="top" content="Delete log">
<el-button
v-if="shiftHeld"
style="color: var(--el-color-danger)"
text
:icon="Delete"
size="small"
:class="['button-pd-0', 'ml-5']"
@click="deleteNotificationLog(scope.row)" />
<el-button
v-else
text
:icon="Delete"
size="small"
:class="['button-pd-0', 'ml-5']"
@click="deleteNotificationLogPrompt(scope.row)" />
</el-tooltip>
</template>
<template
v-if="
scope.row.type !== 'friendRequest' &&
scope.row.type !== 'ignoredFriendRequest' &&
!scope.row.type.includes('group.') &&
!scope.row.type.includes('moderation.')
">
<el-tooltip placement="top" content="Delete log">
<el-button
v-if="shiftHeld"
style="color: var(--el-color-danger); margin-left: 5px"
text
:icon="Close"
size="small"
class="button-pd-0"
@click="deleteNotificationLog(scope.row)" />
<el-button
v-else
text
:icon="Delete"
size="small"
:class="['button-pd-0', 'ml-5']"
@click="deleteNotificationLogPrompt(scope.row)" />
</el-tooltip>
</template>
</template>
</el-table-column>
<el-table-column width="5"></el-table-column>
</DataTable>
<SendInviteResponseDialog <SendInviteResponseDialog
v-model:send-invite-response-dialog="sendInviteResponseDialog" v-model:send-invite-response-dialog="sendInviteResponseDialog"
v-model:sendInviteResponseDialogVisible="sendInviteResponseDialogVisible" /> v-model:sendInviteResponseDialogVisible="sendInviteResponseDialogVisible" />
@@ -429,18 +71,15 @@
<script setup> <script setup>
import { import {
Bell, getCoreRowModel,
ChatLineSquare, getFilteredRowModel,
Check, getPaginationRowModel,
CircleClose, getSortedRowModel,
Close, useVueTable
CollectionTag, } from '@tanstack/vue-table';
Delete, import { computed, ref, watch } from 'vue';
Link,
Refresh
} from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, ref } from 'vue'; import { Refresh } from '@element-plus/icons-vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@@ -448,49 +87,47 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import {
useAppearanceSettingsStore,
useGalleryStore, useGalleryStore,
useGameStore,
useGroupStore, useGroupStore,
useInviteStore, useInviteStore,
useLocationStore, useLocationStore,
useNotificationStore, useNotificationStore,
useUiStore,
useUserStore, useUserStore,
useWorldStore useVrcxStore
} from '../../stores'; } from '../../stores';
import { import { convertFileUrlToImageUrl, escapeTag, parseLocation, removeFromArray } from '../../shared/utils';
checkCanInvite,
convertFileUrlToImageUrl,
escapeTag,
formatDateFilter,
parseLocation,
removeFromArray
} from '../../shared/utils';
import { friendRequest, notificationRequest, worldRequest } from '../../api'; import { friendRequest, notificationRequest, worldRequest } from '../../api';
import { DataTableLayout } from '../../components/ui/data-table';
import { createColumns } from './columns.jsx';
import { database } from '../../service/database'; import { database } from '../../service/database';
import { useTableHeight } from '../../composables/useTableHeight'; import { useDataTableScrollHeight } from '../../composables/useDataTableScrollHeight';
import { valueUpdater } from '../../components/ui/table/utils';
import Emoji from '../../components/Emoji.vue';
import SendInviteRequestResponseDialog from './dialogs/SendInviteRequestResponseDialog.vue'; import SendInviteRequestResponseDialog from './dialogs/SendInviteRequestResponseDialog.vue';
import SendInviteResponseDialog from './dialogs/SendInviteResponseDialog.vue'; import SendInviteResponseDialog from './dialogs/SendInviteResponseDialog.vue';
import configRepository from '../../service/config'; import configRepository from '../../service/config';
const { showUserDialog, showSendBoopDialog } = useUserStore(); const { showUserDialog } = useUserStore();
const { showWorldDialog } = useWorldStore();
const { showGroupDialog } = useGroupStore(); const { showGroupDialog } = useGroupStore();
const { lastLocation, lastLocationDestination } = storeToRefs(useLocationStore()); const { lastLocation, lastLocationDestination } = storeToRefs(useLocationStore());
const { refreshInviteMessageTableData } = useInviteStore(); const { refreshInviteMessageTableData } = useInviteStore();
const { clearInviteImageUpload } = useGalleryStore(); const { clearInviteImageUpload } = useGalleryStore();
const { notificationTable, isNotificationsLoading } = storeToRefs(useNotificationStore()); const { notificationTable, isNotificationsLoading } = storeToRefs(useNotificationStore());
const { refreshNotifications, handleNotificationHide } = useNotificationStore(); const { refreshNotifications, handleNotificationHide } = useNotificationStore();
const { isGameRunning } = storeToRefs(useGameStore());
const { showFullscreenImageDialog } = useGalleryStore(); const { showFullscreenImageDialog } = useGalleryStore();
const { currentUser } = storeToRefs(useUserStore()); const { currentUser } = storeToRefs(useUserStore());
const { shiftHeld } = storeToRefs(useUiStore()); const appearanceSettingsStore = useAppearanceSettingsStore();
const vrcxStore = useVrcxStore();
const { t } = useI18n(); const { t } = useI18n();
const { containerRef: notificationsRef } = useTableHeight(notificationTable); const notificationsRef = ref(null);
const { tableStyle: tableHeightStyle } = useDataTableScrollHeight(notificationsRef, {
offset: 30,
toolbarHeight: 54,
paginationHeight: 52
});
function getNotificationCreatedAt(row) { function getNotificationCreatedAt(row) {
if (typeof row?.created_at === 'string' && row.created_at.length > 0) { if (typeof row?.created_at === 'string' && row.created_at.length > 0) {
@@ -514,18 +151,124 @@
return Number.isFinite(ts) ? ts : 0; return Number.isFinite(ts) ? ts : 0;
} }
const notificationDisplayData = computed(() => { const asRawArray = (value) => (Array.isArray(value) ? value : []);
return notificationTable.value.data.slice().sort((a, b) => { const isEmptyFilterValue = (value) => (Array.isArray(value) ? value.length === 0 : !value);
const aTs = getNotificationCreatedAtTs(a); const applyFilter = (row, filter) => {
const bTs = getNotificationCreatedAtTs(b); if (Array.isArray(filter.prop)) {
if (aTs !== bTs) { return filter.prop.some((propItem) => applyFilter(row, { prop: propItem, value: filter.value }));
return bTs - aTs; }
}
const aId = typeof a?.id === 'string' ? a.id : ''; const cellValue = row[filter.prop];
const bId = typeof b?.id === 'string' ? b.id : ''; if (cellValue === undefined || cellValue === null) {
return aId < bId ? 1 : aId > bId ? -1 : 0; return false;
}); }
if (Array.isArray(filter.value)) {
return filter.value.some((val) => String(cellValue).toLowerCase() === String(val).toLowerCase());
}
return String(cellValue).toLowerCase().includes(String(filter.value).toLowerCase());
};
const notificationDisplayData = computed(() => {
const rawData = asRawArray(notificationTable.value.data);
const rawFilters = Array.isArray(notificationTable.value.filters) ? notificationTable.value.filters : [];
const activeFilters = rawFilters.filter((filter) => !isEmptyFilterValue(filter?.value));
const filtered =
activeFilters.length === 0
? rawData
: rawData.filter((row) => {
for (const filter of activeFilters) {
if (filter.filterFn) {
if (!filter.filterFn(row, filter)) {
return false;
}
continue;
}
if (!applyFilter(row, filter)) {
return false;
}
}
return true;
});
return filtered;
});
const columns = createColumns({
getNotificationCreatedAt,
getNotificationCreatedAtTs,
openNotificationLink,
showFullscreenImageDialog,
getSmallThumbnailUrl,
acceptFriendRequestNotification,
showSendInviteResponseDialog,
showSendInviteRequestResponseDialog,
acceptRequestInvite,
sendNotificationResponse,
hideNotification,
hideNotificationPrompt,
deleteNotificationLog,
deleteNotificationLogPrompt
});
const pageSizes = computed(() => appearanceSettingsStore.tablePageSizes);
const pageSize = computed(() =>
notificationTable.value.pageSizeLinked
? appearanceSettingsStore.tablePageSize
: notificationTable.value.pageSize
);
const sorting = ref([{ id: 'created_at', desc: true }]);
const pagination = ref({
pageIndex: 0,
pageSize: pageSize.value
});
const table = useVueTable({
data: notificationDisplayData,
columns,
getRowId: (row) => row.id ?? `${row.type}:${row.senderUserId ?? ''}:${row.created_at ?? ''}`,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: (updaterOrValue) => valueUpdater(updaterOrValue, sorting),
onPaginationChange: (updaterOrValue) => valueUpdater(updaterOrValue, pagination),
state: {
get sorting() {
return sorting.value;
},
get pagination() {
return pagination.value;
}
}
});
const totalItems = computed(() => {
const length = table.getFilteredRowModel().rows.length;
const max = vrcxStore.maxTableSize;
return length > max && length < max + 51 ? max : length;
});
const handlePageSizeChange = (size) => {
if (notificationTable.value.pageSizeLinked) {
appearanceSettingsStore.setTablePageSize(size);
} else {
notificationTable.value.pageSize = size;
}
};
watch(pageSize, (size) => {
if (pagination.value.pageSize === size) {
return;
}
pagination.value = {
...pagination.value,
pageIndex: 0,
pageSize: size
};
table.setPageSize(size);
}); });
const sendInviteResponseDialog = ref({ const sendInviteResponseDialog = ref({
@@ -537,8 +280,6 @@
const sendInviteRequestResponseDialogVisible = ref(false); const sendInviteRequestResponseDialogVisible = ref(false);
const isGroupId = (id) => typeof id === 'string' && id.startsWith('grp_');
function saveTableFilters() { function saveTableFilters() {
configRepository.setString( configRepository.setString(
'VRCX_notificationTableFilters', 'VRCX_notificationTableFilters',

View File

@@ -0,0 +1,790 @@
import Location from '../../components/Location.vue';
import { Badge } from '../../components/ui/badge';
import { Button } from '../../components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '../../components/ui/tooltip';
import { ArrowUpDown } from 'lucide-vue-next';
import { storeToRefs } from 'pinia';
import { checkCanInvite, formatDateFilter } from '../../shared/utils';
import { i18n } from '../../plugin';
import {
useGameStore,
useGroupStore,
useLocationStore,
useUiStore,
useUserStore,
useWorldStore
} from '../../stores';
import Emoji from '../../components/Emoji.vue';
const { t } = i18n.global;
const isGroupId = (id) => typeof id === 'string' && id.startsWith('grp_');
export const createColumns = ({
getNotificationCreatedAt,
getNotificationCreatedAtTs,
openNotificationLink,
showFullscreenImageDialog,
getSmallThumbnailUrl,
acceptFriendRequestNotification,
showSendInviteResponseDialog,
showSendInviteRequestResponseDialog,
acceptRequestInvite,
sendNotificationResponse,
hideNotification,
hideNotificationPrompt,
deleteNotificationLog,
deleteNotificationLogPrompt
}) => {
const { showUserDialog, showSendBoopDialog } = useUserStore();
const { showWorldDialog } = useWorldStore();
const { showGroupDialog } = useGroupStore();
const { shiftHeld } = storeToRefs(useUiStore());
const { currentUser } = storeToRefs(useUserStore());
const { lastLocation } = storeToRefs(useLocationStore());
const { isGameRunning } = storeToRefs(useGameStore());
const canInvite = () => {
const location = lastLocation.value?.location;
return (
Boolean(location) && isGameRunning.value && checkCanInvite(location)
);
};
const getResponseIconClass = (response, notificationType) => {
if (response?.type === 'link') {
return 'ri-link-m';
}
switch (response?.icon) {
case 'check':
return 'ri-check-line';
case 'cancel':
return 'ri-close-line';
case 'ban':
return 'ri-forbid-2-line';
case 'bell-slash':
return 'ri-notification-off-line';
case 'reply':
return notificationType === 'boop'
? 'ri-chat-1-line'
: 'ri-reply-line';
default:
return 'ri-price-tag-3-line';
}
};
return [
{
accessorFn: (row) => getNotificationCreatedAtTs(row),
id: 'created_at',
meta: {
class: 'w-[120px]'
},
header: ({ column }) => (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
{t('table.notification.date')}
<ArrowUpDown class="ml-1 h-4 w-4" />
</Button>
),
sortingFn: (rowA, rowB, columnId) => {
const a = rowA.getValue(columnId) ?? 0;
const b = rowB.getValue(columnId) ?? 0;
if (a !== b) {
return a - b;
}
const aId =
typeof rowA.original?.id === 'string'
? rowA.original.id
: '';
const bId =
typeof rowB.original?.id === 'string'
? rowB.original.id
: '';
return aId.localeCompare(bId);
},
cell: ({ row }) => {
const createdAt = getNotificationCreatedAt(row.original);
const shortText = formatDateFilter(createdAt, 'short');
const longText = formatDateFilter(createdAt, 'long');
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>{shortText}</span>
</TooltipTrigger>
<TooltipContent side="right">
<span>{longText}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
},
{
accessorKey: 'type',
meta: {
class: 'w-[180px]'
},
header: () => t('table.notification.type'),
cell: ({ row }) => {
const original = row.original;
const label = t(`view.notification.filters.${original.type}`);
if (original.type === 'invite') {
return (
<Badge variant="outline" class="text-muted-foreground">
{label}
</Badge>
);
}
if (
original.type === 'group.queueReady' ||
original.type === 'instance.closed'
) {
return (
<Badge variant="outline" class="text-muted-foreground">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
class="x-link"
onClick={() =>
showWorldDialog(
original.location
)
}
>
{label}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{original.location ? (
<Location
location={original.location}
hint={original.worldName}
grouphint={original.groupName}
link={false}
/>
) : null}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Badge>
);
}
if (original.link) {
return (
<Badge variant="outline" class="text-muted-foreground">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
class="x-link"
onClick={() =>
openNotificationLink(
original.link
)
}
>
{label}
</span>
</TooltipTrigger>
<TooltipContent side="top">
<span>{original.linkText}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Badge>
);
}
return (
<Badge variant="outline" class="text-muted-foreground">
{label}
</Badge>
);
}
},
{
accessorKey: 'senderUsername',
meta: {
class: 'w-[150px] min-w-0 overflow-hidden'
},
header: () => t('table.notification.user'),
cell: ({ row }) => {
const original = row.original;
if (
original.senderUserId &&
!isGroupId(original.senderUserId)
) {
return (
<span class="table-user-text block w-full min-w-0 truncate">
<span
class="x-link block w-full min-w-0 truncate"
onClick={() =>
showUserDialog(original.senderUserId)
}
>
{original.senderUsername}
</span>
</span>
);
}
if (original.link?.startsWith('user:')) {
return (
<span class="table-user-text block w-full min-w-0 truncate">
<span
class="x-link block w-full min-w-0 truncate"
onClick={() =>
openNotificationLink(original.link)
}
>
{original.linkText || original.senderUsername}
</span>
</span>
);
}
if (
original.senderUsername &&
!isGroupId(original.senderUserId)
) {
return (
<span class="table-user-text block w-full min-w-0 truncate">
{original.senderUsername}
</span>
);
}
return null;
}
},
{
accessorKey: 'groupName',
meta: {
class: 'w-[150px] min-w-0 overflow-hidden'
},
header: () => t('table.notification.group'),
cell: ({ row }) => {
const original = row.original;
const label =
original.senderUsername ||
original.groupName ||
original.data?.groupName ||
original.details?.groupName ||
original.linkText;
if (
original.senderUserId &&
(original.type === 'groupChange' ||
isGroupId(original.senderUserId))
) {
return (
<span class="table-user-text block w-full min-w-0 truncate">
<span
class="x-link block w-full min-w-0 truncate"
onClick={() =>
showGroupDialog(original.senderUserId)
}
>
{label}
</span>
</span>
);
}
if (
original.type === 'groupChange' &&
original.senderUsername
) {
return (
<span class="table-user-text block w-full min-w-0 truncate">
{original.senderUsername}
</span>
);
}
if (original.link?.startsWith('group:')) {
return (
<span class="table-user-text block w-full min-w-0 truncate">
<span
class="x-link block w-full min-w-0 truncate"
onClick={() =>
openNotificationLink(original.link)
}
>
{original.data?.groupName || label}
</span>
</span>
);
}
if (original.link?.startsWith('event:')) {
return (
<span class="table-user-text block w-full min-w-0 truncate">
<span
class="x-link block w-full min-w-0 truncate"
onClick={() =>
openNotificationLink(original.link)
}
>
{original.data?.groupName ||
original.groupName ||
label}
</span>
</span>
);
}
if (original.data?.groupName) {
return (
<span class="table-user-text block w-full min-w-0 truncate">
{original.data.groupName}
</span>
);
}
if (original.details?.groupName) {
return (
<span class="table-user-text block w-full min-w-0 truncate">
{original.details.groupName}
</span>
);
}
if (original.groupName) {
return (
<span class="table-user-text block w-full min-w-0 truncate">
{original.groupName}
</span>
);
}
return null;
}
},
{
accessorKey: 'photo',
meta: {
class: 'w-[80px]'
},
header: () => t('table.notification.photo'),
cell: ({ row }) => {
const original = row.original;
if (original.type === 'boop') {
const imageUrl = original.details?.imageUrl;
if (!imageUrl || imageUrl.startsWith('default_')) {
return null;
}
return (
<Emoji
class="x-link h-[30px] w-[30px] rounded object-cover"
onClick={() => showFullscreenImageDialog(imageUrl)}
imageUrl={imageUrl}
size={30}
/>
);
}
if (original.details?.imageUrl) {
return (
<img
class="x-link h-[30px] w-[30px] rounded object-cover"
src={getSmallThumbnailUrl(
original.details.imageUrl
)}
onClick={() =>
showFullscreenImageDialog(
original.details.imageUrl
)
}
loading="lazy"
/>
);
}
if (original.imageUrl) {
return (
<img
class="x-link h-[30px] w-[30px] rounded object-cover"
src={getSmallThumbnailUrl(original.imageUrl)}
onClick={() =>
showFullscreenImageDialog(original.imageUrl)
}
loading="lazy"
/>
);
}
return null;
}
},
{
id: 'message',
header: () => t('table.notification.message'),
enableSorting: false,
meta: {
class: 'min-w-0 overflow-hidden'
},
cell: ({ row }) => {
const original = row.original;
return (
<div class="w-full min-w-0">
{original.type === 'invite' && original.details ? (
<div class="w-full min-w-0">
<Location
location={original.details.worldId}
hint={original.details.worldName}
grouphint={original.details.groupName}
link
/>
</div>
) : null}
{original.message &&
original.message !==
`This is a generated invite to ${original.details?.worldName}` ? (
<span class="block w-full min-w-0 truncate">
{original.message}
</span>
) : null}
{!original.message &&
original.details?.inviteMessage ? (
<span class="block w-full min-w-0 truncate">
{original.details.inviteMessage}
</span>
) : null}
{!original.message &&
original.details?.requestMessage ? (
<span class="block w-full min-w-0 truncate">
{original.details.requestMessage}
</span>
) : null}
{!original.message &&
original.details?.responseMessage ? (
<span class="block w-full min-w-0 truncate">
{original.details.responseMessage}
</span>
) : null}
</div>
);
}
},
{
id: 'action',
meta: {
class: 'w-[120px] max-w-[120px] text-right'
},
header: () => t('table.notification.action'),
enableSorting: false,
cell: ({ row }) => {
const original = row.original;
const hasResponses = Array.isArray(original.responses);
const showDecline =
original.type !== 'requestInviteResponse' &&
original.type !== 'inviteResponse' &&
original.type !== 'message' &&
original.type !== 'boop' &&
original.type !== 'groupChange' &&
!original.type?.includes('group.') &&
!original.type?.includes('moderation.') &&
!original.type?.includes('instance.');
const showDeleteLog =
original.type !== 'friendRequest' &&
original.type !== 'ignoredFriendRequest' &&
!original.type?.includes('group.') &&
!original.type?.includes('moderation.');
return (
<div class="flex items-center justify-end gap-2">
{original.senderUserId !== currentUser.value?.id &&
!original.$isExpired ? (
<span class="inline-flex items-center gap-2">
{original.type === 'friendRequest' ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
class="inline-flex h-6 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() =>
acceptFriendRequestNotification(
original
)
}
>
<i class="ri-check-line" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>Accept</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
{original.type === 'invite' ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
class="inline-flex h-6 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() =>
showSendInviteResponseDialog(
original
)
}
>
<i class="ri-chat-1-line" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>
Decline with message
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
{original.type === 'requestInvite' ? (
<span class="inline-flex items-center">
{canInvite() ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
class="inline-flex h-6 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() =>
acceptRequestInvite(
original
)
}
>
<i class="ri-check-line" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>Invite</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
class="inline-flex h-6 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() =>
showSendInviteRequestResponseDialog(
original
)
}
>
<i class="ri-chat-1-line" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>
Decline with message
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
) : null}
{hasResponses
? original.responses.map((response) => {
const onClick = () => {
if (response.type === 'link') {
openNotificationLink(
response.data
);
return;
}
if (
response.icon === 'reply' &&
original.type === 'boop'
) {
showSendBoopDialog(
original.senderUserId
);
return;
}
sendNotificationResponse(
original.id,
original.responses,
response.type
);
};
const iconClass =
getResponseIconClass(
response,
original.type
);
return (
<TooltipProvider
key={`${response.text}:${response.type}`}
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
class="inline-flex h-6 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={onClick}
>
<i
class={
iconClass
}
/>
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>
{response.text}
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
})
: null}
{showDecline ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
class="inline-flex h-6 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() =>
shiftHeld.value
? hideNotification(
original
)
: hideNotificationPrompt(
original
)
}
>
<i
class={
shiftHeld.value
? 'ri-close-line text-red-600'
: 'ri-close-line'
}
/>
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>Decline</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</span>
) : null}
{original.type === 'group.queueReady' ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
class="inline-flex h-6 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() =>
shiftHeld.value
? deleteNotificationLog(
original
)
: deleteNotificationLogPrompt(
original
)
}
>
<i
class={
shiftHeld.value
? 'ri-close-line text-red-600'
: 'ri-delete-bin-line'
}
/>
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>Delete log</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
{showDeleteLog ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
class="inline-flex h-6 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() =>
shiftHeld.value
? deleteNotificationLog(
original
)
: deleteNotificationLogPrompt(
original
)
}
>
<i
class={
shiftHeld.value
? 'ri-close-line text-red-600'
: 'ri-delete-bin-line'
}
/>
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>Delete log</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
);
}
},
{
id: 'trailing',
header: () => null,
enableSorting: false,
meta: {
class: 'w-[5px]'
},
cell: () => null
}
];
};