136 lines
4.2 KiB
TypeScript
136 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Pick a thread to forward a message into.
|
|
*
|
|
* The destination list is the caller's own inbox, so there is no way to name a
|
|
* thread they aren't in — and the BFF re-checks both memberships anyway, since
|
|
* a client-supplied conversation id proves nothing.
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { X } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
|
import {
|
|
chat,
|
|
conversationTitle,
|
|
groupAvatarUrl,
|
|
type ChatMessage,
|
|
type InboxItem,
|
|
} from "@/lib/chat";
|
|
|
|
export function ForwardDialog({
|
|
message,
|
|
onClose,
|
|
}: {
|
|
message: ChatMessage;
|
|
onClose: () => void;
|
|
}) {
|
|
const [threads, setThreads] = useState<InboxItem[]>([]);
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
chat
|
|
.inbox("chats")
|
|
.then((rows) => {
|
|
if (cancelled) return;
|
|
setThreads(rows ?? []);
|
|
setState("ready");
|
|
})
|
|
.catch(() => !cancelled && setState("error"));
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [onClose]);
|
|
|
|
const forward = async (to: InboxItem) => {
|
|
setBusy(to.id);
|
|
try {
|
|
await chat.forward(to.id, message.id);
|
|
toast.success(`Forwarded to ${conversationTitle(to)}`);
|
|
onClose();
|
|
} catch {
|
|
toast.error("Could not forward the message");
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center">
|
|
<div
|
|
className="absolute inset-0"
|
|
onClick={onClose}
|
|
aria-hidden
|
|
/>
|
|
<div className="relative z-10 max-h-[70vh] w-full max-w-md overflow-hidden rounded-t-2xl border border-border bg-background sm:rounded-2xl">
|
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
|
<h2 className="text-sm font-semibold text-foreground">Forward to…</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
className="rounded-md p-1 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="max-h-[55vh] overflow-y-auto">
|
|
{state === "error" ? (
|
|
<p className="p-4 text-sm text-destructive">
|
|
Could not load your chats.
|
|
</p>
|
|
) : state === "loading" ? (
|
|
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
|
|
) : threads.length === 0 ? (
|
|
<p className="p-4 text-sm text-muted-foreground">
|
|
You have no other chats yet.
|
|
</p>
|
|
) : (
|
|
<ul className="divide-y divide-border">
|
|
{threads.map((t) => (
|
|
<li key={t.id}>
|
|
<button
|
|
type="button"
|
|
disabled={busy !== null}
|
|
onClick={() => forward(t)}
|
|
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60 disabled:opacity-60"
|
|
>
|
|
<UserAvatar
|
|
src={
|
|
t.kind === "group"
|
|
? groupAvatarUrl(t.id, t.avatar_key)
|
|
: t.peer?.avatar_url
|
|
}
|
|
name={conversationTitle(t)}
|
|
className="size-9 shrink-0"
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
|
{conversationTitle(t)}
|
|
</span>
|
|
{busy === t.id && (
|
|
<span className="text-xs text-muted-foreground">
|
|
Sending…
|
|
</span>
|
|
)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|