mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 15:36:18 +00:00
feat(mentions): richer inbox — filters, date groups, type badge, relative time, per-row actions, highlighted preview
This commit is contained in:
@@ -707,6 +707,24 @@
|
|||||||
'chatcmd.client.info': 'Client info',
|
'chatcmd.client.info': 'Client info',
|
||||||
|
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
|
// Mentions
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
'mentions.window.title': 'Mentions',
|
||||||
|
'mentions.window.empty': 'No mentions',
|
||||||
|
'mentions.window.markall': 'Mark all as read',
|
||||||
|
'mentions.tab.title': 'Mentions',
|
||||||
|
'mentions.notification': '%sender% mentioned you in %room%',
|
||||||
|
'mentions.filter.all': 'All',
|
||||||
|
'mentions.filter.unread': 'Unread',
|
||||||
|
'mentions.filter.direct': 'Direct',
|
||||||
|
'mentions.filter.room': 'Room',
|
||||||
|
'mentions.group.today': 'Today',
|
||||||
|
'mentions.group.yesterday': 'Yesterday',
|
||||||
|
'mentions.group.older': 'Earlier',
|
||||||
|
'mentions.type.direct': 'Direct mention',
|
||||||
|
'mentions.type.room': 'Room mention',
|
||||||
|
'mentions.action.goto': 'Go to room',
|
||||||
|
'mentions.action.remove': 'Remove',
|
||||||
// Me-menu settings + User account settings window
|
// Me-menu settings + User account settings window
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
'usersettings.tab.general': "General",
|
'usersettings.tab.general': "General",
|
||||||
@@ -772,13 +790,4 @@
|
|||||||
'usersettings.success.password': "Password updated successfully.",
|
'usersettings.success.password': "Password updated successfully.",
|
||||||
'usersettings.success.email': "Email updated successfully.",
|
'usersettings.success.email': "Email updated successfully.",
|
||||||
'usersettings.success.username': "Username updated. Please log in again with your new name.",
|
'usersettings.success.username': "Username updated. Please log in again with your new name.",
|
||||||
|
|
||||||
// ------------------------------------------------------------------------
|
|
||||||
// Mentions
|
|
||||||
// ------------------------------------------------------------------------
|
|
||||||
'mentions.window.title': 'Mentions',
|
|
||||||
'mentions.window.empty': 'No mentions',
|
|
||||||
'mentions.window.markall': 'Mark all as read',
|
|
||||||
'mentions.tab.title': 'Mentions',
|
|
||||||
'mentions.notification': '%sender% mentioned you in %room%',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -707,6 +707,24 @@
|
|||||||
'chatcmd.client.info': 'Info client',
|
'chatcmd.client.info': 'Info client',
|
||||||
|
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
|
// Mentions
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
'mentions.window.title': 'Menzioni',
|
||||||
|
'mentions.window.empty': 'Nessuna menzione',
|
||||||
|
'mentions.window.markall': 'Segna tutte come lette',
|
||||||
|
'mentions.tab.title': 'Menzioni',
|
||||||
|
'mentions.notification': '%sender% ti ha menzionato in %room%',
|
||||||
|
'mentions.filter.all': 'Tutte',
|
||||||
|
'mentions.filter.unread': 'Non lette',
|
||||||
|
'mentions.filter.direct': 'Dirette',
|
||||||
|
'mentions.filter.room': 'Stanza',
|
||||||
|
'mentions.group.today': 'Oggi',
|
||||||
|
'mentions.group.yesterday': 'Ieri',
|
||||||
|
'mentions.group.older': 'Precedenti',
|
||||||
|
'mentions.type.direct': 'Menzione diretta',
|
||||||
|
'mentions.type.room': 'Menzione di stanza',
|
||||||
|
'mentions.action.goto': 'Vai alla stanza',
|
||||||
|
'mentions.action.remove': 'Rimuovi',
|
||||||
// Me-menu settings + User account settings window
|
// Me-menu settings + User account settings window
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
'usersettings.tab.general': "Generale",
|
'usersettings.tab.general': "Generale",
|
||||||
@@ -772,13 +790,4 @@
|
|||||||
'usersettings.success.password': "Password aggiornata con successo.",
|
'usersettings.success.password': "Password aggiornata con successo.",
|
||||||
'usersettings.success.email': "Email aggiornata con successo.",
|
'usersettings.success.email': "Email aggiornata con successo.",
|
||||||
'usersettings.success.username': "Nome utente aggiornato. Accedi di nuovo con il tuo nuovo nome.",
|
'usersettings.success.username': "Nome utente aggiornato. Accedi di nuovo con il tuo nuovo nome.",
|
||||||
|
|
||||||
// ------------------------------------------------------------------------
|
|
||||||
// Mentions
|
|
||||||
// ------------------------------------------------------------------------
|
|
||||||
'mentions.window.title': 'Menzioni',
|
|
||||||
'mentions.window.empty': 'Nessuna menzione',
|
|
||||||
'mentions.window.markall': 'Segna tutte come lette',
|
|
||||||
'mentions.tab.title': 'Menzioni',
|
|
||||||
'mentions.notification': '%sender% ti ha menzionato in %room%',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -709,6 +709,24 @@
|
|||||||
'chatcmd.client.info': 'Client info',
|
'chatcmd.client.info': 'Client info',
|
||||||
|
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
|
// Mentions
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
'mentions.window.title': 'Vermeldingen',
|
||||||
|
'mentions.window.empty': 'Geen vermeldingen',
|
||||||
|
'mentions.window.markall': 'Alles als gelezen markeren',
|
||||||
|
'mentions.tab.title': 'Vermeldingen',
|
||||||
|
'mentions.notification': '%sender% heeft je genoemd in %room%',
|
||||||
|
'mentions.filter.all': 'Alle',
|
||||||
|
'mentions.filter.unread': 'Ongelezen',
|
||||||
|
'mentions.filter.direct': 'Direct',
|
||||||
|
'mentions.filter.room': 'Kamer',
|
||||||
|
'mentions.group.today': 'Vandaag',
|
||||||
|
'mentions.group.yesterday': 'Gisteren',
|
||||||
|
'mentions.group.older': 'Eerder',
|
||||||
|
'mentions.type.direct': 'Directe vermelding',
|
||||||
|
'mentions.type.room': 'Kamervermelding',
|
||||||
|
'mentions.action.goto': 'Ga naar kamer',
|
||||||
|
'mentions.action.remove': 'Verwijderen',
|
||||||
// Me-menu settings + User account settings window
|
// Me-menu settings + User account settings window
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
'usersettings.tab.general': "Algemeen",
|
'usersettings.tab.general': "Algemeen",
|
||||||
@@ -774,13 +792,4 @@
|
|||||||
'usersettings.success.password': "Wachtwoord succesvol bijgewerkt.",
|
'usersettings.success.password': "Wachtwoord succesvol bijgewerkt.",
|
||||||
'usersettings.success.email': "E-mail succesvol bijgewerkt.",
|
'usersettings.success.email': "E-mail succesvol bijgewerkt.",
|
||||||
'usersettings.success.username': "Gebruikersnaam bijgewerkt. Log opnieuw in met je nieuwe naam.",
|
'usersettings.success.username': "Gebruikersnaam bijgewerkt. Log opnieuw in met je nieuwe naam.",
|
||||||
|
|
||||||
// ------------------------------------------------------------------------
|
|
||||||
// Mentions
|
|
||||||
// ------------------------------------------------------------------------
|
|
||||||
'mentions.window.title': 'Vermeldingen',
|
|
||||||
'mentions.window.empty': 'Geen vermeldingen',
|
|
||||||
'mentions.window.markall': 'Alles als gelezen markeren',
|
|
||||||
'mentions.tab.title': 'Vermeldingen',
|
|
||||||
'mentions.notification': '%sender% heeft je genoemd in %room%',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { ChatEntryType, LocalizeText } from '../../api';
|
import { ChatEntryType, LocalizeText } from '../../api';
|
||||||
import { Flex, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text } from '../../common';
|
import { Flex, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text } from '../../common';
|
||||||
import { useChatHistory, useMentionsSnapshot, useOnClickChat } from '../../hooks';
|
import { useChatHistory, useMentionsSnapshot, useOnClickChat } from '../../hooks';
|
||||||
|
import { useUserDataSnapshot } from '../../hooks/session/useSessionSnapshots';
|
||||||
import { NitroInput } from '../../layout';
|
import { NitroInput } from '../../layout';
|
||||||
import { MentionRowView, useMentionRowClick } from '../mentions';
|
import { MentionRowView, useMentionActions } from '../mentions';
|
||||||
|
|
||||||
const TAB_CHAT = 'chat';
|
const TAB_CHAT = 'chat';
|
||||||
const TAB_MENTIONS = 'mentions';
|
const TAB_MENTIONS = 'mentions';
|
||||||
@@ -16,7 +17,8 @@ export const ChatHistoryView: FC<{}> = props =>
|
|||||||
const [activeTab, setActiveTab] = useState<string>(TAB_CHAT);
|
const [activeTab, setActiveTab] = useState<string>(TAB_CHAT);
|
||||||
const { chatHistory = [] } = useChatHistory();
|
const { chatHistory = [] } = useChatHistory();
|
||||||
const { mentions, unreadCount } = useMentionsSnapshot();
|
const { mentions, unreadCount } = useMentionsSnapshot();
|
||||||
const onMentionRowClick = useMentionRowClick();
|
const { userName: ownMentionUsername = '' } = useUserDataSnapshot();
|
||||||
|
const { open: onMentionOpen, goto: onMentionGoto, remove: onMentionRemove } = useMentionActions();
|
||||||
const { onClickChat } = useOnClickChat();
|
const { onClickChat } = useOnClickChat();
|
||||||
const elementRef = useRef<HTMLDivElement>(null);
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
const isFirstRender = useRef(true);
|
const isFirstRender = useRef(true);
|
||||||
@@ -108,7 +110,13 @@ export const ChatHistoryView: FC<{}> = props =>
|
|||||||
{ (mentions.length === 0)
|
{ (mentions.length === 0)
|
||||||
? <Text center variant="gray">{ LocalizeText('mentions.window.empty') }</Text>
|
? <Text center variant="gray">{ LocalizeText('mentions.window.empty') }</Text>
|
||||||
: mentions.map(mention => (
|
: mentions.map(mention => (
|
||||||
<MentionRowView key={ mention.mentionId } mention={ mention } onClick={ onMentionRowClick } />
|
<MentionRowView
|
||||||
|
key={ mention.mentionId }
|
||||||
|
mention={ mention }
|
||||||
|
onGoto={ onMentionGoto }
|
||||||
|
onOpen={ onMentionOpen }
|
||||||
|
onRemove={ onMentionRemove }
|
||||||
|
ownUsername={ ownMentionUsername } />
|
||||||
)) }
|
)) }
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { FC, Fragment, ReactNode } from 'react';
|
||||||
|
import { tokenIsMention } from '../room/widgets/chat/highlightMentions';
|
||||||
|
|
||||||
|
interface MentionMessageViewProps
|
||||||
|
{
|
||||||
|
text: string;
|
||||||
|
ownUsername: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a mention's message text as React nodes, wrapping the token(s) that
|
||||||
|
* mention the local user or a room-broadcast alias in a `.mention-highlight`
|
||||||
|
* span. Pure text segmentation (no innerHTML) → no XSS risk from other users'
|
||||||
|
* chat content. Original spacing is preserved verbatim.
|
||||||
|
*/
|
||||||
|
export const MentionMessageView: FC<MentionMessageViewProps> = props =>
|
||||||
|
{
|
||||||
|
const { text, ownUsername, className } = props;
|
||||||
|
|
||||||
|
if(!text) return <span className={ className } />;
|
||||||
|
|
||||||
|
const nodes: ReactNode[] = text.split(/(\s+)/).map((segment, index) =>
|
||||||
|
{
|
||||||
|
if(segment.length === 0) return null;
|
||||||
|
|
||||||
|
if(/^\s+$/.test(segment) || !tokenIsMention(segment, ownUsername))
|
||||||
|
{
|
||||||
|
return <Fragment key={ index }>{ segment }</Fragment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span key={ index } className="mention-highlight">{ segment }</span>;
|
||||||
|
});
|
||||||
|
|
||||||
|
return <span className={ className }>{ nodes }</span>;
|
||||||
|
};
|
||||||
@@ -1,28 +1,65 @@
|
|||||||
import { FC } from 'react';
|
import { FC, MouseEvent } from 'react';
|
||||||
import { IMentionEntry } from '../../api';
|
import { IMentionEntry, LocalizeText, MentionType } from '../../api';
|
||||||
import { Flex, Text } from '../../common';
|
import { Flex, Text } from '../../common';
|
||||||
|
import { MentionMessageView } from './MentionMessageView';
|
||||||
|
import { formatMentionTime } from './mentionsFormat';
|
||||||
|
|
||||||
interface MentionRowViewProps
|
interface MentionRowViewProps
|
||||||
{
|
{
|
||||||
mention: IMentionEntry;
|
mention: IMentionEntry;
|
||||||
onClick: (mention: IMentionEntry) => void;
|
ownUsername: string;
|
||||||
|
onOpen: (mention: IMentionEntry) => void;
|
||||||
|
onGoto?: (mention: IMentionEntry) => void;
|
||||||
|
onRemove?: (mention: IMentionEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MentionRowView: FC<MentionRowViewProps> = props =>
|
export const MentionRowView: FC<MentionRowViewProps> = props =>
|
||||||
{
|
{
|
||||||
const { mention, onClick } = props;
|
const { mention, ownUsername, onOpen, onGoto = null, onRemove = null } = props;
|
||||||
|
|
||||||
|
const isRoom = (mention.mentionType === MentionType.ROOM);
|
||||||
|
const typeTitle = LocalizeText(isRoom ? 'mentions.type.room' : 'mentions.type.direct');
|
||||||
|
const time = formatMentionTime(mention.timestamp);
|
||||||
|
|
||||||
|
const stop = (event: MouseEvent, action: () => void) =>
|
||||||
|
{
|
||||||
|
event.stopPropagation();
|
||||||
|
action();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex pointer alignItems="center" className="p-1 hover:bg-black/5" gap={ 2 } onClick={ () => onClick(mention) }>
|
<Flex pointer alignItems="center" className="group relative px-1 py-[3px] rounded hover:bg-black/5" gap={ 2 } onClick={ () => onOpen(mention) }>
|
||||||
<span
|
<span
|
||||||
className={ `inline-block w-[8px] h-[8px] rounded-full shrink-0 ${ mention.read ? 'bg-transparent' : 'bg-[#1e7295]' }` } />
|
className={ `inline-block w-[8px] h-[8px] rounded-full shrink-0 ${ mention.read ? 'bg-transparent' : 'bg-[#1e7295]' }` }
|
||||||
|
title={ mention.read ? '' : LocalizeText('mentions.filter.unread') } />
|
||||||
|
<span
|
||||||
|
title={ typeTitle }
|
||||||
|
className={ `flex items-center justify-center shrink-0 w-[18px] h-[18px] rounded text-[10px] font-bold leading-none text-white ${ isRoom ? 'bg-[#d08a1e]' : 'bg-[#1e7295]' }` }>
|
||||||
|
{ isRoom ? '@∗' : '@' }
|
||||||
|
</span>
|
||||||
<Flex grow column className="min-w-0" gap={ 0 }>
|
<Flex grow column className="min-w-0" gap={ 0 }>
|
||||||
<Flex alignItems="center" gap={ 1 }>
|
<Flex alignItems="center" gap={ 1 } className="min-w-0">
|
||||||
<Text bold={ !mention.read } truncate variant="primary">{ mention.senderUsername }</Text>
|
<Text bold={ !mention.read } truncate variant="primary">{ mention.senderUsername }</Text>
|
||||||
{ (mention.roomName && mention.roomName.length > 0) &&
|
{ (mention.roomName && mention.roomName.length > 0) &&
|
||||||
<Text small truncate variant="gray">{ mention.roomName }</Text> }
|
<Text small truncate variant="gray">· { mention.roomName }</Text> }
|
||||||
|
</Flex>
|
||||||
|
<MentionMessageView className="block truncate text-black text-sm" ownUsername={ ownUsername } text={ mention.message } />
|
||||||
|
</Flex>
|
||||||
|
<Flex alignItems="center" gap={ 1 } className="shrink-0">
|
||||||
|
{ (time.length > 0) &&
|
||||||
|
<Text small variant="gray" className="tabular-nums group-hover:hidden">{ time }</Text> }
|
||||||
|
<Flex alignItems="center" gap={ 1 } className="hidden group-hover:flex">
|
||||||
|
{ onGoto &&
|
||||||
|
<span
|
||||||
|
title={ LocalizeText('mentions.action.goto') }
|
||||||
|
className="flex items-center justify-center w-[18px] h-[18px] rounded bg-black/10 hover:bg-black/20 text-[12px] leading-none"
|
||||||
|
onClick={ event => stop(event, () => onGoto(mention)) }>→</span> }
|
||||||
|
{ onRemove &&
|
||||||
|
<span
|
||||||
|
title={ LocalizeText('mentions.action.remove') }
|
||||||
|
className="flex items-center justify-center w-[18px] h-[18px] rounded bg-black/10 hover:bg-red-500 hover:text-white text-[11px] leading-none"
|
||||||
|
onClick={ event => stop(event, () => onRemove(mention)) }>✕</span> }
|
||||||
</Flex>
|
</Flex>
|
||||||
<Text truncate variant="black">{ mention.message }</Text>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,53 @@
|
|||||||
import { MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
|
import { MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useCallback } from 'react';
|
import { FC, useCallback, useMemo, useState } from 'react';
|
||||||
import { LocalizeText, SendMessageComposer } from '../../api';
|
import { IMentionEntry, LocalizeText, MentionType, SendMessageComposer } from '../../api';
|
||||||
import { Button, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
|
import { Button, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
|
||||||
import { useMentionsSnapshot } from '../../hooks';
|
import { useMentionsSnapshot } from '../../hooks';
|
||||||
import { markAllRead } from '../../hooks/mentions/mentionsStore';
|
import { markAllRead } from '../../hooks/mentions/mentionsStore';
|
||||||
|
import { useUserDataSnapshot } from '../../hooks/session/useSessionSnapshots';
|
||||||
import { MentionRowView } from './MentionRowView';
|
import { MentionRowView } from './MentionRowView';
|
||||||
import { useMentionRowClick } from './useMentionRowClick';
|
import { getMentionDateGroup, MentionDateGroup } from './mentionsFormat';
|
||||||
|
import { useMentionActions } from './useMentionActions';
|
||||||
|
|
||||||
interface MentionsViewProps
|
interface MentionsViewProps
|
||||||
{
|
{
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MentionFilter = 'all' | 'unread' | 'direct' | 'room';
|
||||||
|
|
||||||
|
const FILTERS: ReadonlyArray<{ key: MentionFilter; label: string }> = [
|
||||||
|
{ key: 'all', label: 'mentions.filter.all' },
|
||||||
|
{ key: 'unread', label: 'mentions.filter.unread' },
|
||||||
|
{ key: 'direct', label: 'mentions.filter.direct' },
|
||||||
|
{ key: 'room', label: 'mentions.filter.room' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUP_ORDER: ReadonlyArray<MentionDateGroup> = [ 'today', 'yesterday', 'older' ];
|
||||||
|
const GROUP_LABEL: Record<MentionDateGroup, string> = {
|
||||||
|
today: 'mentions.group.today',
|
||||||
|
yesterday: 'mentions.group.yesterday',
|
||||||
|
older: 'mentions.group.older'
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesFilter = (mention: IMentionEntry, filter: MentionFilter): boolean =>
|
||||||
|
{
|
||||||
|
switch(filter)
|
||||||
|
{
|
||||||
|
case 'unread': return !mention.read;
|
||||||
|
case 'direct': return mention.mentionType === MentionType.DIRECT;
|
||||||
|
case 'room': return mention.mentionType === MentionType.ROOM;
|
||||||
|
default: return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const MentionsView: FC<MentionsViewProps> = props =>
|
export const MentionsView: FC<MentionsViewProps> = props =>
|
||||||
{
|
{
|
||||||
const { onClose } = props;
|
const { onClose } = props;
|
||||||
const { mentions } = useMentionsSnapshot();
|
const { mentions, unreadCount } = useMentionsSnapshot();
|
||||||
const onRowClick = useMentionRowClick();
|
const { userName: ownUsername = '' } = useUserDataSnapshot();
|
||||||
|
const { open, goto, remove } = useMentionActions();
|
||||||
|
const [ filter, setFilter ] = useState<MentionFilter>('all');
|
||||||
|
|
||||||
const onMarkAll = useCallback(() =>
|
const onMarkAll = useCallback(() =>
|
||||||
{
|
{
|
||||||
@@ -24,18 +55,68 @@ export const MentionsView: FC<MentionsViewProps> = props =>
|
|||||||
SendMessageComposer(new MarkMentionsReadComposer(0, 0));
|
SendMessageComposer(new MarkMentionsReadComposer(0, 0));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const groups = useMemo(() =>
|
||||||
|
{
|
||||||
|
const buckets: Record<MentionDateGroup, IMentionEntry[]> = { today: [], yesterday: [], older: [] };
|
||||||
|
|
||||||
|
for(const mention of mentions)
|
||||||
|
{
|
||||||
|
if(!matchesFilter(mention, filter)) continue;
|
||||||
|
buckets[getMentionDateGroup(mention.timestamp)].push(mention);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GROUP_ORDER
|
||||||
|
.map(key => ({ key, items: buckets[key] }))
|
||||||
|
.filter(group => group.items.length > 0);
|
||||||
|
}, [ mentions, filter ]);
|
||||||
|
|
||||||
|
const hasAny = groups.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NitroCardView className="w-[340px] h-[420px]" theme="primary-slim" uniqueKey="mentions">
|
<NitroCardView className="w-[360px] h-[440px]" theme="primary-slim" uniqueKey="mentions">
|
||||||
<NitroCardHeaderView headerText={ LocalizeText('mentions.window.title') } onCloseClick={ onClose } />
|
<NitroCardHeaderView headerText={ LocalizeText('mentions.window.title') } onCloseClick={ onClose } />
|
||||||
<NitroCardContentView gap={ 1 }>
|
<NitroCardContentView gap={ 1 }>
|
||||||
|
<Flex alignItems="center" className="flex-wrap" gap={ 1 }>
|
||||||
|
{ FILTERS.map(({ key, label }) =>
|
||||||
|
{
|
||||||
|
const active = (filter === key);
|
||||||
|
const showCount = ((key === 'unread') && (unreadCount > 0));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ key }
|
||||||
|
type="button"
|
||||||
|
onClick={ () => setFilter(key) }
|
||||||
|
className={ `px-2 py-[2px] rounded-full text-xs border transition-colors ${ active ? 'bg-[#1e7295] text-white border-[#1e7295]' : 'bg-black/5 text-black/70 border-transparent hover:bg-black/10' }` }>
|
||||||
|
{ LocalizeText(label) }{ showCount ? ` (${ unreadCount })` : '' }
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}) }
|
||||||
|
</Flex>
|
||||||
<Flex grow column className="min-h-0 overflow-y-auto" gap={ 0 }>
|
<Flex grow column className="min-h-0 overflow-y-auto" gap={ 0 }>
|
||||||
{ (mentions.length === 0)
|
{ !hasAny &&
|
||||||
? <Text center variant="gray">{ LocalizeText('mentions.window.empty') }</Text>
|
<Flex grow column center gap={ 2 } className="py-6 text-center">
|
||||||
: mentions.map(mention => (
|
<span className="flex items-center justify-center w-[44px] h-[44px] rounded-full bg-black/5 text-[#1e7295] text-[22px] font-bold">@</span>
|
||||||
<MentionRowView key={ mention.mentionId } mention={ mention } onClick={ onRowClick } />
|
<Text center variant="gray">{ LocalizeText('mentions.window.empty') }</Text>
|
||||||
|
</Flex> }
|
||||||
|
{ hasAny && groups.map(group => (
|
||||||
|
<Flex key={ group.key } column gap={ 0 }>
|
||||||
|
<Text small bold variant="gray" className="px-1 pt-2 pb-[2px] uppercase tracking-wide">
|
||||||
|
{ LocalizeText(GROUP_LABEL[group.key]) }
|
||||||
|
</Text>
|
||||||
|
{ group.items.map(mention => (
|
||||||
|
<MentionRowView
|
||||||
|
key={ mention.mentionId }
|
||||||
|
mention={ mention }
|
||||||
|
onGoto={ goto }
|
||||||
|
onOpen={ open }
|
||||||
|
onRemove={ remove }
|
||||||
|
ownUsername={ ownUsername } />
|
||||||
)) }
|
)) }
|
||||||
</Flex>
|
</Flex>
|
||||||
{ (mentions.length > 0) &&
|
)) }
|
||||||
|
</Flex>
|
||||||
|
{ (unreadCount > 0) &&
|
||||||
<Button variant="primary" onClick={ onMarkAll }>{ LocalizeText('mentions.window.markall') }</Button> }
|
<Button variant="primary" onClick={ onMarkAll }>{ LocalizeText('mentions.window.markall') }</Button> }
|
||||||
</NitroCardContentView>
|
</NitroCardContentView>
|
||||||
</NitroCardView>
|
</NitroCardView>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export * from './MentionMessageView';
|
||||||
export * from './MentionRowView';
|
export * from './MentionRowView';
|
||||||
export * from './MentionsView';
|
export * from './MentionsView';
|
||||||
export * from './useMentionRowClick';
|
export * from './mentionsFormat';
|
||||||
|
export * from './useMentionActions';
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatMentionTime, getMentionDateGroup } from './mentionsFormat';
|
||||||
|
|
||||||
|
// Fixed reference "now": 2026-06-02 14:30 local time.
|
||||||
|
const NOW = new Date(2026, 5, 2, 14, 30, 0);
|
||||||
|
const at = (y: number, mo: number, d: number, h = 12, mi = 0): number => Math.floor(new Date(y, mo, d, h, mi, 0).getTime() / 1000);
|
||||||
|
|
||||||
|
describe('getMentionDateGroup', () =>
|
||||||
|
{
|
||||||
|
it('buckets same-day as today', () =>
|
||||||
|
{
|
||||||
|
expect(getMentionDateGroup(at(2026, 5, 2, 9, 15), NOW)).toBe('today');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buckets previous day as yesterday', () =>
|
||||||
|
{
|
||||||
|
expect(getMentionDateGroup(at(2026, 5, 1, 23, 59), NOW)).toBe('yesterday');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buckets two+ days ago as older', () =>
|
||||||
|
{
|
||||||
|
expect(getMentionDateGroup(at(2026, 4, 28, 10, 0), NOW)).toBe('older');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats missing/zero timestamp as older', () =>
|
||||||
|
{
|
||||||
|
expect(getMentionDateGroup(0, NOW)).toBe('older');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatMentionTime', () =>
|
||||||
|
{
|
||||||
|
it('shows HH:MM (zero-padded) for today', () =>
|
||||||
|
{
|
||||||
|
expect(formatMentionTime(at(2026, 5, 2, 9, 5), NOW)).toBe('09:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows HH:MM for yesterday', () =>
|
||||||
|
{
|
||||||
|
expect(formatMentionTime(at(2026, 5, 1, 18, 45), NOW)).toBe('18:45');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows DD-MM for older entries', () =>
|
||||||
|
{
|
||||||
|
expect(formatMentionTime(at(2026, 4, 28, 10, 0), NOW)).toBe('28-05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for missing timestamp', () =>
|
||||||
|
{
|
||||||
|
expect(formatMentionTime(0, NOW)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Date/time helpers for the mentions box. Kept framework-free and pure so they
|
||||||
|
// are unit-testable. Timestamps are unix SECONDS (as carried on the wire).
|
||||||
|
|
||||||
|
export type MentionDateGroup = 'today' | 'yesterday' | 'older';
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
const startOfDay = (d: Date): number => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||||
|
|
||||||
|
const pad = (n: number): string => (n < 10 ? `0${ n }` : `${ n }`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bucket a mention timestamp into today / yesterday / older relative to `now`.
|
||||||
|
*/
|
||||||
|
export const getMentionDateGroup = (timestampSeconds: number, now: Date = new Date()): MentionDateGroup =>
|
||||||
|
{
|
||||||
|
if(!timestampSeconds || timestampSeconds <= 0) return 'older';
|
||||||
|
|
||||||
|
const ts = timestampSeconds * 1000;
|
||||||
|
const todayStart = startOfDay(now);
|
||||||
|
|
||||||
|
if(ts >= todayStart) return 'today';
|
||||||
|
if(ts >= (todayStart - DAY_MS)) return 'yesterday';
|
||||||
|
|
||||||
|
return 'older';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact per-row time label: HH:MM for today/yesterday (the section header
|
||||||
|
* disambiguates the day), DD-MM for older entries. Empty string when unknown.
|
||||||
|
*/
|
||||||
|
export const formatMentionTime = (timestampSeconds: number, now: Date = new Date()): string =>
|
||||||
|
{
|
||||||
|
if(!timestampSeconds || timestampSeconds <= 0) return '';
|
||||||
|
|
||||||
|
const d = new Date(timestampSeconds * 1000);
|
||||||
|
|
||||||
|
if(getMentionDateGroup(timestampSeconds, now) === 'older') return `${ pad(d.getDate()) }-${ pad(d.getMonth() + 1) }`;
|
||||||
|
|
||||||
|
return `${ pad(d.getHours()) }:${ pad(d.getMinutes()) }`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { CreateLinkEvent, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { IMentionEntry, SendMessageComposer } from '../../api';
|
||||||
|
import { markRead, removeMention } from '../../hooks/mentions/mentionsStore';
|
||||||
|
|
||||||
|
export interface MentionActions
|
||||||
|
{
|
||||||
|
/** Row click: mark the mention as read (no navigation). */
|
||||||
|
open: (mention: IMentionEntry) => void;
|
||||||
|
/** Explicit "go to room" action: mark read, then jump to the origin room. */
|
||||||
|
goto: (mention: IMentionEntry) => void;
|
||||||
|
/** Remove from the list (client-side). Marks read on the server so it does
|
||||||
|
* not reappear as unread after a relog. A true server-side delete packet
|
||||||
|
* is a follow-up. */
|
||||||
|
remove: (mention: IMentionEntry) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const markReadOnServer = (mention: IMentionEntry): void =>
|
||||||
|
{
|
||||||
|
if(mention.read) return;
|
||||||
|
markRead(mention.mentionId);
|
||||||
|
SendMessageComposer(new MarkMentionsReadComposer(1, mention.mentionId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shared action handlers used by both MentionsView and the chat-history
|
||||||
|
// "Menzioni" tab so behaviour can't diverge.
|
||||||
|
export const useMentionActions = (): MentionActions => useMemo(() => ({
|
||||||
|
open: (mention) => markReadOnServer(mention),
|
||||||
|
goto: (mention) =>
|
||||||
|
{
|
||||||
|
markReadOnServer(mention);
|
||||||
|
if(mention.roomId > 0) CreateLinkEvent(`navigator/goto/${ mention.roomId }`);
|
||||||
|
},
|
||||||
|
remove: (mention) =>
|
||||||
|
{
|
||||||
|
if(!mention.read) SendMessageComposer(new MarkMentionsReadComposer(1, mention.mentionId));
|
||||||
|
removeMention(mention.mentionId);
|
||||||
|
}
|
||||||
|
}), []);
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { CreateLinkEvent, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { IMentionEntry, SendMessageComposer } from '../../api';
|
|
||||||
import { markRead } from '../../hooks/mentions/mentionsStore';
|
|
||||||
|
|
||||||
// Shared row-click handler used by both MentionsView and the chat-history
|
|
||||||
// "Menzioni" tab so the mark-read + room-navigation behaviour can't diverge.
|
|
||||||
export const useMentionRowClick = (): ((mention: IMentionEntry) => void) =>
|
|
||||||
{
|
|
||||||
return useCallback((mention: IMentionEntry) =>
|
|
||||||
{
|
|
||||||
if(!mention.read)
|
|
||||||
{
|
|
||||||
markRead(mention.mentionId);
|
|
||||||
SendMessageComposer(new MarkMentionsReadComposer(1, mention.mentionId));
|
|
||||||
}
|
|
||||||
|
|
||||||
if(mention.roomId > 0) CreateLinkEvent(`navigator/goto/${ mention.roomId }`);
|
|
||||||
}, []);
|
|
||||||
};
|
|
||||||
@@ -56,6 +56,21 @@ const isMentionToken = (token: string, ownUsernameLower: string, aliases: Readon
|
|||||||
return aliases.has(nick);
|
return aliases.has(nick);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public predicate: does this raw whitespace-delimited token mention the given
|
||||||
|
* user or a room-broadcast alias? Mirrors the server's detection. Reusable by
|
||||||
|
* UI that renders mention previews as React nodes (e.g. the mentions box).
|
||||||
|
*/
|
||||||
|
export const tokenIsMention = (
|
||||||
|
token: string,
|
||||||
|
ownUsername: string,
|
||||||
|
aliases: ReadonlyArray<string> = MENTION_ROOM_ALIASES
|
||||||
|
): boolean =>
|
||||||
|
{
|
||||||
|
const ownUsernameLower = (ownUsername || '').replace(NON_NICK_CHARS, '').toLowerCase();
|
||||||
|
return isMentionToken(token, ownUsernameLower, new Set(aliases.map(a => a.toLowerCase())));
|
||||||
|
};
|
||||||
|
|
||||||
const HIGHLIGHT_OPEN = '<span class="mention-highlight">';
|
const HIGHLIGHT_OPEN = '<span class="mention-highlight">';
|
||||||
const HIGHLIGHT_CLOSE = '</span>';
|
const HIGHLIGHT_CLOSE = '</span>';
|
||||||
|
|
||||||
|
|||||||
@@ -40,4 +40,12 @@ export const markAllRead = (): void =>
|
|||||||
emit();
|
emit();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const removeMention = (mentionId: number): void =>
|
||||||
|
{
|
||||||
|
const next = mentions.filter(m => m.mentionId !== mentionId);
|
||||||
|
if(next.length === mentions.length) return;
|
||||||
|
mentions = next;
|
||||||
|
emit();
|
||||||
|
};
|
||||||
|
|
||||||
export const resetMentions = (): void => { mentions = []; emit(); };
|
export const resetMentions = (): void => { mentions = []; emit(); };
|
||||||
|
|||||||
Reference in New Issue
Block a user