feat(direct): show a green online dot on 1:1 chat avatars in the direct list, matching the profile card indicator

This commit is contained in:
heaven 2026-05-30 01:21:27 +03:00
parent 54f96112ff
commit 45535a0dba
2 changed files with 70 additions and 45 deletions

View file

@ -38,7 +38,7 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { TypingIndicator } from '../../components/typing-indicator';
import { stopPropagation } from '../../utils/keyboard';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias } from '../../utils/matrix';
import { getViaServers } from '../../plugins/via-servers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
@ -60,6 +60,7 @@ import { callChatAtom } from '../../state/callEmbed';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '../../hooks/useLivekitSupport';
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
import { timeHourMinute } from '../../utils/time';
import * as css from './styles.css';
@ -295,6 +296,18 @@ export function DmStreamRow({ room, selected, notificationMode, linkPath }: DmSt
const roomName = useRoomName(room);
// Peer online dot — mirrors the profile-card HeroOnlineDot. Only 1:1 rooms
// resolve a single peer; group rooms have no single presence to surface.
// `guessDmRoomUserId`'s last fallback is my own id, so drop that case — the
// empty string makes `useUserPresence` a no-op rather than reading my own
// presence as if I were the peer.
const myUserId = mx.getSafeUserId();
const oneOnOne = isOneOnOneRoom(room);
const peerCandidate = oneOnOne ? guessDmRoomUserId(room, myUserId) : undefined;
const peerUserId = peerCandidate && peerCandidate !== myUserId ? peerCandidate : undefined;
const peerPresence = useUserPresence(peerUserId ?? '');
const peerOnline = !!peerUserId && peerPresence?.presence === Presence.Online;
const preview = deriveRoomPreview(room);
const previewText = preview.text;
const previewTs = preview.ts;
@ -349,22 +362,13 @@ export function DmStreamRow({ room, selected, notificationMode, linkPath }: DmSt
>
<NavLink to={linkPath} onClick={room.isCallRoom() ? handleStartCall : undefined}>
<NavItemContent>
<Box
as="span"
grow="Yes"
alignItems="Center"
gap="300"
className={css.DmRowInner}
>
<Avatar
size="500"
radii="500"
style={{ width: toRem(48), height: toRem(48) }}
>
<Box as="span" grow="Yes" alignItems="Center" gap="300" className={css.DmRowInner}>
<span className={css.DmRowAvatar}>
<Avatar size="500" radii="500" style={{ width: toRem(48), height: toRem(48) }}>
<RoomAvatar
roomId={room.roomId}
src={
isOneOnOneRoom(room)
oneOnOne
? // 1:1 — fall back to the peer's avatar when the room
// itself has no custom one. Mirrors RoomViewHeader's
// `useRoomAvatar(room, isOneOnOne)` semantics.
@ -383,13 +387,9 @@ export function DmStreamRow({ room, selected, notificationMode, linkPath }: DmSt
)}
/>
</Avatar>
<Box
as="span"
direction="Column"
grow="Yes"
gap="100"
className={css.DmRowText}
>
{peerOnline && <span className={css.DmRowOnlineDot} aria-hidden />}
</span>
<Box as="span" direction="Column" grow="Yes" gap="100" className={css.DmRowText}>
<Box as="span" alignItems="Baseline" gap="200">
<Box as="span" grow="Yes" className={css.DmRowTextStretch}>
<Text
@ -402,9 +402,7 @@ export function DmStreamRow({ room, selected, notificationMode, linkPath }: DmSt
{roomName}
</Text>
</Box>
{timeLabel && !optionsVisible && (
<span className={css.DmRowTime}>{timeLabel}</span>
)}
{timeLabel && !optionsVisible && <span className={css.DmRowTime}>{timeLabel}</span>}
</Box>
<Box as="span" alignItems="Center" gap="200">
<Box as="span" grow="Yes" className={css.DmRowTextStretch}>

View file

@ -25,6 +25,33 @@ export const DmRowInner = style({
padding: `${toRem(10)} 0`,
});
// Positioning context for the peer online dot — inline-flex so the wrapper
// hugs the 48px avatar and the absolutely-positioned dot anchors to its
// bottom-right corner.
export const DmRowAvatar = style({
position: 'relative',
display: 'inline-flex',
flexShrink: 0,
});
// Peer online indicator — green dot inset at the avatar's bottom-right. Mirrors
// the profile-card `HeroOnlineDot` (user-profile/styles.css.ts): same
// `Success.Main` fill and a ring in `Background.Container` — which is exactly
// what the `NavItem variant="Background"` row paints at rest, so the dot punches
// out against the avatar circle. Sized for the smaller 48px list avatar (the
// profile card's 16px would read oversized here): the design bundle's
// `size * 0.28` ≈ 14px, scaled down 1.2× to ~11.7px so it sits quieter.
export const DmRowOnlineDot = style({
position: 'absolute',
right: 0,
bottom: 0,
width: toRem(14 / 1.2),
height: toRem(14 / 1.2),
borderRadius: '50%',
backgroundColor: color.Success.Main,
boxShadow: `0 0 0 ${toRem(2.5)} ${color.Background.Container}`,
});
// Locks the title block to its natural 2-line height so the name does
// not jump vertically when the second row's content (preview / unread
// badge / notification icon) collapses to 0px on hover. Bridged rooms