fix: chat accepts quotes now
This commit is contained in:
@@ -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 <QuoteCard key={a.id} attachment={a} />;
|
||||
|
||||
case "link":
|
||||
return (
|
||||
<LinkPreviewCard
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* A passage shared from the library, rendered as a quotation.
|
||||
*
|
||||
* Tapping it returns the reader to the exact phrase: the stored path carries
|
||||
* the `?hl=` fragment that TextHighlighter resolves on arrival, scrolling to
|
||||
* the passage and flashing it.
|
||||
*
|
||||
* The path is always app-relative — the BFF rejects anything else — so this
|
||||
* can navigate with the router rather than leaving the app.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Quote } from "lucide-react";
|
||||
import type { Attachment } from "@/lib/chat";
|
||||
|
||||
export function QuoteCard({ attachment }: { attachment: Attachment }) {
|
||||
const router = useRouter();
|
||||
const path = attachment.url ?? "";
|
||||
const passage = attachment.description ?? "";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<span className="flex items-start gap-2">
|
||||
<Quote className="mt-0.5 size-3.5 shrink-0 text-primary" aria-hidden />
|
||||
<span className="min-w-0 flex-1">
|
||||
{/* The passage is the point, so it leads; the reference follows in
|
||||
the quieter role a citation normally takes. */}
|
||||
<span className="block text-sm italic leading-relaxed text-foreground">
|
||||
{passage}
|
||||
</span>
|
||||
{attachment.title && (
|
||||
<span className="mt-1 block truncate text-xs font-medium text-primary">
|
||||
{attachment.title}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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<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 });
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user