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
+4
View File
@@ -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
+47
View File
@@ -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>
);
}
+241
View File
@@ -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}
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"
/>
<button
type="button"
+41 -5
View File
@@ -39,6 +39,9 @@ import {
type Comment,
type Post,
} 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 { useAuthGate } from "@/components/auth/auth-gate";
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
// can render a spinner while the user waits.
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
// happens the instant the user taps Reshare. Reconciles to the
// 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
// path (mount the custom SharePostSheet). Provider's openShareSheet
// 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() {
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;
setSharePending(true);
try {
@@ -150,6 +175,12 @@ export function PostActionsFooter({ post }: { post: Post }) {
}
}
const sendGestures = useLongPress({
onClick: onSendClick,
onLongPress: onSendHold,
disabled: sharePending,
});
return (
<>
<div className="flex items-center justify-between px-2">
@@ -197,8 +228,8 @@ export function PostActionsFooter({ post }: { post: Post }) {
{showSend ? (
<ActionButton
label={sharePending ? "Preparing share link…" : "Send"}
onClick={onSendClick}
disabled={sharePending}
{...sendGestures}
icon={
sharePending ? (
<Loader2
@@ -270,29 +301,34 @@ export function PostActionsFooter({ post }: { post: Post }) {
{/* SharePostSheet is mounted globally by SharePostProvider, so
no per-card instance lives here. The provider's
openShareSheet swaps the active post under the hood. */}
{chatBody !== null && (
<ShareToChatDialog body={chatBody} onClose={() => setChatBody(null)} />
)}
</>
);
}
function ActionButton({
label,
onClick,
icon,
disabled = false,
...buttonProps
}: {
label: string;
onClick: () => void;
icon: React.ReactNode;
/** Greys out the button and blocks clicks. Used by the Send button
* while the short URL is being resolved on the mobile native-share
* path so users can't fire navigator.share before the URL is
* ready. */
disabled?: boolean;
}) {
/** onClick, plus the pointer handlers the Send button needs to tell a
* tap from a hold. */
} & React.ComponentProps<"button">) {
return (
<button
{...buttonProps}
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
+8 -22
View File
@@ -38,8 +38,7 @@ import {
useState,
} from "react";
import type { Post } from "@/lib/social";
import { createShortLink } from "@/lib/share";
import { structuredReferenceForPost } from "@/lib/source-reference";
import { buildPostShareText } from "@/lib/post-share-text";
import { useAuth } from "@/components/auth/auth-provider";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { SharePostSheet } from "./share-post-sheet";
@@ -131,30 +130,17 @@ export function SharePostProvider({
// it.
// 3. On any non-AbortError, fall back to the custom sheet.
try {
const longPath = `/u/${encodeURIComponent(handle)}/posts#post-${post.id}`;
const hasImage = !!post.images?.[0];
const [code, imageFile] = await Promise.all([
createShortLink(longPath),
const [share, imageFile] = await Promise.all([
buildPostShareText(post, handle),
hasImage ? fetchShareImageFile(post.id) : Promise.resolve(null),
]);
const origin = window.location.origin;
const url = code
? new URL(`/share/${code}`, origin).toString()
: new URL(longPath, origin).toString();
if (!share) {
setActivePost(post);
return;
}
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);
const text = lines.join("\n");
const title = ref.detail
? `${ref.title} — ${ref.detail}`
: ref.title;
const payload: ShareData = { title, text };
const payload: ShareData = { title: share.title, text: share.text };
// Only attach files when the platform actually accepts them.
// canShare returns false on Chrome desktop / Firefox even
// though navigator.share itself exists, and we want to fail
+45 -12
View File
@@ -6,16 +6,16 @@ import {
Copy,
FolderPlus,
Link as LinkIcon,
MessageCircle,
MoreHorizontal,
Send,
Share2,
} from "lucide-react";
import { toast } from "sonner";
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 {
KautilyaIcon,
useMediaQuery,
useCopyFeedback,
type Position,
@@ -25,6 +25,8 @@ import { FloatingToolbar } from "./selection-toolbar/floating-toolbar";
import { CollectionsDrawer } from "./selection-toolbar/collections-drawer";
import { MorePopover, type MoreItem } from "./selection-toolbar/more-popover";
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 { useAuthGate } from "./auth/auth-gate";
@@ -34,6 +36,8 @@ import { useAuthGate } from "./auth/auth-gate";
export function TextSelectionToolbar() {
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 [showMore, setShowMore] = useState(false);
const [morePos, setMorePos] = useState<Position>({ x: 0, y: 0 });
@@ -337,6 +341,21 @@ export function TextSelectionToolbar() {
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() {
if (!(await requireAuth())) return;
if (isMobile) saveAndClearSelection();
@@ -418,14 +437,11 @@ export function TextSelectionToolbar() {
const isAuthed = !!user;
const askAction: ToolbarAction = {
key: "ask",
label: "Ask Kautilya",
icon: <KautilyaIcon className="size-3.5" />,
onClick: () => {
// TODO: Implement Ask Kautilya
finishAction();
},
const chatAction: ToolbarAction = {
key: "chat",
label: "Chat",
icon: <MessageCircle className="size-3.5" />,
onClick: onChat,
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
};
@@ -445,7 +461,7 @@ export function TextSelectionToolbar() {
};
const authedToolbarActions: ToolbarAction[] = [
askAction,
chatAction,
{
key: "post",
label: "Post",
@@ -466,7 +482,6 @@ export function TextSelectionToolbar() {
];
const publicToolbarActions: ToolbarAction[] = [
askAction,
{
key: "copy",
label: "Copy",
@@ -515,6 +530,17 @@ export function TextSelectionToolbar() {
];
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",
label: "Share as Post",
@@ -601,6 +627,13 @@ export function TextSelectionToolbar() {
isMobile={isMobile}
selectedText={selectedText}
/>
{chatQuote !== null && (
<ShareToChatDialog
quote={chatQuote}
onClose={() => setChatQuote(null)}
/>
)}
</div>
);
}
+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();
},
};
}