mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
c9e7461714
Read user.badges.max.slots from config instead of hardcoded 5. InfoStand layout adapts: 5 slots shows group badge, 6 slots replaces group with 6th badge. Double-click on InfoStand badge removes it. Badge received toast now directly equips the badge via toggleBadge and closes.
286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
import React from 'react';
|
||
import { GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomSessionFavoriteGroupUpdateEvent, RoomSessionUserBadgesEvent, RoomSessionUserFigureUpdateEvent, UserRelationshipsComposer } from '@nitrots/nitro-renderer';
|
||
import { Dispatch, FC, FocusEvent, KeyboardEvent, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { FaPencilAlt, FaTimes } from 'react-icons/fa';
|
||
import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api';
|
||
import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserProfileIconView } from '../../../../../common';
|
||
import { useMessageEvent, useNitroEvent, useRoom } from '../../../../../hooks';
|
||
import { InfoStandBadgeSlotView } from './InfoStandBadgeSlotView';
|
||
import { InfoStandWidgetUserRelationshipsView } from './InfoStandWidgetUserRelationshipsView';
|
||
import { InfoStandWidgetUserTagsView } from './InfoStandWidgetUserTagsView';
|
||
import { BackgroundsView } from '../../../../backgrounds/BackgroundsView';
|
||
|
||
interface InfoStandWidgetUserViewProps {
|
||
avatarInfo: AvatarInfoUser;
|
||
setAvatarInfo: Dispatch<SetStateAction<AvatarInfoUser>>;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props => {
|
||
const { avatarInfo = null, setAvatarInfo = null, onClose = null } = props;
|
||
const [motto, setMotto] = useState<string>(null);
|
||
const [isEditingMotto, setIsEditingMotto] = useState(false);
|
||
const [relationships, setRelationships] = useState<RelationshipStatusInfoMessageParser>(null);
|
||
const [backgroundId, setBackgroundId] = useState<number>(null);
|
||
const [standId, setStandId] = useState<number>(null);
|
||
const [overlayId, setOverlayId] = useState<number>(null);
|
||
const [isVisible, setIsVisible] = useState(false);
|
||
const { roomSession = null } = useRoom();
|
||
|
||
const infostandBackgroundClass = `background-${backgroundId ?? 'default'}`;
|
||
const infostandStandClass = `stand-${standId ?? 'default'}`;
|
||
const infostandOverlayClass = `overlay-${overlayId ?? 'default'}`;
|
||
|
||
const handleProfileClick = useCallback(() => { GetUserProfile(avatarInfo.webID); }, [avatarInfo.webID]);
|
||
|
||
const handleEditClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); setIsVisible(prev => !prev); }, []);
|
||
|
||
const saveMotto = (motto: string) => {
|
||
if (!isEditingMotto || motto.length > GetConfigurationValue<number>('motto.max.length', 38) || !roomSession) return;
|
||
|
||
roomSession.sendMottoMessage(motto);
|
||
setIsEditingMotto(false);
|
||
};
|
||
|
||
const onMottoBlur = (event: FocusEvent<HTMLInputElement>) => saveMotto(event.target.value);
|
||
|
||
const onMottoKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||
event.stopPropagation();
|
||
|
||
switch (event.key) {
|
||
case 'Enter':
|
||
saveMotto((event.target as HTMLInputElement).value);
|
||
return;
|
||
}
|
||
};
|
||
|
||
useNitroEvent<RoomSessionUserBadgesEvent>(RoomSessionUserBadgesEvent.RSUBE_BADGES, event => {
|
||
if (!avatarInfo || avatarInfo.webID !== event.userId) return;
|
||
|
||
// Deduplicate badges from server
|
||
const seen = new Set<string>();
|
||
const dedupedBadges = event.badges.map(code => {
|
||
if (!code || seen.has(code)) return '';
|
||
seen.add(code);
|
||
return code;
|
||
});
|
||
|
||
const oldBadges = avatarInfo.badges.join('');
|
||
|
||
if (oldBadges === dedupedBadges.join('')) return;
|
||
|
||
setAvatarInfo(prevValue => {
|
||
if (!prevValue) return prevValue;
|
||
|
||
const newValue = CloneObject(prevValue);
|
||
newValue.badges = dedupedBadges;
|
||
return newValue;
|
||
});
|
||
});
|
||
|
||
useNitroEvent<RoomSessionUserFigureUpdateEvent>(RoomSessionUserFigureUpdateEvent.USER_FIGURE, event => {
|
||
if (!avatarInfo || avatarInfo.roomIndex !== event.roomIndex) return;
|
||
|
||
setAvatarInfo(prevValue => {
|
||
if (!prevValue) return prevValue;
|
||
|
||
const newValue = CloneObject(prevValue);
|
||
newValue.figure = event.figure;
|
||
newValue.motto = event.customInfo;
|
||
newValue.achievementScore = event.activityPoints;
|
||
newValue.backgroundId = event.backgroundId;
|
||
newValue.standId = event.standId;
|
||
newValue.overlayId = event.overlayId;
|
||
return newValue;
|
||
});
|
||
});
|
||
|
||
useNitroEvent<RoomSessionFavoriteGroupUpdateEvent>(RoomSessionFavoriteGroupUpdateEvent.FAVOURITE_GROUP_UPDATE, event => {
|
||
if (!avatarInfo || avatarInfo.roomIndex !== event.roomIndex) return;
|
||
|
||
setAvatarInfo(prevValue => {
|
||
if (!prevValue) return prevValue;
|
||
|
||
const newValue = CloneObject(prevValue);
|
||
const clearGroup = (event.status === -1) || (event.habboGroupId <= 0);
|
||
|
||
newValue.groupId = clearGroup ? -1 : event.habboGroupId;
|
||
newValue.groupName = clearGroup ? null : event.habboGroupName;
|
||
newValue.groupBadgeId = clearGroup ? null : GetSessionDataManager().getGroupBadge(event.habboGroupId);
|
||
return newValue;
|
||
});
|
||
});
|
||
|
||
useMessageEvent<RelationshipStatusInfoEvent>(RelationshipStatusInfoEvent, event => {
|
||
const parser = event.getParser();
|
||
|
||
if (!avatarInfo || avatarInfo.webID !== parser.userId) return;
|
||
|
||
setRelationships(parser);
|
||
});
|
||
|
||
useEffect(() => {
|
||
setIsEditingMotto(false);
|
||
setMotto(avatarInfo.motto);
|
||
setBackgroundId(avatarInfo.backgroundId);
|
||
setStandId(avatarInfo.standId);
|
||
setOverlayId(avatarInfo.overlayId);
|
||
|
||
SendMessageComposer(new UserRelationshipsComposer(avatarInfo.webID));
|
||
|
||
return () => {
|
||
setIsEditingMotto(false);
|
||
setMotto(null);
|
||
setRelationships(null);
|
||
setBackgroundId(null);
|
||
setStandId(null);
|
||
setOverlayId(null);
|
||
};
|
||
}, [avatarInfo]);
|
||
|
||
if (!avatarInfo) return null;
|
||
|
||
return (
|
||
<>
|
||
<Column className="relative min-w-[190px] max-w-[190px] z-30 pointer-events-auto bg-[rgba(28,28,32,0.95)] [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] rounded">
|
||
<Column className="h-full p-[8px] overflow-auto" gap={1} overflow="visible">
|
||
<div className="flex flex-col gap-1">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-1">
|
||
<UserProfileIconView userId={avatarInfo.webID} />
|
||
<Text small wrap variant="white">{avatarInfo.name}</Text>
|
||
</div>
|
||
<FaTimes className="cursor-pointer fa-icon" onClick={onClose} />
|
||
</div>
|
||
<hr className="m-0 bg-[#0003] border-0 opacity-[0.5] h-px" />
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<div className="flex gap-1">
|
||
<Column
|
||
fullWidth
|
||
className={`flex items-center w-full max-w-[68px] rounded-sm relative overflow-hidden profile-background ${infostandBackgroundClass}`}
|
||
onClick={handleProfileClick}
|
||
>
|
||
<Base position="absolute" className={`profile-stand ${infostandStandClass}`} />
|
||
<LayoutAvatarImageView direction={2} figure={avatarInfo.figure} />
|
||
<Base position="absolute" className={`profile-overlay ${infostandOverlayClass}`} />
|
||
</Column>
|
||
{avatarInfo.type === AvatarInfoUser.OWN_USER && (
|
||
<Base
|
||
className="background-edit-icon background-edit-position"
|
||
style={{ pointerEvents: 'auto', cursor: 'pointer' }}
|
||
onClick={handleEditClick}
|
||
aria-label="Edit profile background"
|
||
/>
|
||
)}
|
||
<Column grow alignItems="center" gap={0}>
|
||
{ (() => {
|
||
const maxSlots = GetConfigurationValue<number>('user.badges.max.slots', 5);
|
||
const isOwnUser = avatarInfo.type === AvatarInfoUser.OWN_USER;
|
||
const showGroup = maxSlots <= 5;
|
||
|
||
const items: React.ReactNode[] = [];
|
||
items.push(<InfoStandBadgeSlotView key={0} slotIndex={0} badgeCode={avatarInfo.badges[0]} isOwnUser={isOwnUser} />);
|
||
|
||
if(showGroup) {
|
||
items.push(
|
||
<Flex key="group" center className="relative w-[40px] h-[40px] bg-no-repeat bg-center" pointer={avatarInfo.groupId > 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}>
|
||
{avatarInfo.groupId > 0 && <LayoutBadgeImageView badgeCode={avatarInfo.groupBadgeId} customTitle={avatarInfo.groupName} isGroup={true} showInfo={true} />}
|
||
</Flex>
|
||
);
|
||
} else {
|
||
items.push(<InfoStandBadgeSlotView key="slot1" slotIndex={1} badgeCode={avatarInfo.badges[1]} isOwnUser={isOwnUser} />);
|
||
}
|
||
|
||
const startIdx = showGroup ? 1 : 2;
|
||
for(let i = startIdx; i < maxSlots; i++) {
|
||
items.push(<InfoStandBadgeSlotView key={i} slotIndex={i} badgeCode={avatarInfo.badges[i]} isOwnUser={isOwnUser} />);
|
||
}
|
||
|
||
const rows: React.ReactNode[][] = [];
|
||
for(let i = 0; i < items.length; i += 2) {
|
||
rows.push(items.slice(i, i + 2));
|
||
}
|
||
|
||
return rows.map((row, idx) => (
|
||
<Flex key={idx} center gap={1}>{row}</Flex>
|
||
));
|
||
})() }
|
||
</Column>
|
||
</div>
|
||
<hr className="m-0 bg-[#0003] border-0 opacity-[0.5] h-px" />
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<Flex alignItems="center" className="bg-light-dark rounded py-1 px-2">
|
||
{avatarInfo.type !== AvatarInfoUser.OWN_USER && (
|
||
<Flex grow alignItems="center" className="min-h-[18px]">
|
||
<Text fullWidth pointer small textBreak wrap variant="white">{motto}</Text>
|
||
</Flex>
|
||
)}
|
||
{avatarInfo.type === AvatarInfoUser.OWN_USER && (
|
||
<Flex grow alignItems="center" gap={2}>
|
||
<FaPencilAlt className="small fa-icon" />
|
||
<Flex grow alignItems="center" className="min-h-[18px]">
|
||
{!isEditingMotto && (
|
||
<Text fullWidth pointer small textBreak wrap variant="white" onClick={event => setIsEditingMotto(true)}>
|
||
{motto}
|
||
</Text>
|
||
)}
|
||
{isEditingMotto && (
|
||
<input
|
||
autoFocus={true}
|
||
className="w-full h-full text-[12px] p-0 outline-0 border-0 text-[#fff] relative bg-transparent resize-none focus:italic border-transparent focus:border-transparent focus:ring-0"
|
||
maxLength={GetConfigurationValue<number>('motto.max.length', 38)}
|
||
type="text"
|
||
value={motto}
|
||
onBlur={onMottoBlur}
|
||
onChange={event => setMotto(event.target.value)}
|
||
onKeyDown={onMottoKeyDown}
|
||
/>
|
||
)}
|
||
</Flex>
|
||
</Flex>
|
||
)}
|
||
</Flex>
|
||
<hr className="m-0 bg-[#0003] border-0 opacity-[0.5] h-px" />
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<Text small wrap variant="white">
|
||
{LocalizeText('infostand.text.achievement_score') + ' ' + avatarInfo.achievementScore}
|
||
</Text>
|
||
{avatarInfo.carryItem > 0 && (
|
||
<>
|
||
<hr className="m-0 bg-[#0003] border-0 opacity-[0.5] h-px" />
|
||
<Text small wrap variant="white">
|
||
{LocalizeText('infostand.text.handitem', ['item'], [LocalizeText('handitem' + avatarInfo.carryItem)])}
|
||
</Text>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<InfoStandWidgetUserRelationshipsView relationships={relationships} />
|
||
</div>
|
||
{GetConfigurationValue('user.tags.enabled') && (
|
||
<Column className="mt-1" gap={1}>
|
||
<InfoStandWidgetUserTagsView tags={GetSessionDataManager().tags} />
|
||
</Column>
|
||
)}
|
||
</Column>
|
||
</Column>
|
||
{isVisible && avatarInfo.type === AvatarInfoUser.OWN_USER && (
|
||
<div className="backgrounds-view-container">
|
||
<BackgroundsView
|
||
setIsVisible={setIsVisible}
|
||
selectedBackground={backgroundId}
|
||
setSelectedBackground={setBackgroundId}
|
||
selectedStand={standId}
|
||
setSelectedStand={setStandId}
|
||
selectedOverlay={overlayId}
|
||
setSelectedOverlay={setOverlayId}
|
||
/>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
};
|