Merge branch 'duckietm:main' into item-descriptions

This commit is contained in:
hotellidev
2026-06-08 00:24:52 +03:00
committed by GitHub
33 changed files with 4207 additions and 52 deletions
@@ -0,0 +1,105 @@
import { FC, MouseEvent, useEffect, useState } from 'react';
import { FriendCategoryData } from '@nitrots/nitro-renderer';
import { LocalizeText } from '../../../../api';
import { Button, Column, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useFriendsActions } from '../../../../hooks';
interface FriendsCategoryManagerViewProps
{
categories: FriendCategoryData[];
onCloseClick: (event: MouseEvent) => void;
}
export const FriendsCategoryManagerView: FC<FriendsCategoryManagerViewProps> = props =>
{
const { categories = [], onCloseClick = null } = props;
const { addCategory, renameCategory, removeCategory } = useFriendsActions();
const [ newName, setNewName ] = useState<string>('');
const [ editingId, setEditingId ] = useState<number>(0);
const [ editingName, setEditingName ] = useState<string>('');
useEffect(() =>
{
if(editingId && !categories.some(category => (category.id === editingId)))
{
setEditingId(0);
setEditingName('');
}
}, [ categories, editingId ]);
const submitAdd = () =>
{
const trimmed = newName.trim();
if(!trimmed.length) return;
addCategory(trimmed);
setNewName('');
};
const submitRename = () =>
{
const trimmed = editingName.trim();
if(editingId && trimmed.length) renameCategory(editingId, trimmed);
setEditingId(0);
setEditingName('');
};
return (
<NitroCardView className="nitro-friends-category-manager" theme="primary-slim" uniqueKey="nitro-friends-category-manager" isResizable={ false } style={ { width: 270, minWidth: 270 } }>
<NitroCardHeaderView headerText={ LocalizeText('friendlist.friends') } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black" gap={ 1 }>
<Flex gap={ 1 } alignItems="center">
<input
className="form-control form-control-sm w-full"
maxLength={ 25 }
type="text"
value={ newName }
onChange={ event => setNewName(event.target.value) }
onKeyDown={ event => (event.key === 'Enter') && submitAdd() } />
<Button disabled={ !newName.trim().length || (categories.length >= 20) } onClick={ submitAdd }>
{ LocalizeText('catalog.admin.create') }
</Button>
</Flex>
<Column gap={ 1 }>
{ categories.map(category => (
<Flex key={ category.id } alignItems="center" gap={ 1 }>
{ (editingId === category.id) ?
<>
<input
autoFocus
className="form-control form-control-sm w-full"
maxLength={ 25 }
type="text"
value={ editingName }
onChange={ event => setEditingName(event.target.value) }
onKeyDown={ event => (event.key === 'Enter') && submitRename() } />
<Button onClick={ submitRename }>
{ LocalizeText('catalog.admin.save') }
</Button>
</>
:
<>
<span className="grow text-sm">{ category.name }</span>
<span
className="cursor-pointer text-base leading-none select-none"
title={ LocalizeText('generic.edit') }
onClick={ () => { setEditingId(category.id); setEditingName(category.name); } }>
{ '✎' }
</span>
<span
className="cursor-pointer text-base leading-none select-none"
title={ LocalizeText('generic.delete') }
onClick={ () => removeCategory(category.id) }>
{ '✕' }
</span>
</> }
</Flex>
)) }
{ !categories.length &&
<span className="text-muted text-center py-2 text-sm">
{ LocalizeText('friendlist.search.nofriendsfound') }
</span> }
</Column>
</NitroCardContentView>
</NitroCardView>
);
};
@@ -0,0 +1,38 @@
import { FriendCategoryData } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { LocalizeText, MessengerFriend, countFriendsByCategory } from '../../../../api';
import { Flex } from '../../../../common';
interface FriendsListGroupChipsViewProps
{
categories: FriendCategoryData[];
friends: MessengerFriend[];
selectedCategoryId: number;
setSelectedCategoryId: (id: number) => void;
onManageClick: () => void;
}
export const FriendsListGroupChipsView: FC<FriendsListGroupChipsViewProps> = props =>
{
const { categories = [], friends = [], selectedCategoryId = 0, setSelectedCategoryId = null, onManageClick = null } = props;
const counts = countFriendsByCategory(friends);
return (
<Flex alignItems="center" className="friends-group-chips px-2 py-1" gap={ 1 }>
<Flex alignItems="center" className="friends-group-chips-scroll" gap={ 1 }>
<div className={ `friends-group-chip${ (selectedCategoryId === 0) ? ' active' : '' }` } onClick={ () => setSelectedCategoryId(0) }>
{ LocalizeText('friendlist.friends') } ({ friends.length })
</div>
{ categories.map(category => (
<div key={ category.id } className={ `friends-group-chip${ (selectedCategoryId === category.id) ? ' active' : '' }` } onClick={ () => setSelectedCategoryId(category.id) }>
{ category.name } ({ counts.get(category.id) ?? 0 })
</div>
)) }
</Flex>
<div className="friends-group-chip friends-group-chip-manage ms-auto" title={ LocalizeText('friendlist.friends') } onClick={ onManageClick }>
{ '⚙' }
</div>
</Flex>
);
};
@@ -1,11 +1,13 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveFriendComposer, RemoveLinkEventTracker, SendRoomInviteComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { LocalizeText, MessengerFriend, SendMessageComposer } from '../../../../api';
import { LocalizeText, MessengerFriend, SendMessageComposer, filterFriendsByCategory } from '../../../../api';
import { Button, Flex, NitroCardAccordionSetView, NitroCardAccordionView, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useFriends } from '../../../../hooks';
import { FriendsCategoryManagerView } from './FriendsCategoryManagerView';
import { FriendsRemoveConfirmationView } from './FriendsListRemoveConfirmationView';
import { FriendsRoomInviteView } from './FriendsListRoomInviteView';
import { FriendsSearchView } from './FriendsListSearchView';
import { FriendsListGroupChipsView } from './FriendsListGroupChipsView';
import { FriendsListGroupView } from './friends-list-group/FriendsListGroupView';
import { FriendsListRequestView } from './friends-list-request/FriendsListRequestView';
@@ -15,7 +17,13 @@ export const FriendsListView: FC<{}> = props =>
const [ selectedFriendsIds, setSelectedFriendsIds ] = useState<number[]>([]);
const [ showRoomInvite, setShowRoomInvite ] = useState<boolean>(false);
const [ showRemoveFriendsConfirmation, setShowRemoveFriendsConfirmation ] = useState<boolean>(false);
const { onlineFriends = [], offlineFriends = [], requests = [], requestFriend = null } = useFriends();
const [ selectedCategoryId, setSelectedCategoryId ] = useState<number>(0);
const [ showCategoryManager, setShowCategoryManager ] = useState<boolean>(false);
const { friends = [], onlineFriends = [], offlineFriends = [], requests = [], requestFriend = null, settings = null } = useFriends();
const categories = settings?.categories ?? [];
const filteredOnlineFriends = filterFriendsByCategory(onlineFriends, selectedCategoryId);
const filteredOfflineFriends = filterFriendsByCategory(offlineFriends, selectedCategoryId);
const removeFriendsText = useMemo(() =>
{
@@ -145,32 +153,38 @@ export const FriendsListView: FC<{}> = props =>
<NitroCardView className="nitro-friends" theme="primary-slim" uniqueKey="nitro-friends">
<NitroCardHeaderView headerText={ LocalizeText('friendlist.friends') } onCloseClick={ event => setIsVisible(false) } />
<NitroCardContentView className="text-black p-0" gap={ 1 } overflow="hidden">
<FriendsListGroupChipsView
categories={ categories }
friends={ friends }
selectedCategoryId={ selectedCategoryId }
setSelectedCategoryId={ setSelectedCategoryId }
onManageClick={ () => setShowCategoryManager(true) } />
<NitroCardAccordionView fullHeight overflow="hidden">
<NitroCardAccordionSetView className="friends-list-section" headerText={ LocalizeText('friendlist.friends') + ` (${ onlineFriends.length })` } isExpanded={ true }>
<NitroCardAccordionSetView className="friends-list-section" headerText={ LocalizeText('friendlist.friends') + ` (${ filteredOnlineFriends.length })` } isExpanded={ true }>
<Flex className="friends-list-toolbar px-2 py-1" justifyContent="end">
<span className="friends-list-toolbar-link" onClick={ event =>
{
event.stopPropagation(); toggleSelectFriends(onlineFriends.map(friend => friend.id));
event.stopPropagation(); toggleSelectFriends(filteredOnlineFriends.map(friend => friend.id));
} }>
{ onlineFriends.length && onlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0))
{ filteredOnlineFriends.length && filteredOnlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0))
? LocalizeText('friendlist.unselect_all')
: LocalizeText('friendlist.select_all') }
</span>
</Flex>
<FriendsListGroupView list={ onlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
<FriendsListGroupView list={ filteredOnlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
</NitroCardAccordionSetView>
<NitroCardAccordionSetView headerText={ LocalizeText('friendlist.friends.offlinecaption') + ` (${ offlineFriends.length })` }>
<NitroCardAccordionSetView headerText={ LocalizeText('friendlist.friends.offlinecaption') + ` (${ filteredOfflineFriends.length })` }>
<Flex className="friends-list-toolbar px-2 py-1" justifyContent="end">
<span className="friends-list-toolbar-link" onClick={ event =>
{
event.stopPropagation(); toggleSelectFriends(offlineFriends.map(friend => friend.id));
event.stopPropagation(); toggleSelectFriends(filteredOfflineFriends.map(friend => friend.id));
} }>
{ offlineFriends.length && offlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0))
{ filteredOfflineFriends.length && filteredOfflineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0))
? LocalizeText('friendlist.unselect_all')
: LocalizeText('friendlist.select_all') }
</span>
</Flex>
<FriendsListGroupView list={ offlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
<FriendsListGroupView list={ filteredOfflineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
</NitroCardAccordionSetView>
<FriendsListRequestView headerText={ LocalizeText('friendlist.tab.friendrequests') + ` (${ requests.length })` } isExpanded={ true } />
<FriendsSearchView headerText={ LocalizeText('people.search.title') } />
@@ -186,6 +200,8 @@ export const FriendsListView: FC<{}> = props =>
<FriendsRoomInviteView selectedFriendsIds={ selectedFriendsIds } sendRoomInvite={ sendRoomInvite } onCloseClick={ () => setShowRoomInvite(false) } /> }
{ showRemoveFriendsConfirmation &&
<FriendsRemoveConfirmationView removeFriendsText={ removeFriendsText } removeSelectedFriends={ removeSelectedFriends } selectedFriendsIds={ selectedFriendsIds } onCloseClick={ () => setShowRemoveFriendsConfirmation(false) } /> }
{ showCategoryManager &&
<FriendsCategoryManagerView categories={ categories } onCloseClick={ () => setShowCategoryManager(false) } /> }
</>
);
};
@@ -9,7 +9,9 @@ export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: b
{
const { friend = null, selected = false, selectFriend = null } = props;
const [ isRelationshipOpen, setIsRelationshipOpen ] = useState<boolean>(false);
const { followFriend = null, updateRelationship = null } = useFriends();
const { followFriend = null, updateRelationship = null, moveFriendToCategory = null, settings = null } = useFriends();
const [ isGroupMenuOpen, setIsGroupMenuOpen ] = useState<boolean>(false);
const categories = settings?.categories ?? [];
const clickFollowFriend = (event: MouseEvent<HTMLDivElement>) =>
{
@@ -74,6 +76,21 @@ export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: b
<div className="nitro-friends-spritesheet icon-follow cursor-pointer" title={ LocalizeText('friendlist.tip.follow') } onClick={ clickFollowFriend } /> }
{ friend.online &&
<div className="nitro-friends-spritesheet icon-chat cursor-pointer" title={ LocalizeText('friendlist.tip.im') } onClick={ openMessengerChat } /> }
{ (friend.id > 0) && (categories.length > 0) &&
<div className="friends-list-group-assign position-relative">
<div className="friends-list-group-toggle cursor-pointer" title={ LocalizeText('friendlist.friends') } onClick={ event => { event.stopPropagation(); setIsGroupMenuOpen(prev => !prev); } }>{ '📁' }</div>
{ isGroupMenuOpen &&
<div className="friends-list-group-menu">
<div className={ `friends-list-group-menu-item${ (friend.categoryId === 0) ? ' active' : '' }` } onClick={ event => { event.stopPropagation(); moveFriendToCategory(friend.id, 0); setIsGroupMenuOpen(false); } }>
{ LocalizeText('friendlist.friends') }
</div>
{ categories.map(category => (
<div key={ category.id } className={ `friends-list-group-menu-item${ (friend.categoryId === category.id) ? ' active' : '' }` } onClick={ event => { event.stopPropagation(); moveFriendToCategory(friend.id, category.id); setIsGroupMenuOpen(false); } }>
{ category.name }
</div>
)) }
</div> }
</div> }
{ (friend.id > 0) &&
<div className={ `nitro-friends-spritesheet icon-${ getCurrentRelationshipName() } cursor-pointer` } title={ LocalizeText('infostand.link.relationship') } onClick={ openRelationship } /> }
</> }
@@ -12,11 +12,53 @@ export const FriendsMessengerView: FC<{}> = props =>
const [ isVisible, setIsVisible ] = useState(false);
const [ lastThreadId, setLastThreadId ] = useState(-1);
const [ messageText, setMessageText ] = useState('');
const { visibleThreads = [], activeThread = null, getMessageThread = null, sendMessage = null, setActiveThreadId = null, closeThread = null } = useMessenger();
const { visibleThreads = [], activeThread = null, getMessageThread = null, sendMessage = null, setActiveThreadId = null, closeThread = null, typingUserIds = [], sendTypingStatus = null } = useMessenger();
const { getFriend = null } = useFriends();
const { report = null } = useHelp();
const { settings, translateOutgoing } = useTranslation();
const messagesBox = useRef<HTMLDivElement>(null);
const isTypingRef = useRef<boolean>(false);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const stopTyping = () =>
{
if(typingTimeoutRef.current)
{
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = null;
}
if(isTypingRef.current && activeThread && activeThread.participant && (activeThread.participant.id > 0))
{
sendTypingStatus(activeThread.participant.id, false);
}
isTypingRef.current = false;
};
const handleInputChange = (value: string) =>
{
setMessageText(value);
const peerId = (activeThread && activeThread.participant) ? activeThread.participant.id : 0;
if(peerId <= 0) return;
if(!value.length)
{
stopTyping();
return;
}
if(!isTypingRef.current)
{
sendTypingStatus(peerId, true);
isTypingRef.current = true;
}
if(typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(() => stopTyping(), 4000);
};
const followFriend = () => (activeThread && activeThread.participant && SendMessageComposer(new FollowFriendMessageComposer(activeThread.participant.id)));
const openProfile = () => (activeThread && activeThread.participant && GetUserProfile(activeThread.participant.id));
@@ -25,6 +67,8 @@ export const FriendsMessengerView: FC<{}> = props =>
{
if(!activeThread || !messageText.length) return;
stopTyping();
const trimmedText = messageText.trimStart();
const shouldTranslateOutgoing = settings.enabled && !!trimmedText.length && (trimmedText.charAt(0) !== ':');
@@ -102,6 +146,14 @@ export const FriendsMessengerView: FC<{}> = props =>
messagesBox.current.scrollTop = messagesBox.current.scrollHeight;
}, [ isVisible, activeThread ]);
useEffect(() =>
{
return () =>
{
stopTyping();
};
}, [ activeThread ]);
useEffect(() =>
{
if(isVisible && !activeThread)
@@ -184,8 +236,13 @@ export const FriendsMessengerView: FC<{}> = props =>
<FriendsMessengerThreadView thread={ activeThread } />
</div>
{ activeThread.participant && (activeThread.participant.id > 0) && (typingUserIds.indexOf(activeThread.participant.id) >= 0) &&
<div className="messenger-typing-indicator">
{ LocalizeText('messenger.typing', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) }
</div> }
<div className="messenger-input-row">
<input maxLength={ 255 } placeholder={ LocalizeText('messenger.window.input.default', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } type="text" value={ messageText } onChange={ event => setMessageText(event.target.value) } onKeyDown={ onKeyDown } />
<input maxLength={ 255 } placeholder={ LocalizeText('messenger.window.input.default', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } type="text" value={ messageText } onChange={ event => handleInputChange(event.target.value) } onKeyDown={ onKeyDown } />
<button className="messenger-btn send" onClick={ () => void send() }>
{ LocalizeText('widgets.chatinput.say') }
</button>
@@ -68,7 +68,13 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M
{
if(!chat.showTranslation)
{
return <Base key={ index } className="text-break">{ chat.message }</Base>;
return (
<Base key={ index } className="text-break">
{ chat.message }
{ chat.offlineDelivered &&
<span className="messenger-offline-tag">{ LocalizeText('messenger.offline.delivered') }</span> }
</Base>
);
}
return (
@@ -86,6 +92,10 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M
}) }
</Base>
<Base className="messenger-message-time">{ group.chats[0].date.toLocaleTimeString() }</Base>
{ isOwnChat && (group.type === MessengerGroupType.PRIVATE_CHAT) && (group.chats[group.chats.length - 1].type === MessengerThreadChat.CHAT) &&
<Base className={ 'messenger-message-status ' + ((group.chats[group.chats.length - 1].status === MessengerThreadChat.READ) ? 'read' : '') }>
{ (group.chats[group.chats.length - 1].status === MessengerThreadChat.READ) ? '✓✓' : '✓' }
</Base> }
</Base>
{ isOwnChat &&
<Base shrink className="message-avatar">
@@ -16,7 +16,7 @@ export const FurniEditorView: FC<{}> = () =>
const {
items, total, page, loading, error, clearError,
selectedItem, setSelectedItem, furniDataEntry,
selectedItem, setSelectedItem, furniDataEntry, furniDataDiagnostic,
interactions,
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions,
updateFurnidata, revertFurnidata, importText, importResult
@@ -151,6 +151,7 @@ export const FurniEditorView: FC<{}> = () =>
<FurniEditorEditView
item={ selectedItem }
furniDataEntry={ furniDataEntry }
furniDataDiagnostic={ furniDataDiagnostic }
interactions={ interactions }
loading={ loading }
onUpdate={ updateItem }
@@ -7,6 +7,7 @@ interface FurniEditorEditViewProps
{
item: FurniDetail;
furniDataEntry: Record<string, unknown> | null;
furniDataDiagnostic: Record<string, unknown> | null;
interactions: string[];
loading: boolean;
onUpdate: (id: number, fields: Record<string, unknown>) => void;
@@ -121,7 +122,7 @@ const CopyValue: FC<{ value: string | number }> = ({ value }) =>
export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
{
const { item, furniDataEntry, interactions, loading, onUpdate, onDelete, onBack, onUpdateFurnidata, onRevertFurnidata, onImportText, importResult } = props;
const { item, furniDataEntry, furniDataDiagnostic, interactions, loading, onUpdate, onDelete, onBack, onUpdateFurnidata, onRevertFurnidata, onImportText, importResult } = props;
const saveRef = useRef<() => void>(null);
const [ form, setForm ] = useState({
@@ -252,6 +253,14 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
furniName !== String(furniDataEntry?.name ?? '') || furniDescription !== String(furniDataEntry?.description ?? ''),
[ furniName, furniDescription, furniDataEntry ]);
const furnidataMissReason = useMemo(() =>
{
const reason = String(furniDataDiagnostic?.reason ?? '');
return reason || 'not_found';
}, [ furniDataDiagnostic ]);
const furnidataSourcePath = String(furniDataDiagnostic?.sourcePath ?? '');
// Apply an "Import from Habbo" result into the editable fields (review then Save).
useEffect(() =>
{
@@ -391,7 +400,7 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
) : (
<div className="flex items-start gap-2 text-[11px] text-slate-500 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 leading-snug">
<span className="text-[#f59e0b] text-sm leading-none mt-px"></span>
<span>This furni has no matching <b>furnidata</b> entry (e.g. a pet or custom item), so its display name can&apos;t be edited here. Clients fall back to the DB <b>Public Name</b> below.</span>
<span>This furni has no matching <b>furnidata</b> entry ({ furnidataMissReason.replace(/_/g, ' ') }), so its display name can&apos;t be edited here. Clients fall back to the DB <b>Public Name</b> below.</span>
</div>
) }
</div>
@@ -424,6 +433,20 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
</Section>
}
<Section title="Furnidata Debug" defaultOpen={ false }>
<div className="grid grid-cols-2 gap-2 mb-2">
<div>
<label className={ labelClass }>Resolution</label>
<CopyValue value={ furnidataMissReason } />
</div>
<div>
<label className={ labelClass }>Source</label>
<CopyValue value={ furnidataSourcePath || 'unresolved' } />
</div>
</div>
<pre className="text-[10px] leading-snug text-slate-600 bg-slate-50 border border-slate-200 rounded-lg p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all font-mono">{ JSON.stringify(furniDataDiagnostic ?? {}, null, 2) }</pre>
</Section>
<Section title="Dimensions">
<div className="grid grid-cols-3 gap-2">
<div>
@@ -236,6 +236,10 @@ export const ChatInputView: FC<{}> = props =>
{
switch(event.chatMode)
{
case RoomWidgetUpdateChatInputContentEvent.TEXT:
setChatValue(event.userName);
inputRef.current?.focus();
return;
case RoomWidgetUpdateChatInputContentEvent.WHISPER: {
setChatValue(`${ chatModeIdWhisper } ${ event.userName } `);
return;