sipher/src/components/ui/dm/DmChannelContent.tsx
2026-01-08 12:12:21 -03:00

356 lines
No EOL
13 KiB
TypeScript

import { useOlmContext } from "@/contexts/olm-context";
import { useSocketContext } from "@/contexts/socket-context";
import { clearUnread, db, sendMessage } from "@/lib/db";
import { useLiveQuery } from "dexie-react-hooks";
import { KeyRound } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Avatar, AvatarFallback, AvatarImage } from "../avatar";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../dialog";
import { Input } from "../input";
interface DMChannelContentProps {
userId: string
channelId: string
participantDetails: SiPher.ParticipantDetail[]
}
export default function DMChannelContent(
{
userId,
channelId,
participantDetails,
}: DMChannelContentProps
) {
const otherUser = useMemo(() => {
return participantDetails.find((p) => p.id !== userId);
}, [participantDetails, userId]);
const [olmSession, setOlmSession] = useState<Olm.Session | null>(null);
const [sessionError, setSessionError] = useState<string | null>(null);
const [messageInput, setMessageInput] = useState("");
const [messageLimit, setMessageLimit] = useState(50);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const { sendMessage: sendMessageToServer } = useSocketContext();
const { olmAccount, password, isReady, getSession } = useOlmContext();
const messagesEndRef = React.useRef<HTMLDivElement>(null);
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const prevScrollHeightRef = React.useRef<number>(0);
// Get total message count
const totalMessageCount = useLiveQuery(
() => db.messages.where("channelId").equals(channelId).count(),
[channelId]
) ?? 0;
// Get messages from the local database with pagination
const allMessages = useLiveQuery(
() => db.messages.where("channelId").equals(channelId).sortBy("timestamp"),
[channelId]
) ?? [];
// Take only the most recent messages based on limit
const messages = useMemo(() => {
return allMessages.slice(-messageLimit);
}, [allMessages, messageLimit]);
const hasMoreMessages = messages.length < totalMessageCount;
// Reset message limit when channel changes
useEffect(() => {
setMessageLimit(50);
}, [channelId]);
// Handle scroll to load more messages
const handleScroll = React.useCallback(async (e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const scrollTop = target.scrollTop;
// If scrolled near the top (within 100px) and there are more messages
if (scrollTop < 100 && hasMoreMessages && !isLoadingMore) {
setIsLoadingMore(true);
// Save current scroll height
prevScrollHeightRef.current = target.scrollHeight;
// Load 50 more messages
await new Promise(resolve => setTimeout(resolve, 100)); // Small delay to avoid rapid firing
setMessageLimit(prev => prev + 50);
setIsLoadingMore(false);
}
}, [hasMoreMessages, isLoadingMore]);
// Preserve scroll position after loading more messages
useEffect(() => {
if (prevScrollHeightRef.current > 0 && scrollContainerRef.current) {
const newScrollHeight = scrollContainerRef.current.scrollHeight;
const scrollDiff = newScrollHeight - prevScrollHeightRef.current;
scrollContainerRef.current.scrollTop += scrollDiff;
prevScrollHeightRef.current = 0;
}
}, [messages.length]);
// Scroll to bottom on initial load / channel change (instant)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto" });
}, [channelId]);
// Auto-scroll to bottom when new messages arrive (smooth)
useEffect(() => {
if (messages.length > 0 && scrollContainerRef.current) {
const container = scrollContainerRef.current;
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 200;
// Only auto-scroll if user is near the bottom
if (isNearBottom) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}
}, [allMessages.length]);
// Clear unread count when entering the channel
useEffect(() => {
clearUnread(channelId);
console.debug("[DMChannelContent] Cleared unread count for channel", channelId);
}, [channelId]);
// Guard: Check if otherUser exists
if (!otherUser) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-muted-foreground">Loading participant information...</p>
</div>
</div>
);
}
// Get or create session when OLM is ready and we have the other user's account
useEffect(() => {
const loadSession = async () => {
if (!isReady || !olmAccount || !otherUser || !otherUser.olmAccount) {
return;
}
setSessionError(null);
try {
const session = await getSession(otherUser.id, otherUser.olmAccount);
if (session) {
setOlmSession(session);
} else {
setSessionError("Failed to create encryption session");
}
} catch (err) {
console.error("[DMChannelContent] Failed to get session:", err);
setSessionError(err instanceof Error ? err.message : "Unknown error");
}
};
loadSession();
}, [isReady, olmAccount, otherUser, password,])
// Check if OLM is ready
if (!isReady || !olmAccount) {
return <div>Loading encryption keys...</div>
}
// Get the other user's id key and OT keys from the server to be prepared for messaging
if (!otherUser.olmAccount) {
return (
<Dialog open={true} onOpenChange={() => { }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-accent/20">
<KeyRound className="h-8 w-8 text-accent-foreground" />
</div>
<DialogTitle className="text-2xl text-center">Encryption Setup Required</DialogTitle>
</DialogHeader>
<DialogDescription className="space-y-4 pt-2">
<div className="rounded-lg bg-card border border-border p-4">
<p className="text-sm text-card-foreground/90 leading-relaxed">
<span className="font-semibold text-card-foreground">{otherUser.name}</span> hasn't set up end-to-end encryption yet.
</p>
</div>
<div className="space-y-2 text-sm text-muted-foreground">
<p className="flex items-start gap-2">
<span className="text-accent-foreground/60 mt-0.5">•</span>
<span>They need to log in and complete the encryption setup</span>
</p>
<p className="flex items-start gap-2">
<span className="text-accent-foreground/60 mt-0.5">•</span>
<span>Once complete, you'll be able to send encrypted messages</span>
</p>
</div>
<p className="text-xs text-center text-muted-foreground/70 pt-2">
🔒 All messages are end-to-end encrypted for your privacy
</p>
</DialogDescription>
</DialogContent>
</Dialog>
)
}
// Show error if session creation failed
if (sessionError) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-destructive">Failed to create encryption session</p>
<p className="text-sm text-muted-foreground">{sessionError}</p>
</div>
</div>
);
}
// Wait for session to be established
if (!olmSession) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-sm text-muted-foreground">Establishing secure connection...</p>
</div>
</div>
);
}
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex-1 min-h-0 overflow-hidden">
<div
ref={scrollContainerRef}
className="h-full overflow-y-auto"
onScroll={handleScroll}
>
<div className="pt-4">
{/* Load more indicator */}
{hasMoreMessages && (
<div className="flex justify-center py-4">
{isLoadingMore ? (
<div className="flex items-center gap-2 text-muted-foreground">
<div className="w-4 h-4 border-2 border-muted-foreground/30 border-t-muted-foreground rounded-full animate-spin" />
<span className="text-xs">Loading older messages...</span>
</div>
) : (
<button
onClick={() => {
setIsLoadingMore(true);
prevScrollHeightRef.current = scrollContainerRef.current?.scrollHeight ?? 0;
setTimeout(() => {
setMessageLimit(prev => prev + 50);
setIsLoadingMore(false);
}, 100);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-muted/50"
>
Load more messages
</button>
)}
</div>
)}
{messages.map((msg, index) => {
const sender = participantDetails.find((p) => p.id === msg.fromUserId);
const selfDetail = participantDetails.find((p) => p.id === userId);
const isSelf = msg.fromUserId === userId;
const displayName = isSelf ? selfDetail?.displayUsername ?? selfDetail?.username ?? selfDetail?.name ?? "You" : (sender?.displayUsername ?? sender?.username ?? sender?.name ?? "Unknown");
const timeLabel = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
// Check if this message is from the same user as the previous one within 5 minutes
const prevMsg = index > 0 ? messages[index - 1] : null;
const isGrouped = prevMsg &&
prevMsg.fromUserId === msg.fromUserId &&
msg.timestamp && prevMsg.timestamp &&
(msg.timestamp - prevMsg.timestamp) < 5 * 60 * 1000;
return (
<div
key={msg.id}
className="group relative px-4 py-0.5 hover:bg-muted/50 transition-colors duration-100"
>
{!isGrouped ? (
// Full message with avatar and header
<div className="flex gap-4 mt-[17px]">
<Avatar className="w-10 h-10 shrink-0 mt-0.5">
<AvatarImage src={sender?.image ?? undefined} alt={displayName} />
<AvatarFallback className="text-xs">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 pt-0.5">
<div className="flex items-baseline gap-2 leading-snug">
<span className="font-semibold text-[15px] text-foreground hover:underline cursor-pointer">
{displayName}
</span>
<span className="text-[11px] text-muted-foreground font-medium">
{timeLabel}
</span>
</div>
<div className="text-[15px] leading-[1.375rem] text-foreground mt-0.5 wrap-break-word">
{msg.content}
</div>
</div>
</div>
) : (
// Compact message without avatar (grouped)
<div className="flex gap-4 leading-[1.375rem]">
<div className="w-10 shrink-0 flex items-start justify-end pt-0.5">
<span className="text-[10px] text-transparent group-hover:text-muted-foreground transition-colors duration-100 font-medium">
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
<div className="flex-1 min-w-0 text-[15px] leading-[1.375rem] text-foreground wrap-break-word">
{msg.content}
</div>
</div>
)}
</div>
);
})}
{/* Invisible element for auto-scrolling */}
<div ref={messagesEndRef} />
</div>
</div>
</div>
{/* Message input */}
<div className="shrink-0 px-4 pb-6 pt-2">
<Input
className="h-11 rounded-lg bg-muted border-0 focus-visible:ring-0 focus-visible:ring-offset-0 px-4 text-[15px]"
placeholder={`Message @${otherUser.username ?? otherUser.name}`}
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
onKeyDown={async (e) => {
if (e.key === 'Enter' && !e.shiftKey && messageInput.trim() && password) {
e.preventDefault();
try {
const messageId = await sendMessage({
channelId,
content: messageInput,
fromUserId: userId,
to: otherUser.id,
timestamp: Date.now(),
status: "sent",
}, olmSession, sendMessageToServer, {
userId,
recipientId: otherUser.id,
password,
});
if (messageId) {
setMessageInput("");
}
} catch (error) {
console.error("[DMChannelContent] Failed to send message:", error);
toast.error("Failed to send message: " + (error instanceof Error ? error.message : "Unknown error"));
}
}
}}
/>
</div>
</div>
);
}