Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions app/api/rooms/[roomId]/members/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> }
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 5 additions & 4 deletions components/presence-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -26,17 +27,17 @@ export function PresenceIndicator({ status, className, showText = false }: Prese
return (
<div
className={cn("flex items-center gap-2", className)}
title={statusLabels[status]}
title={statusLabels[normalizedStatus]}
>
<span
className={cn(
"w-2.5 h-2.5 rounded-full ring-2 ring-background transition-all duration-300",
statusColors[status]
statusColors[normalizedStatus]
)}
/>
{showText && (
<span className="text-xs text-muted-foreground font-medium">
{statusLabels[status]}
{statusLabels[normalizedStatus]}
</span>
)}
</div>
Expand Down
122 changes: 112 additions & 10 deletions components/room-members-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,42 @@
"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<
string,
{ 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
Expand All @@ -34,6 +54,9 @@ export function RoomMembersDialog({
const [votes, setVotes] = useState<VotesByTarget>({})
const [loading, setLoading] = useState(false)
const [votingId, setVotingId] = useState<string | null>(null)
const [presenceByUserId, setPresenceByUserId] = useState<Record<string, MemberPresence>>({})
const { connectionState } = useWebSocket({ autoConnect: false })
const { requestPresenceSnapshot } = useWebSocketSend()

const fetchData = async () => {
if (!roomId) return
Expand All @@ -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<string, MemberPresence> = {}
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()
Expand All @@ -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<MemberPresence, number> = {
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 {
Expand Down Expand Up @@ -126,20 +208,40 @@ export function RoomMembersDialog({
</p>
) : (
<ul className="space-y-2 max-h-64 overflow-y-auto">
{members.map((m) => {
{mergedMembers.map((m) => {
const voteCount = votes[m.user_id]?.count ?? 0
const isVoting = votingId === m.user_id
return (
<li
key={m.user_id}
className="flex items-center justify-between gap-2 rounded-xl bg-[#181822] border border-border/60 px-3 py-2"
className="flex items-center justify-between gap-3 rounded-xl bg-[#181822] border border-border/60 px-3 py-2"
>
<span className="text-sm font-mono truncate" title={m.user_id}>
{displayId(m.user_id)}
{m.is_current_user && (
<span className="ml-2 text-[10px] text-primary">(you)</span>
)}
</span>
<div className="min-w-0 flex items-center gap-2">
<PresenceIndicator
status={m.presence}
showText
className="shrink-0"
/>
<div className="min-w-0">
<WalletAddress
address={m.wallet_address}
fallback={m.display_name || displayId(m.user_id)}
className="max-w-full"
addressClassName="text-sm"
/>
{m.display_name && (
<p className="truncate text-[11px] text-muted-foreground">
{m.display_name}
{m.is_current_user && " • you"}
</p>
)}
{!m.display_name && m.is_current_user && (
<p className="truncate text-[11px] text-muted-foreground">
you
</p>
)}
</div>
</div>
{!m.is_current_user && (
<button
type="button"
Expand Down
7 changes: 7 additions & 0 deletions lib/websocket/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,13 @@ export class WebSocketClient {
timestamp: Date.now(),
});

requestPresenceSnapshot = () =>
this.send({
type: "request_presence_snapshot",
payload: {},
timestamp: Date.now(),
});

/**
* FIX FOR JOB 72926850335:
* Add missing method to acknowledge message delivery
Expand Down
3 changes: 3 additions & 0 deletions lib/websocket/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ export function useWebSocketSend() {
notifyStopTyping: useCallback((roomId: string) => {
client.current.notifyStopTyping(roomId);
}, []),
requestPresenceSnapshot: useCallback(() => {
client.current.requestPresenceSnapshot();
}, []),
/**
* Resolves Job 72843331390 by calling the newly added method in WebSocketClient.
*/
Expand Down
1 change: 1 addition & 0 deletions lib/websocket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export {
sendWebSocketMessage,
notifyWebSocketTyping,
notifyWebSocketStopTyping,
requestWebSocketPresenceSnapshot,
notifyWebSocketWalletEvent,
getWebSocketStatus,
isWebSocketConnected,
Expand Down
Loading