Merge origin/main into main

Resolved 2 messenger conflicts:
- FriendsMessengerView.tsx: union — kept local typingUserIds/sendTypingStatus
  from useMessenger() plus upstream's useFriends().getFriend.
- FriendsView.css: kept local group-chips + typing-indicator styles (upstream
  empty there).
Vitest 545/545 green. (typecheck TS2307 is the un-linked renderer, env-only.)
This commit is contained in:
simoleo89
2026-06-07 11:50:32 +02:00
61 changed files with 1664 additions and 1313 deletions
@@ -1,11 +1,23 @@
import { FC, useRef, useState } from 'react';
import { FC, useLayoutEffect, useRef, useState } from 'react';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { LocalizeText, MessengerFriend } from '../../../../api';
import { FriendBarItemView } from './FriendBarItemView';
import { motion, AnimatePresence, Variants } from 'framer-motion';
// Hard cap on simultaneously-shown friend chips. The effective count is
// reduced below this when the bar would otherwise overflow its (clipped)
// slot in the toolbar — see the width measurement below.
const MAX_DISPLAY_COUNT = 3;
// Layout constants mirrored from FriendBarItemView / the flex gaps here, used
// to compute how many friend chips fit in the available width. A "slot" is one
// w-[132px] button plus the gap-[6px] that precedes it.
const ITEM_SLOT = 138; // 132px chip + 6px gap (friend chip and search chip)
const ARROWS_WIDTH = 52; // two w-[20px] arrows, each + 6px gap
const REQUEST_SLOT = 120; // requests chip (only present when requestsCount > 0)
const BASE_PAD = 8; // container px-[2px] + a little slack
const RIGHT_SAFE = 24; // right inset (right-0/right-3) + pr-3 safety margin
// Mirrored from Toolbar to keep physics identical
const containerVariants: Variants = {
hidden: {},
@@ -23,9 +35,55 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
{
const { onlineFriends = [], requestsCount = 0 } = props;
const [ indexOffset, setIndexOffset ] = useState(0);
const [ maxVisible, setMaxVisible ] = useState(MAX_DISPLAY_COUNT);
const elementRef = useRef<HTMLDivElement>(null);
const hasScrollableFriends = (onlineFriends.length > MAX_DISPLAY_COUNT);
const visibleFriends = onlineFriends.slice(indexOffset, (indexOffset + MAX_DISPLAY_COUNT));
// Auto-fit the visible friend count to the room actually available between
// the bar's left edge and the right side of the viewport. The bar lives in
// a `overflow-x: clip` toolbar slot, so anything that doesn't fit would be
// silently cut off (the scroll arrow / search button disappear). The bar's
// left edge is stable (it sits after fixed-width toolbar icons), so growing
// or shrinking the chip count never moves it — no measurement feedback loop.
useLayoutEffect(() =>
{
const element = elementRef.current;
if(!element) return;
const measure = () =>
{
const left = element.getBoundingClientRect().left;
const available = window.innerWidth - left - RIGHT_SAFE;
const fixed = ARROWS_WIDTH + ITEM_SLOT /* search chip */ + BASE_PAD + ((requestsCount > 0) ? REQUEST_SLOT : 0);
const fit = Math.floor((available - fixed) / ITEM_SLOT);
const next = Math.max(1, Math.min(MAX_DISPLAY_COUNT, fit));
setMaxVisible(prev => ((prev === next) ? prev : next));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(document.documentElement);
window.addEventListener('resize', measure);
return () =>
{
observer.disconnect();
window.removeEventListener('resize', measure);
};
}, [ requestsCount, onlineFriends.length ]);
// `safeOffset` is the offset clamped to the current list/fit. Every read
// below uses it, so a stale `indexOffset` (after the list shrinks or the fit
// grows) renders correctly and self-corrects on the next arrow click — no
// write-back effect needed.
const maxOffset = Math.max(0, (onlineFriends.length - maxVisible));
const safeOffset = Math.min(indexOffset, maxOffset);
const canScrollLeft = (safeOffset > 0);
const canScrollRight = (safeOffset < maxOffset);
const visibleFriends = onlineFriends.slice(safeOffset, (safeOffset + maxVisible));
return (
<motion.div
@@ -44,10 +102,10 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
</motion.div> }
<motion.div variants={itemVariants}>
<div
className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ (!hasScrollableFriends || (indexOffset <= 0)) ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` }
className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ !canScrollLeft ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` }
onClick={ () =>
{
if(indexOffset > 0) setIndexOffset(indexOffset - 1);
if(canScrollLeft) setIndexOffset(safeOffset - 1);
} }
>
<FaChevronLeft className="text-white/70 text-sm drop-shadow-[1px_1px_0_#000]" />
@@ -94,10 +152,10 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
<motion.div variants={itemVariants}>
<div
className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ (!hasScrollableFriends || !((onlineFriends.length > MAX_DISPLAY_COUNT) && ((indexOffset + MAX_DISPLAY_COUNT) <= (onlineFriends.length - 1)))) ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` }
className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ !canScrollRight ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` }
onClick={ () =>
{
if((onlineFriends.length > MAX_DISPLAY_COUNT) && ((indexOffset + MAX_DISPLAY_COUNT) <= (onlineFriends.length - 1))) setIndexOffset(indexOffset + 1);
if(canScrollRight) setIndexOffset(safeOffset + 1);
} }
>
<FaChevronRight className="text-white/70 text-sm drop-shadow-[1px_1px_0_#000]" />
@@ -3,7 +3,8 @@ import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { FaTimes } from 'react-icons/fa';
import { GetUserProfile, LocalizeText, ReportType, SendMessageComposer } from '../../../../api';
import { DraggableWindowPosition, LayoutAvatarImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useHelp, useMessenger, useTranslation } from '../../../../hooks';
import { useFriends, useHelp, useMessenger, useTranslation } from '../../../../hooks';
import { resolveAvatarFigure } from '../friends-list/resolveAvatarFigure';
import { FriendsMessengerThreadView } from './messenger-thread/FriendsMessengerThreadView';
export const FriendsMessengerView: FC<{}> = props =>
@@ -12,6 +13,7 @@ export const FriendsMessengerView: FC<{}> = props =>
const [ lastThreadId, setLastThreadId ] = useState(-1);
const [ messageText, setMessageText ] = useState('');
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);
@@ -185,12 +187,22 @@ export const FriendsMessengerView: FC<{}> = props =>
<div className="messenger-avatar-bar">
{ visibleThreads && (visibleThreads.length > 0) && visibleThreads.map(thread =>
{
const isStaff = (thread.participant.id <= 0);
// Read the live look from the friend list (same source the friends
// list renders) so offline friends show their real avatar instead
// of the standard/anonymous one; resolveAvatarFigure is the final
// fallback when the look is genuinely missing.
const liveFriend = isStaff ? null : getFriend(thread.participant.id);
const figure = isStaff
? (thread.participant.figure === 'ADM' ? 'ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403' : thread.participant.figure)
: resolveAvatarFigure(liveFriend?.figure || thread.participant.figure, liveFriend?.gender ?? thread.participant.gender);
return (
<button key={ thread.threadId } className={ 'messenger-avatar-tab' + ((activeThread === thread) ? ' active' : '') + (thread.unread ? ' unread' : '') } onClick={ event => setActiveThreadId(thread.threadId) }>
<LayoutAvatarImageView
figure={ thread.participant.id > 0 ? thread.participant.figure : thread.participant.figure === 'ADM' ? 'ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403' : thread.participant.figure }
figure={ figure }
headOnly={ true }
direction={ thread.participant.id > 0 ? 2 : 3 }
direction={ isStaff ? 3 : 2 }
/>
</button>
);
@@ -2,10 +2,13 @@ import { GetSessionDataManager } from '@nitrots/nitro-renderer';
import { FC, useMemo } from 'react';
import { GetGroupChatData, LocalizeText, MessengerGroupType, MessengerThread, MessengerThreadChat, MessengerThreadChatGroup } from '../../../../../api';
import { Base, Flex, LayoutAvatarImageView } from '../../../../../common';
import { useFriends } from '../../../../../hooks';
import { resolveAvatarFigure } from '../../friends-list/resolveAvatarFigure';
export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: MessengerThreadChatGroup }> = props =>
{
const { thread = null, group = null } = props;
const { getFriend = null } = useFriends();
const groupChatData = useMemo(() => ((group.type === MessengerGroupType.GROUP_CHAT) && GetGroupChatData(group.chats[0].extraData)), [ group ]);
@@ -50,7 +53,7 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M
<Flex fullWidth gap={ 2 } justifyContent={ isOwnChat ? 'end' : 'start' } className={ 'messenger-message-row ' + (isOwnChat ? 'own' : '') }>
<Base shrink className="message-avatar">
{ ((group.type === MessengerGroupType.PRIVATE_CHAT) && !isOwnChat) &&
<LayoutAvatarImageView direction={ 2 } figure={ thread.participant.figure } headOnly={ true } /> }
<LayoutAvatarImageView direction={ 2 } figure={ resolveAvatarFigure(getFriend?.(thread.participant.id)?.figure || thread.participant.figure, getFriend?.(thread.participant.id)?.gender ?? thread.participant.gender) } headOnly={ true } /> }
{ (groupChatData && !isOwnChat) &&
<LayoutAvatarImageView direction={ 2 } figure={ groupChatData.figure } headOnly={ true } /> }
</Base>