feat: image compress before upload
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
HiOutlineArrowPath,
|
||||
HiOutlineChatBubbleOvalLeft,
|
||||
@@ -32,6 +33,8 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { posts as postsApi, SocialError, type Post } from "@/lib/social";
|
||||
import { createShortLink } from "@/lib/share";
|
||||
import { structuredReferenceForPost } from "@/lib/source-reference";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
@@ -49,6 +52,9 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const [collectionsOpen, setCollectionsOpen] = useState(false);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
// Tracks the short-URL fetch on the direct-mobile-share path so the
|
||||
// Send icon can render a spinner while the user waits.
|
||||
const [sharePending, setSharePending] = useState(false);
|
||||
// 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.
|
||||
@@ -60,6 +66,11 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
||||
// Owners and resharers both see Send — the post is "on their account"
|
||||
// either way, so the action they need is external share, not reshare.
|
||||
const showSend = isOwner || reshared;
|
||||
// Capability-check the Web Share API once per render — supported on
|
||||
// mobile Safari/Chrome and some desktop Safari builds. We only want
|
||||
// the native-share short-circuit on phones.
|
||||
const nativeShareSupported =
|
||||
typeof navigator !== "undefined" && typeof navigator.share === "function";
|
||||
|
||||
async function withAuth(fn: () => void | Promise<void>) {
|
||||
if (!(await requireAuth())) return;
|
||||
@@ -80,6 +91,69 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Direct-mobile share path:
|
||||
// 1. User taps Send (consumes iOS Safari's user activation).
|
||||
// 2. We fetch the short URL — short enough to fit comfortably
|
||||
// inside the same activation window. Loading spinner replaces
|
||||
// the icon while we wait.
|
||||
// 3. navigator.share fires within the same handler. iOS sees the
|
||||
// activation token, opens the system share sheet.
|
||||
// 4. If activation has expired by the time we call share (slow
|
||||
// network) or anything else throws non-AbortError, fall back
|
||||
// to opening the custom in-app sheet so the user isn't stuck.
|
||||
//
|
||||
// Desktop / no native share API → straight to the custom sheet,
|
||||
// which handles its own short-URL fetch + image pre-fetch.
|
||||
async function onSendClick() {
|
||||
if (!isMobile || !nativeShareSupported) {
|
||||
setShareOpen(true);
|
||||
return;
|
||||
}
|
||||
if (sharePending) return;
|
||||
|
||||
const handle = post.author_handle ?? user?.handle ?? "";
|
||||
if (!handle) {
|
||||
// Without a handle we can't build the post URL; fall back to
|
||||
// the custom sheet which has its own resolution path.
|
||||
setShareOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setSharePending(true);
|
||||
try {
|
||||
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);
|
||||
const text = lines.join("\n");
|
||||
const title = ref.detail ? `${ref.title} — ${ref.detail}` : ref.title;
|
||||
|
||||
try {
|
||||
await navigator.share({ title, text });
|
||||
} catch (err) {
|
||||
// User-cancel = AbortError = leave them alone. Any other
|
||||
// failure (most often iOS NotAllowedError when the awaited
|
||||
// fetch overran the user-activation window) → fall back to
|
||||
// the in-app sheet so the user can still complete the share.
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
console.warn("[share] navigator.share failed, opening custom sheet", err);
|
||||
setShareOpen(true);
|
||||
}
|
||||
} finally {
|
||||
setSharePending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-2">
|
||||
@@ -108,9 +182,19 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
||||
state. */}
|
||||
{showSend ? (
|
||||
<ActionButton
|
||||
label="Send"
|
||||
onClick={() => setShareOpen(true)}
|
||||
icon={<HiOutlinePaperAirplane className={ICON_CLASS} />}
|
||||
label={sharePending ? "Preparing share link…" : "Send"}
|
||||
onClick={onSendClick}
|
||||
disabled={sharePending}
|
||||
icon={
|
||||
sharePending ? (
|
||||
<Loader2
|
||||
className={cn(ICON_CLASS, "animate-spin")}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<HiOutlinePaperAirplane className={ICON_CLASS} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ActionButton
|
||||
@@ -150,21 +234,29 @@ function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
icon,
|
||||
disabled = false,
|
||||
}: {
|
||||
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;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
"size-10 inline-flex items-center justify-center rounded-full",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
"transition-colors",
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:bg-transparent",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
|
||||
Reference in New Issue
Block a user