Files
spsays-training/components/chat/share-to-chat-dialog.tsx
T
2026-08-29 16:26:53 +05:30

246 lines
9.5 KiB
TypeScript

"use client";
/**
* Send the selected passage to someone, from the reading toolbar.
*
* Two ways in, because they answer different questions:
*
* Frequent chats — people you already talk to. A grid of avatars, because
* you recognise these faces and want the shortest possible path.
* Friends — people you follow or who follow you but have never messaged.
* Rows with an explicit button, because starting a conversation is a bigger
* step than continuing one and shouldn't be a mis-tap away.
*
* Anyone with an existing thread is removed from Friends, so nobody appears
* twice and the second list is genuinely "people you haven't written to".
*/
import { useEffect, useState } from "react";
import { Check, Loader2, Send } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { UserAvatar } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
import { chat, type ChatPeer, type QuotePayload } from "@/lib/chat";
import { relationships, type UserSummary } from "@/lib/social";
/** How many existing threads to offer before it stops being "frequent". */
const FREQUENT_LIMIT = 9;
/** Friends fetched from each side of the follow graph. */
const FRIENDS_PER_SIDE = 20;
type Frequent = { conversationId: string; peer: ChatPeer };
/** Per-person send state, keyed by handle. */
type SendState = "idle" | "sending" | "sent";
export function ShareToChatDialog({
body = "",
quote,
entity,
onClose,
}: {
/** Plain message text. Empty when sending a quote on its own. */
body?: string;
/** A passage from the library, sent as a tappable quotation. */
quote?: QuotePayload;
/** A post, sent as a card. Only the reference travels — the BFF resolves
* the contents and re-checks visibility for each viewer. */
entity?: { type: "post"; id: string };
onClose: () => void;
}) {
const { user } = useAuth();
const [frequent, setFrequent] = useState<Frequent[]>([]);
const [friends, setFriends] = useState<UserSummary[]>([]);
const [loaded, setLoaded] = useState(false);
// Derived rather than stored: with no session there is nothing to fetch, so
// "loading" is false immediately instead of needing an effect to say so.
const loading = !!user && !loaded;
const [states, setStates] = useState<Record<string, SendState>>({});
useEffect(() => {
// The toolbar gates this behind requireAuth; with no session there is
// nothing to fetch and `loading` is already false.
if (!user) return;
let cancelled = false;
(async () => {
// Existing threads first: they populate the grid AND tell us who to drop
// from Friends, so both lists come out of one pass.
const [inbox, followers, following] = await Promise.all([
chat.inbox("chats").catch(() => []),
relationships
.followers(user.handle, { limit: FRIENDS_PER_SIDE })
.catch(() => ({ users: [] as UserSummary[] })),
relationships
.following(user.handle, { limit: FRIENDS_PER_SIDE })
.catch(() => ({ users: [] as UserSummary[] })),
]);
if (cancelled) return;
// The inbox arrives newest-first, so "frequent" is really "most recent"
// — a reasonable stand-in until there's a message-count to rank by.
const dms: Frequent[] = inbox
.filter((c) => c.kind === "dm" && c.peer)
.slice(0, FREQUENT_LIMIT)
.map((c) => ({ conversationId: c.id, peer: c.peer as ChatPeer }));
const talkingTo = new Set(dms.map((d) => d.peer.handle));
const seen = new Set<string>();
const rest = [...following.users, ...followers.users].filter((u) => {
// Following before followers, so mutuals show up under the side you
// chose. Self can appear in neither.
if (u.handle === user.handle || talkingTo.has(u.handle)) return false;
if (seen.has(u.handle)) return false;
seen.add(u.handle);
return true;
});
setFrequent(dms);
setFriends(rest);
setLoaded(true);
})();
return () => {
cancelled = true;
};
}, [user]);
const mark = (handle: string, state: SendState) =>
setStates((prev) => ({ ...prev, [handle]: state }));
/** Sends into an existing thread, or opens one first. */
const sendTo = async (handle: string, conversationId?: string) => {
if (states[handle] === "sending" || states[handle] === "sent") return;
mark(handle, "sending");
try {
const id = conversationId ?? (await chat.openDM(handle)).id;
await chat.send(id, body, { quote, entity });
mark(handle, "sent");
} catch {
mark(handle, "idle");
toast.error("Could not send that message");
}
};
const sentCount = Object.values(states).filter((s) => s === "sent").length;
return (
<ResponsiveDialog
title="Send to a chat"
description="Share this passage in a conversation."
onClose={onClose}
footer={
<Button variant="outline" className="w-full" onClick={onClose}>
{sentCount > 0 ? "Done" : "Cancel"}
</Button>
}
>
{loading ? (
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
) : frequent.length === 0 && friends.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">
No one to send to yet. Follow someone to start a conversation.
</p>
) : (
<>
{frequent.length > 0 && (
<section className="p-4">
<h3 className="mb-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Frequent chats
</h3>
<ul className="grid grid-cols-3 gap-3">
{frequent.map(({ conversationId, peer }) => {
const state = states[peer.handle] ?? "idle";
return (
<li key={peer.handle}>
<button
type="button"
onClick={() => sendTo(peer.handle, conversationId)}
disabled={state !== "idle"}
className="flex w-full flex-col items-center gap-1.5 rounded-lg p-2 text-center hover:bg-muted/60 disabled:opacity-70"
>
<span className="relative">
<UserAvatar
src={peer.avatar_url}
name={peer.name}
className="size-14"
/>
{state !== "idle" && (
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-background/70">
{state === "sending" ? (
<Loader2 className="size-4 animate-spin text-foreground" />
) : (
<Check className="size-5 text-primary" />
)}
</span>
)}
</span>
<span className="w-full truncate text-xs text-foreground">
{peer.name}
</span>
</button>
</li>
);
})}
</ul>
</section>
)}
{friends.length > 0 && (
<section className={frequent.length > 0 ? "border-t border-border" : ""}>
<h3 className="px-4 pb-2 pt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Friends
</h3>
<ul className="divide-y divide-border">
{friends.map((u) => {
const state = states[u.handle] ?? "idle";
return (
<li
key={u.handle}
className="flex items-center gap-3 px-4 py-2.5"
>
<UserAvatar
src={u.avatar_url}
name={u.name}
className="size-9 shrink-0"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">
{u.name}
</p>
<p className="truncate text-xs text-muted-foreground">
@{u.handle}
</p>
</div>
<Button
size="sm"
variant={state === "sent" ? "outline" : "default"}
disabled={state !== "idle"}
onClick={() => sendTo(u.handle)}
className="shrink-0"
>
{state === "sending" ? (
<Loader2 className="size-4 animate-spin" />
) : state === "sent" ? (
<>
<Check className="size-4" /> Sent
</>
) : (
<>
<Send className="size-4" /> Send
</>
)}
</Button>
</li>
);
})}
</ul>
</section>
)}
</>
)}
</ResponsiveDialog>
);
}