"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([]); const [friends, setFriends] = useState([]); 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>({}); 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(); 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 ( {sentCount > 0 ? "Done" : "Cancel"} } > {loading ? (

Loading…

) : frequent.length === 0 && friends.length === 0 ? (

No one to send to yet. Follow someone to start a conversation.

) : ( <> {frequent.length > 0 && (

Frequent chats

    {frequent.map(({ conversationId, peer }) => { const state = states[peer.handle] ?? "idle"; return (
  • ); })}
)} {friends.length > 0 && (
0 ? "border-t border-border" : ""}>

Friends

    {friends.map((u) => { const state = states[u.handle] ?? "idle"; return (
  • {u.name}

    @{u.handle}

  • ); })}
)} )}
); }