Merge branch 'main' into feat/command-autocomplete-refactor

This commit is contained in:
DuckieTM
2026-06-03 16:33:58 +02:00
committed by GitHub
36 changed files with 1077 additions and 29 deletions
+1
View File
@@ -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';
@@ -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);
});
});
+2
View File
@@ -0,0 +1,2 @@
export * from './useMentionsSnapshot';
export * from './useMentionMessages';
+51
View File
@@ -0,0 +1,51 @@
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<IMentionEntry> => 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 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(); };
+72
View File
@@ -0,0 +1,72 @@
import { MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from '@nitrots/nitro-renderer';
import { useCallback, useEffect } from 'react';
import { GetConfigurationValue, IMentionEntry, LocalizeText, NotificationBubbleType, PlaySound, SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
import { useNotificationActions } from '../notification';
import { addMention, setMentions } from './mentionsStore';
// Dedicated mention chime served from nitro-assets/sounds/<sample>.mp3.
const MENTION_SOUND_SAMPLE = 'mentions_notification';
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<boolean>('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<boolean>('mentions_ui.sound', true)) PlaySound(MENTION_SOUND_SAMPLE);
showSingleBubble(
LocalizeText('mentions.notification', [ 'sender', 'room' ], [ entry.senderUsername, entry.roomName ]),
NotificationBubbleType.INFO,
null,
'mentions/toggle',
entry.senderUsername
);
}, [ showSingleBubble ]);
useMessageEvent<MentionsListEvent>(MentionsListEvent, onMentionsList);
useMessageEvent<MentionReceivedEvent>(MentionReceivedEvent, onMentionReceived);
useEffect(() =>
{
if(!GetConfigurationValue<boolean>('mentions_ui.enabled', true)) return;
SendMessageComposer(new RequestMentionsComposer());
}, []);
};
+10
View File
@@ -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<IMentionEntry>; unreadCount: number } =>
{
const mentions = useExternalSnapshot(subscribeMentions, getMentionsSnapshot);
const unreadCount = useExternalSnapshot(subscribeMentions, getUnreadCount);
return { mentions, unreadCount };
};