Files
fusion/packages/mobile/src/plugins/share.ts
Fusion 123ec5949f feat(FN-1862): add completion state tracking for onboarding
- Add markOnboardingCompleted() to set completedAt timestamp when user finishes onboarding
- Add isOnboardingCompleted() to check if onboarding was completed (vs dismissed)
- Update ModelOnboardingModal to call markOnboardingCompleted on successful completion
- Skip onboarding auto-open when already completed locally
- Add completion state tracking tests in ModelOnboardingModal.test.tsx
- Add model-onboarding-state unit tests
- Update useAuthOnboarding to check local completion state before auto-opening
- Document localStorage completion state tracking pattern in .fusion/memory.md
2026-04-14 23:44:40 -07:00

195 lines
5.4 KiB
TypeScript

import { Capacitor } from "@capacitor/core";
import { EventEmitter } from "node:events";
export interface ShareTaskPayload {
id: string;
title?: string;
description: string;
}
export interface ShareManagerOptions {
/** Base URL for constructing deep links. Default: "fusion://task/" */
deepLinkBaseUrl?: string;
}
export interface ShareEventMap {
"share:success": { taskId: string };
"share:cancelled": { taskId: string };
"share:error": { taskId: string; error: Error };
}
type SharePlugin = typeof import("@capacitor/share");
type NavigatorSharePayload = {
title: string;
text: string;
url: string;
};
type ShareCapacitorResult = {
activityType?: string;
};
export class ShareManager extends EventEmitter {
private readonly deepLinkBaseUrl: string;
private initialized = false;
private sharePlugin: SharePlugin["Share"] | null = null;
constructor(options?: ShareManagerOptions) {
super();
this.deepLinkBaseUrl = options?.deepLinkBaseUrl ?? "fusion://task/";
}
override on<K extends keyof ShareEventMap>(
event: K,
listener: (payload: ShareEventMap[K]) => void,
): this;
override on(event: string | symbol, listener: (...args: any[]) => void): this;
override on(event: string | symbol, listener: (...args: any[]) => void): this {
return super.on(event, listener);
}
override off<K extends keyof ShareEventMap>(
event: K,
listener: (payload: ShareEventMap[K]) => void,
): this;
override off(event: string | symbol, listener: (...args: any[]) => void): this;
override off(event: string | symbol, listener: (...args: any[]) => void): this {
return super.off(event, listener);
}
emit<K extends keyof ShareEventMap>(event: K, payload: ShareEventMap[K]): boolean;
emit(event: string | symbol, payload?: unknown): boolean;
emit(event: string | symbol, payload?: unknown): boolean {
return super.emit(event, payload);
}
async initialize(): Promise<void> {
if (this.initialized) {
return;
}
this.initialized = true;
}
async shareTask(task: ShareTaskPayload): Promise<boolean> {
const sharePayload = this.buildPayload(task);
try {
if (Capacitor.isNativePlatform()) {
const share = await this.loadSharePlugin();
if (!share) {
throw new Error("Native share plugin is unavailable");
}
const result = (await share.share(sharePayload)) as ShareCapacitorResult | undefined;
if (result?.activityType === undefined) {
this.emit("share:cancelled", { taskId: task.id });
return false;
}
this.emit("share:success", { taskId: task.id });
return true;
}
const navigatorShare = this.getNavigatorShare();
if (navigatorShare) {
try {
await navigatorShare(sharePayload);
this.emit("share:success", { taskId: task.id });
return true;
} catch (error) {
if (this.isAbortError(error)) {
this.emit("share:cancelled", { taskId: task.id });
return false;
}
throw error;
}
}
const clipboardWriteText = this.getClipboardWriter();
if (!clipboardWriteText) {
throw new Error("No share mechanism available on this platform");
}
await clipboardWriteText(sharePayload.url);
this.emit("share:success", { taskId: task.id });
return true;
} catch (error) {
const normalizedError = error instanceof Error ? error : new Error(String(error));
this.emit("share:error", { taskId: task.id, error: normalizedError });
return false;
}
}
getDeepLinkBaseUrl(): string {
return this.deepLinkBaseUrl;
}
async destroy(): Promise<void> {
this.initialized = false;
this.removeAllListeners();
}
private buildPayload(task: ShareTaskPayload): NavigatorSharePayload {
const title = task.title ?? `Task ${task.id}`;
const text =
task.description.length > 200
? `${task.description.slice(0, 200)}...`
: task.description;
const url = `${this.deepLinkBaseUrl}${task.id}`;
return { title, text, url };
}
private async loadSharePlugin(): Promise<SharePlugin["Share"] | null> {
if (this.sharePlugin) {
return this.sharePlugin;
}
try {
const mod: SharePlugin = await import("@capacitor/share");
this.sharePlugin = mod.Share;
return this.sharePlugin;
} catch {
return null;
}
}
private getNavigatorShare(): ((payload: NavigatorSharePayload) => Promise<void>) | null {
const nav = globalThis.navigator as
| (Navigator & { share?: (payload: NavigatorSharePayload) => Promise<void> })
| undefined;
if (!nav || typeof nav.share !== "function") {
return null;
}
return nav.share.bind(nav);
}
private getClipboardWriter(): ((value: string) => Promise<void>) | null {
const nav = globalThis.navigator as
| (Navigator & { clipboard?: { writeText?: (value: string) => Promise<void> } })
| undefined;
const clipboard = nav?.clipboard;
const writeText = clipboard?.writeText;
if (typeof writeText !== "function" || !clipboard) {
return null;
}
return writeText.bind(clipboard);
}
private isAbortError(error: unknown): boolean {
return (
(error instanceof DOMException && error.name === "AbortError") ||
(typeof error === "object" &&
error !== null &&
"name" in error &&
(error as { name?: string }).name === "AbortError")
);
}
}