Implement share and export components
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Pure URL builders + a small navigator for cross-app share flows (mailto,
|
||||
* Slack compose, Discord DMs). Native schemes are allowlisted below.
|
||||
*/
|
||||
|
||||
/** Slack: opens native client default workspace — no prefilled compose (Slack lacks team-agnostic native share URLs). docs: slack://open */
|
||||
export const SLACK_NATIVE_OPEN_URL = "slack://open";
|
||||
|
||||
/**
|
||||
* Discord desktop/mobile client DM hub (@me). Mirrors https://discord.com/channels/@me
|
||||
* (widely referenced community/client pattern: discord://-/channels/@me).
|
||||
*/
|
||||
export const DISCORD_NATIVE_DM_HUB_URL = "discord://-/channels/@me";
|
||||
|
||||
const ALLOWLISTED_NATIVE_NAV_URL = new Set<string>([
|
||||
SLACK_NATIVE_OPEN_URL,
|
||||
DISCORD_NATIVE_DM_HUB_URL,
|
||||
]);
|
||||
|
||||
/** Slack historically exposed a web share endpoint; still useful as primary web compose. */
|
||||
export function buildSlackWebShareUrl(externalUrl: string): string {
|
||||
return `https://slack.com/share?url=${encodeURIComponent(externalUrl)}`;
|
||||
}
|
||||
|
||||
/** Opens Discord in the browser / app; user pastes the rule URL manually. */
|
||||
export const DISCORD_WEB_DM_HUB_URL = "https://discord.com/channels/@me";
|
||||
|
||||
/**
|
||||
* RFC 6068-style mailto href with percent-encoded subject and body.
|
||||
* Body may contain newlines; they are encoded as %0A.
|
||||
*/
|
||||
export function buildMailtoShareHref(parts: {
|
||||
subject: string;
|
||||
body: string;
|
||||
}): string {
|
||||
const subject = encodeURIComponent(parts.subject);
|
||||
const body = encodeURIComponent(parts.body);
|
||||
return `mailto:?subject=${subject}&body=${body}`;
|
||||
}
|
||||
|
||||
export const NATIVE_SHARE_FALLBACK_DELAY_MS = 550;
|
||||
|
||||
/** @internal Injectable timer surface for tests. */
|
||||
export interface NativeFallbackTimers {
|
||||
setTimeout(cb: () => void, ms: number): unknown;
|
||||
clearTimeout(handle: unknown): void;
|
||||
}
|
||||
|
||||
/** @internal Location assign / href navigation for tests. */
|
||||
export interface NativeNavigateDeps {
|
||||
assignLocationHref: (_url: string) => void;
|
||||
getVisibilityState: () => Document["visibilityState"];
|
||||
onVisibilityChange: (_listener: () => void) => void;
|
||||
offVisibilityChange: (_listener: () => void) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns an allowlisted `slack:` / `discord:` URL once, then invokes
|
||||
* `fallback` after `delayMs` if the tab never became hidden (blur / minimize).
|
||||
* Cancels fallback when visibility becomes `"hidden"` (tab backgrounded).
|
||||
*
|
||||
* Not a guarantee the native app opens; web cannot detect install reliably.
|
||||
*/
|
||||
export function scheduleNativeSchemeThenFallback(
|
||||
nativeUrl: string,
|
||||
fallback: () => void,
|
||||
deps: NativeNavigateDeps,
|
||||
timers: NativeFallbackTimers,
|
||||
delayMs = NATIVE_SHARE_FALLBACK_DELAY_MS,
|
||||
): () => void {
|
||||
if (!ALLOWLISTED_NATIVE_NAV_URL.has(nativeUrl)) {
|
||||
fallback();
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let cancelledBecauseHidden = deps.getVisibilityState() === "hidden";
|
||||
|
||||
const onHidden = (): void => {
|
||||
if (deps.getVisibilityState() === "hidden") {
|
||||
cancelledBecauseHidden = true;
|
||||
}
|
||||
};
|
||||
|
||||
deps.onVisibilityChange(onHidden);
|
||||
|
||||
try {
|
||||
deps.assignLocationHref(nativeUrl);
|
||||
} catch {
|
||||
deps.offVisibilityChange(onHidden);
|
||||
fallback();
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const id = timers.setTimeout(() => {
|
||||
deps.offVisibilityChange(onHidden);
|
||||
if (!cancelledBecauseHidden && deps.getVisibilityState() === "visible") {
|
||||
fallback();
|
||||
}
|
||||
}, delayMs);
|
||||
|
||||
return () => {
|
||||
timers.clearTimeout(id);
|
||||
deps.offVisibilityChange(onHidden);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user