fix: chat accepts quotes now

This commit is contained in:
gosangam
2026-08-29 11:58:33 +05:30
parent 028a8d7e1c
commit 76cd106ef7
11 changed files with 559 additions and 41 deletions
+17 -1
View File
@@ -168,7 +168,8 @@ export type AttachmentKind =
| "audio"
| "file"
| "link"
| "entity";
| "entity"
| "quote";
export type Attachment = {
id: string;
@@ -259,6 +260,7 @@ export const chat = {
replyToId?: string;
previewUrl?: string;
attachments?: UploadedAttachment[];
quote?: QuotePayload;
},
) =>
call<ChatMessage>(`/chat/conversations/${conversationId}/messages`, {
@@ -270,6 +272,7 @@ export const chat = {
// Only the URL: the BFF fetches the card's contents itself.
preview_url: opts?.previewUrl ?? null,
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 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 = {
key: string;
mime: string;
+53
View File
@@ -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"),
};
}
+12
View File
@@ -303,6 +303,18 @@ export function buildSearchResultUrl(
/**
* 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 {
const fragment = encodeTextFragment(selectedText);
if (!fragment) return window.location.href;
+87
View File
@@ -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();
},
};
}