fix: chat accepts quotes now
This commit is contained in:
+17
-1
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -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