feat(FN-1118): add mobile sharing and deep-link managers
- Implement ShareManager with native Capacitor sharing, web share fallback, and clipboard fallback plus typed share lifecycle events - Implement DeepLinkManager with native appUrlOpen and browser hash listeners, URL parsing for fusion schemes and universal links, and error events - Wire plugin exports and initializePlugins support for share/deepLinks options, and configure Capacitor iOS/Android fusion URL schemes - Add comprehensive Vitest coverage for sharing and deep-link behavior and document usage in the mobile package README
This commit is contained in:
@@ -13,6 +13,8 @@ const config: CapacitorConfig = {
|
|||||||
// to the backend at the configured server url.
|
// to the backend at the configured server url.
|
||||||
url: process.env.FUSION_BACKEND_URL || undefined,
|
url: process.env.FUSION_BACKEND_URL || undefined,
|
||||||
cleartext: true, // Allow HTTP connections to local dev servers
|
cleartext: true, // Allow HTTP connections to local dev servers
|
||||||
|
iosScheme: "fusion",
|
||||||
|
androidScheme: "fusion",
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
SplashScreen: {
|
SplashScreen: {
|
||||||
|
|||||||
@@ -59,3 +59,103 @@ Use `manager.getDeviceToken()` after registration to retrieve the native device
|
|||||||
This package currently handles **receiving** push notifications and in-app routing events only.
|
This package currently handles **receiving** push notifications and in-app routing events only.
|
||||||
|
|
||||||
Server-side FCM/APNs delivery infrastructure (token storage, provider credentials, push sending services) is intentionally out of scope for this feature.
|
Server-side FCM/APNs delivery infrastructure (token storage, provider credentials, push sending services) is intentionally out of scope for this feature.
|
||||||
|
|
||||||
|
## Native Sharing & Deep Links
|
||||||
|
|
||||||
|
### ShareManager
|
||||||
|
|
||||||
|
`ShareManager` opens platform-native sharing when available and always includes a Fusion deep link in the shared payload.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { ShareManager } from "@fusion/mobile";
|
||||||
|
|
||||||
|
const manager = new ShareManager();
|
||||||
|
await manager.initialize();
|
||||||
|
|
||||||
|
await manager.shareTask({
|
||||||
|
id: "FN-1118",
|
||||||
|
title: "Mobile Plugins - Native Sharing & Deep Links",
|
||||||
|
description: "Implements native share sheet support and deep link parsing.",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Share behavior + fallbacks
|
||||||
|
|
||||||
|
- Builds a payload with:
|
||||||
|
- `title`: `task.title` or fallback `Task {id}`
|
||||||
|
- `text`: task description (truncated to 200 chars with `...` when needed)
|
||||||
|
- `url`: `${deepLinkBaseUrl}{task.id}` (default base: `fusion://task/`)
|
||||||
|
- **Native (Capacitor)**: uses `@capacitor/share`
|
||||||
|
- **Web fallback**: uses `navigator.share(...)` when available
|
||||||
|
- **Final fallback**: copies the deep-link URL to `navigator.clipboard.writeText(...)`
|
||||||
|
|
||||||
|
#### Share events
|
||||||
|
|
||||||
|
- `share:success` → `{ taskId }`
|
||||||
|
- `share:cancelled` → `{ taskId }`
|
||||||
|
- `share:error` → `{ taskId, error }`
|
||||||
|
|
||||||
|
### DeepLinkManager
|
||||||
|
|
||||||
|
`DeepLinkManager` handles incoming links and emits parsed payloads for app-level navigation.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { DeepLinkManager } from "@fusion/mobile";
|
||||||
|
|
||||||
|
const deepLinks = new DeepLinkManager({
|
||||||
|
scheme: "fusion://",
|
||||||
|
universalLinkHosts: ["app.fusion.dev"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await deepLinks.initialize();
|
||||||
|
|
||||||
|
deepLinks.on("deeplink:received", (payload) => {
|
||||||
|
// route to screen/task/project in app UI
|
||||||
|
console.log(payload);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Supported URL patterns
|
||||||
|
|
||||||
|
- `fusion://task/{taskId}`
|
||||||
|
- `fusion://project/{projectId}`
|
||||||
|
- `fusion://project/{projectId}/task/{taskId}`
|
||||||
|
- `fusion://settings`
|
||||||
|
- `fusion://agents`
|
||||||
|
- Query params are preserved in `payload.params` for custom-scheme links
|
||||||
|
|
||||||
|
Universal links are supported when the host is allowed in `universalLinkHosts`, e.g.:
|
||||||
|
|
||||||
|
- `https://app.fusion.dev/?task=FN-123`
|
||||||
|
- `https://app.fusion.dev/?project=my-project&task=FN-123&target=task`
|
||||||
|
|
||||||
|
#### Deep link events
|
||||||
|
|
||||||
|
- `deeplink:received` → parsed `DeepLinkPayload`
|
||||||
|
- `deeplink:error` → `{ url, error }`
|
||||||
|
|
||||||
|
Use `handleUrl(url)` for programmatic handling (for example, push-notification tap flows that already provide a URL string).
|
||||||
|
|
||||||
|
### Integration flow: share -> open -> navigate
|
||||||
|
|
||||||
|
A common flow is:
|
||||||
|
|
||||||
|
1. Use `ShareManager.shareTask(...)` to share a task link like `fusion://task/FN-123`
|
||||||
|
2. Recipient opens that link on mobile
|
||||||
|
3. `DeepLinkManager` receives/parses the URL
|
||||||
|
4. Your UI listens to `deeplink:received` and navigates to the matching task view
|
||||||
|
|
||||||
|
### Capacitor deep-link scheme registration
|
||||||
|
|
||||||
|
The Fusion mobile app registers the custom URL scheme in `packages/dashboard/capacitor.config.ts`:
|
||||||
|
|
||||||
|
- `server.iosScheme = "fusion"`
|
||||||
|
- `server.androidScheme = "fusion"`
|
||||||
|
|
||||||
|
### Browser hash listener (development/testing)
|
||||||
|
|
||||||
|
On non-native platforms, `DeepLinkManager` listens for hash changes in the form:
|
||||||
|
|
||||||
|
- `#deeplink=<encoded-url>`
|
||||||
|
|
||||||
|
This hash-based behavior is intended for development/testing only and is not a production universal-link replacement.
|
||||||
|
|||||||
@@ -12,8 +12,10 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@capacitor/app": "^7.1.2",
|
||||||
"@capacitor/core": "^7.0.0",
|
"@capacitor/core": "^7.0.0",
|
||||||
"@capacitor/push-notifications": "^7.0.0"
|
"@capacitor/push-notifications": "^7.0.0",
|
||||||
|
"@capacitor/share": "^7.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/android": "^7.0.0",
|
"@capacitor/android": "^7.0.0",
|
||||||
|
|||||||
325
packages/mobile/src/__tests__/deep-links.test.ts
Normal file
325
packages/mobile/src/__tests__/deep-links.test.ts
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
type AppUrlOpenListener = (event: { url: string }) => void;
|
||||||
|
|
||||||
|
const mockState = vi.hoisted(() => {
|
||||||
|
const state: {
|
||||||
|
isNativePlatform: ReturnType<typeof vi.fn>;
|
||||||
|
addListener: ReturnType<typeof vi.fn>;
|
||||||
|
appListenerRemove: ReturnType<typeof vi.fn>;
|
||||||
|
appUrlOpenListener?: AppUrlOpenListener;
|
||||||
|
} = {
|
||||||
|
isNativePlatform: vi.fn(() => false),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
appListenerRemove: vi.fn(async () => {}),
|
||||||
|
appUrlOpenListener: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.addListener.mockImplementation(
|
||||||
|
async (eventName: string, callback: AppUrlOpenListener) => {
|
||||||
|
if (eventName === "appUrlOpen") {
|
||||||
|
state.appUrlOpenListener = callback;
|
||||||
|
}
|
||||||
|
return { remove: state.appListenerRemove };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@capacitor/core", () => ({
|
||||||
|
Capacitor: {
|
||||||
|
isNativePlatform: mockState.isNativePlatform,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@capacitor/app", () => ({
|
||||||
|
App: {
|
||||||
|
addListener: mockState.addListener,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { DeepLinkManager } from "../plugins/deep-links.js";
|
||||||
|
|
||||||
|
const setupWindowMock = () => {
|
||||||
|
const listeners = new Map<string, (event: Event) => void>();
|
||||||
|
const location = { hash: "" };
|
||||||
|
const addEventListener = vi.fn((event: string, handler: (evt: Event) => void) => {
|
||||||
|
listeners.set(event, handler);
|
||||||
|
});
|
||||||
|
const removeEventListener = vi.fn((event: string, handler: (evt: Event) => void) => {
|
||||||
|
const existing = listeners.get(event);
|
||||||
|
if (existing === handler) {
|
||||||
|
listeners.delete(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("window", {
|
||||||
|
location,
|
||||||
|
addEventListener,
|
||||||
|
removeEventListener,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
listeners,
|
||||||
|
location,
|
||||||
|
addEventListener,
|
||||||
|
removeEventListener,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("DeepLinkManager", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockState.isNativePlatform.mockReturnValue(false);
|
||||||
|
mockState.appUrlOpenListener = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("URL parsing: fusion://task/FN-123", () => {
|
||||||
|
const manager = new DeepLinkManager();
|
||||||
|
|
||||||
|
const payload = manager.handleUrl("fusion://task/FN-123");
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
url: "fusion://task/FN-123",
|
||||||
|
target: "task",
|
||||||
|
taskId: "FN-123",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("URL parsing: fusion://project/my-project", () => {
|
||||||
|
const manager = new DeepLinkManager();
|
||||||
|
|
||||||
|
const payload = manager.handleUrl("fusion://project/my-project");
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
url: "fusion://project/my-project",
|
||||||
|
target: "project",
|
||||||
|
projectId: "my-project",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("URL parsing: fusion://project/my-project/task/FN-123", () => {
|
||||||
|
const manager = new DeepLinkManager();
|
||||||
|
|
||||||
|
const payload = manager.handleUrl("fusion://project/my-project/task/FN-123");
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
url: "fusion://project/my-project/task/FN-123",
|
||||||
|
target: "project",
|
||||||
|
projectId: "my-project",
|
||||||
|
taskId: "FN-123",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("URL parsing: fusion://settings", () => {
|
||||||
|
const manager = new DeepLinkManager();
|
||||||
|
|
||||||
|
const payload = manager.handleUrl("fusion://settings");
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
url: "fusion://settings",
|
||||||
|
target: "settings",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("URL parsing: fusion://agents", () => {
|
||||||
|
const manager = new DeepLinkManager();
|
||||||
|
|
||||||
|
const payload = manager.handleUrl("fusion://agents");
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
url: "fusion://agents",
|
||||||
|
target: "agents",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("URL parsing: fusion://task/FN-123?tab=workflow", () => {
|
||||||
|
const manager = new DeepLinkManager();
|
||||||
|
|
||||||
|
const payload = manager.handleUrl("fusion://task/FN-123?tab=workflow");
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
url: "fusion://task/FN-123?tab=workflow",
|
||||||
|
target: "task",
|
||||||
|
taskId: "FN-123",
|
||||||
|
params: { tab: "workflow" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("URL parsing: universal link https://app.fusion.dev/?task=FN-123", () => {
|
||||||
|
const manager = new DeepLinkManager({ universalLinkHosts: ["app.fusion.dev"] });
|
||||||
|
|
||||||
|
const payload = manager.handleUrl("https://app.fusion.dev/?task=FN-123");
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
url: "https://app.fusion.dev/?task=FN-123",
|
||||||
|
taskId: "FN-123",
|
||||||
|
projectId: undefined,
|
||||||
|
target: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("URL parsing: universal link from unrecognized host emits deeplink:error", () => {
|
||||||
|
const manager = new DeepLinkManager({ universalLinkHosts: ["app.fusion.dev"] });
|
||||||
|
const onError = vi.fn();
|
||||||
|
manager.on("deeplink:error", onError);
|
||||||
|
|
||||||
|
const payload = manager.handleUrl("https://untrusted.example/?task=FN-123");
|
||||||
|
|
||||||
|
expect(payload).toBeNull();
|
||||||
|
expect(onError).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: "https://untrusted.example/?task=FN-123",
|
||||||
|
error: expect.any(Error),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("URL parsing: malformed URL emits deeplink:error and handleUrl returns null", () => {
|
||||||
|
const manager = new DeepLinkManager();
|
||||||
|
const onError = vi.fn();
|
||||||
|
manager.on("deeplink:error", onError);
|
||||||
|
|
||||||
|
const payload = manager.handleUrl("not a url");
|
||||||
|
|
||||||
|
expect(payload).toBeNull();
|
||||||
|
expect(onError).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: "not a url",
|
||||||
|
error: expect.any(Error),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("URL parsing: original URL preserved in url field of all payloads", () => {
|
||||||
|
const manager = new DeepLinkManager({ universalLinkHosts: ["app.fusion.dev"] });
|
||||||
|
|
||||||
|
const customPayload = manager.handleUrl("fusion://task/FN-321");
|
||||||
|
const universalPayload = manager.handleUrl(
|
||||||
|
"https://app.fusion.dev/?task=FN-654&target=task",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(customPayload?.url).toBe("fusion://task/FN-321");
|
||||||
|
expect(universalPayload?.url).toBe(
|
||||||
|
"https://app.fusion.dev/?task=FN-654&target=task",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Native listener: initialize() registers App.addListener(appUrlOpen) on native platform", async () => {
|
||||||
|
mockState.isNativePlatform.mockReturnValue(true);
|
||||||
|
const manager = new DeepLinkManager();
|
||||||
|
|
||||||
|
await manager.initialize();
|
||||||
|
|
||||||
|
expect(mockState.addListener).toHaveBeenCalledWith("appUrlOpen", expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Native listener: incoming appUrlOpen event is parsed and emits deeplink:received", async () => {
|
||||||
|
mockState.isNativePlatform.mockReturnValue(true);
|
||||||
|
const manager = new DeepLinkManager();
|
||||||
|
const onReceived = vi.fn();
|
||||||
|
manager.on("deeplink:received", onReceived);
|
||||||
|
|
||||||
|
await manager.initialize();
|
||||||
|
mockState.appUrlOpenListener?.({ url: "fusion://task/FN-900" });
|
||||||
|
|
||||||
|
expect(onReceived).toHaveBeenCalledWith({
|
||||||
|
url: "fusion://task/FN-900",
|
||||||
|
target: "task",
|
||||||
|
taskId: "FN-900",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Native listener: initialize() does NOT register App listener on non-native platform", async () => {
|
||||||
|
mockState.isNativePlatform.mockReturnValue(false);
|
||||||
|
const manager = new DeepLinkManager();
|
||||||
|
setupWindowMock();
|
||||||
|
|
||||||
|
await manager.initialize();
|
||||||
|
|
||||||
|
expect(mockState.addListener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Browser listener: initialize() registers hashchange listener on non-native platform", async () => {
|
||||||
|
const windowMock = setupWindowMock();
|
||||||
|
const manager = new DeepLinkManager();
|
||||||
|
|
||||||
|
await manager.initialize();
|
||||||
|
|
||||||
|
expect(windowMock.addEventListener).toHaveBeenCalledWith("hashchange", expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Browser listener: #deeplink=fusion://task/FN-123 hash change triggers deeplink:received event", async () => {
|
||||||
|
const windowMock = setupWindowMock();
|
||||||
|
const manager = new DeepLinkManager();
|
||||||
|
const onReceived = vi.fn();
|
||||||
|
manager.on("deeplink:received", onReceived);
|
||||||
|
|
||||||
|
await manager.initialize();
|
||||||
|
|
||||||
|
windowMock.location.hash = `#deeplink=${encodeURIComponent("fusion://task/FN-123")}`;
|
||||||
|
const hashHandler = windowMock.listeners.get("hashchange");
|
||||||
|
hashHandler?.(new Event("hashchange"));
|
||||||
|
|
||||||
|
expect(onReceived).toHaveBeenCalledWith({
|
||||||
|
url: "fusion://task/FN-123",
|
||||||
|
target: "task",
|
||||||
|
taskId: "FN-123",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Lifecycle: initialize() is idempotent (second call is no-op)", async () => {
|
||||||
|
mockState.isNativePlatform.mockReturnValue(true);
|
||||||
|
const manager = new DeepLinkManager();
|
||||||
|
|
||||||
|
await manager.initialize();
|
||||||
|
await manager.initialize();
|
||||||
|
|
||||||
|
expect(mockState.addListener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Lifecycle: getScheme() returns the configured scheme string", () => {
|
||||||
|
const manager = new DeepLinkManager({ scheme: "fusion-custom://" });
|
||||||
|
|
||||||
|
expect(manager.getScheme()).toBe("fusion-custom://");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Lifecycle: destroy() removes Capacitor App listener via handle.remove()", async () => {
|
||||||
|
mockState.isNativePlatform.mockReturnValue(true);
|
||||||
|
const manager = new DeepLinkManager();
|
||||||
|
|
||||||
|
await manager.initialize();
|
||||||
|
await manager.destroy();
|
||||||
|
|
||||||
|
expect(mockState.appListenerRemove).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Lifecycle: destroy() removes browser hashchange listener", async () => {
|
||||||
|
const windowMock = setupWindowMock();
|
||||||
|
const manager = new DeepLinkManager();
|
||||||
|
|
||||||
|
await manager.initialize();
|
||||||
|
await manager.destroy();
|
||||||
|
|
||||||
|
expect(windowMock.removeEventListener).toHaveBeenCalledWith(
|
||||||
|
"hashchange",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Lifecycle: destroy() removes all EventEmitter listeners", async () => {
|
||||||
|
const manager = new DeepLinkManager();
|
||||||
|
const onReceived = vi.fn();
|
||||||
|
manager.on("deeplink:received", onReceived);
|
||||||
|
|
||||||
|
await manager.destroy();
|
||||||
|
|
||||||
|
manager.emit("deeplink:received", { url: "fusion://task/FN-001" });
|
||||||
|
expect(onReceived).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
245
packages/mobile/src/__tests__/share.test.ts
Normal file
245
packages/mobile/src/__tests__/share.test.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockState = vi.hoisted(() => ({
|
||||||
|
isNativePlatform: vi.fn(() => false),
|
||||||
|
nativeShare: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@capacitor/core", () => ({
|
||||||
|
Capacitor: {
|
||||||
|
isNativePlatform: mockState.isNativePlatform,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@capacitor/share", () => ({
|
||||||
|
Share: {
|
||||||
|
share: mockState.nativeShare,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ShareManager } from "../plugins/share.js";
|
||||||
|
|
||||||
|
describe("ShareManager", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockState.isNativePlatform.mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Native share: Share.share() called with correct title, text (truncated), and deep link URL", async () => {
|
||||||
|
mockState.isNativePlatform.mockReturnValue(true);
|
||||||
|
mockState.nativeShare.mockResolvedValue({ activityType: "copy" });
|
||||||
|
const manager = new ShareManager();
|
||||||
|
const longDescription = "x".repeat(250);
|
||||||
|
|
||||||
|
await manager.shareTask({ id: "FN-123", description: longDescription });
|
||||||
|
|
||||||
|
expect(mockState.nativeShare).toHaveBeenCalledWith({
|
||||||
|
title: "Task FN-123",
|
||||||
|
text: `${"x".repeat(200)}...`,
|
||||||
|
url: "fusion://task/FN-123",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Native share: emits share:success and returns true when share completes with activityType", async () => {
|
||||||
|
mockState.isNativePlatform.mockReturnValue(true);
|
||||||
|
mockState.nativeShare.mockResolvedValue({ activityType: "mail" });
|
||||||
|
const manager = new ShareManager();
|
||||||
|
const onSuccess = vi.fn();
|
||||||
|
manager.on("share:success", onSuccess);
|
||||||
|
|
||||||
|
const result = await manager.shareTask({
|
||||||
|
id: "FN-123",
|
||||||
|
title: "Test Task",
|
||||||
|
description: "Short description",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(onSuccess).toHaveBeenCalledWith({ taskId: "FN-123" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Native share: emits share:cancelled and returns false when activityType is undefined", async () => {
|
||||||
|
mockState.isNativePlatform.mockReturnValue(true);
|
||||||
|
mockState.nativeShare.mockResolvedValue({});
|
||||||
|
const manager = new ShareManager();
|
||||||
|
const onCancelled = vi.fn();
|
||||||
|
manager.on("share:cancelled", onCancelled);
|
||||||
|
|
||||||
|
const result = await manager.shareTask({
|
||||||
|
id: "FN-124",
|
||||||
|
description: "Short description",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(onCancelled).toHaveBeenCalledWith({ taskId: "FN-124" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Native share: emits share:error and returns false when Share.share() throws", async () => {
|
||||||
|
mockState.isNativePlatform.mockReturnValue(true);
|
||||||
|
mockState.nativeShare.mockRejectedValue(new Error("share failed"));
|
||||||
|
const manager = new ShareManager();
|
||||||
|
const onError = vi.fn();
|
||||||
|
manager.on("share:error", onError);
|
||||||
|
|
||||||
|
const result = await manager.shareTask({
|
||||||
|
id: "FN-125",
|
||||||
|
description: "Short description",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(onError).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
taskId: "FN-125",
|
||||||
|
error: expect.any(Error),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Native share: uses task.title when provided, falls back to Task {id} when title is undefined", async () => {
|
||||||
|
mockState.isNativePlatform.mockReturnValue(true);
|
||||||
|
mockState.nativeShare.mockResolvedValue({ activityType: "copy" });
|
||||||
|
const manager = new ShareManager();
|
||||||
|
|
||||||
|
await manager.shareTask({ id: "FN-126", title: "Explicit Title", description: "Body" });
|
||||||
|
await manager.shareTask({ id: "FN-127", description: "Body" });
|
||||||
|
|
||||||
|
expect(mockState.nativeShare).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({ title: "Explicit Title" }),
|
||||||
|
);
|
||||||
|
expect(mockState.nativeShare).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({ title: "Task FN-127" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Native share: constructs correct deep link URL fusion://task/{id}", async () => {
|
||||||
|
mockState.isNativePlatform.mockReturnValue(true);
|
||||||
|
mockState.nativeShare.mockResolvedValue({ activityType: "copy" });
|
||||||
|
const manager = new ShareManager();
|
||||||
|
|
||||||
|
await manager.shareTask({ id: "KB-777", description: "Task body" });
|
||||||
|
|
||||||
|
expect(mockState.nativeShare).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ url: "fusion://task/KB-777" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Native share: respects custom deepLinkBaseUrl option", async () => {
|
||||||
|
mockState.isNativePlatform.mockReturnValue(true);
|
||||||
|
mockState.nativeShare.mockResolvedValue({ activityType: "copy" });
|
||||||
|
const manager = new ShareManager({ deepLinkBaseUrl: "fusion://project/demo/task/" });
|
||||||
|
|
||||||
|
await manager.shareTask({ id: "FN-201", description: "Task body" });
|
||||||
|
|
||||||
|
expect(mockState.nativeShare).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ url: "fusion://project/demo/task/FN-201" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Native share: truncates description longer than 200 chars and appends ...", async () => {
|
||||||
|
mockState.isNativePlatform.mockReturnValue(true);
|
||||||
|
mockState.nativeShare.mockResolvedValue({ activityType: "copy" });
|
||||||
|
const manager = new ShareManager();
|
||||||
|
|
||||||
|
await manager.shareTask({ id: "FN-202", description: "a".repeat(201) });
|
||||||
|
|
||||||
|
expect(mockState.nativeShare).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ text: `${"a".repeat(200)}...` }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Web fallback: calls navigator.share() when not native but Web Share API is available", async () => {
|
||||||
|
const share = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.stubGlobal("navigator", {
|
||||||
|
share,
|
||||||
|
clipboard: {
|
||||||
|
writeText: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new ShareManager();
|
||||||
|
const result = await manager.shareTask({ id: "FN-203", description: "Body" });
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(share).toHaveBeenCalledWith({
|
||||||
|
title: "Task FN-203",
|
||||||
|
text: "Body",
|
||||||
|
url: "fusion://task/FN-203",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Web fallback: emits share:cancelled when navigator.share() rejects with AbortError", async () => {
|
||||||
|
const share = vi.fn().mockRejectedValue({ name: "AbortError" });
|
||||||
|
vi.stubGlobal("navigator", {
|
||||||
|
share,
|
||||||
|
clipboard: {
|
||||||
|
writeText: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new ShareManager();
|
||||||
|
const onCancelled = vi.fn();
|
||||||
|
manager.on("share:cancelled", onCancelled);
|
||||||
|
|
||||||
|
const result = await manager.shareTask({ id: "FN-204", description: "Body" });
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(onCancelled).toHaveBeenCalledWith({ taskId: "FN-204" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Clipboard fallback: copies deep link URL via navigator.clipboard.writeText() when neither native nor Web Share API", async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.stubGlobal("navigator", {
|
||||||
|
clipboard: {
|
||||||
|
writeText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new ShareManager();
|
||||||
|
const result = await manager.shareTask({ id: "FN-205", description: "Body" });
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(writeText).toHaveBeenCalledWith("fusion://task/FN-205");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Lifecycle: initialize() succeeds and sets initialized state", async () => {
|
||||||
|
const manager = new ShareManager();
|
||||||
|
|
||||||
|
await manager.initialize();
|
||||||
|
|
||||||
|
expect((manager as any).initialized).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Lifecycle: initialize() is idempotent (second call is no-op)", async () => {
|
||||||
|
const manager = new ShareManager();
|
||||||
|
|
||||||
|
await manager.initialize();
|
||||||
|
await expect(manager.initialize()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect((manager as any).initialized).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Lifecycle: getDeepLinkBaseUrl() returns the configured URL", () => {
|
||||||
|
const manager = new ShareManager({ deepLinkBaseUrl: "fusion://custom/" });
|
||||||
|
|
||||||
|
expect(manager.getDeepLinkBaseUrl()).toBe("fusion://custom/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Lifecycle: destroy() removes all listeners and resets initialized state", async () => {
|
||||||
|
const manager = new ShareManager();
|
||||||
|
const onSuccess = vi.fn();
|
||||||
|
manager.on("share:success", onSuccess);
|
||||||
|
|
||||||
|
await manager.initialize();
|
||||||
|
await manager.destroy();
|
||||||
|
|
||||||
|
expect((manager as any).initialized).toBe(false);
|
||||||
|
|
||||||
|
manager.emit("share:success", { taskId: "FN-206" });
|
||||||
|
expect(onSuccess).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,44 +1,104 @@
|
|||||||
|
import {
|
||||||
|
DeepLinkManager,
|
||||||
|
type DeepLinkManagerOptions,
|
||||||
|
} from "./plugins/deep-links.js";
|
||||||
import {
|
import {
|
||||||
PushNotificationManager,
|
PushNotificationManager,
|
||||||
type PushNotificationManagerOptions,
|
type PushNotificationManagerOptions,
|
||||||
} from "./plugins/push-notifications.js";
|
} from "./plugins/push-notifications.js";
|
||||||
|
import { ShareManager, type ShareManagerOptions } from "./plugins/share.js";
|
||||||
|
|
||||||
|
export { DeepLinkManager } from "./plugins/deep-links.js";
|
||||||
|
export type {
|
||||||
|
DeepLinkEventMap,
|
||||||
|
DeepLinkManagerOptions,
|
||||||
|
DeepLinkPayload,
|
||||||
|
} from "./plugins/deep-links.js";
|
||||||
export { PushNotificationManager } from "./plugins/push-notifications.js";
|
export { PushNotificationManager } from "./plugins/push-notifications.js";
|
||||||
export type {
|
export type {
|
||||||
PushNotificationEventMap,
|
PushNotificationEventMap,
|
||||||
PushNotificationManagerOptions,
|
PushNotificationManagerOptions,
|
||||||
} from "./plugins/push-notifications.js";
|
} from "./plugins/push-notifications.js";
|
||||||
|
export { ShareManager } from "./plugins/share.js";
|
||||||
|
export type {
|
||||||
|
ShareEventMap,
|
||||||
|
ShareManagerOptions,
|
||||||
|
ShareTaskPayload,
|
||||||
|
} from "./plugins/share.js";
|
||||||
export type { MobilePluginManager, PluginEventMap } from "./types.js";
|
export type { MobilePluginManager, PluginEventMap } from "./types.js";
|
||||||
|
|
||||||
|
interface LifecycleManager {
|
||||||
|
initialize?: () => Promise<void>;
|
||||||
|
start?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InitializePluginsOptions {
|
export interface InitializePluginsOptions {
|
||||||
pushNotifications?:
|
pushNotifications?:
|
||||||
| boolean
|
| boolean
|
||||||
| PushNotificationManager
|
| PushNotificationManager
|
||||||
| PushNotificationManagerOptions;
|
| PushNotificationManagerOptions;
|
||||||
|
share?: boolean | ShareManager | ShareManagerOptions;
|
||||||
|
deepLinks?: boolean | DeepLinkManager | DeepLinkManagerOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InitializePluginsResult {
|
export interface InitializePluginsResult {
|
||||||
pushNotifications?: PushNotificationManager;
|
pushNotifications?: PushNotificationManager;
|
||||||
|
share?: ShareManager;
|
||||||
|
deepLinks?: DeepLinkManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeManager(manager: LifecycleManager): Promise<void> {
|
||||||
|
if (typeof manager.initialize === "function") {
|
||||||
|
await manager.initialize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof manager.start === "function") {
|
||||||
|
await manager.start();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initializePlugins(
|
export async function initializePlugins(
|
||||||
options: InitializePluginsOptions = {},
|
options: InitializePluginsOptions = {},
|
||||||
): Promise<InitializePluginsResult> {
|
): Promise<InitializePluginsResult> {
|
||||||
const result: InitializePluginsResult = {};
|
const result: InitializePluginsResult = {};
|
||||||
const pushOptions = options.pushNotifications;
|
|
||||||
|
|
||||||
if (!pushOptions) {
|
const pushOptions = options.pushNotifications;
|
||||||
return result;
|
if (pushOptions) {
|
||||||
|
const pushNotifications =
|
||||||
|
pushOptions instanceof PushNotificationManager
|
||||||
|
? pushOptions
|
||||||
|
: new PushNotificationManager(
|
||||||
|
typeof pushOptions === "object" ? pushOptions : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
await initializeManager(pushNotifications);
|
||||||
|
result.pushNotifications = pushNotifications;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pushNotifications =
|
const shareOptions = options.share;
|
||||||
pushOptions instanceof PushNotificationManager
|
if (shareOptions) {
|
||||||
? pushOptions
|
const share =
|
||||||
: new PushNotificationManager(
|
shareOptions instanceof ShareManager
|
||||||
typeof pushOptions === "object" ? pushOptions : undefined,
|
? shareOptions
|
||||||
);
|
: new ShareManager(typeof shareOptions === "object" ? shareOptions : undefined);
|
||||||
|
|
||||||
|
await initializeManager(share);
|
||||||
|
result.share = share;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deepLinkOptions = options.deepLinks;
|
||||||
|
if (deepLinkOptions) {
|
||||||
|
const deepLinks =
|
||||||
|
deepLinkOptions instanceof DeepLinkManager
|
||||||
|
? deepLinkOptions
|
||||||
|
: new DeepLinkManager(
|
||||||
|
typeof deepLinkOptions === "object" ? deepLinkOptions : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
await initializeManager(deepLinks);
|
||||||
|
result.deepLinks = deepLinks;
|
||||||
|
}
|
||||||
|
|
||||||
await pushNotifications.start();
|
|
||||||
result.pushNotifications = pushNotifications;
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
273
packages/mobile/src/plugins/deep-links.ts
Normal file
273
packages/mobile/src/plugins/deep-links.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { Capacitor } from "@capacitor/core";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
|
export interface DeepLinkPayload {
|
||||||
|
url: string;
|
||||||
|
taskId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
target?: string;
|
||||||
|
params?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeepLinkManagerOptions {
|
||||||
|
/** Custom URL scheme. Default: "fusion://" */
|
||||||
|
scheme?: string;
|
||||||
|
/** Universal link hosts to recognize. Default: [] */
|
||||||
|
universalLinkHosts?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeepLinkEventMap {
|
||||||
|
"deeplink:received": DeepLinkPayload;
|
||||||
|
"deeplink:error": { url: string; error: Error };
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppModule = typeof import("@capacitor/app");
|
||||||
|
type AppListenerHandle = { remove: () => Promise<void> };
|
||||||
|
|
||||||
|
type HashChangeEventHandler = (event: HashChangeEvent) => void;
|
||||||
|
|
||||||
|
export class DeepLinkManager extends EventEmitter {
|
||||||
|
private readonly scheme: string;
|
||||||
|
private readonly universalLinkHosts: string[];
|
||||||
|
private initialized = false;
|
||||||
|
private appListenerHandle?: AppListenerHandle;
|
||||||
|
private boundHashHandler?: HashChangeEventHandler;
|
||||||
|
private appPlugin: AppModule["App"] | null = null;
|
||||||
|
|
||||||
|
constructor(options?: DeepLinkManagerOptions) {
|
||||||
|
super();
|
||||||
|
this.scheme = options?.scheme ?? "fusion://";
|
||||||
|
this.universalLinkHosts = options?.universalLinkHosts ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
override on<K extends keyof DeepLinkEventMap>(
|
||||||
|
event: K,
|
||||||
|
listener: (payload: DeepLinkEventMap[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 DeepLinkEventMap>(
|
||||||
|
event: K,
|
||||||
|
listener: (payload: DeepLinkEventMap[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 DeepLinkEventMap>(event: K, payload: DeepLinkEventMap[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;
|
||||||
|
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
const app = await this.loadAppPlugin();
|
||||||
|
if (!app) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.appListenerHandle = await app.addListener("appUrlOpen", (event) => {
|
||||||
|
this.handleUrl(event.url);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = globalThis.window;
|
||||||
|
if (!win || typeof win.addEventListener !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.boundHashHandler = () => {
|
||||||
|
const hash = win.location.hash ?? "";
|
||||||
|
if (!hash.startsWith("#deeplink=")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedUrl = hash.slice("#deeplink=".length);
|
||||||
|
if (!encodedUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decodedUrl = decodeURIComponent(encodedUrl);
|
||||||
|
this.handleUrl(decodedUrl);
|
||||||
|
} catch (error) {
|
||||||
|
const normalizedError =
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error("Invalid deeplink hash payload");
|
||||||
|
this.emit("deeplink:error", {
|
||||||
|
url: encodedUrl,
|
||||||
|
error: normalizedError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
win.addEventListener("hashchange", this.boundHashHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUrl(url: string): DeepLinkPayload | null {
|
||||||
|
try {
|
||||||
|
const payload = this.parseUrl(url);
|
||||||
|
this.emit("deeplink:received", payload);
|
||||||
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
this.emit("deeplink:error", {
|
||||||
|
url,
|
||||||
|
error: normalizedError,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getScheme(): string {
|
||||||
|
return this.scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy(): Promise<void> {
|
||||||
|
if (this.appListenerHandle) {
|
||||||
|
try {
|
||||||
|
await this.appListenerHandle.remove();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to remove appUrlOpen listener", error);
|
||||||
|
}
|
||||||
|
this.appListenerHandle = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.boundHashHandler && globalThis.window) {
|
||||||
|
globalThis.window.removeEventListener("hashchange", this.boundHashHandler);
|
||||||
|
this.boundHashHandler = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initialized = false;
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseUrl(url: string): DeepLinkPayload {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
|
||||||
|
if (this.isCustomSchemeUrl(url, parsedUrl)) {
|
||||||
|
return this.parseCustomScheme(url, parsedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isRecognizedUniversalLink(parsedUrl)) {
|
||||||
|
return this.parseUniversalLink(url, parsedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unsupported deep link URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCustomScheme(url: string, parsedUrl: URL): DeepLinkPayload {
|
||||||
|
const segments = [parsedUrl.hostname, ...parsedUrl.pathname.split("/").filter(Boolean)].filter(
|
||||||
|
(segment) => segment.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload: DeepLinkPayload = { url };
|
||||||
|
const [first, second, third, fourth] = segments;
|
||||||
|
|
||||||
|
if (first) {
|
||||||
|
payload.target = first;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first === "task" && second) {
|
||||||
|
payload.taskId = second;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first === "project" && second) {
|
||||||
|
payload.projectId = second;
|
||||||
|
if (third === "task" && fourth) {
|
||||||
|
payload.taskId = fourth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = this.collectParams(parsedUrl.searchParams);
|
||||||
|
if (Object.keys(params).length > 0) {
|
||||||
|
payload.params = params;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseUniversalLink(url: string, parsedUrl: URL): DeepLinkPayload {
|
||||||
|
const payload: DeepLinkPayload = {
|
||||||
|
url,
|
||||||
|
taskId: parsedUrl.searchParams.get("task") ?? undefined,
|
||||||
|
projectId: parsedUrl.searchParams.get("project") ?? undefined,
|
||||||
|
target: parsedUrl.searchParams.get("target") ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const params = this.collectParams(parsedUrl.searchParams, ["task", "project", "target"]);
|
||||||
|
if (Object.keys(params).length > 0) {
|
||||||
|
payload.params = params;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectParams(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
exclusions: string[] = [],
|
||||||
|
): Record<string, string> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
const exclusionSet = new Set(exclusions);
|
||||||
|
|
||||||
|
for (const [key, value] of searchParams.entries()) {
|
||||||
|
if (exclusionSet.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
params[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRecognizedUniversalLink(parsedUrl: URL): boolean {
|
||||||
|
return (
|
||||||
|
parsedUrl.protocol === "https:" &&
|
||||||
|
this.universalLinkHosts.includes(parsedUrl.hostname)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCustomSchemeUrl(url: string, parsedUrl: URL): boolean {
|
||||||
|
const configuredSchemeProtocol = this.normalizeSchemeProtocol(this.scheme);
|
||||||
|
return parsedUrl.protocol === configuredSchemeProtocol || url.startsWith(this.scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSchemeProtocol(scheme: string): string {
|
||||||
|
if (scheme.endsWith("://")) {
|
||||||
|
return `${scheme.slice(0, -3)}:`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheme.endsWith(":")) {
|
||||||
|
return scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${scheme}:`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadAppPlugin(): Promise<AppModule["App"] | null> {
|
||||||
|
if (this.appPlugin) {
|
||||||
|
return this.appPlugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mod: AppModule = await import("@capacitor/app");
|
||||||
|
this.appPlugin = mod.App;
|
||||||
|
return this.appPlugin;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
194
packages/mobile/src/plugins/share.ts
Normal file
194
packages/mobile/src/plugins/share.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -334,12 +334,18 @@ importers:
|
|||||||
|
|
||||||
packages/mobile:
|
packages/mobile:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@capacitor/app':
|
||||||
|
specifier: ^7.1.2
|
||||||
|
version: 7.1.2(@capacitor/core@7.6.1)
|
||||||
'@capacitor/core':
|
'@capacitor/core':
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.6.1
|
version: 7.6.1
|
||||||
'@capacitor/push-notifications':
|
'@capacitor/push-notifications':
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.0.6(@capacitor/core@7.6.1)
|
version: 7.0.6(@capacitor/core@7.6.1)
|
||||||
|
'@capacitor/share':
|
||||||
|
specifier: ^7.0.4
|
||||||
|
version: 7.0.4(@capacitor/core@7.6.1)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@capacitor/android':
|
'@capacitor/android':
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user