mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
@@ -3,6 +3,7 @@ export interface IMentionEntry
|
||||
mentionId: number;
|
||||
senderId: number;
|
||||
senderUsername: string;
|
||||
senderFigure: string;
|
||||
roomId: number;
|
||||
roomName: string;
|
||||
message: string;
|
||||
|
||||
@@ -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,75 @@
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
import { LayoutAvatarImageView } from '../../../../common';
|
||||
|
||||
export type MentionSuggestionKind = 'user' | 'alias';
|
||||
|
||||
export interface MentionSuggestion
|
||||
{
|
||||
key: string;
|
||||
kind: MentionSuggestionKind;
|
||||
name: string;
|
||||
insertToken: string;
|
||||
figure?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
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 ]);
|
||||
|
||||
if(suggestions.length === 0) return null;
|
||||
|
||||
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) =>
|
||||
{
|
||||
const isSelected = (index === selectedIndex);
|
||||
const rowClass = `px-3 py-1.5 cursor-pointer text-sm flex items-center gap-2 ${ isSelected ? 'bg-[#283F5D] text-white' : 'hover:bg-gray-300' }`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ suggestion.key }
|
||||
className={ rowClass }
|
||||
onClick={ () => onSelect(suggestion) }
|
||||
onMouseEnter={ () => onHover(index) }
|
||||
>
|
||||
{ suggestion.kind === 'user' && suggestion.figure
|
||||
? (
|
||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full bg-black/10">
|
||||
<LayoutAvatarImageView
|
||||
figure={ suggestion.figure }
|
||||
direction={ 2 }
|
||||
headOnly
|
||||
style={ { backgroundSize: 'auto', backgroundPosition: '-22px -32px' } }
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center justify-center h-11 w-11 rounded-full bg-black/20 text-white text-[14px] font-bold shrink-0">@</div>
|
||||
) }
|
||||
<span className="font-bold">@{ suggestion.name }</span>
|
||||
{ suggestion.description && <span className={ `text-xs ${ isSelected ? 'text-gray-300' : 'text-gray-500' }` }>{ suggestion.description }</span> }
|
||||
</div>
|
||||
);
|
||||
}) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,11 +4,25 @@ 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 { useRoomUserListSnapshot } from '../../../../hooks/session/useSessionSnapshots';
|
||||
import { ChatInputCommandSelectorView } from './ChatInputCommandSelectorView';
|
||||
import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView';
|
||||
import { ChatInputMentionSelectorView, MentionSuggestion } from './ChatInputMentionSelectorView';
|
||||
import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView';
|
||||
|
||||
export const ChatInputView: FC = () =>
|
||||
const USER_TYPE_REAL_USER = 1;
|
||||
const MAX_MENTION_SUGGESTIONS = 8;
|
||||
const MENTION_ALIASES: ReadonlyArray<{ key: string; label: string; description?: string }> = [
|
||||
{ key: 'all', label: 'all', description: 'Everyone in the hotel' },
|
||||
{ key: 'everyone', label: 'everyone', description: 'Everyone in the hotel' },
|
||||
{ key: 'tutti', label: 'tutti', description: 'Everyone in the hotel' },
|
||||
{ key: 'friends', label: 'friends', description: 'Your online friends' },
|
||||
{ key: 'amici', label: 'amici', description: 'Your online friends' },
|
||||
{ key: 'room', label: 'room', description: 'Everyone in this room' },
|
||||
{ key: 'stanza', label: 'stanza', description: 'Everyone in this room' }
|
||||
];
|
||||
|
||||
export const ChatInputView: FC<{}> = props =>
|
||||
{
|
||||
const [ chatValue, setChatValue ] = useState<string>('');
|
||||
const { chatStyleId = 0, updateChatStyleId = null } = useSessionInfo();
|
||||
@@ -16,6 +30,98 @@ export const ChatInputView: FC = () =>
|
||||
const { roomSession = null } = useRoom();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { isVisible: commandSelectorVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close: closeCommandSelector } = useChatCommandSelector(chatValue);
|
||||
const roomUserList = useRoomUserListSnapshot();
|
||||
const [ mentionSelectedIndex, setMentionSelectedIndex ] = useState<number>(0);
|
||||
|
||||
const mentionContext = useMemo(() =>
|
||||
{
|
||||
if(!chatValue) return null;
|
||||
if(commandSelectorVisible) return null;
|
||||
|
||||
const caret = inputRef.current?.selectionStart ?? chatValue.length;
|
||||
const upToCaret = chatValue.slice(0, caret);
|
||||
const at = upToCaret.lastIndexOf('@');
|
||||
if(at < 0) return null;
|
||||
|
||||
if(at > 0 && !/\s/.test(upToCaret.charAt(at - 1))) return null;
|
||||
|
||||
const query = upToCaret.slice(at + 1);
|
||||
|
||||
if(/\s/.test(query)) return null;
|
||||
|
||||
return { atIndex: at, replaceFrom: at, replaceTo: caret, query };
|
||||
}, [ chatValue, commandSelectorVisible ]);
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(() =>
|
||||
{
|
||||
if(!mentionContext) return [];
|
||||
|
||||
const query = mentionContext.query.toLowerCase();
|
||||
const out: MentionSuggestion[] = [];
|
||||
|
||||
for(const user of roomUserList)
|
||||
{
|
||||
if(!user || user.type !== USER_TYPE_REAL_USER) continue;
|
||||
if(!user.name) continue;
|
||||
if(query.length > 0 && !user.name.toLowerCase().startsWith(query)) continue;
|
||||
|
||||
out.push({
|
||||
key: `user:${ user.webID }`,
|
||||
kind: 'user',
|
||||
name: user.name,
|
||||
insertToken: user.name,
|
||||
figure: user.figure || ''
|
||||
});
|
||||
|
||||
if(out.length >= MAX_MENTION_SUGGESTIONS) break;
|
||||
}
|
||||
|
||||
for(const alias of MENTION_ALIASES)
|
||||
{
|
||||
if(query.length > 0 && !alias.key.toLowerCase().startsWith(query)) continue;
|
||||
|
||||
out.push({
|
||||
key: `alias:${ alias.key }`,
|
||||
kind: 'alias',
|
||||
name: alias.label,
|
||||
insertToken: alias.key,
|
||||
description: alias.description
|
||||
});
|
||||
|
||||
if(out.length >= MAX_MENTION_SUGGESTIONS) break;
|
||||
}
|
||||
|
||||
return out;
|
||||
}, [ mentionContext, roomUserList ]);
|
||||
|
||||
const mentionSelectorVisible = mentionSuggestions.length > 0;
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(mentionSelectedIndex >= mentionSuggestions.length) setMentionSelectedIndex(0);
|
||||
}, [ mentionSuggestions.length, mentionSelectedIndex ]);
|
||||
|
||||
const applyMentionSuggestion = useCallback((suggestion: MentionSuggestion) =>
|
||||
{
|
||||
if(!suggestion || !mentionContext) return;
|
||||
|
||||
const before = chatValue.slice(0, mentionContext.replaceFrom);
|
||||
const after = chatValue.slice(mentionContext.replaceTo);
|
||||
const inserted = `@${ suggestion.insertToken } `;
|
||||
const next = `${ before }${ inserted }${ after }`;
|
||||
|
||||
setChatValue(next);
|
||||
|
||||
requestAnimationFrame(() =>
|
||||
{
|
||||
if(!inputRef.current) return;
|
||||
const caret = before.length + inserted.length;
|
||||
inputRef.current.focus();
|
||||
inputRef.current.setSelectionRange(caret, caret);
|
||||
});
|
||||
|
||||
setMentionSelectedIndex(0);
|
||||
}, [ chatValue, mentionContext ]);
|
||||
|
||||
const chatModeIdWhisper = useMemo(() => LocalizeText('widgets.chatinput.mode.whisper'), []);
|
||||
const chatModeIdShout = useMemo(() => LocalizeText('widgets.chatinput.mode.shout'), []);
|
||||
@@ -166,7 +272,6 @@ export const ChatInputView: FC = () =>
|
||||
return;
|
||||
case 'Tab':
|
||||
event.preventDefault();
|
||||
// fall through
|
||||
case 'NumpadEnter':
|
||||
case 'Enter': {
|
||||
const selected = selectCurrent();
|
||||
@@ -186,6 +291,45 @@ export const ChatInputView: FC = () =>
|
||||
}
|
||||
}
|
||||
|
||||
if(mentionSelectorVisible)
|
||||
{
|
||||
switch(event.key)
|
||||
{
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setMentionSelectedIndex(prev => (prev <= 0) ? (mentionSuggestions.length - 1) : (prev - 1));
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setMentionSelectedIndex(prev => (prev >= mentionSuggestions.length - 1) ? 0 : (prev + 1));
|
||||
return;
|
||||
case 'Tab':
|
||||
case 'NumpadEnter':
|
||||
case 'Enter': {
|
||||
const picked = mentionSuggestions[mentionSelectedIndex] ?? mentionSuggestions[0];
|
||||
|
||||
if(picked)
|
||||
{
|
||||
event.preventDefault();
|
||||
applyMentionSuggestion(picked);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
setMentionSelectedIndex(0);
|
||||
|
||||
if(mentionContext)
|
||||
{
|
||||
const before = chatValue.slice(0, mentionContext.replaceFrom);
|
||||
const after = chatValue.slice(mentionContext.replaceTo);
|
||||
setChatValue(before + after);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
|
||||
switch(event.key)
|
||||
@@ -211,7 +355,7 @@ export const ChatInputView: FC = () =>
|
||||
return;
|
||||
}
|
||||
|
||||
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, setChatInputValue, closeCommandSelector ]);
|
||||
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector, mentionSelectorVisible, mentionSuggestions, mentionSelectedIndex, applyMentionSuggestion, mentionContext, chatValue ]);
|
||||
|
||||
useUiEvent<RoomWidgetUpdateChatInputContentEvent>(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event =>
|
||||
{
|
||||
@@ -310,6 +454,13 @@ export const ChatInputView: FC = () =>
|
||||
} }
|
||||
onHover={ setSelectedIndex }
|
||||
/> }
|
||||
{ mentionSelectorVisible && !commandSelectorVisible &&
|
||||
<ChatInputMentionSelectorView
|
||||
suggestions={ mentionSuggestions }
|
||||
selectedIndex={ mentionSelectedIndex }
|
||||
onSelect={ applyMentionSuggestion }
|
||||
onHover={ setMentionSelectedIndex }
|
||||
/> }
|
||||
<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() } /> }
|
||||
|
||||
@@ -1,39 +1,11 @@
|
||||
/**
|
||||
* Cosmetic-only mention highlighting for in-room chat bubbles.
|
||||
*
|
||||
* The bubble text is rendered through {@link RoomChatFormatter}, which emits
|
||||
* an HTML string (wired markup `<strong>`/`<em>`/`<u>`, font-colour
|
||||
* `<span style>`, `<br />`, plus HTML-entity-encoded special characters) and
|
||||
* is injected via `dangerouslySetInnerHTML`. We therefore operate on the
|
||||
* already-formatted HTML string and wrap mention tokens that appear in the
|
||||
* TEXT regions (never inside a `<tag>`), returning a new HTML string. This
|
||||
* keeps every existing formatting behaviour intact and is purely visual — it
|
||||
* does not touch `chat.text`, parsing, chat history, or any wire payload.
|
||||
*
|
||||
* Token detection mirrors the server's `MentionManager.process` exactly:
|
||||
* - split on whitespace
|
||||
* - a candidate token has length >= 2 and starts with `@`
|
||||
* - strip the `@`, remove every char that is not [A-Za-z0-9_], lowercase
|
||||
* - match against the local username or a room-broadcast alias
|
||||
*
|
||||
* This means `@Bob!`, `@bob,` etc. all match the nick `Bob` (case-insensitive)
|
||||
* just like the server, while the highlighted span keeps the original token
|
||||
* text (`@` + original casing + trailing punctuation) verbatim.
|
||||
*/
|
||||
|
||||
// Mirror of `mentions.room.aliases` default in Arcturus
|
||||
// (com.eu.habbo.habbohotel.mentions.MentionManager#roomAliases).
|
||||
export const MENTION_ROOM_ALIASES: ReadonlyArray<string> = [
|
||||
'amici', 'friends', 'all', 'everyone', 'tutti', 'room', 'stanza'
|
||||
'all', 'everyone', 'tutti',
|
||||
'friends', 'amici',
|
||||
'room', 'stanza'
|
||||
];
|
||||
|
||||
const NON_NICK_CHARS = /[^A-Za-z0-9_]/g;
|
||||
|
||||
/**
|
||||
* Normalise a raw `@token` the same way the server does: drop the leading `@`,
|
||||
* strip any non-nick characters (trailing punctuation, etc.), lowercase.
|
||||
* Returns an empty string when nothing usable remains.
|
||||
*/
|
||||
const normalizeToken = (token: string): string =>
|
||||
{
|
||||
if(!token || token.length < 2 || token.charAt(0) !== '@') return '';
|
||||
@@ -41,10 +13,7 @@ const normalizeToken = (token: string): string =>
|
||||
return token.substring(1).replace(NON_NICK_CHARS, '').toLowerCase();
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the given raw whitespace-delimited token mentions the local user
|
||||
* or a room-broadcast alias.
|
||||
*/
|
||||
|
||||
const isMentionToken = (token: string, ownUsernameLower: string, aliases: ReadonlySet<string>): boolean =>
|
||||
{
|
||||
const nick = normalizeToken(token);
|
||||
@@ -56,11 +25,6 @@ const isMentionToken = (token: string, ownUsernameLower: string, aliases: Readon
|
||||
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,
|
||||
@@ -74,17 +38,10 @@ export const tokenIsMention = (
|
||||
const HIGHLIGHT_OPEN = '<span class="mention-highlight">';
|
||||
const HIGHLIGHT_CLOSE = '</span>';
|
||||
|
||||
/**
|
||||
* Wrap mention tokens in a single text chunk (no HTML tags inside it).
|
||||
* Whitespace runs between tokens are preserved verbatim by re-using the
|
||||
* original substrings around each match.
|
||||
*/
|
||||
const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: ReadonlySet<string>): string =>
|
||||
{
|
||||
if(chunk.indexOf('@') < 0) return chunk;
|
||||
|
||||
// Split into alternating [whitespace, token, whitespace, token, ...]
|
||||
// segments so the exact original spacing is rebuilt unchanged.
|
||||
const segments = chunk.split(/(\s+)/);
|
||||
|
||||
let result = '';
|
||||
@@ -93,7 +50,6 @@ const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: Re
|
||||
{
|
||||
if(segment.length === 0) continue;
|
||||
|
||||
// Whitespace runs and non-mention tokens pass through untouched.
|
||||
if(/^\s+$/.test(segment) || !isMentionToken(segment, ownUsernameLower, aliases))
|
||||
{
|
||||
result += segment;
|
||||
@@ -106,15 +62,6 @@ const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: Re
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Take the formatted bubble HTML and return new HTML where every mention
|
||||
* token (own nick or room alias) in the text regions is wrapped in
|
||||
* `<span class="mention-highlight">…</span>`. HTML tags are passed through
|
||||
* untouched so existing markup keeps working.
|
||||
*
|
||||
* Returns the input unchanged when there is no `@`, no own username, and no
|
||||
* possibility of a match (fast path), or when nothing matches.
|
||||
*/
|
||||
export const highlightMentions = (
|
||||
formattedHtml: string,
|
||||
ownUsername: string,
|
||||
@@ -126,10 +73,8 @@ export const highlightMentions = (
|
||||
const ownUsernameLower = (ownUsername || '').replace(NON_NICK_CHARS, '').toLowerCase();
|
||||
const aliasSet = new Set(aliases.map(a => a.toLowerCase()));
|
||||
|
||||
// Nothing could ever match → return verbatim.
|
||||
if(!ownUsernameLower && aliasSet.size === 0) return formattedHtml;
|
||||
|
||||
// Walk the string, only highlighting inside text regions (outside `<...>`).
|
||||
let result = '';
|
||||
let cursor = 0;
|
||||
|
||||
@@ -143,7 +88,6 @@ export const highlightMentions = (
|
||||
break;
|
||||
}
|
||||
|
||||
// Text region before the next tag.
|
||||
if(tagStart > cursor)
|
||||
{
|
||||
result += highlightTextChunk(formattedHtml.slice(cursor, tagStart), ownUsernameLower, aliasSet);
|
||||
@@ -153,12 +97,10 @@ export const highlightMentions = (
|
||||
|
||||
if(tagEnd < 0)
|
||||
{
|
||||
// Malformed trailing `<` with no closing `>` — emit the rest verbatim.
|
||||
result += formattedHtml.slice(tagStart);
|
||||
break;
|
||||
}
|
||||
|
||||
// Emit the tag (including the angle brackets) untouched.
|
||||
result += formattedHtml.slice(tagStart, tagEnd + 1);
|
||||
cursor = tagEnd + 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
.mention-toasts {
|
||||
position: fixed;
|
||||
top: 130px;
|
||||
right: 12px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 320px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mention-toast {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 300px;
|
||||
padding: 8px 10px;
|
||||
background: #2b2f3a;
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-left: 3px solid #1e7295;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.42);
|
||||
cursor: pointer;
|
||||
animation: mention-toast-in 0.22s ease;
|
||||
}
|
||||
|
||||
@keyframes mention-toast-in {
|
||||
from { opacity: 0; transform: translateX(22px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.mention-toast-avatar {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* ricetta testa headOnly: l'avatar-image riempie il box (inset-0) e si croppa
|
||||
sulla testa via background-position (come l'avatar-testa della toolbar),
|
||||
invece di scalare il corpo. */
|
||||
.mention-toast-avatar .avatar-image {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
background-size: auto !important;
|
||||
background-position: -23px -32px !important;
|
||||
}
|
||||
|
||||
.mention-toast-body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mention-toast-title {
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: #6cb6e0;
|
||||
}
|
||||
|
||||
.mention-toast-message {
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
color: #e6e8ec;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mention-toast-dismiss {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #c9ccd3;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mention-toast-dismiss:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* dropdown autocomplete @ : testine + alias */
|
||||
.mention-suggest-avatar {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mention-suggest-avatar .avatar-image {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
background-size: auto !important;
|
||||
background-position: -27px -34px !important;
|
||||
}
|
||||
|
||||
.mention-suggest-alias {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-weight: 700;
|
||||
color: #1e7295;
|
||||
background: rgba(30, 114, 149, 0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BuildersClubFurniCountMessageEvent, BuildersClubPlaceRoomItemMessageComposer, BuildersClubPlaceWallItemMessageComposer, BuildersClubQueryFurniCountMessageComposer, BuildersClubSubscriptionStatusMessageEvent, CatalogPageMessageEvent, CatalogPagesListEvent, CatalogPublishedMessageEvent, CreateLinkEvent, FrontPageItem, FurniturePlaceComposer, FurniturePlacePaintComposer, GetCatalogIndexComposer, GetCatalogPageComposer, GetRoomEngine, GetSessionDataManager, GetTickerTime, LegacyDataType, LimitedEditionSoldOutEvent, MarketplaceMakeOfferResult, ProductOfferEvent, PurchaseErrorMessageEvent, PurchaseFromCatalogComposer, PurchaseNotAllowedMessageEvent, PurchaseOKMessageEvent, RoomEngineObjectPlacedEvent, RoomObjectPlacementSource, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer';
|
||||
import { BuildersClubFurniCountMessageEvent, BuildersClubPlaceRoomItemMessageComposer, BuildersClubPlaceWallItemMessageComposer, BuildersClubQueryFurniCountMessageComposer, BuildersClubSubscriptionStatusMessageEvent, CatalogPageMessageEvent, CatalogPagesListEvent, CatalogPublishedMessageEvent, CreateLinkEvent, FrontPageItem, FurniturePlaceComposer, FurniturePlacePaintComposer, GetCatalogIndexComposer, GetCatalogPageComposer, GetConfiguration, GetRoomContentLoader, GetRoomEngine, GetSessionDataManager, GetTickerTime, LegacyDataType, LimitedEditionSoldOutEvent, MarketplaceMakeOfferResult, ProductOfferEvent, PurchaseErrorMessageEvent, PurchaseFromCatalogComposer, PurchaseNotAllowedMessageEvent, PurchaseOKMessageEvent, RoomEngineObjectPlacedEvent, RoomObjectPlacementSource, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useBetween } from 'use-between';
|
||||
import { BuilderFurniPlaceableStatus, CatalogPage, CatalogType, DispatchUiEvent, FurniCategory, GetFurnitureData, GetProductDataForLocalization, GetRoomSession, ICatalogNode, ICatalogPage, IPageLocalization, IProduct, IPurchasableOffer, IPurchaseOptions, LocalizeText, NotificationAlertType, Offer, PageLocalization, PlacedObjectPurchaseData, PlaySound, Product, ProductTypeEnum, RequestedPage, SearchResult, SendMessageComposer, SoundNames } from '../../api';
|
||||
@@ -89,6 +89,27 @@ const useCatalogStore = () =>
|
||||
setCurrentType(normalizeCatalogType(type));
|
||||
}, []);
|
||||
|
||||
// Real-time furni importati: ri-mergia il chunk custom/imported.json5 nelle Map
|
||||
// furnidata + RoomContentLoader all'apertura del catalogo, SENZA reload del client.
|
||||
const refreshImportedFurnidata = useCallback(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const base = GetConfiguration().getValue<string>('furnidata.url');
|
||||
|
||||
if(!base || !base.length) return;
|
||||
|
||||
const importedUrl = base.replace(/\/+$/, '') + '/custom/imported.json5';
|
||||
|
||||
GetSessionDataManager().mergeFurnitureDataFromUrl(importedUrl).then(added =>
|
||||
{
|
||||
if(added && added.length) GetRoomContentLoader().processFurnitureData(added);
|
||||
}).catch(() => {});
|
||||
}
|
||||
catch
|
||||
{}
|
||||
}, []);
|
||||
|
||||
const openCatalogByType = useCallback((type?: string) =>
|
||||
{
|
||||
const catalogType = normalizeCatalogType(type);
|
||||
@@ -98,8 +119,10 @@ const useCatalogStore = () =>
|
||||
resetVisibleCatalogState(catalogType);
|
||||
}
|
||||
|
||||
refreshImportedFurnidata();
|
||||
|
||||
setIsVisible(true);
|
||||
}, [ currentType, resetVisibleCatalogState ]);
|
||||
}, [ currentType, resetVisibleCatalogState, refreshImportedFurnidata ]);
|
||||
|
||||
const toggleCatalogByType = useCallback((type?: string) =>
|
||||
{
|
||||
@@ -117,8 +140,10 @@ const useCatalogStore = () =>
|
||||
resetVisibleCatalogState(catalogType);
|
||||
}
|
||||
|
||||
refreshImportedFurnidata();
|
||||
|
||||
setIsVisible(true);
|
||||
}, [ isVisible, currentType, resetVisibleCatalogState ]);
|
||||
}, [ isVisible, currentType, resetVisibleCatalogState, refreshImportedFurnidata ]);
|
||||
|
||||
const getBuilderFurniPlaceableStatus = useCallback((offer: IPurchasableOffer) =>
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { addMention, setMentions, markAllRead, markRead, getMentionsSnapshot, ge
|
||||
import { IMentionEntry } from '../../../api/mentions';
|
||||
|
||||
const make = (id: number, read = false): IMentionEntry => ({
|
||||
mentionId: id, senderId: 1, senderUsername: 'Bob', roomId: 9, roomName: 'R',
|
||||
mentionId: id, senderId: 1, senderUsername: 'Bob', senderFigure: '', roomId: 9, roomName: 'R',
|
||||
message: '@me hi', mentionType: 0, timestamp: 0, read
|
||||
});
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './useMentionsSnapshot';
|
||||
export * from './useMentionMessages';
|
||||
export * from './useMentionAutocomplete';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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,89 @@
|
||||
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 + ' ')
|
||||
};
|
||||
};
|
||||
@@ -1,17 +1,15 @@
|
||||
import { MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { GetConfigurationValue, IMentionEntry, LocalizeText, NotificationBubbleType, PlaySound, SendMessageComposer } from '../../api';
|
||||
import { GetConfigurationValue, IMentionEntry, PlaySound, SendMessageComposer } from '../../api';
|
||||
import { useMessageEvent } from '../events';
|
||||
import { useNotificationActions } from '../notification';
|
||||
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 { showSingleBubble } = useNotificationActions();
|
||||
|
||||
const onMentionsList = useCallback((event: MentionsListEvent) =>
|
||||
{
|
||||
const list = event.getParser().mentions;
|
||||
@@ -20,6 +18,7 @@ export const useMentionMessages = (): void =>
|
||||
mentionId: m.mentionId,
|
||||
senderId: m.senderId,
|
||||
senderUsername: m.senderUsername,
|
||||
senderFigure: m.senderFigure,
|
||||
roomId: m.roomId,
|
||||
roomName: m.roomName,
|
||||
message: m.message,
|
||||
@@ -39,6 +38,7 @@ export const useMentionMessages = (): void =>
|
||||
mentionId: m.mentionId,
|
||||
senderId: m.senderId,
|
||||
senderUsername: m.senderUsername,
|
||||
senderFigure: m.senderFigure,
|
||||
roomId: m.roomId,
|
||||
roomName: m.roomName,
|
||||
message: m.message,
|
||||
@@ -51,14 +51,9 @@ export const useMentionMessages = (): void =>
|
||||
|
||||
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 ]);
|
||||
// Notifica laterale custom (avatar + messaggio + dismiss) invece del bubble generico.
|
||||
pushMentionToast(entry);
|
||||
}, []);
|
||||
|
||||
useMessageEvent<MentionsListEvent>(MentionsListEvent, onMentionsList);
|
||||
useMessageEvent<MentionReceivedEvent>(MentionReceivedEvent, onMentionReceived);
|
||||
|
||||
@@ -23,6 +23,7 @@ import './css/catalog/CatalogClassicView.css';
|
||||
import './css/emustats/EmuStatsView.css';
|
||||
|
||||
import './css/chat/Chats.css';
|
||||
import './css/mentions/MentionToasts.css';
|
||||
|
||||
import './css/common/Buttons.css';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user