375 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|