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 { LinkPreviewCard } from "@/components/chat/link-preview-card";
|
||||||
import { MediaGrid } from "@/components/chat/media-grid";
|
import { MediaGrid } from "@/components/chat/media-grid";
|
||||||
import { MediaLightbox } from "@/components/chat/media-lightbox";
|
import { MediaLightbox } from "@/components/chat/media-lightbox";
|
||||||
|
import { QuoteCard } from "@/components/chat/quote-card";
|
||||||
import { attachmentUrl, type Attachment } from "@/lib/chat";
|
import { attachmentUrl, type Attachment } from "@/lib/chat";
|
||||||
|
|
||||||
function humanSize(bytes?: number | null): string {
|
function humanSize(bytes?: number | null): string {
|
||||||
@@ -57,6 +58,9 @@ export function MessageAttachments({
|
|||||||
|
|
||||||
{rest.map((a) => {
|
{rest.map((a) => {
|
||||||
switch (a.kind) {
|
switch (a.kind) {
|
||||||
|
case "quote":
|
||||||
|
return <QuoteCard key={a.id} attachment={a} />;
|
||||||
|
|
||||||
case "link":
|
case "link":
|
||||||
return (
|
return (
|
||||||
<LinkPreviewCard
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -382,7 +382,10 @@ export function PostCommentsDrawer({
|
|||||||
rows={1}
|
rows={1}
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
placeholder={replyTo ? "Write a reply…" : "Add a comment…"}
|
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"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ import {
|
|||||||
type Comment,
|
type Comment,
|
||||||
type Post,
|
type Post,
|
||||||
} from "@/lib/social";
|
} from "@/lib/social";
|
||||||
|
import { buildPostShareText } from "@/lib/post-share-text";
|
||||||
|
import { useLongPress } from "@/lib/use-long-press";
|
||||||
|
import { ShareToChatDialog } from "@/components/chat/share-to-chat-dialog";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
@@ -62,6 +65,8 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
// on mobile native-share; sheet mount on desktop) so the Send icon
|
// on mobile native-share; sheet mount on desktop) so the Send icon
|
||||||
// can render a spinner while the user waits.
|
// can render a spinner while the user waits.
|
||||||
const [sharePending, setSharePending] = useState(false);
|
const [sharePending, setSharePending] = useState(false);
|
||||||
|
// The post as chat-ready text. Non-null opens the picker.
|
||||||
|
const [chatBody, setChatBody] = useState<string | null>(null);
|
||||||
// Optimistic local mirror of post.viewer_has_reshared so the swap
|
// Optimistic local mirror of post.viewer_has_reshared so the swap
|
||||||
// happens the instant the user taps Reshare. Reconciles to the
|
// happens the instant the user taps Reshare. Reconciles to the
|
||||||
// server's value via rollback if the API call fails.
|
// server's value via rollback if the API call fails.
|
||||||
@@ -140,7 +145,27 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
// parallel, then navigator.share) and the desktop/no-native-share
|
// parallel, then navigator.share) and the desktop/no-native-share
|
||||||
// path (mount the custom SharePostSheet). Provider's openShareSheet
|
// path (mount the custom SharePostSheet). Provider's openShareSheet
|
||||||
// is always-resolves, so a single try/finally toggles the spinner.
|
// is always-resolves, so a single try/finally toggles the spinner.
|
||||||
|
// Tap → send to a chat. Hold → the platform's own share sheet, for
|
||||||
|
// everything outside the app. The link text is identical either way; only
|
||||||
|
// the destination differs.
|
||||||
async function onSendClick() {
|
async function onSendClick() {
|
||||||
|
if (sharePending) return;
|
||||||
|
setSharePending(true);
|
||||||
|
try {
|
||||||
|
const share = await buildPostShareText(post, post.author_handle ?? "");
|
||||||
|
if (!share) {
|
||||||
|
// No handle means no post URL — fall back to the sheet, which
|
||||||
|
// resolves the handle itself and has its own failure path.
|
||||||
|
await openShareSheet(post);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setChatBody(share.text);
|
||||||
|
} finally {
|
||||||
|
setSharePending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSendHold() {
|
||||||
if (sharePending) return;
|
if (sharePending) return;
|
||||||
setSharePending(true);
|
setSharePending(true);
|
||||||
try {
|
try {
|
||||||
@@ -150,6 +175,12 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendGestures = useLongPress({
|
||||||
|
onClick: onSendClick,
|
||||||
|
onLongPress: onSendHold,
|
||||||
|
disabled: sharePending,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between px-2">
|
<div className="flex items-center justify-between px-2">
|
||||||
@@ -197,8 +228,8 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
{showSend ? (
|
{showSend ? (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label={sharePending ? "Preparing share link…" : "Send"}
|
label={sharePending ? "Preparing share link…" : "Send"}
|
||||||
onClick={onSendClick}
|
|
||||||
disabled={sharePending}
|
disabled={sharePending}
|
||||||
|
{...sendGestures}
|
||||||
icon={
|
icon={
|
||||||
sharePending ? (
|
sharePending ? (
|
||||||
<Loader2
|
<Loader2
|
||||||
@@ -270,29 +301,34 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
{/* SharePostSheet is mounted globally by SharePostProvider, so
|
{/* SharePostSheet is mounted globally by SharePostProvider, so
|
||||||
no per-card instance lives here. The provider's
|
no per-card instance lives here. The provider's
|
||||||
openShareSheet swaps the active post under the hood. */}
|
openShareSheet swaps the active post under the hood. */}
|
||||||
|
|
||||||
|
{chatBody !== null && (
|
||||||
|
<ShareToChatDialog body={chatBody} onClose={() => setChatBody(null)} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionButton({
|
function ActionButton({
|
||||||
label,
|
label,
|
||||||
onClick,
|
|
||||||
icon,
|
icon,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
...buttonProps
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
/** Greys out the button and blocks clicks. Used by the Send button
|
/** Greys out the button and blocks clicks. Used by the Send button
|
||||||
* while the short URL is being resolved on the mobile native-share
|
* while the short URL is being resolved on the mobile native-share
|
||||||
* path so users can't fire navigator.share before the URL is
|
* path so users can't fire navigator.share before the URL is
|
||||||
* ready. */
|
* ready. */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
/** onClick, plus the pointer handlers the Send button needs to tell a
|
||||||
|
* tap from a hold. */
|
||||||
|
} & React.ComponentProps<"button">) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
{...buttonProps}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
title={label}
|
title={label}
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { Post } from "@/lib/social";
|
import type { Post } from "@/lib/social";
|
||||||
import { createShortLink } from "@/lib/share";
|
import { buildPostShareText } from "@/lib/post-share-text";
|
||||||
import { structuredReferenceForPost } from "@/lib/source-reference";
|
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
import { SharePostSheet } from "./share-post-sheet";
|
import { SharePostSheet } from "./share-post-sheet";
|
||||||
@@ -131,30 +130,17 @@ export function SharePostProvider({
|
|||||||
// it.
|
// it.
|
||||||
// 3. On any non-AbortError, fall back to the custom sheet.
|
// 3. On any non-AbortError, fall back to the custom sheet.
|
||||||
try {
|
try {
|
||||||
const longPath = `/u/${encodeURIComponent(handle)}/posts#post-${post.id}`;
|
|
||||||
const hasImage = !!post.images?.[0];
|
const hasImage = !!post.images?.[0];
|
||||||
const [code, imageFile] = await Promise.all([
|
const [share, imageFile] = await Promise.all([
|
||||||
createShortLink(longPath),
|
buildPostShareText(post, handle),
|
||||||
hasImage ? fetchShareImageFile(post.id) : Promise.resolve(null),
|
hasImage ? fetchShareImageFile(post.id) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
const origin = window.location.origin;
|
if (!share) {
|
||||||
const url = code
|
setActivePost(post);
|
||||||
? new URL(`/share/${code}`, origin).toString()
|
return;
|
||||||
: new URL(longPath, origin).toString();
|
}
|
||||||
|
|
||||||
const ref = structuredReferenceForPost(
|
const payload: ShareData = { title: share.title, text: share.text };
|
||||||
post.source_url,
|
|
||||||
post.source_title,
|
|
||||||
);
|
|
||||||
const lines = [`“${post.caption}”`, "", "Source:", ref.title];
|
|
||||||
if (ref.detail) lines.push(ref.detail);
|
|
||||||
lines.push(url);
|
|
||||||
const text = lines.join("\n");
|
|
||||||
const title = ref.detail
|
|
||||||
? `${ref.title} — ${ref.detail}`
|
|
||||||
: ref.title;
|
|
||||||
|
|
||||||
const payload: ShareData = { title, text };
|
|
||||||
// Only attach files when the platform actually accepts them.
|
// Only attach files when the platform actually accepts them.
|
||||||
// canShare returns false on Chrome desktop / Firefox even
|
// canShare returns false on Chrome desktop / Firefox even
|
||||||
// though navigator.share itself exists, and we want to fail
|
// though navigator.share itself exists, and we want to fail
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
|
MessageCircle,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Send,
|
Send,
|
||||||
Share2,
|
Share2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { formatCopyText, getSourceReference } from "@/lib/source-reference";
|
import { formatCopyText, getSourceReference } from "@/lib/source-reference";
|
||||||
import { buildHighlightUrl } from "@/lib/text-highlight";
|
import { buildHighlightPath, buildHighlightUrl } from "@/lib/text-highlight";
|
||||||
import { bookmarks, SocialError } from "@/lib/social";
|
import { bookmarks, SocialError } from "@/lib/social";
|
||||||
import {
|
import {
|
||||||
KautilyaIcon,
|
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
useCopyFeedback,
|
useCopyFeedback,
|
||||||
type Position,
|
type Position,
|
||||||
@@ -25,6 +25,8 @@ import { FloatingToolbar } from "./selection-toolbar/floating-toolbar";
|
|||||||
import { CollectionsDrawer } from "./selection-toolbar/collections-drawer";
|
import { CollectionsDrawer } from "./selection-toolbar/collections-drawer";
|
||||||
import { MorePopover, type MoreItem } from "./selection-toolbar/more-popover";
|
import { MorePopover, type MoreItem } from "./selection-toolbar/more-popover";
|
||||||
import { ComposePost } from "./selection-toolbar/compose-post";
|
import { ComposePost } from "./selection-toolbar/compose-post";
|
||||||
|
import { ShareToChatDialog } from "@/components/chat/share-to-chat-dialog";
|
||||||
|
import type { QuotePayload } from "@/lib/chat";
|
||||||
import { useAuth } from "./auth/auth-provider";
|
import { useAuth } from "./auth/auth-provider";
|
||||||
import { useAuthGate } from "./auth/auth-gate";
|
import { useAuthGate } from "./auth/auth-gate";
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ import { useAuthGate } from "./auth/auth-gate";
|
|||||||
|
|
||||||
export function TextSelectionToolbar() {
|
export function TextSelectionToolbar() {
|
||||||
const [selectedText, setSelectedText] = useState("");
|
const [selectedText, setSelectedText] = useState("");
|
||||||
|
// The passage to share, captured at click time. Non-null opens the dialog.
|
||||||
|
const [chatQuote, setChatQuote] = useState<QuotePayload | null>(null);
|
||||||
const [toolbarPos, setToolbarPos] = useState<Position | null>(null);
|
const [toolbarPos, setToolbarPos] = useState<Position | null>(null);
|
||||||
const [showMore, setShowMore] = useState(false);
|
const [showMore, setShowMore] = useState(false);
|
||||||
const [morePos, setMorePos] = useState<Position>({ x: 0, y: 0 });
|
const [morePos, setMorePos] = useState<Position>({ x: 0, y: 0 });
|
||||||
@@ -337,6 +341,21 @@ export function TextSelectionToolbar() {
|
|||||||
setShowCollections(true);
|
setShowCollections(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onChat() {
|
||||||
|
// Snapshot the passage first: requireAuth may open a login flow, and
|
||||||
|
// finishAction clears the selection — the dialog outlives both.
|
||||||
|
const ref = getSourceReference();
|
||||||
|
const quote: QuotePayload = {
|
||||||
|
// Relative, not absolute: the BFF only accepts in-app paths for quotes.
|
||||||
|
path: buildHighlightPath(selectedText),
|
||||||
|
title: ref ? (ref.detail ? `${ref.title} — ${ref.detail}` : ref.title) : "",
|
||||||
|
text: selectedText,
|
||||||
|
};
|
||||||
|
if (!(await requireAuth())) return;
|
||||||
|
setChatQuote(quote);
|
||||||
|
finishAction();
|
||||||
|
}
|
||||||
|
|
||||||
async function onPost() {
|
async function onPost() {
|
||||||
if (!(await requireAuth())) return;
|
if (!(await requireAuth())) return;
|
||||||
if (isMobile) saveAndClearSelection();
|
if (isMobile) saveAndClearSelection();
|
||||||
@@ -418,14 +437,11 @@ export function TextSelectionToolbar() {
|
|||||||
|
|
||||||
const isAuthed = !!user;
|
const isAuthed = !!user;
|
||||||
|
|
||||||
const askAction: ToolbarAction = {
|
const chatAction: ToolbarAction = {
|
||||||
key: "ask",
|
key: "chat",
|
||||||
label: "Ask Kautilya",
|
label: "Chat",
|
||||||
icon: <KautilyaIcon className="size-3.5" />,
|
icon: <MessageCircle className="size-3.5" />,
|
||||||
onClick: () => {
|
onClick: onChat,
|
||||||
// TODO: Implement Ask Kautilya
|
|
||||||
finishAction();
|
|
||||||
},
|
|
||||||
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
|
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -445,7 +461,7 @@ export function TextSelectionToolbar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const authedToolbarActions: ToolbarAction[] = [
|
const authedToolbarActions: ToolbarAction[] = [
|
||||||
askAction,
|
chatAction,
|
||||||
{
|
{
|
||||||
key: "post",
|
key: "post",
|
||||||
label: "Post",
|
label: "Post",
|
||||||
@@ -466,7 +482,6 @@ export function TextSelectionToolbar() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const publicToolbarActions: ToolbarAction[] = [
|
const publicToolbarActions: ToolbarAction[] = [
|
||||||
askAction,
|
|
||||||
{
|
{
|
||||||
key: "copy",
|
key: "copy",
|
||||||
label: "Copy",
|
label: "Copy",
|
||||||
@@ -515,6 +530,17 @@ export function TextSelectionToolbar() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const publicMoreItems: MoreItem[] = [
|
const publicMoreItems: MoreItem[] = [
|
||||||
|
{
|
||||||
|
key: "chat-public",
|
||||||
|
label: "Chat",
|
||||||
|
description: "Send this passage to someone",
|
||||||
|
icon: <MessageCircle className="size-5" />,
|
||||||
|
locked: true,
|
||||||
|
onClick: async () => {
|
||||||
|
setShowMore(false);
|
||||||
|
await onChat();
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "share-as-post",
|
key: "share-as-post",
|
||||||
label: "Share as Post",
|
label: "Share as Post",
|
||||||
@@ -601,6 +627,13 @@ export function TextSelectionToolbar() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
selectedText={selectedText}
|
selectedText={selectedText}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{chatQuote !== null && (
|
||||||
|
<ShareToChatDialog
|
||||||
|
quote={chatQuote}
|
||||||
|
onClose={() => setChatQuote(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -168,7 +168,8 @@ export type AttachmentKind =
|
|||||||
| "audio"
|
| "audio"
|
||||||
| "file"
|
| "file"
|
||||||
| "link"
|
| "link"
|
||||||
| "entity";
|
| "entity"
|
||||||
|
| "quote";
|
||||||
|
|
||||||
export type Attachment = {
|
export type Attachment = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -259,6 +260,7 @@ export const chat = {
|
|||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
attachments?: UploadedAttachment[];
|
attachments?: UploadedAttachment[];
|
||||||
|
quote?: QuotePayload;
|
||||||
},
|
},
|
||||||
) =>
|
) =>
|
||||||
call<ChatMessage>(`/chat/conversations/${conversationId}/messages`, {
|
call<ChatMessage>(`/chat/conversations/${conversationId}/messages`, {
|
||||||
@@ -270,6 +272,7 @@ export const chat = {
|
|||||||
// Only the URL: the BFF fetches the card's contents itself.
|
// Only the URL: the BFF fetches the card's contents itself.
|
||||||
preview_url: opts?.previewUrl ?? null,
|
preview_url: opts?.previewUrl ?? null,
|
||||||
attachments: opts?.attachments ?? [],
|
attachments: opts?.attachments ?? [],
|
||||||
|
quote: opts?.quote ?? null,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -399,6 +402,19 @@ export const chat = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** A file already uploaded to the private bucket, ready to attach on send. */
|
/** A file already uploaded to the private bucket, ready to attach on send. */
|
||||||
|
/**
|
||||||
|
* A passage shared from the library.
|
||||||
|
*
|
||||||
|
* `path` is app-relative on purpose — the BFF refuses anything absolute or
|
||||||
|
* protocol-relative, because a quote renders as trusted in-app content and
|
||||||
|
* must not be able to carry the reader off-site.
|
||||||
|
*/
|
||||||
|
export type QuotePayload = {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type UploadedAttachment = {
|
export type UploadedAttachment = {
|
||||||
key: string;
|
key: string;
|
||||||
mime: string;
|
mime: string;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Builds the shareable text for a post — the caption, its source reference,
|
||||||
|
* and a link.
|
||||||
|
*
|
||||||
|
* Extracted so the native share sheet and "send to a chat" quote a post
|
||||||
|
* identically. They differ only in where the text ends up, and two copies of
|
||||||
|
* this would drift the moment either format changed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createShortLink } from "@/lib/share";
|
||||||
|
import { structuredReferenceForPost } from "@/lib/source-reference";
|
||||||
|
import type { Post } from "@/lib/social";
|
||||||
|
|
||||||
|
export type PostShareText = {
|
||||||
|
/** Short link when the shortener answered, canonical URL otherwise. */
|
||||||
|
url: string;
|
||||||
|
/** Source reference on one line, for a native share sheet's title slot. */
|
||||||
|
title: string;
|
||||||
|
/** Caption, source reference and link — the body of the share. */
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `viewerHandle` covers the case where the BFF's create response omits
|
||||||
|
* author_handle: the poster IS the signed-in user right after posting.
|
||||||
|
* Returns null when neither yields a handle, since there is no post URL
|
||||||
|
* without one.
|
||||||
|
*/
|
||||||
|
export async function buildPostShareText(
|
||||||
|
post: Post,
|
||||||
|
viewerHandle: string | undefined,
|
||||||
|
): Promise<PostShareText | null> {
|
||||||
|
const handle = post.author_handle ?? viewerHandle ?? "";
|
||||||
|
if (!handle) return null;
|
||||||
|
|
||||||
|
const longPath = `/u/${encodeURIComponent(handle)}/posts#post-${post.id}`;
|
||||||
|
const code = await createShortLink(longPath);
|
||||||
|
const origin = window.location.origin;
|
||||||
|
const url = code
|
||||||
|
? new URL(`/share/${code}`, origin).toString()
|
||||||
|
: new URL(longPath, origin).toString();
|
||||||
|
|
||||||
|
const ref = structuredReferenceForPost(post.source_url, post.source_title);
|
||||||
|
const lines = [`“${post.caption}”`, "", "Source:", ref.title];
|
||||||
|
if (ref.detail) lines.push(ref.detail);
|
||||||
|
lines.push(url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
title: ref.detail ? `${ref.title} — ${ref.detail}` : ref.title,
|
||||||
|
text: lines.join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -303,6 +303,18 @@ export function buildSearchResultUrl(
|
|||||||
/**
|
/**
|
||||||
* Build the full URL with highlight fragment for the selected text.
|
* Build the full URL with highlight fragment for the selected text.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The highlight link as an app-relative path ("/books/bg/2/13?hl=…").
|
||||||
|
*
|
||||||
|
* Chat quotes store a path rather than a full URL: the BFF refuses absolute
|
||||||
|
* links there, since a quote renders as trusted in-app content and must not be
|
||||||
|
* able to carry the reader off-site.
|
||||||
|
*/
|
||||||
|
export function buildHighlightPath(selectedText: string): string {
|
||||||
|
const url = new URL(buildHighlightUrl(selectedText));
|
||||||
|
return url.pathname + url.search + url.hash;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildHighlightUrl(selectedText: string): string {
|
export function buildHighlightUrl(selectedText: string): string {
|
||||||
const fragment = encodeTextFragment(selectedText);
|
const fragment = encodeTextFragment(selectedText);
|
||||||
if (!fragment) return window.location.href;
|
if (!fragment) return window.location.href;
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distinguishes a tap from a press-and-hold on the same control.
|
||||||
|
*
|
||||||
|
* Built on Pointer Events so one implementation covers touch, mouse and pen.
|
||||||
|
* Two details make it behave:
|
||||||
|
*
|
||||||
|
* - A hold that fires must swallow the click that follows it, or the tap
|
||||||
|
* action runs straight after the hold action.
|
||||||
|
* - Small finger movement during a hold is normal, so the press only
|
||||||
|
* cancels once it drifts past a slop threshold — otherwise holds fail
|
||||||
|
* constantly on touch.
|
||||||
|
*
|
||||||
|
* Timing and slop match the chat bubbles' gesture handler so a hold feels the
|
||||||
|
* same everywhere in the app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const LONG_PRESS_MS = 450;
|
||||||
|
const LONG_PRESS_SLOP_PX = 8;
|
||||||
|
|
||||||
|
export function useLongPress({
|
||||||
|
onClick,
|
||||||
|
onLongPress,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
onLongPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const start = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const fired = useRef(false);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
timer.current = null;
|
||||||
|
start.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A hold can end with the pointer outside the button, or with the component
|
||||||
|
// unmounting behind whatever the hold opened; either would leak the timer.
|
||||||
|
useEffect(() => clear, [clear]);
|
||||||
|
|
||||||
|
const onPointerDown = (e: React.PointerEvent) => {
|
||||||
|
if (disabled) return;
|
||||||
|
fired.current = false;
|
||||||
|
start.current = { x: e.clientX, y: e.clientY };
|
||||||
|
timer.current = setTimeout(() => {
|
||||||
|
fired.current = true;
|
||||||
|
clear();
|
||||||
|
onLongPress();
|
||||||
|
}, LONG_PRESS_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: React.PointerEvent) => {
|
||||||
|
if (!start.current) return;
|
||||||
|
const dx = e.clientX - start.current.x;
|
||||||
|
const dy = e.clientY - start.current.y;
|
||||||
|
if (Math.abs(dx) > LONG_PRESS_SLOP_PX || Math.abs(dy) > LONG_PRESS_SLOP_PX) {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onPointerDown,
|
||||||
|
onPointerMove,
|
||||||
|
onPointerUp: clear,
|
||||||
|
onPointerCancel: clear,
|
||||||
|
onPointerLeave: clear,
|
||||||
|
// Suppress iOS's press-and-hold callout, which would otherwise cover the
|
||||||
|
// sheet the hold just opened.
|
||||||
|
onContextMenu: (e: React.MouseEvent) => {
|
||||||
|
if (fired.current) e.preventDefault();
|
||||||
|
},
|
||||||
|
onClick: () => {
|
||||||
|
// The hold already acted; the browser's trailing click is not a tap.
|
||||||
|
if (fired.current) {
|
||||||
|
fired.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!disabled) onClick();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user