Skip to content
Merged
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
195 changes: 195 additions & 0 deletions app/api/messages/[messageId]/read/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { createClient } from "@/lib/supabase/server"
import { type NextRequest, NextResponse } from "next/server"

/**
* POST /api/messages/[messageId]/read
* Mark a message as read by the current user
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ messageId: string }> },
) {
try {
const { messageId } = await params
const supabase = await createClient()

const {
data: { user },
} = await supabase.auth.getUser()

if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}

if (!messageId) {
return NextResponse.json({ error: "Message ID is required" }, { status: 400 })
}

// Verify message exists and user has access to the room
const { data: message, error: messageError } = await supabase
.from("messages")
.select("id, room_id")
.eq("id", messageId)
.single()

if (messageError || !message) {
return NextResponse.json({ error: "Message not found" }, { status: 404 })
}

// Verify user is a member of the room
const { data: membership, error: memberError } = await supabase
.from("room_members")
.select("id, removed_at")
.eq("room_id", message.room_id)
.eq("user_id", user.id)
.single()

if (memberError || !membership) {
return NextResponse.json(
{ error: "You are not a member of this room" },
{ status: 403 },
)
}

if (membership.removed_at) {
return NextResponse.json(
{ error: "You have been removed from this room" },
{ status: 403 },
)
}

// Insert or update the read receipt
const { data: readReceipt, error: insertError } = await supabase
.from("message_reads")
.upsert(
{
message_id: messageId,
user_id: user.id,
read_at: new Date().toISOString(),
},
{ onConflict: "message_id,user_id" },
)
.select()

if (insertError) {
console.error("[read-receipt] Error marking message as read:", insertError)
throw insertError
}

return NextResponse.json(
{
success: true,
readReceipt: readReceipt?.[0],
},
{ status: 200 },
)
} catch (error) {
console.error("[read-receipt] POST error:", error)
return NextResponse.json(
{ error: "Failed to mark message as read" },
{ status: 500 },
)
}
}

/**
* GET /api/messages/[messageId]/read
* Get all read receipts for a message (who has read it and when)
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ messageId: string }> },
) {
try {
const { messageId } = await params
const supabase = await createClient()

const {
data: { user },
} = await supabase.auth.getUser()

if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}

if (!messageId) {
return NextResponse.json({ error: "Message ID is required" }, { status: 400 })
}

// Verify message exists and user has access to the room
const { data: message, error: messageError } = await supabase
.from("messages")
.select("id, room_id, user_id")
.eq("id", messageId)
.single()

if (messageError || !message) {
return NextResponse.json({ error: "Message not found" }, { status: 404 })
}

// Verify user is a member of the room
const { data: membership, error: memberError } = await supabase
.from("room_members")
.select("id, removed_at")
.eq("room_id", message.room_id)
.eq("user_id", user.id)
.single()

if (memberError || !membership) {
return NextResponse.json(
{ error: "You are not a member of this room" },
{ status: 403 },
)
}

if (membership.removed_at) {
return NextResponse.json(
{ error: "You have been removed from this room" },
{ status: 403 },
)
}

// Get all read receipts for this message with user info
const { data: readReceipts, error: readError } = await supabase
.from("message_reads")
.select(
`
id,
user_id,
read_at,
profiles:user_id (
id,
display_name,
avatar_url,
wallet_address
)
`,
)
.eq("message_id", messageId)
.order("read_at", { ascending: true })

if (readError) {
console.error("[read-receipt] Error fetching read receipts:", readError)
throw readError
}

const readCount = readReceipts?.length || 0
const senderRead = readReceipts?.some((r) => r.user_id === message.user_id) || false

return NextResponse.json(
{
messageId,
readCount,
senderRead,
readReceipts: readReceipts || [],
},
{ status: 200 },
)
} catch (error) {
console.error("[read-receipt] GET error:", error)
return NextResponse.json(
{ error: "Failed to fetch read receipts" },
{ status: 500 },
)
}
}
106 changes: 106 additions & 0 deletions components/MessageReadReceiptIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React, { useState, useEffect } from 'react';
import { useMessageReadReceipts, type ReadReceipt } from '@/src/hooks/useMessageReadReceipts';

export interface MessageReadReceiptIndicatorProps {
messageId: string;
roomId: string;
senderId: string;
currentUserId: string;
isOwn: boolean;
disabled?: boolean;
}

export function MessageReadReceiptIndicator({
messageId,
roomId,
senderId,
currentUserId,
isOwn,
disabled = false,
}: MessageReadReceiptIndicatorProps) {
const [showDetails, setShowDetails] = useState(false);
const { readStatus, fetchReadReceipts } = useMessageReadReceipts({
roomId,
userId: currentUserId,
enabled: !disabled
});

const messageReadData = readStatus[messageId];

useEffect(() => {
// Only fetch for own messages (sender can see read receipts)
if (isOwn && !messageReadData && !disabled) {
fetchReadReceipts(messageId);
}
}, [messageId, isOwn, messageReadData, disabled, fetchReadReceipts]);

if (!isOwn || disabled || !messageReadData) {
return null;
}

const { readCount, readReceipts } = messageReadData;

if (readCount === 0) {
return (
<div className="flex items-center gap-1 text-xs text-gray-400" title="No read receipts">
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
);
}

return (
<div className="relative">
<button
onClick={() => setShowDetails(!showDetails)}
className="flex items-center gap-1 text-xs text-blue-500 hover:text-blue-600 cursor-pointer"
title={`Read by ${readCount} ${readCount === 1 ? 'person' : 'people'}`}
>
<svg
className="w-3 h-3"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
</svg>
<span>{readCount}</span>
</button>

{showDetails && readReceipts.length > 0 && (
<div className="absolute bottom-full right-0 mb-2 bg-gray-900 text-white rounded-lg shadow-lg p-2 z-10 min-w-max text-xs">
<div className="font-semibold mb-1 text-gray-300">Read by:</div>
<div className="space-y-1">
{readReceipts.map((receipt: ReadReceipt) => (
<div key={receipt.userId} className="flex items-center gap-2">
{receipt.avatar_url && (
<img
src={receipt.avatar_url}
alt={receipt.displayName}
className="w-4 h-4 rounded-full"
/>
)}
<div>
<div className="text-gray-100">{receipt.displayName}</div>
<div className="text-gray-400">
{new Date(receipt.readAt).toLocaleTimeString()}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
32 changes: 32 additions & 0 deletions lib/websocket/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,38 @@ export function createWebSocketServer(port: number = 3001) {
break
}

case "message_read": {
const readRoomId = message.payload.roomId
const readMessageId = message.payload.messageId
const readUserId = connection.userId

if (!readUserId) {
ws.send(
JSON.stringify({
type: "error",
payload: { message: "Not authenticated" },
timestamp: Date.now(),
}),
)
break
}

// Broadcast read receipt to the room
// The actual read receipt will be persisted to the database via the REST API
broadcastToRoom(readRoomId, {
type: "message_read_receipt",
payload: {
messageId: readMessageId,
userId: readUserId,
displayName: connection.user?.displayName,
readAt: Date.now(),
roomId: readRoomId,
},
timestamp: Date.now(),
})
break
}

case "typing": {
const typingRoomId = message.payload.roomId

Expand Down
Loading
Loading