mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
feat(mentions): overhaul, refactor, notification bubble & window update
Chat tagging: - Any @user is a visible tag in chat bubbles (the .mention-tag CSS never existed, so highlighting was invisible); self/alias mentions get a gold emphasis. Fixes cross-room tags not being highlighted. Mentions window: - Redesigned: unread count in the header, restyled filter chips + a refresh button, CSS-driven list/date-groups, adaptive height (compact when few, capped + scroll when many), polished empty state. - Rows: framed avatar (friends-list head crop so the face is never clipped), per-row unread dot, type marker, icon action buttons (goto / remove). - Re-requests from the server each time it opens. Autocomplete: - Never suggests the viewer themselves; suggests room users + online friends + aliases. Notifications: - Mention toast removed; mentions flow through the client's standard notification stream via a dedicated mention bubble (avatar + actions) in the default position. EVERY received mention surfaces (independent of the generic info-feed toggle, gated only by mentions_ui.enabled). Refactor (behaviour-preserving): - Centralised @-token classification in api/mentions/mentionTokens. - Moved mentionsFormat -> api/mentions, useMentionActions -> hooks/mentions. - Extracted ChatInputView @-autocomplete into a tested useChatMentions hook + pure helper; removed the dead duplicate useMentionAutocomplete.
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
export * from './useMentionsSnapshot';
|
||||
export * from './useMentionMessages';
|
||||
export * from './useMentionAutocomplete';
|
||||
export * from './useMentionActions';
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { IMentionEntry } from '../../api';
|
||||
|
||||
// Toast laterali per le menzioni appena ricevute (avatar + messaggio + dismiss).
|
||||
// Separato da mentionsStore: i toast sono effimeri, le menzioni persistono nel pannello.
|
||||
export interface MentionToast
|
||||
{
|
||||
mentionId: number;
|
||||
senderId: number;
|
||||
senderUsername: string;
|
||||
senderFigure: string;
|
||||
message: string;
|
||||
roomName: string;
|
||||
}
|
||||
|
||||
const MAX_TOASTS = 4;
|
||||
|
||||
let toasts: MentionToast[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emit = (): void =>
|
||||
{
|
||||
for(const listener of listeners) listener();
|
||||
};
|
||||
|
||||
export const subscribeMentionToasts = (callback: () => void): (() => void) =>
|
||||
{
|
||||
listeners.add(callback);
|
||||
return () => { listeners.delete(callback); };
|
||||
};
|
||||
|
||||
export const getMentionToasts = (): ReadonlyArray<MentionToast> => toasts;
|
||||
|
||||
export const pushMentionToast = (entry: IMentionEntry): void =>
|
||||
{
|
||||
toasts = [
|
||||
{
|
||||
mentionId: entry.mentionId,
|
||||
senderId: entry.senderId,
|
||||
senderUsername: entry.senderUsername,
|
||||
senderFigure: entry.senderFigure,
|
||||
message: entry.message,
|
||||
roomName: entry.roomName
|
||||
},
|
||||
...toasts.filter(toast => toast.mentionId !== entry.mentionId)
|
||||
].slice(0, MAX_TOASTS);
|
||||
|
||||
emit();
|
||||
};
|
||||
|
||||
export const dismissMentionToast = (mentionId: number): void =>
|
||||
{
|
||||
const next = toasts.filter(toast => toast.mentionId !== mentionId);
|
||||
|
||||
if(next.length === toasts.length) return;
|
||||
|
||||
toasts = next;
|
||||
emit();
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { CreateLinkEvent, DeleteMentionComposer, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
|
||||
import { useMemo } from 'react';
|
||||
import { IMentionEntry, SendMessageComposer } from '../../api';
|
||||
import { markRead, removeMention } from './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;
|
||||
/** Permanently delete the mention server-side (DeleteMentionComposer) and
|
||||
* drop it from the local list, so it does not reappear after a relog. */
|
||||
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) =>
|
||||
{
|
||||
// Permanent server-side delete, then drop it from the local list.
|
||||
SendMessageComposer(new DeleteMentionComposer(mention.mentionId));
|
||||
removeMention(mention.mentionId);
|
||||
}
|
||||
}), []);
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MENTION_ROOM_ALIASES } from '../../components/room/widgets/chat/highlightMentions';
|
||||
import { useFriendsState } from '../friends/useFriends';
|
||||
import { useRoomUserListSnapshot } from '../session/useSessionSnapshots';
|
||||
|
||||
export interface MentionSuggestion
|
||||
{
|
||||
name: string;
|
||||
figure: string;
|
||||
isAlias: boolean;
|
||||
}
|
||||
|
||||
const MAX_SUGGESTIONS = 8;
|
||||
|
||||
// Trova il token @<parziale> che si sta digitando alla FINE del valore.
|
||||
// Restituisce il parziale (anche '' subito dopo @) oppure null se non si è in un @mention.
|
||||
const activeMentionPartial = (value: string): string | null =>
|
||||
{
|
||||
if(!value || value.indexOf('@') < 0) return null;
|
||||
|
||||
const match = /(?:^|\s)@([A-Za-z0-9_]*)$/.exec(value);
|
||||
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
export interface MentionAutocompleteState
|
||||
{
|
||||
isVisible: boolean;
|
||||
suggestions: MentionSuggestion[];
|
||||
selectedIndex: number;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
moveUp: () => void;
|
||||
moveDown: () => void;
|
||||
current: () => MentionSuggestion | null;
|
||||
// Inserisce il nome scelto sostituendo il parziale @... alla fine del valore.
|
||||
applyTo: (value: string, name: string) => string;
|
||||
}
|
||||
|
||||
export const useMentionAutocomplete = (chatValue: string): MentionAutocompleteState =>
|
||||
{
|
||||
const roomUsers = useRoomUserListSnapshot();
|
||||
const { onlineFriends } = useFriendsState();
|
||||
const [ selectedIndex, setSelectedIndex ] = useState(0);
|
||||
|
||||
const partial = useMemo(() => activeMentionPartial(chatValue), [ chatValue ]);
|
||||
|
||||
const suggestions = useMemo<MentionSuggestion[]>(() =>
|
||||
{
|
||||
if(partial === null) return [];
|
||||
|
||||
const query = partial.toLowerCase();
|
||||
const seen = new Set<string>();
|
||||
const out: MentionSuggestion[] = [];
|
||||
|
||||
const add = (name: string, figure: string, isAlias: boolean) =>
|
||||
{
|
||||
if(!name || out.length >= MAX_SUGGESTIONS) return;
|
||||
|
||||
const key = name.toLowerCase();
|
||||
|
||||
if(seen.has(key)) return;
|
||||
if(query && !key.startsWith(query)) return;
|
||||
|
||||
seen.add(key);
|
||||
out.push({ name, figure: figure || '', isAlias });
|
||||
};
|
||||
|
||||
for(const user of (roomUsers || [])) add(user?.name, (user as any)?.figure, false);
|
||||
for(const friend of (onlineFriends || [])) add(friend?.name, friend?.figure, false);
|
||||
for(const alias of MENTION_ROOM_ALIASES) add(alias, '', true);
|
||||
|
||||
return out;
|
||||
}, [ partial, roomUsers, onlineFriends ]);
|
||||
|
||||
useEffect(() => { setSelectedIndex(0); }, [ partial ]);
|
||||
|
||||
const isVisible = (partial !== null) && (suggestions.length > 0);
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
moveUp: () => setSelectedIndex(index => (index <= 0 ? suggestions.length - 1 : index - 1)),
|
||||
moveDown: () => setSelectedIndex(index => (index >= suggestions.length - 1 ? 0 : index + 1)),
|
||||
current: () => suggestions[selectedIndex] ?? null,
|
||||
applyTo: (value: string, name: string) => value.replace(/@([A-Za-z0-9_]*)$/, '@' + name + ' ')
|
||||
};
|
||||
};
|
||||
@@ -2,14 +2,16 @@ import { MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { GetConfigurationValue, IMentionEntry, PlaySound, SendMessageComposer } from '../../api';
|
||||
import { useMessageEvent } from '../events';
|
||||
import { useNotification } from '../notification/useNotification';
|
||||
import { addMention, setMentions } from './mentionsStore';
|
||||
import { pushMentionToast } from './mentionToastsStore';
|
||||
|
||||
// Dedicated mention chime served from nitro-assets/sounds/<sample>.mp3.
|
||||
const MENTION_SOUND_SAMPLE = 'mentions_notification';
|
||||
|
||||
export const useMentionMessages = (): void =>
|
||||
{
|
||||
const { showMentionBubble } = useNotification();
|
||||
|
||||
const onMentionsList = useCallback((event: MentionsListEvent) =>
|
||||
{
|
||||
const list = event.getParser().mentions;
|
||||
@@ -51,9 +53,10 @@ export const useMentionMessages = (): void =>
|
||||
|
||||
if(GetConfigurationValue<boolean>('mentions_ui.sound', true)) PlaySound(MENTION_SOUND_SAMPLE);
|
||||
|
||||
// Notifica laterale custom (avatar + messaggio + dismiss) invece del bubble generico.
|
||||
pushMentionToast(entry);
|
||||
}, []);
|
||||
// Surface it through the client's standard notification stream, using the
|
||||
// dedicated mention bubble layout (avatar + actions).
|
||||
showMentionBubble(entry);
|
||||
}, [ showMentionBubble ]);
|
||||
|
||||
useMessageEvent<MentionsListEvent>(MentionsListEvent, onMentionsList);
|
||||
useMessageEvent<MentionReceivedEvent>(MentionReceivedEvent, onMentionReceived);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AchievementNotificationMessageEvent, ActivityPointNotificationMessageEvent, BadgeReceivedEvent, ClubGiftNotificationEvent, ClubGiftSelectedEvent, ConnectionErrorEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, HabboBroadcastMessageEvent, HotelClosedAndOpensEvent, HotelClosesAndWillOpenAtEvent, HotelWillCloseInMinutesEvent, InfoFeedEnableMessageEvent, MaintenanceStatusMessageEvent, ModeratorCautionEvent, ModeratorMessageEvent, MOTDNotificationEvent, NotificationDialogMessageEvent, PetLevelNotificationEvent, PetReceivedMessageEvent, RespectReceivedEvent, RoomEnterEffect, RoomEnterEvent, SimpleAlertMessageEvent, UserBannedMessageEvent, Vector3d, WiredRewardResultMessageEvent } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useBetween } from 'use-between';
|
||||
import { GetConfigurationValue, LocalizeBadgeName, LocalizeText, NotificationAlertItem, NotificationAlertType, NotificationBubbleItem, NotificationBubbleType, NotificationConfirmItem, PlaySound, ProductImageUtility, TradingNotificationType } from '../../api';
|
||||
import { GetConfigurationValue, IMentionEntry, LocalizeBadgeName, LocalizeText, MentionNotificationBubbleItem, NotificationAlertItem, NotificationAlertType, NotificationBubbleItem, NotificationBubbleType, NotificationConfirmItem, PlaySound, ProductImageUtility, TradingNotificationType } from '../../api';
|
||||
import { useMessageEvent } from '../events';
|
||||
|
||||
const cleanText = (text: string) => (text && text.length) ? text.replace(/\\r/g, '\r') : '';
|
||||
@@ -86,6 +86,16 @@ const useNotificationStore = () =>
|
||||
});
|
||||
}, [ bubblesDisabled ]);
|
||||
|
||||
const showMentionBubble = useCallback((mention: IMentionEntry) =>
|
||||
{
|
||||
// Mentions always surface: they have their own `mentions_ui.enabled` gate
|
||||
// (checked in useMentionMessages) and are intentionally independent of the
|
||||
// generic info-feed toggle, so EVERY received mention shows a bubble.
|
||||
const item = new MentionNotificationBubbleItem(mention);
|
||||
|
||||
setBubbleAlerts(prevValue => [ item, ...prevValue ]);
|
||||
}, []);
|
||||
|
||||
const showNotification = (type: string, options: Map<string, string> = null) =>
|
||||
{
|
||||
if(!options) options = new Map();
|
||||
@@ -490,7 +500,7 @@ const useNotificationStore = () =>
|
||||
|
||||
useMessageEvent<RoomEnterEvent>(RoomEnterEvent, onRoomEnterEvent);
|
||||
|
||||
return { alerts, bubbleAlerts, confirms, simpleAlert, showNitroAlert, showTradeAlert, showConfirm, showSingleBubble, closeAlert, closeBubbleAlert, closeConfirm };
|
||||
return { alerts, bubbleAlerts, confirms, simpleAlert, showNitroAlert, showTradeAlert, showConfirm, showSingleBubble, showMentionBubble, closeAlert, closeBubbleAlert, closeConfirm };
|
||||
};
|
||||
|
||||
export const useNotificationState = () =>
|
||||
@@ -508,6 +518,7 @@ export const useNotificationActions = () =>
|
||||
showTradeAlert,
|
||||
showConfirm,
|
||||
showSingleBubble,
|
||||
showMentionBubble,
|
||||
closeAlert,
|
||||
closeBubbleAlert,
|
||||
closeConfirm
|
||||
@@ -519,6 +530,7 @@ export const useNotificationActions = () =>
|
||||
showTradeAlert,
|
||||
showConfirm,
|
||||
showSingleBubble,
|
||||
showMentionBubble,
|
||||
closeAlert,
|
||||
closeBubbleAlert,
|
||||
closeConfirm
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './furniture';
|
||||
export * from './useAvatarInfoWidget';
|
||||
export * from './useChatCommandSelector';
|
||||
export * from './useChatInputWidget';
|
||||
export * from './useChatMentions';
|
||||
export * from './useChatWidget';
|
||||
export * from './useDoorbellActions';
|
||||
export * from './useDoorbellState';
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildChatMentionSuggestions, computeMentionContext, MentionAlias } from './useChatMentions.helpers';
|
||||
|
||||
const ALIASES: MentionAlias[] = [
|
||||
{ key: 'all', scope: 'everyone', description: '' },
|
||||
{ key: 'room', scope: 'room', description: '' }
|
||||
];
|
||||
|
||||
const ROOM = [
|
||||
{ webID: 1, type: 1, name: 'tester', figure: 'a' },
|
||||
{ webID: 2, type: 1, name: 'alice', figure: 'b' },
|
||||
{ webID: 3, type: 1, name: 'bob', figure: 'c' },
|
||||
{ webID: 9, type: 2, name: 'petbot', figure: 'd' } // non-real user (pet/bot)
|
||||
];
|
||||
|
||||
describe('computeMentionContext', () =>
|
||||
{
|
||||
it('detects a trailing @query', () =>
|
||||
{
|
||||
expect(computeMentionContext('hi @al', false)).toEqual({ atIndex: 3, replaceFrom: 3, replaceTo: 6, query: 'al' });
|
||||
});
|
||||
|
||||
it('detects @ at the very start', () =>
|
||||
{
|
||||
expect(computeMentionContext('@al', false)).toEqual({ atIndex: 0, replaceFrom: 0, replaceTo: 3, query: 'al' });
|
||||
});
|
||||
|
||||
it('returns null when a command popover is open', () =>
|
||||
{
|
||||
expect(computeMentionContext('hi @al', true)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when @ is glued to a previous word (e.g. an email)', () =>
|
||||
{
|
||||
expect(computeMentionContext('mail me@al', false)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the @token is not at the end', () =>
|
||||
{
|
||||
expect(computeMentionContext('@al ready', false)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when there is no @', () =>
|
||||
{
|
||||
expect(computeMentionContext('plain text', false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildChatMentionSuggestions', () =>
|
||||
{
|
||||
it('excludes the viewer by id and keeps the other real users + aliases', () =>
|
||||
{
|
||||
const names = buildChatMentionSuggestions('', ROOM, ALIASES, 1, 'tester').map(s => s.name);
|
||||
|
||||
expect(names).not.toContain('tester'); // own user (webID 1)
|
||||
expect(names).toContain('alice');
|
||||
expect(names).toContain('bob');
|
||||
expect(names).toContain('all');
|
||||
expect(names).toContain('room');
|
||||
});
|
||||
|
||||
it('excludes the viewer by name when the id does not line up', () =>
|
||||
{
|
||||
const names = buildChatMentionSuggestions('', ROOM, ALIASES, -1, 'TESTER').map(s => s.name);
|
||||
|
||||
expect(names).not.toContain('tester');
|
||||
});
|
||||
|
||||
it('skips non-real users (pets/bots)', () =>
|
||||
{
|
||||
const names = buildChatMentionSuggestions('', ROOM, [], 1, 'tester').map(s => s.name);
|
||||
|
||||
expect(names).not.toContain('petbot');
|
||||
});
|
||||
|
||||
it('prefix-filters users and aliases by the query', () =>
|
||||
{
|
||||
const names = buildChatMentionSuggestions('al', ROOM, ALIASES, 1, 'tester').map(s => s.name);
|
||||
|
||||
expect(names).toContain('alice');
|
||||
expect(names).toContain('all');
|
||||
expect(names).not.toContain('bob');
|
||||
expect(names).not.toContain('room');
|
||||
});
|
||||
|
||||
it('tags user vs alias kinds', () =>
|
||||
{
|
||||
const out = buildChatMentionSuggestions('', ROOM, ALIASES, 1, 'tester');
|
||||
|
||||
expect(out.find(s => s.name === 'alice')?.kind).toBe('user');
|
||||
expect(out.find(s => s.name === 'all')?.kind).toBe('alias');
|
||||
});
|
||||
|
||||
it('caps the total at max', () =>
|
||||
{
|
||||
const many = Array.from({ length: 20 }, (_, i) => ({ webID: 100 + i, type: 1, name: `u${ i }`, figure: '' }));
|
||||
const out = buildChatMentionSuggestions('', many, ALIASES, -1, 'me', 5);
|
||||
|
||||
expect(out).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// Pure helpers for the chat-input @-mention autocomplete. Kept framework-free
|
||||
// so the suggestion building and the caret-context detection are unit-testable.
|
||||
|
||||
export type MentionSuggestionKind = 'user' | 'alias';
|
||||
|
||||
export interface MentionSuggestion
|
||||
{
|
||||
key: string;
|
||||
kind: MentionSuggestionKind;
|
||||
name: string;
|
||||
insertToken: string;
|
||||
figure?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type MentionAliasScope = 'everyone' | 'friends' | 'room';
|
||||
|
||||
export interface MentionAlias
|
||||
{
|
||||
key: string;
|
||||
scope: MentionAliasScope;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface MentionContext
|
||||
{
|
||||
atIndex: number;
|
||||
replaceFrom: number;
|
||||
replaceTo: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
export const MAX_MENTION_SUGGESTIONS = 8;
|
||||
|
||||
const USER_TYPE_REAL_USER = 1;
|
||||
|
||||
export const MENTION_ALIAS_CONFIG_KEY: Record<MentionAliasScope, string> = {
|
||||
everyone: 'mentions_ui.aliases.everyone',
|
||||
friends: 'mentions_ui.aliases.friends',
|
||||
room: 'mentions_ui.aliases.room'
|
||||
};
|
||||
|
||||
export const MENTION_ALIAS_DEFAULTS: Record<MentionAliasScope, string[]> = {
|
||||
everyone: [ 'all', 'everyone', 'tutti' ],
|
||||
friends: [ 'friends', 'amici' ],
|
||||
room: [ 'room', 'stanza' ]
|
||||
};
|
||||
|
||||
export const MENTION_ALIAS_DESCRIPTION_KEY: Record<MentionAliasScope, string> = {
|
||||
everyone: 'mentions.alias.description.everyone',
|
||||
friends: 'mentions.alias.description.friends',
|
||||
room: 'mentions.alias.description.room'
|
||||
};
|
||||
|
||||
export const sanitizeAliasList = (raw: unknown, fallback: string[]): string[] =>
|
||||
{
|
||||
if(!Array.isArray(raw)) return fallback;
|
||||
|
||||
const out: string[] = [];
|
||||
|
||||
for(const entry of raw)
|
||||
{
|
||||
if(typeof entry !== 'string') continue;
|
||||
|
||||
const trimmed = entry.trim();
|
||||
|
||||
if(!trimmed) continue;
|
||||
|
||||
out.push(trimmed);
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect an in-progress `@partial` token at the END of the input. Returns the
|
||||
* token bounds + the query (text after `@`), or null when not in a mention (no
|
||||
* trailing `@token`, `@` glued to a previous word, or a command popover open).
|
||||
* End-anchored (no caret read) so it stays a pure render-safe computation.
|
||||
*/
|
||||
export const computeMentionContext = (value: string, commandSelectorVisible: boolean): MentionContext | null =>
|
||||
{
|
||||
if(!value) return null;
|
||||
if(commandSelectorVisible) return null;
|
||||
|
||||
const match = /(?:^|\s)@([A-Za-z0-9_]*)$/.exec(value);
|
||||
|
||||
if(!match) return null;
|
||||
|
||||
const query = match[1];
|
||||
const atIndex = value.length - query.length - 1;
|
||||
|
||||
return { atIndex, replaceFrom: atIndex, replaceTo: value.length, query };
|
||||
};
|
||||
|
||||
interface MentionRoomUser
|
||||
{
|
||||
webID?: number;
|
||||
type?: number;
|
||||
name?: string;
|
||||
figure?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the suggestion list for the current query: real room users (minus the
|
||||
* viewer themselves — match by id, name as fallback) then the broadcast
|
||||
* aliases, prefix-filtered and capped.
|
||||
*/
|
||||
export const buildChatMentionSuggestions = (
|
||||
query: string,
|
||||
roomUserList: ReadonlyArray<MentionRoomUser>,
|
||||
aliases: ReadonlyArray<MentionAlias>,
|
||||
ownUserId: number,
|
||||
ownUsername: string,
|
||||
max: number = MAX_MENTION_SUGGESTIONS
|
||||
): MentionSuggestion[] =>
|
||||
{
|
||||
const q = (query || '').toLowerCase();
|
||||
const ownNameLower = (ownUsername || '').toLowerCase();
|
||||
const out: MentionSuggestion[] = [];
|
||||
|
||||
for(const user of (roomUserList || []))
|
||||
{
|
||||
if(out.length >= max) break;
|
||||
if(!user || (user.type !== USER_TYPE_REAL_USER)) continue;
|
||||
if(!user.name) continue;
|
||||
// You can't mention yourself — skip the own user (match by id, name as fallback).
|
||||
if((user.webID === ownUserId) || (ownNameLower && (user.name.toLowerCase() === ownNameLower))) continue;
|
||||
if((q.length > 0) && !user.name.toLowerCase().startsWith(q)) continue;
|
||||
|
||||
out.push({ key: `user:${ user.webID }`, kind: 'user', name: user.name, insertToken: user.name, figure: user.figure || '' });
|
||||
}
|
||||
|
||||
for(const alias of aliases)
|
||||
{
|
||||
if(out.length >= max) break;
|
||||
if((q.length > 0) && !alias.key.toLowerCase().startsWith(q)) continue;
|
||||
|
||||
out.push({ key: `alias:${ alias.key }`, kind: 'alias', name: alias.key, insertToken: alias.key, description: alias.description });
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { GetSessionDataManager } from '@nitrots/nitro-renderer';
|
||||
import { RefObject, useCallback, useMemo, useState } from 'react';
|
||||
import { GetConfigurationValue, LocalizeText } from '../../../api';
|
||||
import { useRoomUserListSnapshot } from '../../session/useSessionSnapshots';
|
||||
import { buildChatMentionSuggestions, computeMentionContext, MentionAlias, MentionAliasScope, MentionSuggestion, MENTION_ALIAS_CONFIG_KEY, MENTION_ALIAS_DEFAULTS, MENTION_ALIAS_DESCRIPTION_KEY, sanitizeAliasList } from './useChatMentions.helpers';
|
||||
|
||||
export type { MentionSuggestion, MentionSuggestionKind } from './useChatMentions.helpers';
|
||||
|
||||
export interface ChatMentionsState
|
||||
{
|
||||
visible: boolean;
|
||||
suggestions: MentionSuggestion[];
|
||||
selectedIndex: number;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
moveUp: () => void;
|
||||
moveDown: () => void;
|
||||
/** Apply the highlighted suggestion (or the first). Returns true if one was applied. */
|
||||
applyCurrent: () => boolean;
|
||||
apply: (suggestion: MentionSuggestion) => void;
|
||||
/** Escape: reset selection and strip the in-progress @query from the input. */
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat-input @-mention autocomplete. Owns the caret-context detection, the
|
||||
* config-driven broadcast aliases, the room-user suggestion list (minus the
|
||||
* viewer), keyboard navigation and the insert/cancel actions — so ChatInputView
|
||||
* just wires its keydown handler and the selector view to this hook.
|
||||
*/
|
||||
export const useChatMentions = (
|
||||
chatValue: string,
|
||||
setChatValue: (value: string) => void,
|
||||
inputRef: RefObject<HTMLInputElement>,
|
||||
commandSelectorVisible: boolean
|
||||
): ChatMentionsState =>
|
||||
{
|
||||
const roomUserList = useRoomUserListSnapshot();
|
||||
const [ selectedIndex, setSelectedIndex ] = useState(0);
|
||||
|
||||
const mentionContext = useMemo(() => computeMentionContext(chatValue, commandSelectorVisible), [ chatValue, commandSelectorVisible ]);
|
||||
|
||||
const aliases = useMemo<MentionAlias[]>(() =>
|
||||
{
|
||||
const out: MentionAlias[] = [];
|
||||
const seen = new Set<string>();
|
||||
const scopes: MentionAliasScope[] = [ 'everyone', 'friends', 'room' ];
|
||||
|
||||
for(const scope of scopes)
|
||||
{
|
||||
const list = sanitizeAliasList(GetConfigurationValue<unknown>(MENTION_ALIAS_CONFIG_KEY[scope], MENTION_ALIAS_DEFAULTS[scope]), MENTION_ALIAS_DEFAULTS[scope]);
|
||||
|
||||
for(const key of list)
|
||||
{
|
||||
const lower = key.toLowerCase();
|
||||
|
||||
if(seen.has(lower)) continue;
|
||||
|
||||
seen.add(lower);
|
||||
out.push({ key, scope, description: LocalizeText(MENTION_ALIAS_DESCRIPTION_KEY[scope]) });
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
const suggestions = useMemo<MentionSuggestion[]>(() =>
|
||||
{
|
||||
if(!mentionContext) return [];
|
||||
|
||||
const session = GetSessionDataManager();
|
||||
|
||||
return buildChatMentionSuggestions(mentionContext.query, roomUserList, aliases, session?.userId ?? -1, session?.userName || '');
|
||||
}, [ mentionContext, roomUserList, aliases ]);
|
||||
|
||||
const visible = (suggestions.length > 0);
|
||||
// Clamp the selection to the current list (derived, no effect) so a
|
||||
// shrinking list never leaves the highlight out of range.
|
||||
const safeIndex = (selectedIndex < suggestions.length) ? selectedIndex : 0;
|
||||
|
||||
const apply = useCallback((suggestion: MentionSuggestion) =>
|
||||
{
|
||||
if(!suggestion || !mentionContext) return;
|
||||
|
||||
const before = chatValue.slice(0, mentionContext.replaceFrom);
|
||||
const after = chatValue.slice(mentionContext.replaceTo);
|
||||
const inserted = `@${ suggestion.insertToken } `;
|
||||
|
||||
setChatValue(`${ before }${ inserted }${ after }`);
|
||||
|
||||
requestAnimationFrame(() =>
|
||||
{
|
||||
if(!inputRef.current) return;
|
||||
|
||||
const caret = before.length + inserted.length;
|
||||
|
||||
inputRef.current.focus();
|
||||
inputRef.current.setSelectionRange(caret, caret);
|
||||
});
|
||||
|
||||
setSelectedIndex(0);
|
||||
}, [ chatValue, mentionContext, setChatValue, inputRef ]);
|
||||
|
||||
const moveUp = useCallback(() => setSelectedIndex((safeIndex <= 0) ? (suggestions.length - 1) : (safeIndex - 1)), [ safeIndex, suggestions.length ]);
|
||||
const moveDown = useCallback(() => setSelectedIndex((safeIndex >= (suggestions.length - 1)) ? 0 : (safeIndex + 1)), [ safeIndex, suggestions.length ]);
|
||||
|
||||
const applyCurrent = useCallback(() =>
|
||||
{
|
||||
const picked = suggestions[safeIndex] ?? suggestions[0];
|
||||
|
||||
if(!picked) return false;
|
||||
|
||||
apply(picked);
|
||||
|
||||
return true;
|
||||
}, [ suggestions, safeIndex, apply ]);
|
||||
|
||||
const cancel = useCallback(() =>
|
||||
{
|
||||
setSelectedIndex(0);
|
||||
|
||||
if(!mentionContext) return;
|
||||
|
||||
const before = chatValue.slice(0, mentionContext.replaceFrom);
|
||||
const after = chatValue.slice(mentionContext.replaceTo);
|
||||
|
||||
setChatValue(before + after);
|
||||
}, [ mentionContext, chatValue, setChatValue ]);
|
||||
|
||||
return { visible, suggestions, selectedIndex: safeIndex, setSelectedIndex, moveUp, moveDown, applyCurrent, apply, cancel };
|
||||
};
|
||||
Reference in New Issue
Block a user