mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
feat(mentions): @ autocomplete, blue @nick, avatar notification toast
- Chat input @ autocomplete: typing @ shows online users (room users + online friends + room aliases) with avatars; arrows/Tab/Enter to pick. - Any valid @nick token is highlighted blue in chat bubbles (like @all), giving visual feedback that it is a recognised mention. - Side notification toast on a received mention: sender avatar (from the new senderFigure wire field) + message + dismiss; dismiss marks it read so the toolbar unread badge updates. Auto-hides after 8s. - IMentionEntry/parsers carry senderFigure end to end.
This commit is contained in:
@@ -48,7 +48,7 @@ import { UserAccountSettingsView } from './user-settings/UserAccountSettingsView
|
||||
import { UserSettingsView } from './user-settings/UserSettingsView';
|
||||
import { WiredView } from './wired/WiredView';
|
||||
import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView';
|
||||
import { MentionsView } from './mentions';
|
||||
import { MentionsView, MentionToastsView } from './mentions';
|
||||
|
||||
export const MainView: FC<{}> = props =>
|
||||
{
|
||||
@@ -242,6 +242,8 @@ export const MainView: FC<{}> = props =>
|
||||
{ GetConfigurationValue<boolean>('radio_ui.enabled', false) && <RadioView /> }
|
||||
{ (GetConfigurationValue<boolean>('mentions_ui.enabled', true) && mentionsVisible) &&
|
||||
<MentionsView onClose={ () => setMentionsVisible(false) } /> }
|
||||
{ GetConfigurationValue<boolean>('mentions_ui.enabled', true) &&
|
||||
<MentionToastsView /> }
|
||||
<ExternalPluginLoader />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { CreateLinkEvent, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, MouseEvent, useEffect } from 'react';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
import { LocalizeText, SendMessageComposer } from '../../api';
|
||||
import { LayoutAvatarImageView } from '../../common';
|
||||
import { useExternalSnapshot } from '../../hooks/events/useExternalSnapshot';
|
||||
import { markRead } from '../../hooks/mentions/mentionsStore';
|
||||
import { dismissMentionToast, getMentionToasts, MentionToast, subscribeMentionToasts } from '../../hooks/mentions/mentionToastsStore';
|
||||
|
||||
// Quanto resta visibile un toast prima di nascondersi da solo (resta non-letto).
|
||||
const AUTO_DISMISS_MS = 8000;
|
||||
|
||||
const MentionToastItemView: FC<{ toast: MentionToast }> = ({ toast }) =>
|
||||
{
|
||||
useEffect(() =>
|
||||
{
|
||||
const timer = window.setTimeout(() => dismissMentionToast(toast.mentionId), AUTO_DISMISS_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [ toast.mentionId ]);
|
||||
|
||||
// Dismiss esplicito: segna letta (badge toolbar si aggiorna) + persiste sul server + chiude.
|
||||
const onDismiss = (event: MouseEvent) =>
|
||||
{
|
||||
event.stopPropagation();
|
||||
markRead(toast.mentionId);
|
||||
SendMessageComposer(new MarkMentionsReadComposer(1, toast.mentionId));
|
||||
dismissMentionToast(toast.mentionId);
|
||||
};
|
||||
|
||||
const onOpen = () =>
|
||||
{
|
||||
CreateLinkEvent('mentions/toggle');
|
||||
dismissMentionToast(toast.mentionId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mention-toast" onClick={ onOpen }>
|
||||
<div className="mention-toast-avatar">
|
||||
<LayoutAvatarImageView headOnly direction={ 2 } figure={ toast.senderFigure } />
|
||||
</div>
|
||||
<div className="mention-toast-body">
|
||||
<div className="mention-toast-title">{ toast.senderUsername }</div>
|
||||
<div className="mention-toast-message">{ toast.message }</div>
|
||||
</div>
|
||||
<button className="mention-toast-dismiss" title={ LocalizeText('generic.cancel') } type="button" onClick={ onDismiss }>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MentionToastsView: FC = () =>
|
||||
{
|
||||
const toasts = useExternalSnapshot(subscribeMentionToasts, getMentionToasts);
|
||||
|
||||
if(!toasts || !toasts.length) return null;
|
||||
|
||||
return (
|
||||
<div className="mention-toasts">
|
||||
{ toasts.map(toast => <MentionToastItemView key={ toast.mentionId } toast={ toast } />) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './MentionMessageView';
|
||||
export * from './MentionRowView';
|
||||
export * from './MentionsView';
|
||||
export * from './MentionToastsView';
|
||||
export * from './mentionsFormat';
|
||||
export * from './useMentionActions';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
import { LayoutAvatarImageView } from '../../../../common';
|
||||
import { MentionSuggestion } from '../../../../hooks/mentions/useMentionAutocomplete';
|
||||
|
||||
interface ChatInputMentionSelectorViewProps
|
||||
{
|
||||
suggestions: MentionSuggestion[];
|
||||
selectedIndex: number;
|
||||
onSelect: (suggestion: MentionSuggestion) => void;
|
||||
onHover: (index: number) => void;
|
||||
}
|
||||
|
||||
export const ChatInputMentionSelectorView: FC<ChatInputMentionSelectorViewProps> = props =>
|
||||
{
|
||||
const { suggestions = [], selectedIndex = 0, onSelect = null, onHover = null } = props;
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!listRef.current) return;
|
||||
|
||||
const selected = listRef.current.children[selectedIndex] as HTMLElement;
|
||||
|
||||
if(selected) selected.scrollIntoView({ block: 'nearest' });
|
||||
}, [ selectedIndex ]);
|
||||
|
||||
return (
|
||||
<div ref={ listRef } className="absolute bottom-full left-0 w-full bg-[#e8e8e8] border-2 border-black border-b-0 rounded-t-lg max-h-[240px] overflow-y-auto z-[1070]">
|
||||
{ suggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={ suggestion.name }
|
||||
className={ `px-2 py-1 cursor-pointer text-sm flex items-center gap-2 ${ index === selectedIndex ? 'bg-[#283F5D] text-white' : 'hover:bg-gray-300' }` }
|
||||
onClick={ () => onSelect(suggestion) }
|
||||
onMouseEnter={ () => onHover(index) }
|
||||
>
|
||||
<div className="mention-suggest-avatar">
|
||||
{ suggestion.isAlias
|
||||
? <span className="mention-suggest-alias">@</span>
|
||||
: <LayoutAvatarImageView headOnly direction={ 2 } figure={ suggestion.figure } /> }
|
||||
</div>
|
||||
<span className="font-bold">@{ suggestion.name }</span>
|
||||
</div>
|
||||
)) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,9 +3,10 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, RoomWidgetUpdateChatInputContentEvent } from '../../../../api';
|
||||
import { Text } from '../../../../common';
|
||||
import { useChatCommandSelector, useChatInputWidget, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
|
||||
import { useChatCommandSelector, useChatInputWidget, useMentionAutocomplete, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
|
||||
import { ChatInputCommandSelectorView } from './ChatInputCommandSelectorView';
|
||||
import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView';
|
||||
import { ChatInputMentionSelectorView } from './ChatInputMentionSelectorView';
|
||||
import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView';
|
||||
|
||||
export const ChatInputView: FC<{}> = props =>
|
||||
@@ -16,6 +17,7 @@ export const ChatInputView: FC<{}> = props =>
|
||||
const { roomSession = null } = useRoom();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { isVisible: commandSelectorVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close: closeCommandSelector } = useChatCommandSelector(chatValue);
|
||||
const mention = useMentionAutocomplete(chatValue);
|
||||
|
||||
const chatModeIdWhisper = useMemo(() => LocalizeText('widgets.chatinput.mode.whisper'), []);
|
||||
const chatModeIdShout = useMemo(() => LocalizeText('widgets.chatinput.mode.shout'), []);
|
||||
@@ -171,6 +173,36 @@ export const ChatInputView: FC<{}> = props =>
|
||||
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
|
||||
if(mention.isVisible)
|
||||
{
|
||||
switch(event.key)
|
||||
{
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
mention.moveUp();
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
mention.moveDown();
|
||||
return;
|
||||
case 'Tab':
|
||||
event.preventDefault();
|
||||
// fall through
|
||||
case 'NumpadEnter':
|
||||
case 'Enter': {
|
||||
const current = mention.current();
|
||||
|
||||
if(current)
|
||||
{
|
||||
event.preventDefault();
|
||||
setChatValue(prev => mention.applyTo(prev, current.name));
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch(event.key)
|
||||
{
|
||||
case ' ':
|
||||
@@ -194,7 +226,7 @@ export const ChatInputView: FC<{}> = props =>
|
||||
return;
|
||||
}
|
||||
|
||||
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector ]);
|
||||
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector, mention ]);
|
||||
|
||||
useUiEvent<RoomWidgetUpdateChatInputContentEvent>(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event =>
|
||||
{
|
||||
@@ -290,6 +322,16 @@ export const ChatInputView: FC<{}> = props =>
|
||||
} }
|
||||
onHover={ setSelectedIndex }
|
||||
/> }
|
||||
{ (!commandSelectorVisible && mention.isVisible) &&
|
||||
<ChatInputMentionSelectorView
|
||||
suggestions={ mention.suggestions }
|
||||
selectedIndex={ mention.selectedIndex }
|
||||
onSelect={ (suggestion) =>
|
||||
{
|
||||
setChatValue(prev => mention.applyTo(prev, suggestion.name)); inputRef.current?.focus();
|
||||
} }
|
||||
onHover={ mention.setSelectedIndex }
|
||||
/> }
|
||||
<div className="flex-1 items-center input-sizer">
|
||||
{ !floodBlocked &&
|
||||
<input ref={ inputRef } className="w-full border-none bg-transparent px-[10px] text-[0.86rem] text-black placeholder:text-[#6c757d] focus:border-current focus:shadow-none focus:ring-0" maxLength={ maxChatLength } placeholder={ LocalizeText('widgets.chatinput.default') } type="text" value={ chatValue } onChange={ event => updateChatInput(event.target.value) } onMouseDown={ event => setInputFocus() } /> }
|
||||
|
||||
@@ -51,9 +51,14 @@ const isMentionToken = (token: string, ownUsernameLower: string, aliases: Readon
|
||||
|
||||
if(!nick) return false;
|
||||
|
||||
// Own nick and room-broadcast aliases always count.
|
||||
if(ownUsernameLower && nick === ownUsernameLower) return true;
|
||||
if(aliases.has(nick)) return true;
|
||||
|
||||
return aliases.has(nick);
|
||||
// Any other valid @nick token is also highlighted (blue), so a direct
|
||||
// @username mention reads the same as @all — visual feedback that it is a
|
||||
// recognised mention. (Cosmetic only; the server decides actual delivery.)
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user