Files
VRCX/src/views/Notifications/columns.jsx
2026-03-13 20:04:34 +09:00

864 lines
38 KiB
JavaScript

import Location from '../../components/Location.vue';
import {
Avatar,
AvatarFallback,
AvatarImage
} from '../../components/ui/avatar';
import { Badge } from '../../components/ui/badge';
import { Button } from '../../components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipWrapper
} from '../../components/ui/tooltip';
import {
ArrowUpDown,
Ban,
BellOff,
Check,
ImageOff,
Link,
MessageCircle,
Reply,
Tag,
Trash2,
X
} from 'lucide-vue-next';
import { storeToRefs } from 'pinia';
import { formatDateFilter } from '../../shared/utils';
import { checkCanInvite } from '../../shared/utils/invite';
import { i18n } from '../../plugins';
import {
useGameStore,
useInstanceStore,
useLocationStore,
useUiStore,
useUserStore,
useNotificationStore
} from '../../stores';
import { showUserDialog } from '../../coordinators/userCoordinator';
import { showWorldDialog } from '../../coordinators/worldCoordinator';
import { showGroupDialog } from '../../coordinators/groupCoordinator';
import Emoji from '../../components/Emoji.vue';
const { t, te } = 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 { showSendBoopDialog } = useUserStore();
const { shiftHeld } = storeToRefs(useUiStore());
const { currentUser } = storeToRefs(useUserStore());
const { lastLocation } = storeToRefs(useLocationStore());
const { isGameRunning } = storeToRefs(useGameStore());
const { isNotificationExpired } = useNotificationStore();
const { cachedInstances } = storeToRefs(useInstanceStore());
const canInvite = () => {
const location = lastLocation.value?.location;
return (
Boolean(location) && isGameRunning.value && checkCanInvite(location, {
currentUserId: currentUser.value?.id,
lastLocationStr: lastLocation.value?.location,
cachedInstances: cachedInstances.value
})
);
};
const getResponseIcon = (response, notificationType) => {
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 notificationType === 'boop' ? MessageCircle : Reply;
default:
return Tag;
}
};
return [
{
accessorFn: (row) => getNotificationCreatedAtTs(row),
id: 'created_at',
size: 120,
meta: { label: () => t('table.notification.date') },
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 (
<Tooltip>
<TooltipTrigger asChild>
<span>{shortText}</span>
</TooltipTrigger>
<TooltipContent side="right">
<span>{longText}</span>
</TooltipContent>
</Tooltip>
);
}
},
{
accessorKey: 'type',
size: 180,
header: () => t('table.notification.type'),
meta: { label: () => t('table.notification.type') },
cell: ({ row }) => {
const original = row.original;
const typeKey = `view.notification.filters.${original.type}`;
const label = te(typeKey) ? t(typeKey) : 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">
<Tooltip>
<TooltipTrigger asChild>
<span
class="cursor-pointer"
onClick={() =>
showWorldDialog(original.location)
}
>
{label}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{original.location ? (
<Location
location={original.location}
hint={original.worldName}
grouphint={original.groupName}
link={true}
/>
) : null}
</TooltipContent>
</Tooltip>
</Badge>
);
}
if (original.link) {
return (
<Badge variant="outline" class="text-muted-foreground">
<Tooltip>
<TooltipTrigger asChild>
<span
class="cursor-pointer"
onClick={() =>
openNotificationLink(original.link)
}
>
{label}
</span>
</TooltipTrigger>
<TooltipContent side="top">
<span>{original.linkText}</span>
</TooltipContent>
</Tooltip>
</Badge>
);
}
return (
<Badge variant="outline" class="text-muted-foreground">
{label}
</Badge>
);
}
},
{
accessorKey: 'senderUsername',
meta: {
class: 'overflow-hidden',
label: () => t('table.notification.user')
},
size: 150,
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="cursor-pointer 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="cursor-pointer 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: 'overflow-hidden',
label: () => t('table.notification.group')
},
size: 150,
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="cursor-pointer 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="cursor-pointer 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="cursor-pointer 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',
size: 80,
header: () => t('table.notification.photo'),
meta: { label: () => t('table.notification.photo') },
cell: ({ row }) => {
const original = row.original;
if (original.type === 'boop') {
const imageUrl =
original.details?.imageUrl || original.imageUrl;
if (!imageUrl || imageUrl.startsWith('default_')) {
return null;
}
return (
<Emoji
class="cursor-pointer h-7.5 w-7.5 rounded object-cover"
onClick={() => showFullscreenImageDialog(imageUrl)}
imageUrl={imageUrl}
size={30}
/>
);
}
if (original.details?.imageUrl) {
const detailsUrl = getSmallThumbnailUrl(
original.details.imageUrl
);
return (
<Avatar
class="cursor-pointer size-7.5 rounded"
onClick={() =>
showFullscreenImageDialog(
original.details.imageUrl
)
}
>
<AvatarImage
src={detailsUrl}
class="object-cover"
/>
<AvatarFallback class="rounded">
<ImageOff class="size-4 text-muted-foreground" />
</AvatarFallback>
</Avatar>
);
}
if (original.imageUrl) {
const imgUrl = getSmallThumbnailUrl(original.imageUrl);
return (
<Avatar
class="cursor-pointer size-7.5 rounded"
onClick={() =>
showFullscreenImageDialog(original.imageUrl)
}
>
<AvatarImage src={imgUrl} class="object-cover" />
<AvatarFallback class="rounded">
<ImageOff class="size-4 text-muted-foreground" />
</AvatarFallback>
</Avatar>
);
}
return null;
}
},
{
id: 'message',
header: () => t('table.notification.message'),
enableSorting: false,
meta: {
class: 'min-w-0 overflow-hidden',
stretch: true,
label: () => t('table.notification.message')
},
minSize: 100,
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.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.title &&
original.message !==
`This is a generated invite to ${original.details?.worldName}` ? (
<TooltipWrapper
content={original.message}
delayDuration={500}
>
<span class="block w-full min-w-0 truncate">
{original.message}
</span>
</TooltipWrapper>
) : null}
{!original.message &&
original.details?.inviteMessage ? (
<TooltipWrapper
content={original.details.inviteMessage}
delayDuration={500}
>
<span class="block w-full min-w-0 truncate">
{original.details.inviteMessage}
</span>
</TooltipWrapper>
) : null}
{!original.message &&
original.details?.requestMessage ? (
<TooltipWrapper
content={original.details.requestMessage}
delayDuration={500}
>
<span class="block w-full min-w-0 truncate">
{original.details.requestMessage}
</span>
</TooltipWrapper>
) : null}
{!original.message &&
original.details?.responseMessage ? (
<TooltipWrapper
content={original.details.responseMessage}
delayDuration={500}
>
<span class="block w-full min-w-0 truncate">
{original.details.responseMessage}
</span>
</TooltipWrapper>
) : null}
</div>
);
}
},
{
id: 'action',
meta: {
class: 'text-right',
label: () => t('table.notification.action')
},
size: 120,
minSize: 120,
maxSize: 120,
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.') &&
!original.link?.startsWith('economy.');
const showDeleteLog =
original.type !== 'friendRequest' &&
original.type !== 'ignoredFriendRequest';
return (
<div class="flex items-center justify-end gap-2">
{original.senderUserId !== currentUser.value?.id &&
!isNotificationExpired(original) ? (
<span class="inline-flex items-center gap-2">
{original.type === 'friendRequest' ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
class="inline-flex h-6 ml-1 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() =>
acceptFriendRequestNotification(
original
)
}
>
<Check class="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>
{t(
'view.notification.actions.accept'
)}
</span>
</TooltipContent>
</Tooltip>
) : null}
{original.type === 'invite' ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
class="inline-flex h-6 ml-1 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() =>
showSendInviteResponseDialog(
original
)
}
>
<MessageCircle class="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>
{t(
'view.notification.actions.decline_with_message'
)}
</span>
</TooltipContent>
</Tooltip>
) : null}
{original.type === 'requestInvite' ? (
<span class="inline-flex items-center">
{canInvite() ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
class="inline-flex h-6 ml-1 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() =>
acceptRequestInvite(
original
)
}
>
<Check class="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>
{t(
'view.notification.actions.invite'
)}
</span>
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
class="inline-flex h-6 ml-1 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() =>
showSendInviteRequestResponseDialog(
original
)
}
>
<MessageCircle class="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>
{t(
'view.notification.actions.decline_with_message'
)}
</span>
</TooltipContent>
</Tooltip>
</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 ResponseIcon = getResponseIcon(
response,
original.type
);
return (
<Tooltip
key={`${response.text}:${response.type}`}
>
<TooltipTrigger asChild>
<button
type="button"
class="inline-flex h-6 ml-1 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={onClick}
>
<ResponseIcon class="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>
{response.text}
</span>
</TooltipContent>
</Tooltip>
);
})
: null}
{showDecline ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
class="inline-flex h-6 ml-1 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() =>
shiftHeld.value
? hideNotification(
original
)
: hideNotificationPrompt(
original
)
}
>
<X
class={
shiftHeld.value
? 'h-4 w-4 text-red-600'
: 'h-4 w-4'
}
/>
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>
{t(
'view.notification.actions.decline'
)}
</span>
</TooltipContent>
</Tooltip>
) : null}
{original.type === 'group.queueReady' ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
class="inline-flex h-6 ml-1 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() =>
shiftHeld.value
? deleteNotificationLog(
original
)
: deleteNotificationLogPrompt(
original
)
}
>
{shiftHeld.value ? (
<X class="h-4 w-4 text-red-600" />
) : (
<Trash2 class="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>
{t(
'view.notification.actions.delete_log'
)}
</span>
</TooltipContent>
</Tooltip>
) : null}
</span>
) : null}
{showDeleteLog ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
class="inline-flex h-6 ml-1 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() =>
shiftHeld.value
? deleteNotificationLog(
original
)
: deleteNotificationLogPrompt(
original
)
}
>
{shiftHeld.value ? (
<X class="h-4 w-4 text-red-600" />
) : (
<Trash2 class="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span>
{t(
'view.notification.actions.delete_log'
)}
</span>
</TooltipContent>
</Tooltip>
) : null}
</div>
);
}
},
{
id: 'trailing',
header: () => null,
enableSorting: false,
enableResizing: false,
size: 5,
cell: () => null
}
];
};