diff --git a/app/api/rooms/[roomId]/members/route.ts b/app/api/rooms/[roomId]/members/route.ts index c46c494..521dff4 100644 --- a/app/api/rooms/[roomId]/members/route.ts +++ b/app/api/rooms/[roomId]/members/route.ts @@ -7,6 +7,14 @@ type MemberRow = { joined_at: string } +type ProfileRow = { + id: string + display_name: string | null + username: string | null + wallet_address: string | null + avatar_url: string | null +} + export async function GET( request: NextRequest, { params }: { params: Promise<{ roomId: string }> } @@ -34,11 +42,37 @@ export async function GET( if (error) throw error + const memberIds = (members ?? []).map((member) => member.user_id) + let profiles: ProfileRow[] = [] + let profilesError: unknown = null + + if (memberIds.length) { + const profileResult = await supabase + .from("profiles") + .select("id, display_name, username, wallet_address, avatar_url") + .in("id", memberIds) + + profiles = (profileResult.data ?? []) as ProfileRow[] + profilesError = profileResult.error + } + + if (profilesError) { + console.warn("[rooms/members] profile lookup failed:", profilesError) + } + + const profileById = new Map(profiles.map((profile) => [profile.id, profile] as const)) + return NextResponse.json({ members: ((members ?? []) as MemberRow[]).map((m) => ({ user_id: m.user_id, joined_at: m.joined_at, is_current_user: m.user_id === user.id, + display_name: + profileById.get(m.user_id)?.display_name ?? + profileById.get(m.user_id)?.username ?? + null, + wallet_address: profileById.get(m.user_id)?.wallet_address ?? null, + avatar_url: profileById.get(m.user_id)?.avatar_url ?? null, })), }) } catch (error) { diff --git a/components/presence-indicator.tsx b/components/presence-indicator.tsx index ffe095d..100c785 100644 --- a/components/presence-indicator.tsx +++ b/components/presence-indicator.tsx @@ -2,7 +2,7 @@ import { cn } from "@/lib/utils" -export type PresenceStatus = "online" | "offline" | "recently_active" +export type PresenceStatus = "online" | "offline" | "recently_active" | "away" interface PresenceIndicatorProps { status: PresenceStatus @@ -11,6 +11,7 @@ interface PresenceIndicatorProps { } export function PresenceIndicator({ status, className, showText = false }: PresenceIndicatorProps) { + const normalizedStatus = status === "away" ? "recently_active" : status const statusColors = { online: "bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.4)]", offline: "bg-muted-foreground/40", @@ -26,17 +27,17 @@ export function PresenceIndicator({ status, className, showText = false }: Prese return (
{showText && ( - {statusLabels[status]} + {statusLabels[normalizedStatus]} )}
diff --git a/components/room-members-dialog.tsx b/components/room-members-dialog.tsx index 8ebfc78..ded8480 100644 --- a/components/room-members-dialog.tsx +++ b/components/room-members-dialog.tsx @@ -1,15 +1,21 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import * as Dialog from "@radix-ui/react-dialog" import { Users, UserMinus, Loader2 } from "lucide-react" import toast from "react-hot-toast" import { cn } from "@/lib/utils" +import { PresenceIndicator } from "@/components/presence-indicator" +import { WalletAddress } from "@/components/wallet-address" +import { useWebSocket, useWebSocketMessage, useWebSocketSend } from "@/lib/websocket/hooks" type Member = { user_id: string joined_at: string is_current_user: boolean + display_name: string | null + wallet_address: string | null + avatar_url: string | null } type VotesByTarget = Record< @@ -17,6 +23,20 @@ type VotesByTarget = Record< { count: number; voters: string[] } > +type MemberPresence = "online" | "offline" | "away" + +type PresenceSnapshotPayload = { + users?: Array<{ + userId: string + status: MemberPresence + }> +} + +type PresenceUpdatePayload = { + userId: string + status: MemberPresence +} + type RoomMembersDialogProps = { roomId: string open: boolean @@ -34,6 +54,9 @@ export function RoomMembersDialog({ const [votes, setVotes] = useState({}) const [loading, setLoading] = useState(false) const [votingId, setVotingId] = useState(null) + const [presenceByUserId, setPresenceByUserId] = useState>({}) + const { connectionState } = useWebSocket({ autoConnect: false }) + const { requestPresenceSnapshot } = useWebSocketSend() const fetchData = async () => { if (!roomId) return @@ -45,9 +68,18 @@ export function RoomMembersDialog({ ]) if (membersRes.ok) { const data = await membersRes.json() - setMembers(data.members ?? []) + const nextMembers: Member[] = data.members ?? [] + setMembers(nextMembers) + setPresenceByUserId((prev) => { + const next: Record = {} + for (const member of nextMembers) { + next[member.user_id] = prev[member.user_id] ?? "offline" + } + return next + }) } else { setMembers([]) + setPresenceByUserId({}) } if (votesRes.ok) { const data = await votesRes.json() @@ -59,15 +91,65 @@ export function RoomMembersDialog({ toast.error("Failed to load room members") setMembers([]) setVotes({}) + setPresenceByUserId({}) } finally { setLoading(false) } } + const mergedMembers = useMemo(() => { + const statusRank: Record = { + online: 0, + away: 1, + offline: 2, + } + + return [...members] + .map((member) => ({ + ...member, + presence: presenceByUserId[member.user_id] ?? "offline", + })) + .sort((left, right) => { + const statusDiff = + statusRank[left.presence] - statusRank[right.presence] + if (statusDiff !== 0) return statusDiff + return left.joined_at.localeCompare(right.joined_at) + }) + }, [members, presenceByUserId]) + useEffect(() => { if (open && roomId) fetchData() }, [open, roomId]) + useEffect(() => { + if (open && roomId && connectionState === "connected") { + requestPresenceSnapshot() + } + }, [open, roomId, connectionState, requestPresenceSnapshot]) + + useWebSocketMessage("presence_snapshot", (msg) => { + const payload = msg.payload as PresenceSnapshotPayload + if (!payload.users?.length) return + + setPresenceByUserId((prev) => { + const next = { ...prev } + for (const user of payload.users ?? []) { + next[user.userId] = user.status + } + return next + }) + }) + + useWebSocketMessage("presence_update", (msg) => { + const payload = msg.payload as PresenceUpdatePayload + if (!payload.userId) return + + setPresenceByUserId((prev) => ({ + ...prev, + [payload.userId]: payload.status, + })) + }) + const handleVoteRemove = async (targetUserId: string) => { setVotingId(targetUserId) try { @@ -126,20 +208,40 @@ export function RoomMembersDialog({

) : (
    - {members.map((m) => { + {mergedMembers.map((m) => { const voteCount = votes[m.user_id]?.count ?? 0 const isVoting = votingId === m.user_id return (
  • - - {displayId(m.user_id)} - {m.is_current_user && ( - (you) - )} - +
    + +
    + + {m.display_name && ( +

    + {m.display_name} + {m.is_current_user && " • you"} +

    + )} + {!m.display_name && m.is_current_user && ( +

    + you +

    + )} +
    +
    {!m.is_current_user && (