From afb8100300000513e26c0605a6ac66c266fcccde Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 31 May 2026 21:56:35 +0200 Subject: [PATCH] feat(mentions): client api types, store, snapshot + message hooks --- src/api/index.ts | 1 + src/api/mentions/IMentionEntry.ts | 12 +++ src/api/mentions/MentionType.ts | 5 ++ src/api/mentions/index.ts | 2 + src/hooks/index.ts | 1 + .../mentions/__tests__/mentionsStore.test.ts | 43 +++++++++++ src/hooks/mentions/index.ts | 2 + src/hooks/mentions/mentionsStore.ts | 43 +++++++++++ src/hooks/mentions/useMentionMessages.ts | 73 +++++++++++++++++++ src/hooks/mentions/useMentionsSnapshot.ts | 10 +++ 10 files changed, 192 insertions(+) create mode 100644 src/api/mentions/IMentionEntry.ts create mode 100644 src/api/mentions/MentionType.ts create mode 100644 src/api/mentions/index.ts create mode 100644 src/hooks/mentions/__tests__/mentionsStore.test.ts create mode 100644 src/hooks/mentions/index.ts create mode 100644 src/hooks/mentions/mentionsStore.ts create mode 100644 src/hooks/mentions/useMentionMessages.ts create mode 100644 src/hooks/mentions/useMentionsSnapshot.ts diff --git a/src/api/index.ts b/src/api/index.ts index ae86f5d..75a8126 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -17,6 +17,7 @@ export * from './hc-center'; export * from './help'; export * from './housekeeping'; export * from './inventory'; +export * from './mentions'; export * from './mod-tools'; export * from './navigator'; export * from './nitro'; diff --git a/src/api/mentions/IMentionEntry.ts b/src/api/mentions/IMentionEntry.ts new file mode 100644 index 0000000..c8b89b3 --- /dev/null +++ b/src/api/mentions/IMentionEntry.ts @@ -0,0 +1,12 @@ +export interface IMentionEntry +{ + mentionId: number; + senderId: number; + senderUsername: string; + roomId: number; + roomName: string; + message: string; + mentionType: number; + timestamp: number; + read: boolean; +} diff --git a/src/api/mentions/MentionType.ts b/src/api/mentions/MentionType.ts new file mode 100644 index 0000000..1755931 --- /dev/null +++ b/src/api/mentions/MentionType.ts @@ -0,0 +1,5 @@ +export class MentionType +{ + public static DIRECT: number = 0; + public static ROOM: number = 1; +} diff --git a/src/api/mentions/index.ts b/src/api/mentions/index.ts new file mode 100644 index 0000000..57433b6 --- /dev/null +++ b/src/api/mentions/index.ts @@ -0,0 +1,2 @@ +export * from './MentionType'; +export * from './IMentionEntry'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 46a0287..87643b2 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -11,6 +11,7 @@ export * from './groups'; export * from './help'; export * from './housekeeping'; export * from './inventory'; +export * from './mentions'; export * from './mod-tools'; export * from './navigator'; export * from './notification'; diff --git a/src/hooks/mentions/__tests__/mentionsStore.test.ts b/src/hooks/mentions/__tests__/mentionsStore.test.ts new file mode 100644 index 0000000..cc3557d --- /dev/null +++ b/src/hooks/mentions/__tests__/mentionsStore.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { addMention, setMentions, markAllRead, markRead, getMentionsSnapshot, getUnreadCount, resetMentions } from '../mentionsStore'; +import { IMentionEntry } from '../../../api/mentions'; + +const make = (id: number, read = false): IMentionEntry => ({ + mentionId: id, senderId: 1, senderUsername: 'Bob', roomId: 9, roomName: 'R', + message: '@me hi', mentionType: 0, timestamp: 0, read +}); + +describe('mentionsStore', () => +{ + beforeEach(() => resetMentions()); + + it('adds newest-first and counts unread', () => + { + addMention(make(1)); + addMention(make(2)); + expect(getMentionsSnapshot()[0].mentionId).toBe(2); + expect(getUnreadCount()).toBe(2); + }); + + it('setMentions replaces and recomputes unread', () => + { + setMentions([make(1, true), make(2, false)]); + expect(getMentionsSnapshot()).toHaveLength(2); + expect(getUnreadCount()).toBe(1); + }); + + it('markAllRead zeroes unread', () => + { + setMentions([make(1), make(2)]); + markAllRead(); + expect(getUnreadCount()).toBe(0); + }); + + it('markRead clears a single entry', () => + { + setMentions([make(1), make(2)]); + markRead(1); + expect(getUnreadCount()).toBe(1); + expect(getMentionsSnapshot().find(m => m.mentionId === 1)!.read).toBe(true); + }); +}); diff --git a/src/hooks/mentions/index.ts b/src/hooks/mentions/index.ts new file mode 100644 index 0000000..3486da8 --- /dev/null +++ b/src/hooks/mentions/index.ts @@ -0,0 +1,2 @@ +export * from './useMentionsSnapshot'; +export * from './useMentionMessages'; diff --git a/src/hooks/mentions/mentionsStore.ts b/src/hooks/mentions/mentionsStore.ts new file mode 100644 index 0000000..324c8ac --- /dev/null +++ b/src/hooks/mentions/mentionsStore.ts @@ -0,0 +1,43 @@ +import { IMentionEntry } from '../../api/mentions'; + +let mentions: IMentionEntry[] = []; +const listeners = new Set<() => void>(); + +const emit = () => { for(const l of listeners) l(); }; + +export const subscribeMentions = (onChange: () => void): (() => void) => +{ + listeners.add(onChange); + return () => { listeners.delete(onChange); }; +}; + +export const getMentionsSnapshot = (): ReadonlyArray => mentions; + +export const getUnreadCount = (): number => mentions.reduce((n, m) => n + (m.read ? 0 : 1), 0); + +export const setMentions = (list: IMentionEntry[]): void => +{ + mentions = [...list].sort((a, b) => b.mentionId - a.mentionId); + emit(); +}; + +export const addMention = (entry: IMentionEntry): void => +{ + if(mentions.some(m => m.mentionId === entry.mentionId && entry.mentionId !== 0)) return; + mentions = [entry, ...mentions]; + emit(); +}; + +export const markRead = (mentionId: number): void => +{ + mentions = mentions.map(m => m.mentionId === mentionId ? { ...m, read: true } : m); + emit(); +}; + +export const markAllRead = (): void => +{ + mentions = mentions.map(m => m.read ? m : { ...m, read: true }); + emit(); +}; + +export const resetMentions = (): void => { mentions = []; emit(); }; diff --git a/src/hooks/mentions/useMentionMessages.ts b/src/hooks/mentions/useMentionMessages.ts new file mode 100644 index 0000000..11a7452 --- /dev/null +++ b/src/hooks/mentions/useMentionMessages.ts @@ -0,0 +1,73 @@ +import { MarkMentionsReadComposer, MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from '@nitrots/nitro-renderer'; +import { useCallback, useEffect } from 'react'; +import { GetConfigurationValue, IMentionEntry, LocalizeText, NotificationBubbleType, PlaySound, SendMessageComposer, SoundNames } from '../../api'; +import { useMessageEvent } from '../events'; +import { useNotificationActions } from '../notification'; +import { addMention, setMentions } from './mentionsStore'; + +// MarkMentionsReadComposer is part of the mentions wire contract; it is sent by +// the UI layer (later phase) when the user opens / clears the mentions window. +void MarkMentionsReadComposer; + +export const useMentionMessages = (): void => +{ + const { showSingleBubble } = useNotificationActions(); + + const onMentionsList = useCallback((event: MentionsListEvent) => + { + const list = event.getParser().mentions; + + setMentions(list.map(m => ({ + mentionId: m.mentionId, + senderId: m.senderId, + senderUsername: m.senderUsername, + roomId: m.roomId, + roomName: m.roomName, + message: m.message, + mentionType: m.mentionType, + timestamp: m.timestamp, + read: m.read + }))); + }, []); + + const onMentionReceived = useCallback((event: MentionReceivedEvent) => + { + if(!GetConfigurationValue('mentions_ui.enabled', true)) return; + + const m = event.getParser().mention; + + const entry: IMentionEntry = { + mentionId: m.mentionId, + senderId: m.senderId, + senderUsername: m.senderUsername, + roomId: m.roomId, + roomName: m.roomName, + message: m.message, + mentionType: m.mentionType, + timestamp: m.timestamp, + read: false + }; + + addMention(entry); + + if(GetConfigurationValue('mentions_ui.sound', true)) PlaySound(SoundNames.MESSENGER_MESSAGE_RECEIVED); + + showSingleBubble( + LocalizeText('mentions.notification', [ 'sender', 'room' ], [ entry.senderUsername, entry.roomName ]), + NotificationBubbleType.INFO, + null, + 'mentions/toggle', + entry.senderUsername + ); + }, [ showSingleBubble ]); + + useMessageEvent(MentionsListEvent, onMentionsList); + useMessageEvent(MentionReceivedEvent, onMentionReceived); + + useEffect(() => + { + if(!GetConfigurationValue('mentions_ui.enabled', true)) return; + + SendMessageComposer(new RequestMentionsComposer()); + }, []); +}; diff --git a/src/hooks/mentions/useMentionsSnapshot.ts b/src/hooks/mentions/useMentionsSnapshot.ts new file mode 100644 index 0000000..e5b6e77 --- /dev/null +++ b/src/hooks/mentions/useMentionsSnapshot.ts @@ -0,0 +1,10 @@ +import { IMentionEntry } from '../../api/mentions'; +import { useExternalSnapshot } from '../events/useExternalSnapshot'; +import { getMentionsSnapshot, getUnreadCount, subscribeMentions } from './mentionsStore'; + +export const useMentionsSnapshot = (): { mentions: ReadonlyArray; unreadCount: number } => +{ + const mentions = useExternalSnapshot(subscribeMentions, getMentionsSnapshot); + const unreadCount = useExternalSnapshot(subscribeMentions, getUnreadCount); + return { mentions, unreadCount }; +};