356 lines
No EOL
13 KiB
TypeScript
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>
|
|
);
|
|
} |