Files
community-rule/app/(app)/create/hooks/useCompletedRuleShareExport.ts
T
2026-04-29 22:27:46 -06:00

375 lines
11 KiB
TypeScript

"use client";
import { useCallback } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { readLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import {
buildMailtoShareHref,
buildSlackWebShareUrl,
DISCORD_NATIVE_DM_HUB_URL,
DISCORD_WEB_DM_HUB_URL,
scheduleNativeSchemeThenFallback,
SLACK_NATIVE_OPEN_URL,
type NativeFallbackTimers,
type NativeNavigateDeps,
} from "../../../../lib/create/shareChannels";
import {
buildPublicRuleUrl,
downloadStoredRuleAsPdf,
downloadTextFile,
exportFilenameBase,
exportStoredRuleAsCsv,
exportStoredRuleAsMarkdown,
} from "../../../../lib/create/ruleExport";
export type CompletedFlowActionBanner = {
key: string;
status: "positive" | "danger";
title: string;
description?: string;
};
function browserNativeShareNavigateDeps(win: Window): NativeNavigateDeps {
return {
assignLocationHref: (url: string): void => {
// Transient <a>: same-tab custom-protocol handshake as location.href without replacing the SPA.
const anchor = win.document.createElement("a");
anchor.href = url;
anchor.rel = "noreferrer noopener";
anchor.style.position = "absolute";
anchor.style.left = "-9999px";
win.document.body.appendChild(anchor);
anchor.click();
anchor.remove();
},
getVisibilityState: (): Document["visibilityState"] =>
win.document.visibilityState,
onVisibilityChange: (listener: () => void): void => {
win.document.addEventListener("visibilitychange", listener);
},
offVisibilityChange: (listener: () => void): void => {
win.document.removeEventListener("visibilitychange", listener);
},
};
}
function browserNativeTimers(win: Window): NativeFallbackTimers {
return {
setTimeout: (cb: () => void, ms: number): unknown => win.setTimeout(cb, ms),
clearTimeout: (handle: unknown): void =>
win.clearTimeout(
handle as ReturnType<typeof win.setTimeout>,
),
};
}
/**
* After native app handoff, the page can stay `visibilityState === "visible"` while
* focus moves to the other app. Skip clipboard fallbacks in that case to avoid
* `NotAllowedError` noise when Slack/compose already succeeded.
*/
function shouldSkipShareClipboardFallback(win: Window): boolean {
return (
win.document.visibilityState === "hidden" || !win.document.hasFocus()
);
}
function resolvePublishedRuleShareContext(windowObj: Window): {
url: string;
title: string;
text: string;
} | null {
const rule = readLastPublishedRule();
if (!rule) return null;
const url = buildPublicRuleUrl(windowObj.location.origin, rule.id);
const summary =
typeof rule.summary === "string" ? rule.summary.trim() : "";
const text = summary.length > 0 ? summary : rule.title;
return { url, title: rule.title, text };
}
/**
* Share / export handlers for the completed step (`readLastPublishedRule`).
*/
export function useCompletedRuleShareExport({
setActionBanner,
}: {
setActionBanner: (_: CompletedFlowActionBanner | null) => void;
}): {
copyPublishedRuleLink: () => Promise<void>;
mailtoPublishedRule: () => void;
sharePublishedRuleViaSignal: () => Promise<void>;
sharePublishedRuleViaSlack: () => Promise<void>;
sharePublishedRuleViaDiscord: () => Promise<void>;
onSelectExportFormat: (_format: "pdf" | "csv" | "markdown") => void;
} {
const t = useTranslation("create.reviewAndComplete.completed");
const bannerNoRule = useCallback(() => {
setActionBanner({
key: "completedShareNoRule",
status: "danger",
title: t("shareNoRuleTitle"),
description: t("shareNoRuleDescription"),
});
}, [setActionBanner, t]);
const bannerCopied = useCallback(() => {
setActionBanner({
key: "completedShareCopied",
status: "positive",
title: t("shareLinkCopiedTitle"),
description: t("shareLinkCopiedDescription"),
});
}, [setActionBanner, t]);
const bannerCopyFailed = useCallback(() => {
setActionBanner({
key: "completedShareCopyFailed",
status: "danger",
title: t("shareCopyFailedTitle"),
description: t("shareCopyFailedDescription"),
});
}, [setActionBanner, t]);
const copyUrlToClipboard = useCallback(
async (
url: string,
banner?: () => void,
options?: { suppressFailureWhenDocumentNotFocused?: boolean },
) => {
try {
await navigator.clipboard.writeText(url);
(banner ?? bannerCopied)();
} catch {
if (
options?.suppressFailureWhenDocumentNotFocused === true &&
typeof window !== "undefined" &&
shouldSkipShareClipboardFallback(window)
) {
return;
}
bannerCopyFailed();
}
},
[bannerCopied, bannerCopyFailed],
);
const copyPublishedRuleLink = useCallback(async () => {
if (typeof window === "undefined") return;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
}
await copyUrlToClipboard(ctx.url);
}, [bannerNoRule, copyUrlToClipboard]);
const mailtoPublishedRule = useCallback(() => {
if (typeof window === "undefined") return;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
}
const body = `${ctx.text}\n\n${ctx.url}`;
window.location.href = buildMailtoShareHref({
subject: ctx.title,
body,
});
}, [bannerNoRule]);
const tryNavigatorShareAbortOk = useCallback(
async (data: ShareData): Promise<boolean> => {
if (typeof navigator.share !== "function") return false;
const can =
typeof navigator.canShare !== "function" || navigator.canShare(data);
if (!can) return false;
try {
await navigator.share(data);
return true;
} catch (e) {
const err = e as { name?: string };
if (err?.name === "AbortError") return true;
return false;
}
},
[],
);
/** Prefer URL-only share data when the platform allows it (common on mobile). */
const shareViaWebShareApiOrFalse = useCallback(
async (ctx: { url: string; title: string; text: string }) => {
const urlOnly: ShareData = { url: ctx.url };
if (await tryNavigatorShareAbortOk(urlOnly)) return true;
const full: ShareData = {
title: ctx.title,
text: ctx.text,
url: ctx.url,
};
return tryNavigatorShareAbortOk(full);
},
[tryNavigatorShareAbortOk],
);
const sharePublishedRuleViaSignal = useCallback(async () => {
if (typeof window === "undefined") return;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
}
if (await shareViaWebShareApiOrFalse(ctx)) return;
await copyUrlToClipboard(ctx.url);
}, [bannerNoRule, copyUrlToClipboard, shareViaWebShareApiOrFalse]);
const sharePublishedRuleViaSlack = useCallback(async () => {
if (typeof window === "undefined") return;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
}
const runSlackWebComposeFallback = async (): Promise<void> => {
const slackUrl = buildSlackWebShareUrl(ctx.url);
const popup = window.open(
slackUrl,
"_blank",
"noopener,noreferrer",
);
if (popup) return;
if (shouldSkipShareClipboardFallback(window)) return;
if (await shareViaWebShareApiOrFalse(ctx)) return;
if (shouldSkipShareClipboardFallback(window)) return;
await copyUrlToClipboard(
ctx.url,
() =>
setActionBanner({
key: "completedShareSlackFallback",
status: "positive",
title: t("shareSlackFallbackTitle"),
description: t("shareSlackFallbackDescription"),
}),
{ suppressFailureWhenDocumentNotFocused: true },
);
};
scheduleNativeSchemeThenFallback(
SLACK_NATIVE_OPEN_URL,
() => void runSlackWebComposeFallback(),
browserNativeShareNavigateDeps(window),
browserNativeTimers(window),
);
}, [
bannerNoRule,
copyUrlToClipboard,
shareViaWebShareApiOrFalse,
setActionBanner,
t,
]);
const sharePublishedRuleViaDiscord = useCallback(async () => {
if (typeof window === "undefined") return;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
}
if (await shareViaWebShareApiOrFalse(ctx)) return;
try {
await navigator.clipboard.writeText(ctx.url);
setActionBanner({
key: "completedShareDiscordPaste",
status: "positive",
title: t("shareDiscordPasteTitle"),
description: t("shareDiscordPasteDescription"),
});
} catch {
bannerCopyFailed();
}
scheduleNativeSchemeThenFallback(
DISCORD_NATIVE_DM_HUB_URL,
() =>
void window.open(
DISCORD_WEB_DM_HUB_URL,
"_blank",
"noopener,noreferrer",
),
browserNativeShareNavigateDeps(window),
browserNativeTimers(window),
);
}, [
bannerCopyFailed,
bannerNoRule,
shareViaWebShareApiOrFalse,
setActionBanner,
t,
]);
const onSelectExportFormat = useCallback(
(format: "pdf" | "csv" | "markdown") => {
if (typeof window === "undefined") return;
const rule = readLastPublishedRule();
if (!rule) {
setActionBanner({
key: "completedExportNoRule",
status: "danger",
title: t("shareNoRuleTitle"),
description: t("shareNoRuleDescription"),
});
return;
}
const base = exportFilenameBase(rule);
try {
if (format === "pdf") {
downloadStoredRuleAsPdf(rule);
} else if (format === "csv") {
const csv = exportStoredRuleAsCsv(rule);
downloadTextFile(
`${base}-community-rule.csv`,
csv,
"text/csv;charset=utf-8",
);
} else {
const md = exportStoredRuleAsMarkdown(rule);
downloadTextFile(
`${base}-community-rule.md`,
md,
"text/markdown;charset=utf-8",
);
}
} catch (e) {
const msg = e instanceof Error && e.message === "exportEmptyDocument";
setActionBanner({
key: "completedExportFailed",
status: "danger",
title: msg ? t("exportEmptyDocumentTitle") : t("exportFailedTitle"),
description: msg
? t("exportEmptyDocumentDescription")
: t("exportFailedDescription"),
});
}
},
[setActionBanner, t],
);
return {
copyPublishedRuleLink,
mailtoPublishedRule,
sharePublishedRuleViaSignal,
sharePublishedRuleViaSlack,
sharePublishedRuleViaDiscord,
onSelectExportFormat,
};
}