feat(web): show live presence in the member and user panels
The web client only refreshed presence on load, so the dots went stale within seconds and a fresh hard refresh often showed everyone grey while the socket was still connecting. Poll presence every 30s while the tab is visible, refetch at once when it comes back to the foreground or when the socket reports a presence change, and stop the timer while hidden. The member panel now splits online from offline, draws a state-coloured dot on each avatar and turns a row into a DM shortcut, and the user panel reads its own presence straight from the API before any event arrives.
ZhenYi committed
Modified web/src/entities/chat/model/api.ts web/src/entities/chat/model/api.ts +4 −0 View web/src/entities/chat/model/api.ts
@@ -25,6 +25,7 @@
2525 MessageEdit,
2626 MessagePage,
2727 PinnedPage,
28 Presence,
2829 ReadState,
2930 ReactionAgg,
3031 ReactionUser,
@@ -399,6 +400,9 @@
399400 Apis.chat.list_space_presences({ pathParams: { space_name: spaceName } }),
400401 )
401402
403export const getUserPresence = (userId: string) =>
404 unwrapApi<Presence>(Apis.chat.get_presence({ pathParams: { user_id: userId } }))
405
402406/* ------------------------------------------------------------------ emojis */
403407
404408export const listSpaceEmojis = (spaceName: string) =>
Modified web/src/features/chat/model/use-room-data.ts web/src/features/chat/model/use-room-data.ts +40 −2 View web/src/features/chat/model/use-room-data.ts
@@ -8,8 +8,9 @@
88 * a private room whose member list is forbidden still chats, an unreachable
99 * presence endpoint just leaves every dot grey.
1010 */
11import { useCallback, useMemo, useState } from 'react'
11import { useCallback, useEffect, useMemo, useState } from 'react'
1212import { roomKey, spaceEmojisKey, useAsync, type AsyncState } from '@/shared/lib'
13import { useWsUserEvents } from '@/shared/ws'
1314import {
1415 listRoomMembers,
1516 listPins,
@@ -83,12 +84,49 @@
8384 { cacheKey: gate ? roomKey(roomId, 'threads') : null },
8485 )
8586 // Presence is live-only state: a stale online dot is worse than no dot.
87 // 队友的在线变化没有广播通道(`presence` 事件只回发给本人),所以在标签页
88 // 可见时每 30s 重拉一次,切回前台立刻补一次;隐藏时停表,不空转网络。
89 const [presenceTick, setPresenceTick] = useState(0)
90 const livePresence = gate && Boolean(spaceName) && spaceName !== '@me'
91 useEffect(() => {
92 if (!livePresence) return
93 const tick = () => setPresenceTick(value => value + 1)
94 let timer: ReturnType<typeof setInterval> | undefined
95 const start = () => {
96 if (timer === undefined) timer = setInterval(tick, 30_000)
97 }
98 const stop = () => {
99 if (timer !== undefined) {
100 clearInterval(timer)
101 timer = undefined
102 }
103 }
104 const onVisibility = () => {
105 if (document.visibilityState === 'visible') {
106 tick()
107 start()
108 } else {
109 stop()
110 }
111 }
112 if (document.visibilityState === 'visible') start()
113 document.addEventListener('visibilitychange', onVisibility)
114 return () => {
115 stop()
116 document.removeEventListener('visibilitychange', onVisibility)
117 }
118 }, [livePresence])
119 // 自己上线/改状态(`presence` 事件只回发给本人)时立即补拉一次:硬刷新直接
120 // 进房间时,挂载那一刻 socket 可能还没连上,首屏的在线状态会短暂缺失。
121 useWsUserEvents(event => {
122 if (event.kind === 'presence') setPresenceTick(value => value + 1)
123 })
86124 const presences = useAsync(
87125 () =>
88126 gate && spaceName && spaceName !== '@me'
89127 ? listSpacePresences(spaceName).catch(() => [])
90128 : Promise.resolve([]),
91 [gate, spaceName, reloadKey],
129 [gate, spaceName, reloadKey, presenceTick],
92130 )
93131 const emojis = useAsync(
94132 () =>
Modified web/src/features/chat/ui/side-panel.tsx web/src/features/chat/ui/side-panel.tsx +138 −85 View web/src/features/chat/ui/side-panel.tsx
@@ -13,13 +13,15 @@
1313import {
1414 Bell,
1515 BellOff,
16 Clock,
1617 Crosshair,
17 Hash,
1818 Loader2,
1919 Lock,
20 MessageSquare,
2021 Pin,
2122 Search,
2223 UserPlus,
24 Users,
2325 X,
2426} from 'lucide-react'
2527import {
@@ -344,15 +346,15 @@
344346 {loading ? (
345347 <PanelBusy />
346348 ) : pins.length === 0 ? (
347 <PanelEmpty>{t('chat.pins.empty')}</PanelEmpty>
349 <PanelEmpty icon={<Pin className="h-[18px] w-[18px]" />}>{t('chat.pins.empty')}</PanelEmpty>
348350 ) : (
349 <ul className="space-y-1.5">
351 <ul className="space-y-0.5">
350352 {pins.map(message => (
351353 <li
352354 key={message.id}
353 className="group flex items-start gap-2 rounded-md border border-shell-hairline bg-shell-field p-2"
355 className="group flex items-start gap-2.5 rounded-[10px] p-2.5 transition-colors hover:bg-shell-field"
354356 >
355 <UserAvatar user={message.author} id={message.author_id} size={24} />
357 <UserAvatar user={message.author} id={message.author_id} size={26} />
356358 <button
357359 type="button"
358360 onClick={() => onJump(message.id)}
@@ -365,18 +367,18 @@
365367 {clockStamp(message.created_at)}
366368 </span>
367369 </span>
368 <span className="line-clamp-3 block whitespace-pre-wrap break-words text-[12.5px]">
370 <span className="mt-0.5 line-clamp-3 block whitespace-pre-wrap break-words text-[12.5px]">
369371 {message.deleted_at
370372 ? t('chat.transcript.deletedShort')
371373 : message.content || t('chat.composer.attachmentPlaceholder')}
372374 </span>
373375 </button>
374 <div className="flex shrink-0 flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
376 <div className="flex shrink-0 flex-col gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
375377 <button
376378 type="button"
377379 title={t('chat.pins.jumpShort')}
378380 onClick={() => onJump(message.id)}
379 className="grid h-6 w-6 place-content-center rounded hover:bg-shell-active"
381 className="grid h-6 w-6 place-content-center rounded-md hover:bg-shell-active"
380382 >
381383 <Crosshair className="h-3.5 w-3.5 text-shell-ink-muted" />
382384 </button>
@@ -384,7 +386,7 @@
384386 type="button"
385387 title={t('chat.message.unpin')}
386388 onClick={() => onUnpin(message)}
387 className="grid h-6 w-6 place-content-center rounded hover:bg-shell-active"
389 className="grid h-6 w-6 place-content-center rounded-md hover:bg-shell-active"
388390 >
389391 <Pin className="h-3.5 w-3.5 text-shell-ink-muted" />
390392 </button>
@@ -440,6 +442,65 @@
440442 a.user.username.localeCompare(b.user.username)),
441443 [members, online],
442444 )
445 const onlineMembers = useMemo(
446 () => sorted.filter(member => online.get(member.user.user_id)?.online),
447 [sorted, online],
448 )
449 const offlineMembers = useMemo(
450 () => sorted.filter(member => !online.get(member.user.user_id)?.online),
451 [sorted, online],
452 )
453 const renderMember = (member: RoomMember) => {
454 const presence = online.get(member.user.user_id)
455 const self = member.user.user_id === viewerId
456 return (
457 <li key={member.user.user_id}>
458 <button
459 type="button"
460 disabled={self}
461 onClick={() => onDm(member.user)}
462 title={
463 self
464 ? t('chat.member.self')
465 : t('chat.member.dmTitle', { username: member.user.username })
466 }
467 className="flex w-full items-center gap-2.5 rounded-[10px] px-2.5 py-2 text-left transition-colors hover:bg-shell-field disabled:cursor-default disabled:hover:bg-transparent"
468 >
469 <span className="relative shrink-0">
470 <UserAvatar user={member.user} id={member.user.user_id} size={32} />
471 {presence?.online ? (
472 <span
473 aria-hidden
474 title={presence.status}
475 className="absolute -bottom-0.5 -right-0.5 h-[11px] w-[11px] rounded-full border-2 border-shell-surface"
476 style={{
477 backgroundColor:
478 presence.status === 'Active'
479 ? '#3ba55d'
480 : presence.status === 'Away'
481 ? '#f0b232'
482 : '#da3e4c',
483 }}
484 />
485 ) : null}
486 </span>
487 <span className="min-w-0 flex-1">
488 <span className="block truncate text-[13px] font-medium">
489 {member.nick || member.user.display_name || member.user.username}
490 </span>
491 <span className="block truncate text-[11px] text-shell-ink-muted">
492 @{member.user.username}
493 </span>
494 </span>
495 {member.role !== 'member' ? (
496 <span className="shrink-0 rounded-full bg-shell-active px-1.5 py-px text-[10px] font-semibold text-shell-ink-muted">
497 {member.role === 'owner' ? t('chat.member.owner') : member.role}
498 </span>
499 ) : null}
500 </button>
501 </li>
502 )
503 }
443504
444505 return (
445506 <>
@@ -482,59 +543,24 @@
482543 ) : loading && members.length === 0 ? (
483544 <PanelBusy />
484545 ) : (
485 <ul className="space-y-0.5">
486 {sorted.map(member => {
487 const presence = online.get(member.user.user_id)
488 const self = member.user.user_id === viewerId
489 return (
490 <li key={member.user.user_id}>
491 <button
492 type="button"
493 disabled={self}
494 onClick={() => onDm(member.user)}
495 title={
496 self
497 ? t('chat.member.self')
498 : t('chat.member.dmTitle', { username: member.user.username })
499 }
500 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-shell-field disabled:cursor-default disabled:hover:bg-transparent"
501 >
502 <span className="relative shrink-0">
503 <UserAvatar user={member.user} id={member.user.user_id} size={30} />
504 {presence?.online ? (
505 <span
506 aria-hidden
507 title={presence.status}
508 className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-shell-surface"
509 style={{
510 backgroundColor:
511 presence.status === 'Active'
512 ? '#3ba55d'
513 : presence.status === 'Away'
514 ? '#f0b232'
515 : '#da3e4c',
516 }}
517 />
518 ) : null}
519 </span>
520 <span className="min-w-0 flex-1">
521 <span className="block truncate text-[13px] font-medium">
522 {member.nick || member.user.display_name || member.user.username}
523 </span>
524 <span className="block truncate text-[11px] text-shell-ink-muted">
525 @{member.user.username}
526 </span>
527 </span>
528 {member.role !== 'member' ? (
529 <span className="shrink-0 rounded bg-shell-field px-1 text-[10px] font-semibold text-shell-ink-muted">
530 {member.role === 'owner' ? t('chat.member.owner') : member.role}
531 </span>
532 ) : null}
533 </button>
534 </li>
535 )
536 })}
537 </ul>
546 <div className="space-y-1">
547 {onlineMembers.length > 0 ? (
548 <section>
549 <p className="px-2.5 pb-0.5 pt-2 text-[11px] font-semibold text-shell-ink-muted">
550 {t('label.presence.online')} · {onlineMembers.length}
551 </p>
552 <ul className="space-y-0.5">{onlineMembers.map(renderMember)}</ul>
553 </section>
554 ) : null}
555 {offlineMembers.length > 0 ? (
556 <section>
557 <p className="px-2.5 pb-0.5 pt-2 text-[11px] font-semibold text-shell-ink-muted">
558 {t('label.presence.offline')} · {offlineMembers.length}
559 </p>
560 <ul className="space-y-0.5">{offlineMembers.map(renderMember)}</ul>
561 </section>
562 ) : null}
563 </div>
538564 )}
539565 </div>
540566 </>
@@ -720,7 +746,9 @@
720746 <PanelBusy label={t('chat.panel.searching')} />
721747 ) : query.trim() ? (
722748 hits.length === 0 ? (
723 <PanelEmpty>{t('chat.search.emptyFor', { query: query.trim() })}</PanelEmpty>
749 <PanelEmpty icon={<Search className="h-[18px] w-[18px]" />}>
750 {t('chat.search.emptyFor', { query: query.trim() })}
751 </PanelEmpty>
724752 ) : (
725753 <ul className="space-y-1.5">
726754 {hits.map(hit => (
@@ -743,10 +771,7 @@
743771 </ul>
744772 )
745773 ) : (
746 <PanelEmpty>
747 <Hash className="mr-1 inline h-3.5 w-3.5" />
748 {t('chat.search.hint')}
749 </PanelEmpty>
774 <PanelEmpty icon={<Search className="h-[18px] w-[18px]" />}>{t('chat.search.hint')}</PanelEmpty>
750775 )}
751776 </div>
752777 </>
@@ -780,30 +805,53 @@
780805 {loading ? (
781806 <PanelBusy />
782807 ) : threads.length === 0 ? (
783 <PanelEmpty>{t('chat.thread.emptyHint')}</PanelEmpty>
808 <PanelEmpty icon={<MessageSquare className="h-[18px] w-[18px]" />}>
809 {t('chat.thread.emptyHint')}
810 </PanelEmpty>
784811 ) : (
785 <ul className="space-y-1.5">
812 <ul className="space-y-0.5">
786813 {threads.map(thread => (
787814 <li key={thread.id}>
788815 <button
789816 type="button"
790817 onClick={() => onOpen(thread)}
791 className="w-full rounded-md border border-shell-hairline bg-shell-field p-2 text-left transition-colors hover:border-shell-ink-muted"
818 className="flex w-full items-center gap-2.5 rounded-[10px] p-2.5 text-left transition-colors hover:bg-shell-field"
792819 >
793 <p className="truncate text-[12.5px] font-semibold">
794 {thread.name || t('chat.thread.fromOwner', { name: nameOf(thread.owner_id) })}
795 </p>
796 <p className="mt-0.5 flex items-center gap-2 text-[11px] text-shell-ink-muted">
797 <span>{t('chat.thread.replies', { count: thread.reply_count })}</span>
798 <span>{tc('chat.thread.participants', thread.participant_count)}</span>
799 {thread.archived ? <span>{t('chat.thread.archived')}</span> : null}
800 {thread.locked ? (
801 <span className="flex items-center gap-0.5">
802 <Lock className="h-3 w-3" />
803 {t('chat.thread.locked')}
820 {thread.owner ? (
821 <UserAvatar user={thread.owner} id={thread.owner_id} size={34} />
822 ) : (
823 <span className="grid h-[34px] w-[34px] shrink-0 place-items-center rounded-full bg-shell-field text-shell-ink-muted">
824 <MessageSquare className="h-4 w-4" />
825 </span>
826 )}
827 <span className="min-w-0 flex-1">
828 <span className="block truncate text-[13px] font-semibold">
829 {thread.name || t('chat.thread.fromOwner', { name: nameOf(thread.owner_id) })}
830 </span>
831 <span className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-shell-ink-muted">
832 <span className="flex items-center gap-1">
833 <MessageSquare className="h-3 w-3" />
834 {t('chat.thread.replies', { count: thread.reply_count })}
804835 </span>
805 ) : null}
806 </p>
836 <span className="flex items-center gap-1">
837 <Users className="h-3 w-3" />
838 {tc('chat.thread.participants', thread.participant_count)}
839 </span>
840 {thread.last_reply_at ? (
841 <span className="flex items-center gap-1">
842 <Clock className="h-3 w-3" />
843 {clockStamp(thread.last_reply_at)}
844 </span>
845 ) : null}
846 {thread.archived ? <span>{t('chat.thread.archived')}</span> : null}
847 {thread.locked ? (
848 <span className="flex items-center gap-1">
849 <Lock className="h-3 w-3" />
850 {t('chat.thread.locked')}
851 </span>
852 ) : null}
853 </span>
854 </span>
807855 </button>
808856 </li>
809857 ))}
@@ -826,8 +874,13 @@
826874 )
827875}
828876
829function PanelEmpty({ children }: { children: React.ReactNode }) {
830 return <p className="chat-panel-empty px-2 py-6 text-center text-[12.5px] leading-[1.6] text-shell-ink-muted">{children}</p>
877function PanelEmpty({ icon, children }: { icon?: React.ReactNode; children: React.ReactNode }) {
878 return (
879 <div className="chat-panel-empty">
880 {icon ? <span className="chat-panel-empty-icon">{icon}</span> : null}
881 <p className="text-[12.5px] leading-[1.65] text-shell-ink-muted">{children}</p>
882 </div>
883 )
831884}
832885
833886function PanelNote({ children }: { children: React.ReactNode }) {
Modified web/src/pages/workspace/ui/user-panel.tsx web/src/pages/workspace/ui/user-panel.tsx +50 −3 View web/src/pages/workspace/ui/user-panel.tsx
@@ -1,7 +1,9 @@
11import { useEffect, useRef, useState } from 'react'
22import { Check, Copy, HeadphoneOff, Headphones, Mic, MicOff, Pencil, Settings } from 'lucide-react'
3import { getUserPresence, type PresenceState } from '@/entities/chat'
34import { useT } from '@/shared/i18n'
45import { useVoiceSession, voiceSession } from '@/shared/voice'
6import { useWsUserEvents } from '@/shared/ws'
57import { initial } from '../model/initial'
68import type { Me } from '../model/workspace'
79
@@ -11,6 +13,10 @@
1113 const session = useVoiceSession()
1214 const [cardOpen, setCardOpen] = useState(false)
1315 const [copied, setCopied] = useState(false)
16 // 自己的在线状态:进页面先读一次快照,之后以 WS 的 `presence` 事件为准
17 // (事件回发给本人,用于跨标签页镜像)。事件先到时不回填旧快照。
18 const [presence, setPresence] = useState<PresenceState | undefined>(undefined)
19 const presenceFromEvent = useRef(false)
1420 const root = useRef<HTMLDivElement>(null)
1521 const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
1622 const inCall = session.roomId !== null && session.status !== 'idle'
@@ -28,6 +34,27 @@
2834 useEffect(() => () => clearTimeout(copyTimer.current), [])
2935
3036 useEffect(() => {
37 let active = true
38 getUserPresence(me.user.user_id)
39 .then(row => {
40 if (active && !presenceFromEvent.current) setPresence(row.status)
41 })
42 .catch(() => {
43 // 读不到就维持默认(在线):卡片只是展示,不值得打扰用户。
44 })
45 return () => {
46 active = false
47 }
48 }, [me.user.user_id])
49
50 useWsUserEvents(event => {
51 if (event.kind === 'presence') {
52 presenceFromEvent.current = true
53 setPresence(event.status)
54 }
55 })
56
57 useEffect(() => {
3158 if (!cardOpen) return
3259 const onDown = (event: PointerEvent) => {
3360 if (!root.current?.contains(event.target as Node)) setCardOpen(false)
@@ -44,6 +71,19 @@
4471 }, [cardOpen])
4572
4673 const name = me.user.display_name || me.user.username
74 const status = presence ?? 'Active'
75 const statusColor = {
76 Active: '#23a55a',
77 Away: '#f0b232',
78 Dnd: '#da3e4c',
79 Offline: '#80848e',
80 }[status]
81 const statusLabel = {
82 Active: t('label.presence.online'),
83 Away: t('label.presence.away'),
84 Dnd: t('label.presence.busy'),
85 Offline: t('label.presence.offline'),
86 }[status]
4787
4888 async function copyText(value: string) {
4989 try {
@@ -78,7 +118,10 @@
78118 {initial(name)}
79119 </span>
80120 )}
81 <span className="absolute -bottom-0.5 -right-0.5 h-4 w-4 rounded-full border-[3px] border-shell-surface bg-[#23a55a]" />
121 <span
122 className="absolute -bottom-0.5 -right-0.5 h-4 w-4 rounded-full border-[3px] border-shell-surface"
123 style={{ backgroundColor: statusColor }}
124 />
82125 </span>
83126 <p className="truncate text-[17px] font-semibold leading-tight">{name}</p>
84127 <span className="mt-0.5 flex min-w-0 items-center gap-1">
@@ -95,8 +138,12 @@
95138 </span>
96139 <div className="mt-3 overflow-hidden rounded-lg bg-shell-field">
97140 <div className="flex items-center gap-2 px-3 py-2 text-[13px]">
98 <span className="h-2.5 w-2.5 shrink-0 rounded-full bg-[#23a55a]" aria-hidden />
99 {t('shell.user.online')}
141 <span
142 className="h-2.5 w-2.5 shrink-0 rounded-full"
143 style={{ backgroundColor: statusColor }}
144 aria-hidden
145 />
146 {statusLabel}
100147 </div>
101148 <button
102149 type="button"