import type { RawMessage } from "../values"; import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { type SelectedUser, useUserFields } from "@tensamin/user/context"; import { Avatar, AvatarFallback, AvatarImage, Button, Card, cn, Skeleton, } from "@methanium/ui"; import MessageContextMenu from "./messageContextMenu"; import Media from "./media/media"; import { useStorage } from "@tensamin/storage/context"; import { useMTP } from "@tensamin/mtp"; import { getMessage, useChat } from "../context"; import { decryptChatText, encryptChatText } from "@tensamin/crypto/chatSecret"; import { log, toast } from "@tensamin/shared/log"; import { Emoji, Input, normalizeShortcode, Text } from "@methanium/ui/markdown"; import { useRecordEmojiUse } from "./emoji/emojiRanks"; import ReplyBox from "./replyBox"; import { useHotkey } from "@tensamin/hotkeys"; import { cancelMessageEditHotkey } from "../hotkeys"; function MessageComponent({ editing, grouped, message, onSetEditing, user, }: { editing: boolean; grouped: boolean; message: RawMessage & { failed?: boolean; decryptionFailed?: boolean; }; onSetEditing: (editing: boolean) => void; user: SelectedUser | null; }) { const actuallyFailed = (message.failed && message.MessageState === "awaiting") || message.decryptionFailed; // Fade-in const [hasFadedIn, setHasFadedIn] = useState(false); const opacityClass = !hasFadedIn ? "opacity-0" : message.MessageState === "awaiting" ? "opacity-50" : "opacity-100"; const messageRef = useRef(null); useEffect(() => { const timeout = window.setTimeout(() => { setHasFadedIn(true); }, 100); return () => { window.clearTimeout(timeout); }; }, []); // Message states const { load } = useStorage(); const { send } = useMTP(); const [ownId, setOwnId] = useState(0); useEffect(() => { load("user_id").then(setOwnId); }, [load]); const messageStateReadUpdate = useCallback(async () => { const readConfirmations = await load("settings.read_confirmations"); if (readConfirmations) { if (!user?.UserId) return; await send("MessageState", { ChatPartnerId: user?.UserId, SendTime: message.SendTime, MessageState: "read", }); } else { await send("MessageState", { ChatPartnerId: user?.UserId, SendTime: message.SendTime, MessageState: "received", }); } }, [load, message.SendTime, user?.UserId, send]); useEffect(() => { if (message.SenderId === ownId || ownId === 0) return; let cancelled = false; async function run() { const receiveConfirmations = await load("settings.receive_confirmations"); if (cancelled) return; switch (message.MessageState) { case "sent": if (receiveConfirmations) { messageStateReadUpdate(); } break; case "received": messageStateReadUpdate(); break; default: break; } } run(); return () => { cancelled = true; }; }, [ message.MessageState, load, messageStateReadUpdate, message.SenderId, ownId, ]); // Check if message is a url const [isValidURL, setIsValidURL] = useState(false); useEffect(() => { try { if (message.Content.split(" ").length > 1) throw new Error(); new URL(message.Content); setIsValidURL(true); } catch { setIsValidURL(false); } }, [message.Content]); // Message editing const { addReaction, chatSecret, editMessage, userId, removeReaction, replyTo, } = useChat(); const [replyMessage, setReplyMessage] = useState(null); const { data: replyUser } = useUserFields(replyMessage?.SenderId ?? null, [ "Avatar", "Display", ]); useEffect(() => { if (!message.ReplyId || !ownId || !chatSecret) { setReplyMessage(null); return; } let active = true; void getMessage({ sendTime: message.ReplyId, ownId, chatPartnerId: userId, send, }) .then(async (reply) => { const Content = await decryptChatText(chatSecret, reply.Content); if (!active) return; setReplyMessage({ ...reply, Content }); }) .catch((err) => { if (!active) return; setReplyMessage(null); log(1, "chat", "red", "Failed to get replied-to message", err); }); return () => { active = false; }; }, [chatSecret, message.ReplyId, ownId, send, userId]); const recordUse = useRecordEmojiUse(); const editingRef = useRef(editing); const onSetEditingRef = useRef(onSetEditing); useEffect(() => { editingRef.current = editing; onSetEditingRef.current = onSetEditing; }, [editing, onSetEditing]); useEffect( () => () => { if (editingRef.current) onSetEditingRef.current(false); }, [], ); const [editDraft, setEditDraft] = useState(message.Content); const cancelEditing = useCallback(() => { setEditDraft(message.Content); onSetEditing(false); }, [message.Content, onSetEditing]); useHotkey(cancelMessageEditHotkey, cancelEditing, { enabled: editing, target: messageRef, }); useEffect(() => { if (!editing) { setEditDraft(message.Content); } }, [editing, message.Content]); const submitEditMessage = useCallback( async (newContent: string) => { if (!chatSecret) return; const encryptedContent = await encryptChatText( chatSecret, newContent, ).catch((err) => { toast("error", "Failed to encrypt edit", String(err)); }); if (!encryptedContent) return; const previousContent = message.Content; editMessage(message.SendTime, { Content: newContent, Edited: true }); try { const response = await send("MessageEdit", { ChatPartnerId: userId, Content: encryptedContent, SendTime: message.SendTime, }); if (response.type.startsWith("Error")) { throw new Error(response.type); } } catch (err) { editMessage(message.SendTime, { Content: previousContent, Edited: message.Edited ?? false, }); log(1, "chat", "red", "Failed to edit message", err); toast("error", "Failed to edit message", String(err)); } }, [ chatSecret, editMessage, userId, message.Content, message.Edited, message.SendTime, send, ], ); const groupedReactions = Object.entries( (message.Reactions ?? []).reduce< Record >((groups, item) => { const reaction = normalizeShortcode(item.Reaction); if (!reaction) return groups; const group = groups[reaction] ?? { count: 0, reactedByMe: false, }; group.count += 1; group.reactedByMe ||= item.SenderId === ownId; groups[reaction] = group; return groups; }, {}), ); function toggleReaction(reaction: string) { const reactedByMe = message.Reactions?.some( (item) => normalizeShortcode(item.Reaction) === reaction && item.SenderId === ownId, ); if (reactedByMe) { return removeReaction(message.SendTime, reaction); } recordUse(reaction); return addReaction(message.SendTime, reaction); } return (
{/* reply */} {replyMessage && replyUser && ( )} {user && message.Content ? ( <>
<> {grouped ? (

{new Date(message.SendTime).toLocaleString([], { hour: "2-digit", minute: "2-digit", })}

) : ( {user.Display.slice(0, 2).toUpperCase()} )}
{!grouped && (

{user.Display}

{new Date(message.SendTime).toLocaleString([], { hour: "2-digit", minute: "2-digit", })}

{message.SenderId !== ownId ? null : message.MessageState === "read" ? ( ) : message.MessageState === "received" ? ( ) : message.MessageState === "sent" ? ( ) : message.MessageState === "sending" ? ( ) : null} {actuallyFailed && ( )}
)} {editing ? (
{ if (!editDraft.trim()) return; if (editDraft !== message.Content) { void submitEditMessage(editDraft); } onSetEditing(false); }} />
) : (
{isValidURL ? ( ) : (
)}
)} {groupedReactions.length > 0 && (
{groupedReactions.map( ([reaction, { count, reactedByMe }]) => ( ), )}
)}
) : ( )}
); } export default memo(MessageComponent, (prev, next) => { return ( prev.message.SendTime === next.message.SendTime && prev.editing === next.editing && prev.message.Content === next.message.Content && prev.message.SenderId === next.message.SenderId && prev.message.ReplyId === next.message.ReplyId && prev.message.MessageState === next.message.MessageState && prev.message.failed === next.message.failed && prev.message.decryptionFailed === next.message.decryptionFailed && JSON.stringify(prev.message.Reactions) === JSON.stringify(next.message.Reactions) && prev.grouped === next.grouped && prev.user === next.user ); });