Files
Nitro-V3/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetAvatarView.tsx
T
simoleo89 d28819db89 fix(snapshots): re-apply the 3 snapshot-consumer migrations with the use-between/useSyncExternalStore incompatibility resolved
Root cause of last session's "(intermediate value)() is undefined" at
ToolbarView.tsx:46:

  use-between 1.x ships its own React-dispatcher proxy (ownDispatcher
  in node_modules/use-between/release/index.esm.js:54-169) that
  re-implements only useState, useReducer, useEffect, useLayoutEffect,
  useCallback, useMemo, useRef and useImperativeHandle. It does NOT
  implement useSyncExternalStore. When the inner state function of
  useBetween(stateFn) calls useSyncExternalStore (directly or via
  useExternalSnapshot / useUserDataSnapshot), React resolves the
  dispatcher to use-between's proxy, finds .useSyncExternalStore
  missing, and calls undefined() — that's the exact production crash
  in Firefox. Chrome reports the same as
  "dispatcher.useSyncExternalStore is not a function".

Neither the vite alias (790ad2b) nor the defensive renderer-method
guards (c35a2d4) could fix it — both addressed downstream symptoms
(stale dist / missing manager methods) but the dispatcher is upstream
of both. That's why every retry kept reproducing the same error.

Fix is structural: snapshot hooks (useUserDataSnapshot,
useIsUserIgnored, etc.) MUST run outside any useBetween scope. Three
re-applied migrations:

- useSessionInfo: snapshot read moved into the outer wrapper. The
  inner useSessionInfoState (useBetween-shared) now contains only
  use-between-safe hooks: useState, useMessageEvent, plain actions.
  userFigure / userRespectRemaining / petRespectRemaining come from
  useUserDataSnapshot() OUTSIDE useBetween, so useSyncExternalStore
  installs against the real React dispatcher.

- useChatWidget.ownUserId: direct snapshot read. useChatWidget is
  exported as `useChatWidget = useChatWidgetState` (NOT wrapped in
  useBetween), so this hook never sat inside the broken scope — the
  precautionary rollback was unnecessary in retrospect. Gains
  session-change reactivity (e.g. reconnect under a different user id).

- AvatarInfoWidgetAvatarView Ignore/Unignore: useIsUserIgnored(name)
  read directly in the component body. Same reasoning as
  useChatWidget — never inside useBetween. The menu auto-flips
  Ignore <-> Unignore while the popup is open.

Added regression guard at src/hooks/session/useSessionSnapshots.test.tsx
with two cases: (1) useSyncExternalStore inside useBetween throws,
(2) useSyncExternalStore outside useBetween in the same render works.
Pins the constraint so future migrations cannot reintroduce the bad
shape silently.

Verification: yarn typecheck clean, yarn test 209/209 (207 baseline
+ 2 new regression cases), no consumer surface changes — every
destructured field (userFigure, userRespectRemaining, respectUser,
petRespectRemaining, respectPet, chatStyleId, updateChatStyleId) is
still returned with the same name and shape.
2026-05-19 17:30:03 +02:00

386 lines
20 KiB
TypeScript

import { CreateLinkEvent, GetSessionDataManager, RoomControllerLevel, RoomObjectCategory, RoomObjectVariable, RoomUnitGiveHandItemComposer, SetRelationshipStatusComposer, TradingOpenComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { AvatarInfoUser, DispatchUiEvent, GetOwnRoomObject, GetUserProfile, LocalizeText, MessengerFriend, ReportType, RoomWidgetUpdateChatInputContentEvent, SendMessageComposer } from '../../../../../api';
import { Flex } from '../../../../../common';
import { useFriends, useHelp, useIsUserIgnored, useRoom, useSessionInfo, useWiredTools } from '../../../../../hooks';
import { ContextMenuHeaderView } from '../../context-menu/ContextMenuHeaderView';
import { ContextMenuListItemView } from '../../context-menu/ContextMenuListItemView';
import { ContextMenuView } from '../../context-menu/ContextMenuView';
interface AvatarInfoWidgetAvatarViewProps
{
avatarInfo: AvatarInfoUser;
onClose: () => void;
}
const MODE_NORMAL = 0;
const MODE_MODERATE = 1;
const MODE_MODERATE_BAN = 2;
const MODE_MODERATE_MUTE = 3;
const MODE_AMBASSADOR = 4;
const MODE_AMBASSADOR_MUTE = 5;
const MODE_RELATIONSHIP = 6;
export const AvatarInfoWidgetAvatarView: FC<AvatarInfoWidgetAvatarViewProps> = props =>
{
const { avatarInfo = null, onClose = null } = props;
const [ mode, setMode ] = useState(MODE_NORMAL);
const { canRequestFriend = null } = useFriends();
const { report = null } = useHelp();
const { roomSession = null, isHandItemBlocked = false } = useRoom();
const { userRespectRemaining = 0, respectUser = null } = useSessionInfo();
const { openInspectionForUser, showInspectButton } = useWiredTools();
// Reactive: the menu auto-flips Ignore <-> Unignore if the state
// changes while the popup is open. Direct hook call (no useBetween
// scope here) so useSyncExternalStore installs against the real
// React dispatcher.
const isIgnored = useIsUserIgnored(avatarInfo.name);
const isShowGiveRights = useMemo(() =>
{
return (avatarInfo.amIOwner && (avatarInfo.targetRoomControllerLevel < RoomControllerLevel.GUEST) && !avatarInfo.isGuildRoom);
}, [ avatarInfo ]);
const isShowRemoveRights = useMemo(() =>
{
return (avatarInfo.amIOwner && (avatarInfo.targetRoomControllerLevel === RoomControllerLevel.GUEST) && !avatarInfo.isGuildRoom);
}, [ avatarInfo ]);
const moderateMenuHasContent = useMemo(() =>
{
return (avatarInfo.canBeKicked || avatarInfo.canBeBanned || avatarInfo.canBeMuted || isShowGiveRights || isShowRemoveRights);
}, [ isShowGiveRights, isShowRemoveRights, avatarInfo ]);
const canGiveHandItem = useMemo(() =>
{
if(isHandItemBlocked) return false;
let flag = false;
const roomObject = GetOwnRoomObject();
if(roomObject)
{
const carryId = roomObject.model.getValue<number>(RoomObjectVariable.FIGURE_CARRY_OBJECT);
if((carryId > 0) && (carryId < 999999)) flag = true;
}
return flag;
}, [ isHandItemBlocked ]);
const processAction = (name: string) =>
{
let hideMenu = true;
if(name)
{
switch(name)
{
case 'moderate':
hideMenu = false;
setMode(MODE_MODERATE);
break;
case 'ban':
hideMenu = false;
setMode(MODE_MODERATE_BAN);
break;
case 'mute':
hideMenu = false;
setMode(MODE_MODERATE_MUTE);
break;
case 'ambassador':
hideMenu = false;
setMode(MODE_AMBASSADOR);
break;
case 'ambassador_mute':
hideMenu = false;
setMode(MODE_AMBASSADOR_MUTE);
break;
case 'back_moderate':
hideMenu = false;
setMode(MODE_MODERATE);
break;
case 'back_ambassador':
hideMenu = false;
setMode(MODE_AMBASSADOR);
break;
case 'back':
hideMenu = false;
setMode(MODE_NORMAL);
break;
case 'whisper':
DispatchUiEvent(new RoomWidgetUpdateChatInputContentEvent(RoomWidgetUpdateChatInputContentEvent.WHISPER, avatarInfo.name));
break;
case 'friend':
CreateLinkEvent(`friends/request/${ avatarInfo.webID }/${ avatarInfo.name }`);
break;
case 'relationship':
hideMenu = false;
setMode(MODE_RELATIONSHIP);
break;
case 'respect': {
respectUser(avatarInfo.webID);
if((userRespectRemaining - 1) >= 1) hideMenu = false;
break;
}
case 'ignore':
GetSessionDataManager().ignoreUser(avatarInfo.name);
break;
case 'unignore':
GetSessionDataManager().unignoreUser(avatarInfo.name);
break;
case 'kick':
roomSession.sendKickMessage(avatarInfo.webID);
break;
case 'ban_hour':
roomSession.sendBanMessage(avatarInfo.webID, 'RWUAM_BAN_USER_HOUR');
break;
case 'ban_day':
roomSession.sendBanMessage(avatarInfo.webID, 'RWUAM_BAN_USER_DAY');
break;
case 'perm_ban':
roomSession.sendBanMessage(avatarInfo.webID, 'RWUAM_BAN_USER_PERM');
break;
case 'mute_2min':
roomSession.sendMuteMessage(avatarInfo.webID, 2);
break;
case 'mute_5min':
roomSession.sendMuteMessage(avatarInfo.webID, 5);
break;
case 'mute_10min':
roomSession.sendMuteMessage(avatarInfo.webID, 10);
break;
case 'give_rights':
roomSession.sendGiveRightsMessage(avatarInfo.webID);
break;
case 'remove_rights':
roomSession.sendTakeRightsMessage(avatarInfo.webID);
break;
case 'trade':
SendMessageComposer(new TradingOpenComposer(avatarInfo.roomIndex));
break;
case 'report':
report(ReportType.BULLY, { reportedUserId: avatarInfo.webID });
break;
case 'inspect':
openInspectionForUser(avatarInfo.roomIndex);
break;
case 'pass_hand_item':
SendMessageComposer(new RoomUnitGiveHandItemComposer(avatarInfo.webID));
break;
case 'ambassador_alert':
roomSession.sendAmbassadorAlertMessage(avatarInfo.webID);
break;
case 'ambassador_kick':
roomSession.sendKickMessage(avatarInfo.webID);
break;
case 'ambassador_mute_2min':
roomSession.sendMuteMessage(avatarInfo.webID, 2);
break;
case 'ambassador_mute_10min':
roomSession.sendMuteMessage(avatarInfo.webID, 10);
break;
case 'ambassador_mute_60min':
roomSession.sendMuteMessage(avatarInfo.webID, 60);
break;
case 'ambassador_mute_18hour':
roomSession.sendMuteMessage(avatarInfo.webID, 1080);
break;
case 'rship_heart':
SendMessageComposer(new SetRelationshipStatusComposer(avatarInfo.webID, MessengerFriend.RELATIONSHIP_HEART));
break;
case 'rship_smile':
SendMessageComposer(new SetRelationshipStatusComposer(avatarInfo.webID, MessengerFriend.RELATIONSHIP_SMILE));
break;
case 'rship_bobba':
SendMessageComposer(new SetRelationshipStatusComposer(avatarInfo.webID, MessengerFriend.RELATIONSHIP_BOBBA));
break;
case 'rship_none':
SendMessageComposer(new SetRelationshipStatusComposer(avatarInfo.webID, MessengerFriend.RELATIONSHIP_NONE));
break;
}
}
if(hideMenu) onClose();
};
useEffect(() =>
{
setMode(MODE_NORMAL);
}, [ avatarInfo ]);
return (
<ContextMenuView category={ RoomObjectCategory.UNIT } classNames={ [ 'nitro-avatar-action-menu' ] } collapsable={ true } objectId={ avatarInfo.roomIndex } userType={ avatarInfo.userType } onClose={ onClose }>
<ContextMenuHeaderView className="cursor-pointer" onClick={ event => GetUserProfile(avatarInfo.webID) } dangerouslySetInnerHTML={ { __html: `${ avatarInfo.name }` } }></ContextMenuHeaderView>
{ (mode === MODE_NORMAL) &&
<>
{ canRequestFriend(avatarInfo.webID) &&
<ContextMenuListItemView onClick={ event => processAction('friend') }>
{ LocalizeText('infostand.button.friend') }
</ContextMenuListItemView> }
<ContextMenuListItemView onClick={ event => processAction('trade') }>
{ LocalizeText('infostand.button.trade') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('whisper') }>
{ LocalizeText('infostand.button.whisper') }
</ContextMenuListItemView>
{ (userRespectRemaining > 0) &&
<ContextMenuListItemView onClick={ event => processAction('respect') }>
{ LocalizeText('infostand.button.respect', [ 'count' ], [ userRespectRemaining.toString() ]) }
</ContextMenuListItemView> }
{ !canRequestFriend(avatarInfo.webID) &&
<ContextMenuListItemView onClick={ event => processAction('relationship') }>
{ LocalizeText('infostand.link.relationship') }
<FaChevronRight className="right fa-icon" />
</ContextMenuListItemView> }
{ !isIgnored &&
<ContextMenuListItemView onClick={ event => processAction('ignore') }>
{ LocalizeText('infostand.button.ignore') }
</ContextMenuListItemView> }
{ isIgnored &&
<ContextMenuListItemView onClick={ event => processAction('unignore') }>
{ LocalizeText('infostand.button.unignore') }
</ContextMenuListItemView> }
<ContextMenuListItemView onClick={ event => processAction('report') }>
{ LocalizeText('infostand.button.report') }
</ContextMenuListItemView>
{ showInspectButton &&
<ContextMenuListItemView onClick={ event => processAction('inspect') }>
Inspect
</ContextMenuListItemView> }
{ moderateMenuHasContent &&
<ContextMenuListItemView onClick={ event => processAction('moderate') }>
<FaChevronRight className="right fa-icon" />
{ LocalizeText('infostand.link.moderate') }
</ContextMenuListItemView> }
{ avatarInfo.isAmbassador &&
<ContextMenuListItemView onClick={ event => processAction('ambassador') }>
<FaChevronRight className="right fa-icon" />
{ LocalizeText('infostand.link.ambassador') }
</ContextMenuListItemView> }
{ canGiveHandItem && <ContextMenuListItemView onClick={ event => processAction('pass_hand_item') }>
{ LocalizeText('avatar.widget.pass_hand_item') }
</ContextMenuListItemView> }
</> }
{ (mode === MODE_MODERATE) &&
<>
<ContextMenuListItemView onClick={ event => processAction('kick') }>
{ LocalizeText('infostand.button.kick') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('mute') }>
<FaChevronRight className="right fa-icon" />
{ LocalizeText('infostand.button.mute') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('ban') }>
<FaChevronRight className="right fa-icon" />
{ LocalizeText('infostand.button.ban') }
</ContextMenuListItemView>
{ isShowGiveRights &&
<ContextMenuListItemView onClick={ event => processAction('give_rights') }>
{ LocalizeText('infostand.button.giverights') }
</ContextMenuListItemView> }
{ isShowRemoveRights &&
<ContextMenuListItemView onClick={ event => processAction('remove_rights') }>
{ LocalizeText('infostand.button.removerights') }
</ContextMenuListItemView> }
<ContextMenuListItemView onClick={ event => processAction('back') }>
<FaChevronLeft className="left fa-icon" />
{ LocalizeText('generic.back') }
</ContextMenuListItemView>
</> }
{ (mode === MODE_MODERATE_BAN) &&
<>
<ContextMenuListItemView onClick={ event => processAction('ban_hour') }>
{ LocalizeText('infostand.button.ban_hour') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('ban_day') }>
{ LocalizeText('infostand.button.ban_day') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('perm_ban') }>
{ LocalizeText('infostand.button.perm_ban') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('back_moderate') }>
<FaChevronLeft className="left fa-icon" />
{ LocalizeText('generic.back') }
</ContextMenuListItemView>
</> }
{ (mode === MODE_MODERATE_MUTE) &&
<>
<ContextMenuListItemView onClick={ event => processAction('mute_2min') }>
{ LocalizeText('infostand.button.mute_2min') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('mute_5min') }>
{ LocalizeText('infostand.button.mute_5min') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('mute_10min') }>
{ LocalizeText('infostand.button.mute_10min') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('back_moderate') }>
<FaChevronLeft className="left fa-icon" />
{ LocalizeText('generic.back') }
</ContextMenuListItemView>
</> }
{ (mode === MODE_AMBASSADOR) &&
<>
<ContextMenuListItemView onClick={ event => processAction('ambassador_alert') }>
{ LocalizeText('infostand.button.alert') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('ambassador_kick') }>
{ LocalizeText('infostand.button.kick') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('ambassador_mute') }>
{ LocalizeText('infostand.button.mute') }
<FaChevronRight className="right fa-icon" />
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('back') }>
<FaChevronLeft className="left fa-icon" />
{ LocalizeText('generic.back') }
</ContextMenuListItemView>
</> }
{ (mode === MODE_AMBASSADOR_MUTE) &&
<>
<ContextMenuListItemView onClick={ event => processAction('ambassador_mute_2min') }>
{ LocalizeText('infostand.button.mute_2min') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('ambassador_mute_10min') }>
{ LocalizeText('infostand.button.mute_10min') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('ambassador_mute_60min') }>
{ LocalizeText('infostand.button.mute_60min') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('ambassador_mute_18hr') }>
{ LocalizeText('infostand.button.mute_18hour') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('back_ambassador') }>
<FaChevronLeft className="left fa-icon" />
{ LocalizeText('generic.back') }
</ContextMenuListItemView>
</> }
{ (mode === MODE_RELATIONSHIP) &&
<>
<Flex className="menu-list-split-3">
<ContextMenuListItemView onClick={ event => processAction('rship_heart') }>
<div className="nitro-friends-spritesheet icon-heart cursor-pointer" />
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('rship_smile') }>
<div className="nitro-friends-spritesheet icon-smile cursor-pointer" />
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('rship_bobba') }>
<div className="nitro-friends-spritesheet icon-bobba cursor-pointer" />
</ContextMenuListItemView>
</Flex>
<ContextMenuListItemView onClick={ event => processAction('rship_none') }>
{ LocalizeText('avatar.widget.clear_relationship') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('back') }>
<FaChevronLeft className="left fa-icon" />
{ LocalizeText('generic.back') }
</ContextMenuListItemView>
</> }
</ContextMenuView>
);
};