736 lines
25 KiB
TypeScript
736 lines
25 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* /chats/[id] — a single thread.
|
|
*
|
|
* History pages upward from the newest message using `before_seq` (a keyset
|
|
* cursor, not an offset), so paging stays stable while new messages keep
|
|
* arriving at the top.
|
|
*
|
|
* Live updates come from the thread's own private channel. Subscribing is
|
|
* gated by the BFF: it only signs `private-conversation.{id}` for an actual
|
|
* participant, so an unauthorised subscription is refused at the server.
|
|
*
|
|
* Composer, attachments and the message context menu land in later phases —
|
|
* this screen is read + receipts.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { ArrowLeft, BellOff } from "lucide-react";
|
|
import { useAuth } from "@/components/auth/auth-provider";
|
|
import { useAuthGate } from "@/components/auth/auth-gate";
|
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
|
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
|
|
import { MessageList } from "@/components/chat/message-list";
|
|
import { ChatComposer } from "@/components/chat/chat-composer";
|
|
import {
|
|
MessageMenu,
|
|
type MenuPoint,
|
|
} from "@/components/chat/message-menu";
|
|
import { ForwardDialog } from "@/components/chat/forward-dialog";
|
|
import { ReactionsDrawer } from "@/components/chat/reactions-drawer";
|
|
import { toast } from "sonner";
|
|
import { channelAuthorizer } from "@/lib/realtime";
|
|
import { cn } from "@/lib/utils";
|
|
import { useVisualViewport } from "@/lib/use-visual-viewport";
|
|
import { ConfirmModal } from "@/components/ui/confirm-modal";
|
|
import { useNotifications } from "@/components/notifications/notifications-provider";
|
|
import {
|
|
chat,
|
|
conversationTitle,
|
|
groupAvatarUrl,
|
|
isMuted,
|
|
senderName,
|
|
senderPeer,
|
|
type ChatMessage,
|
|
type ConversationDetail,
|
|
type Receipts,
|
|
type UploadedAttachment,
|
|
} from "@/lib/chat";
|
|
|
|
/** How long a typing indicator survives its last ping. */
|
|
const TYPING_TTL_MS = 5000;
|
|
/** How often we tell the thread we are typing, while typing. */
|
|
const TYPING_PING_MS = 3000;
|
|
|
|
const PAGE_SIZE = 30;
|
|
|
|
export default function ChatThreadPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const { user, loading } = useAuth();
|
|
const { requireAuth } = useAuthGate();
|
|
const router = useRouter();
|
|
|
|
const [meta, setMeta] = useState<ConversationDetail | null>(null);
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [receipts, setReceipts] = useState<Receipts | null>(null);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
|
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
|
|
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
|
|
const [forwarding, setForwarding] = useState<ChatMessage | null>(null);
|
|
// Non-null asks before deleting for everyone. Deleting only for yourself is
|
|
// reversible by the others still having it, so it goes straight through.
|
|
const [confirmDelete, setConfirmDelete] = useState<ChatMessage | null>(null);
|
|
const [starredIds, setStarredIds] = useState<Set<string>>(new Set());
|
|
// user id → when they last typed. Kept as timestamps rather than a boolean
|
|
// so the indicator expires on its own: there is no "stopped typing" event,
|
|
// and inventing one would still be lost on a dropped connection.
|
|
const [typingAt, setTypingAt] = useState<Record<string, number>>({});
|
|
|
|
// While the keyboard is up the thread is sized from the visual viewport
|
|
// instead of dvh — see useVisualViewport for why dvh can't do this on iOS.
|
|
// Declared with the other hooks: the early return below means anything
|
|
// placed after it would run conditionally.
|
|
const { refreshChatUnread } = useNotifications();
|
|
const viewport = useVisualViewport();
|
|
const keyboardOpen = viewport?.keyboardOpen ?? false;
|
|
// Guards against two loads racing (scroll handler + the short-page effect).
|
|
const loadingRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (loading || user) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
const ok = await requireAuth();
|
|
if (!ok && !cancelled) router.replace("/");
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [loading, user, requireAuth, router]);
|
|
|
|
/** Newest page first; messages are held ascending for rendering. */
|
|
const loadNewest = useCallback(async () => {
|
|
try {
|
|
const page = await chat.messages(id, 0, PAGE_SIZE);
|
|
setMessages([...page].reverse());
|
|
setHasMore(page.length === PAGE_SIZE);
|
|
setState("ready");
|
|
} catch {
|
|
setState("error");
|
|
}
|
|
}, [id]);
|
|
|
|
const loadOlder = useCallback(async () => {
|
|
if (loadingRef.current || !hasMore) return;
|
|
const oldest = messages[0]?.seq;
|
|
if (!oldest) return;
|
|
loadingRef.current = true;
|
|
setLoadingMore(true);
|
|
try {
|
|
const page = await chat.messages(id, oldest, PAGE_SIZE);
|
|
if (page.length > 0) {
|
|
setMessages((prev) => [...[...page].reverse(), ...prev]);
|
|
}
|
|
setHasMore(page.length === PAGE_SIZE);
|
|
} catch {
|
|
// Leave hasMore alone — a transient failure shouldn't permanently end
|
|
// history; the next scroll retries.
|
|
} finally {
|
|
loadingRef.current = false;
|
|
setLoadingMore(false);
|
|
}
|
|
}, [id, hasMore, messages]);
|
|
|
|
// One request for the thread: title, avatar, mute state, and the
|
|
// participant list the reply banner and quoted lines need to name people.
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
let cancelled = false;
|
|
chat
|
|
.detail(id)
|
|
.then((d) => {
|
|
if (!cancelled) setMeta(d);
|
|
})
|
|
.catch(() => {
|
|
/* the message list surfaces the failure */
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [id, user]);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
void loadNewest();
|
|
chat.receipts(id).then(setReceipts).catch(() => {});
|
|
}, [id, user, loadNewest]);
|
|
|
|
/**
|
|
* Send optimistically: the bubble appears immediately keyed by a
|
|
* client-generated id, then the server's version replaces it. That id is
|
|
* also what makes a retry idempotent server-side, so a flaky connection
|
|
* can't produce two bubbles.
|
|
*/
|
|
const sendMessage = useCallback(
|
|
(
|
|
body: string,
|
|
replyToId?: string,
|
|
previewUrl?: string,
|
|
attachments?: UploadedAttachment[],
|
|
stickerKey?: string,
|
|
) => {
|
|
const clientMsgId = crypto.randomUUID();
|
|
const optimistic: ChatMessage = {
|
|
id: clientMsgId,
|
|
conversation_id: id,
|
|
seq: Number.MAX_SAFE_INTEGER,
|
|
sender_id: user?.id ?? null,
|
|
kind: "text",
|
|
body,
|
|
entities: [],
|
|
client_msg_id: clientMsgId,
|
|
reply_to_id: replyToId ?? null,
|
|
attachments: [],
|
|
reactions: [],
|
|
created_at: new Date().toISOString(),
|
|
pending: true,
|
|
};
|
|
setMessages((prev) => [...prev, optimistic]);
|
|
|
|
chat
|
|
.send(id, body, {
|
|
clientMsgId,
|
|
replyToId,
|
|
previewUrl,
|
|
attachments,
|
|
stickerKey,
|
|
})
|
|
.then((saved) => {
|
|
setMessages((prev) => {
|
|
// Drop the placeholder and any realtime echo of the same message.
|
|
const without = prev.filter(
|
|
(m) => m.id !== clientMsgId && m.id !== saved.id,
|
|
);
|
|
return [...without, saved];
|
|
});
|
|
})
|
|
.catch(() => {
|
|
// Keep the bubble and its text — discarding it loses what the user
|
|
// wrote. Marking it failed offers a retry instead.
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === clientMsgId ? { ...m, pending: false, failed: true } : m,
|
|
),
|
|
);
|
|
});
|
|
},
|
|
[id, user?.id],
|
|
);
|
|
|
|
/**
|
|
* Retry a failed send, REUSING its client_msg_id. If the original actually
|
|
* reached the server and only the response was lost, the server returns the
|
|
* stored message instead of writing a second one.
|
|
*/
|
|
const retryMessage = useCallback(
|
|
(failed: ChatMessage) => {
|
|
const clientMsgId = failed.client_msg_id ?? failed.id;
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === failed.id ? { ...m, pending: true, failed: false } : m,
|
|
),
|
|
);
|
|
chat
|
|
.send(id, failed.body, {
|
|
clientMsgId,
|
|
replyToId: failed.reply_to_id ?? undefined,
|
|
})
|
|
.then((saved) => {
|
|
setMessages((prev) => [
|
|
...prev.filter((m) => m.id !== failed.id && m.id !== saved.id),
|
|
saved,
|
|
]);
|
|
})
|
|
.catch(() => {
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === failed.id ? { ...m, pending: false, failed: true } : m,
|
|
),
|
|
);
|
|
});
|
|
},
|
|
[id],
|
|
);
|
|
|
|
// Which messages the caller has starred, so bubbles can show it.
|
|
const loadStars = useCallback(() => {
|
|
chat
|
|
.starred(id)
|
|
.then((rows) => setStarredIds(new Set((rows ?? []).map((m) => m.id))))
|
|
.catch(() => {});
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
loadStars();
|
|
}, [user, loadStars]);
|
|
|
|
const toggleStar = useCallback(
|
|
async (m: ChatMessage) => {
|
|
const starred = starredIds.has(m.id);
|
|
try {
|
|
await chat.setStar(id, m.id, !starred);
|
|
setStarredIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (starred) next.delete(m.id);
|
|
else next.add(m.id);
|
|
return next;
|
|
});
|
|
} catch {
|
|
toast.error("Could not update the star");
|
|
}
|
|
},
|
|
[id, starredIds],
|
|
);
|
|
|
|
const copyMessage = useCallback((m: ChatMessage) => {
|
|
// The stored body is clean text — no markers, no markup — so this is
|
|
// exactly what the reader sees.
|
|
navigator.clipboard
|
|
.writeText(m.body)
|
|
.then(() => toast.success("Copied"))
|
|
.catch(() => toast.error("Could not copy"));
|
|
}, []);
|
|
|
|
const deleteMessage = useCallback(
|
|
async (m: ChatMessage, scope: "me" | "everyone") => {
|
|
try {
|
|
await chat.deleteMessage(id, m.id, scope);
|
|
if (scope === "me") {
|
|
setMessages((prev) => prev.filter((x) => x.id !== m.id));
|
|
} else {
|
|
// Tombstone in place — the realtime event does the same for others.
|
|
setMessages((prev) =>
|
|
prev.map((x) =>
|
|
x.id === m.id
|
|
? { ...x, deleted_at: new Date().toISOString(), body: "", attachments: [] }
|
|
: x,
|
|
),
|
|
);
|
|
}
|
|
} catch {
|
|
toast.error("Could not delete the message");
|
|
}
|
|
},
|
|
[id],
|
|
);
|
|
|
|
const reportMessage = useCallback(
|
|
async (m: ChatMessage) => {
|
|
const reason = window.prompt(
|
|
"Report this message. What's wrong with it?",
|
|
"",
|
|
);
|
|
if (reason === null) return;
|
|
try {
|
|
await chat.report(id, m.id, "abuse", reason);
|
|
toast.success("Reported. Our moderators will review it.");
|
|
} catch {
|
|
toast.error("Could not send the report");
|
|
}
|
|
},
|
|
[id],
|
|
);
|
|
|
|
/** Mark everything visible as read, so the sender's ticks turn blue. */
|
|
// Expire stale typers. A ticking clock rather than a timer per person: one
|
|
// interval regardless of how many are in the room, and it stops entirely
|
|
// once nobody is typing.
|
|
const [now, setNow] = useState(0);
|
|
useEffect(() => {
|
|
if (Object.keys(typingAt).length === 0) return;
|
|
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
return () => clearInterval(t);
|
|
}, [typingAt]);
|
|
|
|
// Ids only. Resolving names here would call nameFor before it is declared
|
|
// further down — a temporal-dead-zone crash that TypeScript does not catch,
|
|
// because the call would sit inside a .map callback.
|
|
// `now || Date.now()` keeps the first render correct while still depending
|
|
// on the ticking clock above.
|
|
const typingIds = Object.entries(typingAt)
|
|
.filter(([, at]) => (now || Date.now()) - at < TYPING_TTL_MS)
|
|
.map(([uid]) => uid);
|
|
|
|
/** Tell the thread we are composing, at most once every few seconds. */
|
|
const typingSentAt = useRef(0);
|
|
const notifyTyping = useCallback(() => {
|
|
const t = Date.now();
|
|
if (t - typingSentAt.current < TYPING_PING_MS) return;
|
|
typingSentAt.current = t;
|
|
chat.typing(id).catch(() => {});
|
|
}, [id]);
|
|
|
|
/** Replace one message in place, after its reactions changed. */
|
|
const refreshMessage = useCallback(
|
|
async (messageId: string) => {
|
|
try {
|
|
const fresh = await chat.message(id, messageId);
|
|
setMessages((prev) =>
|
|
prev.map((m) => (m.id === messageId ? { ...m, ...fresh } : m)),
|
|
);
|
|
} catch {
|
|
/* a stale chip is not worth an error surface */
|
|
}
|
|
},
|
|
[id],
|
|
);
|
|
|
|
/**
|
|
* Set or toggle a reaction. Picking the emoji you already chose removes it,
|
|
* which is what makes the chip and the double-tap heart both behave as
|
|
* toggles.
|
|
*/
|
|
const react = useCallback(
|
|
async (message: ChatMessage, emoji: string) => {
|
|
const mine = message.reactions?.find((r) => r.mine)?.emoji;
|
|
try {
|
|
if (mine === emoji) await chat.unreact(id, message.id);
|
|
else await chat.react(id, message.id, emoji);
|
|
// The server publishes a `reaction` event, but the actor is on the
|
|
// same channel and would otherwise wait a round trip to see it.
|
|
await refreshMessage(message.id);
|
|
} catch {
|
|
toast.error("Could not react");
|
|
}
|
|
},
|
|
[id, refreshMessage],
|
|
);
|
|
|
|
// Which message's reaction list is open, if any.
|
|
const [reactionsFor, setReactionsFor] = useState<string | null>(null);
|
|
|
|
/**
|
|
* The trail does one of two things, never both: retract your reaction if
|
|
* you have one, otherwise show who reacted. Rolling both into one control
|
|
* keeps the bubble uncluttered, at the cost that seeing the list means
|
|
* removing your own reaction first.
|
|
*/
|
|
const onTrailClick = useCallback(
|
|
(message: ChatMessage) => {
|
|
const mine = message.reactions?.find((r) => r.mine)?.emoji;
|
|
if (mine) void react(message, mine);
|
|
else setReactionsFor(message.id);
|
|
},
|
|
[react],
|
|
);
|
|
|
|
const markRead = useCallback(
|
|
(seq: number) => {
|
|
if (!seq) return;
|
|
// Refresh the nav badge afterwards: without this it stays lit until the
|
|
// next incoming message, since nothing else tells it this thread was
|
|
// read.
|
|
chat
|
|
.markRead(id, seq)
|
|
.then(refreshChatUnread)
|
|
.catch(() => {});
|
|
},
|
|
[id, refreshChatUnread],
|
|
);
|
|
|
|
useEffect(() => {
|
|
// Skip the optimistic sentinel — only a real server seq is worth acking.
|
|
const real = messages.filter((m) => !m.pending && !m.failed);
|
|
const newest = real[real.length - 1]?.seq;
|
|
if (newest) markRead(newest);
|
|
}, [messages, markRead]);
|
|
|
|
// Live thread updates.
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
const key = process.env.NEXT_PUBLIC_SOKETI_KEY;
|
|
const host = process.env.NEXT_PUBLIC_SOKETI_HOST;
|
|
if (!key || !host) return;
|
|
|
|
const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443");
|
|
const forceTLS =
|
|
(process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false";
|
|
const channelName = `private-conversation.${id}`;
|
|
|
|
let cancelled = false;
|
|
let cleanup: (() => void) | undefined;
|
|
|
|
void import("pusher-js").then(({ default: Pusher }) => {
|
|
if (cancelled) return;
|
|
const pusher = new Pusher(key, {
|
|
wsHost: host,
|
|
wsPort: port,
|
|
wssPort: port,
|
|
forceTLS,
|
|
enabledTransports: ["ws", "wss"],
|
|
disableStats: true,
|
|
cluster: "",
|
|
channelAuthorization: { customHandler: channelAuthorizer },
|
|
});
|
|
const channel = pusher.subscribe(channelName);
|
|
|
|
channel.bind("message", (m: ChatMessage) => {
|
|
setMessages((prev) => {
|
|
// The echo of our own send may arrive before or after the HTTP
|
|
// response, so match on both ids: the server's and the one we
|
|
// generated for the optimistic bubble.
|
|
if (
|
|
prev.some(
|
|
(x) => x.id === m.id || (!!m.client_msg_id && x.id === m.client_msg_id),
|
|
)
|
|
) {
|
|
return prev.map((x) =>
|
|
m.client_msg_id && x.id === m.client_msg_id ? m : x,
|
|
);
|
|
}
|
|
return [...prev, m];
|
|
});
|
|
});
|
|
channel.bind("message_deleted", ({ message_id }: { message_id: string }) => {
|
|
setMessages((prev) =>
|
|
prev.map((x) =>
|
|
x.id === message_id
|
|
? { ...x, deleted_at: new Date().toISOString(), body: "", attachments: [] }
|
|
: x,
|
|
),
|
|
);
|
|
});
|
|
channel.bind("reaction", ({ message_id }: { message_id: string }) => {
|
|
void refreshMessage(message_id);
|
|
});
|
|
channel.bind("typing", ({ user_id }: { user_id: string }) => {
|
|
// Ignore your own keystrokes echoing back off the channel.
|
|
if (!user_id || user_id === user?.id) return;
|
|
setTypingAt((prev) => ({ ...prev, [user_id]: Date.now() }));
|
|
});
|
|
channel.bind("receipt", () => {
|
|
chat.receipts(id).then(setReceipts).catch(() => {});
|
|
});
|
|
|
|
cleanup = () => {
|
|
channel.unbind_all();
|
|
pusher.unsubscribe(channelName);
|
|
pusher.disconnect();
|
|
};
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
cleanup?.();
|
|
};
|
|
}, [id, user, refreshMessage]);
|
|
|
|
if (loading || !user) {
|
|
return (
|
|
<main className="flex-1 px-4 py-6">
|
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
const nameFor = (senderId: string | null | undefined) =>
|
|
senderName(senderId, meta?.participants ?? [], user.id);
|
|
// Groups need the actual sender, not the conversation's `peer` — `peer` is
|
|
// only set on DMs, so a group has no single counterpart to read from.
|
|
const senderFor = (senderId: string | null | undefined) =>
|
|
senderPeer(senderId, meta?.participants ?? []);
|
|
const title = meta ? conversationTitle(meta) : "Chat";
|
|
const muted = meta ? isMuted(meta) : false;
|
|
|
|
return (
|
|
<main
|
|
className={cn(
|
|
// Match the inbox and profile widths — a thread stretched across a
|
|
// desktop monitor gives bubbles an unreadable line length, and the
|
|
// eye has to travel between the avatar and the timestamp.
|
|
"mx-auto flex w-full max-w-2xl flex-col",
|
|
// Side rules so the narrower column reads as a panel rather than
|
|
// text floating in space.
|
|
"md:border-x md:border-border",
|
|
// Fill the viewport MINUS the chrome around it: a sticky h-12/sm:h-14
|
|
// header above, and on small screens the h-16 bottom nav the app
|
|
// layout reserves with pb-16. Plain 100dvh ignores both and pushes the
|
|
// composer below the fold.
|
|
// NB: the underscores are load-bearing. Tailwind turns `_` into a
|
|
// space, and calc() requires whitespace around a binary `-` — written
|
|
// without it the whole declaration is invalid and dropped, which
|
|
// collapses this to height:auto.
|
|
// Only while the keyboard is closed; otherwise the inline style below
|
|
// takes over and these would fight it.
|
|
// The full viewport: a thread hides the app's header AND its tab bar,
|
|
// so there is no longer any chrome to subtract.
|
|
!keyboardOpen && "h-[100dvh]",
|
|
)}
|
|
style={
|
|
keyboardOpen && viewport
|
|
? {
|
|
// Pinned to the visible strip above the keyboard. Fixed rather
|
|
// than in-flow because iOS scrolls the layout viewport instead
|
|
// of resizing it, so an in-flow column keeps its old height and
|
|
// hides the composer underneath. left/right 0 with the existing
|
|
// mx-auto keeps the desktop column centred.
|
|
position: "fixed",
|
|
top: viewport.offsetTop,
|
|
left: 0,
|
|
right: 0,
|
|
height: viewport.height,
|
|
// Above the app's fixed bottom nav (z-40) so the composer can
|
|
// never be painted over by it.
|
|
zIndex: 50,
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
|
|
<Link
|
|
href="/chats"
|
|
aria-label="Back to chats"
|
|
className="-ml-1 rounded-md p-1 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ArrowLeft className="size-5" />
|
|
</Link>
|
|
|
|
{meta?.kind === "ai" ? (
|
|
<span
|
|
aria-hidden
|
|
className="inline-flex size-9 items-center justify-center rounded-full bg-primary/10 text-primary"
|
|
>
|
|
<KautilyaIcon className="size-4" />
|
|
</span>
|
|
) : (
|
|
<UserAvatar
|
|
src={
|
|
meta?.kind === "group"
|
|
? groupAvatarUrl(id, meta?.avatar_key)
|
|
: meta?.peer?.avatar_url
|
|
}
|
|
name={title}
|
|
className="size-9"
|
|
/>
|
|
)}
|
|
|
|
<Link
|
|
href={`/chats/${id}/profile`}
|
|
className="flex min-w-0 flex-1 flex-col text-left hover:opacity-80"
|
|
aria-label="Chat details"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="truncate text-sm font-semibold text-foreground">
|
|
{title}
|
|
</h1>
|
|
{muted && (
|
|
<BellOff
|
|
className="size-3.5 text-muted-foreground"
|
|
aria-label="Muted"
|
|
/>
|
|
)}
|
|
</div>
|
|
{meta?.peer?.handle && (
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
@{meta.peer.handle}
|
|
</p>
|
|
)}
|
|
</Link>
|
|
</header>
|
|
|
|
{state === "error" ? (
|
|
<p className="p-4 text-sm text-destructive">
|
|
Could not load this conversation.
|
|
</p>
|
|
) : state === "loading" ? (
|
|
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
|
|
) : (
|
|
<MessageList
|
|
messages={messages}
|
|
selfId={user.id}
|
|
senderFor={senderFor}
|
|
receipts={receipts}
|
|
isGroup={meta?.kind === "group"}
|
|
hasMore={hasMore}
|
|
loadingMore={loadingMore}
|
|
onLoadMore={loadOlder}
|
|
onReply={setReplyTo}
|
|
onMenu={(message, point) => setMenu({ message, point })}
|
|
starredIds={starredIds}
|
|
onRetry={retryMessage}
|
|
nameFor={nameFor}
|
|
typingNames={typingIds.map(nameFor).filter(Boolean)}
|
|
onReact={react}
|
|
onTrailClick={onTrailClick}
|
|
/>
|
|
)}
|
|
|
|
{meta?.kind === "ai" ? (
|
|
<div className="border-t border-border px-4 py-3 text-center text-xs text-muted-foreground">
|
|
Kautilya is coming soon.
|
|
</div>
|
|
) : (
|
|
<ChatComposer
|
|
conversationId={id}
|
|
replyTo={replyTo}
|
|
replyToName={nameFor(replyTo?.sender_id)}
|
|
onCancelReply={() => setReplyTo(null)}
|
|
onSend={sendMessage}
|
|
onTyping={notifyTyping}
|
|
disabled={state !== "ready"}
|
|
/>
|
|
)}
|
|
|
|
{menu && (
|
|
<MessageMenu
|
|
point={menu.point}
|
|
starred={starredIds.has(menu.message.id)}
|
|
myReaction={menu.message.reactions?.find((r) => r.mine)?.emoji}
|
|
onReact={(emoji) => react(menu.message, emoji)}
|
|
onClose={() => setMenu(null)}
|
|
actions={{
|
|
onReply: () => setReplyTo(menu.message),
|
|
onCopy: () => copyMessage(menu.message),
|
|
onStar: () => void toggleStar(menu.message),
|
|
onForward: () => setForwarding(menu.message),
|
|
onDeleteForMe: () => void deleteMessage(menu.message, "me"),
|
|
// Only the sender may erase a message for everyone.
|
|
onDeleteForEveryone:
|
|
menu.message.sender_id === user.id
|
|
? // Ask first — the modal calls deleteMessage on confirm.
|
|
() => setConfirmDelete(menu.message)
|
|
: undefined,
|
|
// Reporting your own message is meaningless.
|
|
onReport:
|
|
menu.message.sender_id === user.id
|
|
? undefined
|
|
: () => void reportMessage(menu.message),
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{reactionsFor && (
|
|
<ReactionsDrawer
|
|
conversationId={id}
|
|
messageId={reactionsFor}
|
|
onClose={() => setReactionsFor(null)}
|
|
/>
|
|
)}
|
|
|
|
<ConfirmModal
|
|
open={confirmDelete !== null}
|
|
onClose={() => setConfirmDelete(null)}
|
|
title="Delete for everyone?"
|
|
message="This removes the message from the conversation for all participants. It can't be undone."
|
|
confirmLabel="Delete"
|
|
onConfirm={async () => {
|
|
if (confirmDelete) await deleteMessage(confirmDelete, "everyone");
|
|
}}
|
|
/>
|
|
|
|
{forwarding && (
|
|
<ForwardDialog
|
|
message={forwarding}
|
|
onClose={() => setForwarding(null)}
|
|
/>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|