diff --git a/components/chat/message-attachments.tsx b/components/chat/message-attachments.tsx index 788bc080..c61be257 100644 --- a/components/chat/message-attachments.tsx +++ b/components/chat/message-attachments.tsx @@ -17,6 +17,7 @@ import { FileText } from "lucide-react"; import { LinkPreviewCard } from "@/components/chat/link-preview-card"; import { MediaGrid } from "@/components/chat/media-grid"; import { MediaLightbox } from "@/components/chat/media-lightbox"; +import { QuoteCard } from "@/components/chat/quote-card"; import { attachmentUrl, type Attachment } from "@/lib/chat"; function humanSize(bytes?: number | null): string { @@ -57,6 +58,9 @@ export function MessageAttachments({ {rest.map((a) => { switch (a.kind) { + case "quote": + return ; + case "link": return ( path && router.push(path)} + disabled={!path} + className="mt-1 block w-full rounded-lg border-l-2 border-primary bg-primary/5 px-3 py-2 text-left transition-colors hover:bg-primary/10 disabled:cursor-default disabled:hover:bg-primary/5" + > + + + + {/* The passage is the point, so it leads; the reference follows in + the quieter role a citation normally takes. */} + + {passage} + + {attachment.title && ( + + {attachment.title} + + )} + + + + ); +} diff --git a/components/chat/share-to-chat-dialog.tsx b/components/chat/share-to-chat-dialog.tsx new file mode 100644 index 00000000..b3485113 --- /dev/null +++ b/components/chat/share-to-chat-dialog.tsx @@ -0,0 +1,241 @@ +"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, + 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; + 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 }); + 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} +

    +
    + +
  • + ); + })} +
+
+ )} + + )} +
+ ); +} diff --git a/components/social/comment/post-comments-drawer.tsx b/components/social/comment/post-comments-drawer.tsx index 2c20ea6c..bdfced26 100644 --- a/components/social/comment/post-comments-drawer.tsx +++ b/components/social/comment/post-comments-drawer.tsx @@ -382,7 +382,10 @@ export function PostCommentsDrawer({ rows={1} maxLength={2000} placeholder={replyTo ? "Write a reply…" : "Add a comment…"} - className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-base sm:text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/40 max-h-32" + // 16px wherever the pointer is coarse, so iOS Safari doesn't + // auto-zoom on focus. Keyed off pointer type rather than width: + // an iPad is wider than `sm` but still zooms a 14px field. + className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm pointer-coarse:text-base text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/40 max-h-32" />