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 && (