From 727d133b2a3d63b98b816a63013679a9a63e39dc Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Tue, 7 Jul 2026 11:00:38 +0100 Subject: [PATCH] feat(channels): add shareable deep links for channels and threads "Copy link" on a channel (sidebar dropdown + context menu) and on a thread (channel task header) copies an https link that resolves to a Cloud interstitial firing the new posthog-code://channel/[/tasks/] scheme, mirroring the canvas share-link pattern end to end (ChannelLinkService, deepLink router endpoints, useChannelDeepLink). Generated-By: PostHog Code Task-Id: b60d15a7-ac68-4869-bb42-55e66943823a --- apps/code/src/main/di/bindings.ts | 5 + apps/code/src/main/di/container.ts | 5 + apps/code/src/main/di/tokens.ts | 3 + apps/code/src/main/index.ts | 7 +- docs/DEEP-LINKS.md | 23 ++- packages/core/src/links/channel-link.test.ts | 142 ++++++++++++++++++ packages/core/src/links/channel-link.ts | 123 +++++++++++++++ packages/core/src/links/identifiers.ts | 3 + .../src/routers/deep-link.router.ts | 25 +++ packages/shared/src/analytics-events.ts | 13 +- .../canvas/components/ChannelsList.tsx | 10 +- .../components/CopyThreadLinkButton.tsx | 37 +++++ .../canvas/hooks/useChannelDeepLink.ts | 80 ++++++++++ .../features/canvas/utils/copyChannelLink.ts | 52 +++++++ .../task-detail/components/TaskDetail.tsx | 18 ++- packages/ui/src/router/routes/__root.tsx | 2 + packages/ui/src/utils/posthogLinks.ts | 18 +++ 17 files changed, 557 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/links/channel-link.test.ts create mode 100644 packages/core/src/links/channel-link.ts create mode 100644 packages/ui/src/features/canvas/components/CopyThreadLinkButton.tsx create mode 100644 packages/ui/src/features/canvas/hooks/useChannelDeepLink.ts create mode 100644 packages/ui/src/features/canvas/utils/copyChannelLink.ts diff --git a/apps/code/src/main/di/bindings.ts b/apps/code/src/main/di/bindings.ts index 4eb6343372..ba0f490822 100644 --- a/apps/code/src/main/di/bindings.ts +++ b/apps/code/src/main/di/bindings.ts @@ -46,9 +46,11 @@ import type { import type { SlackIntegrationService } from "@posthog/core/integrations/slack"; import type { ApprovalLinkService } from "@posthog/core/links/approval-link"; import type { CanvasLinkService } from "@posthog/core/links/canvas-link"; +import type { ChannelLinkService } from "@posthog/core/links/channel-link"; import type { APPROVAL_LINK_SERVICE, CANVAS_LINK_SERVICE, + CHANNEL_LINK_SERVICE, INBOX_LINK_SERVICE, NEW_TASK_LINK_SERVICE, OPEN_TARGET_LINK_SERVICE, @@ -256,6 +258,7 @@ import type { AUTH_SERVICE as MAIN_AUTH_SERVICE, AUTH_SESSION_REPOSITORY as MAIN_AUTH_SESSION_REPOSITORY, CANVAS_LINK_SERVICE as MAIN_CANVAS_LINK_SERVICE, + CHANNEL_LINK_SERVICE as MAIN_CHANNEL_LINK_SERVICE, CLOUD_TASK_SERVICE as MAIN_CLOUD_TASK_SERVICE, CONTEXT_MENU_SERVICE as MAIN_CONTEXT_MENU_SERVICE, DATABASE_SERVICE as MAIN_DATABASE_SERVICE, @@ -431,6 +434,7 @@ export interface MainBindings { [MAIN_APPROVAL_LINK_SERVICE]: ApprovalLinkService; [MAIN_OPEN_TARGET_LINK_SERVICE]: OpenTargetLinkService; [MAIN_CANVAS_LINK_SERVICE]: CanvasLinkService; + [MAIN_CHANNEL_LINK_SERVICE]: ChannelLinkService; [TASK_LINK_SERVICE]: TaskLinkService; [INBOX_LINK_SERVICE]: InboxLinkService; [SCOUT_LINK_SERVICE]: ScoutLinkService; @@ -438,6 +442,7 @@ export interface MainBindings { [APPROVAL_LINK_SERVICE]: ApprovalLinkService; [OPEN_TARGET_LINK_SERVICE]: OpenTargetLinkService; [CANVAS_LINK_SERVICE]: CanvasLinkService; + [CHANNEL_LINK_SERVICE]: ChannelLinkService; // Watcher registry [MAIN_WATCHER_REGISTRY_SERVICE]: WatcherRegistryService; diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 08f83dff0b..bca03305ef 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -48,9 +48,11 @@ import { HANDOFF_HOST } from "@posthog/core/handoff/identifiers"; import { integrationsModule } from "@posthog/core/integrations/integrations.module"; import { ApprovalLinkService } from "@posthog/core/links/approval-link"; import { CanvasLinkService } from "@posthog/core/links/canvas-link"; +import { ChannelLinkService } from "@posthog/core/links/channel-link"; import { APPROVAL_LINK_SERVICE, CANVAS_LINK_SERVICE, + CHANNEL_LINK_SERVICE, INBOX_LINK_SERVICE, NEW_TASK_LINK_SERVICE, OPEN_TARGET_LINK_SERVICE, @@ -269,6 +271,7 @@ import { AUTH_SERVICE as MAIN_AUTH_SERVICE, AUTH_SESSION_REPOSITORY as MAIN_AUTH_SESSION_REPOSITORY, CANVAS_LINK_SERVICE as MAIN_CANVAS_LINK_SERVICE, + CHANNEL_LINK_SERVICE as MAIN_CHANNEL_LINK_SERVICE, CLOUD_TASK_SERVICE as MAIN_CLOUD_TASK_SERVICE, CONTEXT_MENU_SERVICE as MAIN_CONTEXT_MENU_SERVICE, DATABASE_SERVICE as MAIN_DATABASE_SERVICE, @@ -654,6 +657,8 @@ container .toService(MAIN_OPEN_TARGET_LINK_SERVICE); container.bind(MAIN_CANVAS_LINK_SERVICE).to(CanvasLinkService); container.bind(CANVAS_LINK_SERVICE).toService(MAIN_CANVAS_LINK_SERVICE); +container.bind(MAIN_CHANNEL_LINK_SERVICE).to(ChannelLinkService); +container.bind(CHANNEL_LINK_SERVICE).toService(MAIN_CHANNEL_LINK_SERVICE); container.load(watcherRegistryModule); container .bind(MAIN_WATCHER_REGISTRY_SERVICE) diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index c87ae924f0..51ec3fac57 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -112,6 +112,9 @@ export const OPEN_TARGET_LINK_SERVICE = Symbol.for( export const CANVAS_LINK_SERVICE = Symbol.for( "posthog.host.main.canvas-link.service", ); +export const CHANNEL_LINK_SERVICE = Symbol.for( + "posthog.host.main.channel-link.service", +); export const WATCHER_REGISTRY_SERVICE = Symbol.for( "posthog.host.main.watcher-registry.service", ); diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 71ec77ceaa..07838e381c 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -24,6 +24,7 @@ import { import type { SlackIntegrationService } from "@posthog/core/integrations/slack"; import type { ApprovalLinkService } from "@posthog/core/links/approval-link"; import type { CanvasLinkService } from "@posthog/core/links/canvas-link"; +import type { ChannelLinkService } from "@posthog/core/links/channel-link"; import type { InboxLinkService } from "@posthog/core/links/inbox-link"; import type { NewTaskLinkService } from "@posthog/core/links/new-task-link"; import type { ScoutLinkService } from "@posthog/core/links/scout-link"; @@ -54,6 +55,7 @@ import { APPROVAL_LINK_SERVICE, AUTH_SERVICE, CANVAS_LINK_SERVICE, + CHANNEL_LINK_SERVICE, DATABASE_SERVICE, DEV_NETWORK_SERVICE, DISCORD_PRESENCE_SERVICE, @@ -257,9 +259,10 @@ async function initializeServices(): Promise { container.get(SCOUT_LINK_SERVICE); container.get(NEW_TASK_LINK_SERVICE); container.get(APPROVAL_LINK_SERVICE); - // Eagerly resolved so its constructor registers the `canvas` deep-link - // handler at boot, before any link arrives. + // Eagerly resolved so their constructors register the `canvas` / `channel` + // deep-link handlers at boot, before any link arrives. container.get(CANVAS_LINK_SERVICE); + container.get(CHANNEL_LINK_SERVICE); container.get(GITHUB_INTEGRATION_SERVICE); container.get(SLACK_INTEGRATION_SERVICE); container.get(EXTERNAL_APPS_SERVICE); diff --git a/docs/DEEP-LINKS.md b/docs/DEEP-LINKS.md index e3cf158506..1567db9f33 100644 --- a/docs/DEEP-LINKS.md +++ b/docs/DEEP-LINKS.md @@ -10,7 +10,7 @@ PostHog Code registers custom URL schemes so the desktop app can be opened with | Development | `posthog-code-dev://` | | Legacy (production only) | `twig://`, `array://` | -All schemes route through the same dispatcher. The host portion of the URL selects the handler (`task`, `inbox`, `scout`, `approval`, `canvas`, `new`, `plan`, `issue`, `callback`, `integration`, `slack-integration`, `mcp-oauth-complete`). +All schemes route through the same dispatcher. The host portion of the URL selects the handler (`task`, `inbox`, `scout`, `approval`, `canvas`, `channel`, `new`, `plan`, `issue`, `callback`, `integration`, `slack-integration`, `mcp-oauth-complete`). If the app is not running, the OS launches it and the link is queued until the renderer is ready. If the app is minimised, it is restored and focused before the link is handled. @@ -149,6 +149,26 @@ whether or not they have the app. posthog-code://canvas/019ebc38-d862-77f2-9e56-c5ec42965758/dash_abc123 ``` +### `posthog-code://channel/[/tasks/]` + +Open a Channels-space channel — or a thread (channel-filed task) inside it — +straight in the desktop app. Gated on the `project-bluebird` flag. Like canvas +links, users don't share this scheme link directly — the "Copy link" affordances +on a channel and on a thread copy an **https** link +(`/code/channel/[/tasks/]`) that resolves to a web +interstitial in PostHog Cloud, which fires this scheme (or offers the +desktop-app download). + +| Segment | Required | Description | +|---|---|---| +| `` | Yes | Channel (folder) row id. Stable, rename-proof desktop file-system row id. | +| `tasks/` | No | Thread (task filed to the channel) to open inside it. | + +``` +posthog-code://channel/019ebc38-d862-77f2-9e56-c5ec42965758 +posthog-code://channel/019ebc38-d862-77f2-9e56-c5ec42965758/tasks/task_abc123 +``` + ## OAuth callback links These are issued by external services and consumed by the app. You should not need to construct them yourself, but they are documented for completeness. @@ -215,6 +235,7 @@ In development the same payload is delivered to `http://localhost:8238/mcp-oauth | `scout` | [packages/core/src/links/scout-link.ts](../packages/core/src/links/scout-link.ts) | | `approval` | [packages/core/src/links/approval-link.ts](../packages/core/src/links/approval-link.ts) | | `canvas` | [packages/core/src/links/canvas-link.ts](../packages/core/src/links/canvas-link.ts) | +| `channel` | [packages/core/src/links/channel-link.ts](../packages/core/src/links/channel-link.ts) | | `new`, `plan`, `issue` | [packages/core/src/links/new-task-link.ts](../packages/core/src/links/new-task-link.ts) | | `callback` | [packages/core/src/oauth/oauth.ts](../packages/core/src/oauth/oauth.ts) | | `integration` | [packages/core/src/integrations/github.ts](../packages/core/src/integrations/github.ts) | diff --git a/packages/core/src/links/channel-link.test.ts b/packages/core/src/links/channel-link.test.ts new file mode 100644 index 0000000000..872e4f5be2 --- /dev/null +++ b/packages/core/src/links/channel-link.test.ts @@ -0,0 +1,142 @@ +import type { + DeepLinkHandler, + IDeepLinkRegistry, +} from "@posthog/platform/deep-link"; +import type { IMainWindow } from "@posthog/platform/main-window"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ChannelLinkEvent, ChannelLinkService } from "./channel-link"; + +function makeLogger() { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + scope: vi.fn(() => logger), + }; + return logger; +} + +function makeDeepLinkService() { + const handlers = new Map(); + const service = { + registerHandler: vi.fn((key: string, handler: DeepLinkHandler) => { + handlers.set(key, handler); + }), + trigger: (key: string, path: string) => { + const handler = handlers.get(key); + if (!handler) throw new Error(`No handler for ${key}`); + return handler(path, new URLSearchParams()); + }, + }; + return service as unknown as IDeepLinkRegistry & { + trigger: (key: string, path: string) => boolean; + }; +} + +function makeMainWindow() { + return { + focus: vi.fn(), + restore: vi.fn(), + isMinimized: vi.fn().mockReturnValue(false), + } as unknown as IMainWindow & { + focus: ReturnType; + restore: ReturnType; + isMinimized: ReturnType; + }; +} + +describe("ChannelLinkService", () => { + let deepLinkService: ReturnType; + let mainWindow: ReturnType; + let service: ChannelLinkService; + + beforeEach(() => { + deepLinkService = makeDeepLinkService(); + mainWindow = makeMainWindow(); + service = new ChannelLinkService(deepLinkService, mainWindow, makeLogger()); + }); + + it("registers a 'channel' handler on the DeepLinkService", () => { + expect(deepLinkService.registerHandler).toHaveBeenCalledWith( + "channel", + expect.any(Function), + ); + }); + + it("emits OpenChannel with just the channel id", () => { + const listener = vi.fn(); + service.on(ChannelLinkEvent.OpenChannel, listener); + + const result = deepLinkService.trigger("channel", "chan-1"); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith({ channelId: "chan-1" }); + }); + + it("emits OpenChannel with a thread task id", () => { + const listener = vi.fn(); + service.on(ChannelLinkEvent.OpenChannel, listener); + + const result = deepLinkService.trigger("channel", "chan-1/tasks/task-2"); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith({ + channelId: "chan-1", + taskId: "task-2", + }); + }); + + it("decodes URL-encoded id segments", () => { + const listener = vi.fn(); + service.on(ChannelLinkEvent.OpenChannel, listener); + + deepLinkService.trigger("channel", "chan%2Fa/tasks/task%20b"); + + expect(listener).toHaveBeenCalledWith({ + channelId: "chan/a", + taskId: "task b", + }); + }); + + it("queues a pending deep link when no listener is attached", () => { + deepLinkService.trigger("channel", "chan-1/tasks/task-2"); + + const pending = service.consumePendingDeepLink(); + expect(pending).toEqual({ channelId: "chan-1", taskId: "task-2" }); + + // Draining clears it + expect(service.consumePendingDeepLink()).toBeNull(); + }); + + it.each([ + ["empty path", ""], + ["unknown sub-path", "chan-1/dashboards/dash-2"], + ["tasks without a task id", "chan-1/tasks"], + ["trailing segments after the task id", "chan-1/tasks/task-2/extra"], + ])("returns false and does not emit for %s", (_label, path) => { + const listener = vi.fn(); + service.on(ChannelLinkEvent.OpenChannel, listener); + + const result = deepLinkService.trigger("channel", path); + + expect(result).toBe(false); + expect(listener).not.toHaveBeenCalled(); + }); + + it("focuses the main window on link arrival", () => { + deepLinkService.trigger("channel", "chan-1"); + + expect(mainWindow.focus).toHaveBeenCalledTimes(1); + expect(mainWindow.restore).not.toHaveBeenCalled(); + }); + + it("restores the main window when it is minimized", () => { + mainWindow.isMinimized.mockReturnValue(true); + + deepLinkService.trigger("channel", "chan-1"); + + expect(mainWindow.restore).toHaveBeenCalledTimes(1); + expect(mainWindow.focus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/links/channel-link.ts b/packages/core/src/links/channel-link.ts new file mode 100644 index 0000000000..47fbd5e496 --- /dev/null +++ b/packages/core/src/links/channel-link.ts @@ -0,0 +1,123 @@ +import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; +import { + DEEP_LINK_SERVICE, + type IDeepLinkRegistry, +} from "@posthog/platform/deep-link"; +import { + type IMainWindow, + MAIN_WINDOW_SERVICE, +} from "@posthog/platform/main-window"; +import { TypedEventEmitter } from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import type { LinkLogger } from "./identifiers"; + +export const ChannelLinkEvent = { + OpenChannel: "openChannel", +} as const; + +export interface ChannelLinkPayload { + /** Channel (folder) row id. */ + channelId: string; + /** When present, the thread (channel-filed task) to open inside the channel. */ + taskId?: string; +} + +export interface ChannelLinkEvents { + [ChannelLinkEvent.OpenChannel]: ChannelLinkPayload; +} + +/** + * Handles channel deep links (`://channel/{channelId}` and + * `://channel/{channelId}/tasks/{taskId}`, e.g. `posthog-code://…` in + * production and `posthog-code-dev://…` in local dev). Shareable channel links + * resolve to a web interstitial that fires this scheme, so a teammate can open + * a channel — or a thread inside it — straight in the desktop app. The channel + * id is a stable, rename-proof desktop file-system row id. + * + * Mirrors `CanvasLinkService`: queues a link that arrived before the renderer + * was ready, and emits for links delivered while the app is already running. + */ +@injectable() +export class ChannelLinkService extends TypedEventEmitter { + private pendingDeepLink: ChannelLinkPayload | null = null; + private readonly log: LinkLogger; + + constructor( + @inject(DEEP_LINK_SERVICE) + private readonly deepLinkService: IDeepLinkRegistry, + @inject(MAIN_WINDOW_SERVICE) + private readonly mainWindow: IMainWindow, + @inject(ROOT_LOGGER) + rootLogger: RootLogger, + ) { + super(); + this.log = rootLogger.scope("channel-link-service"); + + this.deepLinkService.registerHandler("channel", (path) => + this.handleChannelLink(path), + ); + } + + private handleChannelLink(path: string): boolean { + const segments = path.split("/").map((segment) => decodeSegment(segment)); + + const payload = parseChannelSegments(segments); + if (!payload) { + this.log.warn(`Channel link has unrecognised path: ${path}`); + return false; + } + + const hasListeners = this.listenerCount(ChannelLinkEvent.OpenChannel) > 0; + + if (hasListeners) { + this.log.info( + `Emitting channel link event: channelId=${payload.channelId} taskId=${payload.taskId ?? "-"}`, + ); + this.emit(ChannelLinkEvent.OpenChannel, payload); + } else { + this.log.info( + `Queueing channel link (renderer not ready): channelId=${payload.channelId} taskId=${payload.taskId ?? "-"}`, + ); + this.pendingDeepLink = payload; + } + + this.log.info("Deep link focusing window", payload); + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + + return true; + } + + public consumePendingDeepLink(): ChannelLinkPayload | null { + const pending = this.pendingDeepLink; + this.pendingDeepLink = null; + if (pending) { + this.log.info( + `Consumed pending channel link: channelId=${pending.channelId} taskId=${pending.taskId ?? "-"}`, + ); + } + return pending; + } +} + +// Accepts exactly `` or `/tasks/` — anything else +// is rejected rather than guessed at, so a malformed link can't half-navigate. +function parseChannelSegments(segments: string[]): ChannelLinkPayload | null { + const [channelId, kind, taskId, ...rest] = segments; + if (!channelId) return null; + if (kind === undefined) return { channelId }; + if (kind === "tasks" && taskId && rest.length === 0) { + return { channelId, taskId }; + } + return null; +} + +function decodeSegment(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} diff --git a/packages/core/src/links/identifiers.ts b/packages/core/src/links/identifiers.ts index 6c70ba5173..043a0b3ed0 100644 --- a/packages/core/src/links/identifiers.ts +++ b/packages/core/src/links/identifiers.ts @@ -21,3 +21,6 @@ export const OPEN_TARGET_LINK_SERVICE = Symbol.for( "posthog.core.openTargetLinkService", ); export const CANVAS_LINK_SERVICE = Symbol.for("posthog.core.canvasLinkService"); +export const CHANNEL_LINK_SERVICE = Symbol.for( + "posthog.core.channelLinkService", +); diff --git a/packages/host-router/src/routers/deep-link.router.ts b/packages/host-router/src/routers/deep-link.router.ts index e908446ae9..0bd1c0cb9b 100644 --- a/packages/host-router/src/routers/deep-link.router.ts +++ b/packages/host-router/src/routers/deep-link.router.ts @@ -8,9 +8,15 @@ import { type CanvasLinkPayload, type CanvasLinkService, } from "@posthog/core/links/canvas-link"; +import { + ChannelLinkEvent, + type ChannelLinkPayload, + type ChannelLinkService, +} from "@posthog/core/links/channel-link"; import { APPROVAL_LINK_SERVICE, CANVAS_LINK_SERVICE, + CHANNEL_LINK_SERVICE, INBOX_LINK_SERVICE, NEW_TASK_LINK_SERVICE, OPEN_TARGET_LINK_SERVICE, @@ -181,4 +187,23 @@ export const deepLinkRouter = router({ .consumePendingDeepLink(); }, ), + + onOpenChannel: publicProcedure.subscription(async function* (opts) { + const service = + opts.ctx.container.get(CHANNEL_LINK_SERVICE); + const iterable = service.toIterable(ChannelLinkEvent.OpenChannel, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), + + getPendingChannelLink: publicProcedure.query( + ({ ctx }): ChannelLinkPayload | null => { + return ctx.container + .get(CHANNEL_LINK_SERVICE) + .consumePendingDeepLink(); + }, + ), }); diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index e692c2f1ae..3792f2e718 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -365,6 +365,12 @@ export interface DeepLinkCanvasProperties { dashboard_id: string; } +export interface DeepLinkChannelProperties { + channel_id: string; + /** Present when the link targets a thread inside the channel. */ + task_id?: string; +} + // Feedback events export interface TaskFeedbackProperties { task_id: string; @@ -853,14 +859,15 @@ export type ChannelActionType = | "archive_task" | "open_task" | "collapse_thread" - | "expand_thread"; + | "expand_thread" + | "copy_link"; export interface ChannelActionProperties { action_type: ChannelActionType; surface: ChannelsSurface; /** The channel acted on, when one is in scope. */ channel_id?: string; - /** For file/unfile/archive/open task actions. */ + /** For file/unfile/archive/open task actions; for copy_link of a thread. */ task_id?: string; /** For file_task: destination channel when different from `channel_id`. */ target_channel_id?: string; @@ -1132,6 +1139,7 @@ export const ANALYTICS_EVENTS = { DEEP_LINK_ISSUE: "Deep link issue", DEEP_LINK_ISSUE_FAILED: "Deep link issue failed", DEEP_LINK_CANVAS: "Deep link canvas", + DEEP_LINK_CHANNEL: "Deep link channel", // Error events TASK_CREATION_FAILED: "Task creation failed", @@ -1288,6 +1296,7 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.DEEP_LINK_ISSUE]: DeepLinkIssueProperties; [ANALYTICS_EVENTS.DEEP_LINK_ISSUE_FAILED]: DeepLinkIssueFailedProperties; [ANALYTICS_EVENTS.DEEP_LINK_CANVAS]: DeepLinkCanvasProperties; + [ANALYTICS_EVENTS.DEEP_LINK_CHANNEL]: DeepLinkChannelProperties; // Error events [ANALYTICS_EVENTS.TASK_CREATION_FAILED]: TaskCreationFailedProperties; diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index d1182b6847..b66444f566 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -3,6 +3,7 @@ import { DotsThreeIcon, FileTextIcon, HashIcon, + LinkIcon, LockSimpleIcon, PencilSimpleIcon, PlusIcon, @@ -55,6 +56,7 @@ import { PERSONAL_CHANNEL_NAME, useTaskChannels, } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; import { Box, Flex, Text } from "@radix-ui/themes"; @@ -75,7 +77,7 @@ type ChannelActionItem = { separatorBefore?: boolean; }; -// The channel actions (star, edit context, rename, delete) plus the rename-modal +// The channel actions (star, copy link, rename, delete) plus the rename-modal // state they drive. Single source of truth so the dropdown and context menus // stay in lockstep — add an action here and both surfaces pick it up. function useChannelActions(channel: Channel): { @@ -158,6 +160,12 @@ function useChannelActions(channel: Channel): { toggleStar(); }, }, + { + key: "copy-link", + label: "Copy link", + icon: , + onSelect: () => void copyChannelLink(channel.id, "sidebar"), + }, { key: "rename", label: "Rename channel…", diff --git a/packages/ui/src/features/canvas/components/CopyThreadLinkButton.tsx b/packages/ui/src/features/canvas/components/CopyThreadLinkButton.tsx new file mode 100644 index 0000000000..6258754324 --- /dev/null +++ b/packages/ui/src/features/canvas/components/CopyThreadLinkButton.tsx @@ -0,0 +1,37 @@ +import { LinkIcon } from "@phosphor-icons/react"; +import { + Button, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@posthog/quill"; +import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; + +// Header affordance on a thread (channel-filed task): copies the thread's +// shareable https link, which deep-links back into this exact thread. +export function CopyThreadLinkButton({ + channelId, + taskId, +}: { + channelId: string; + taskId: string; +}) { + return ( + + void copyChannelLink(channelId, "title_bar", taskId)} + > + + + } + /> + Copy link to thread + + ); +} diff --git a/packages/ui/src/features/canvas/hooks/useChannelDeepLink.ts b/packages/ui/src/features/canvas/hooks/useChannelDeepLink.ts new file mode 100644 index 0000000000..0235c24e7d --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useChannelDeepLink.ts @@ -0,0 +1,80 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { + navigateToChannel, + navigateToChannelTask, +} from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; +import { logger } from "@posthog/ui/shell/logger"; +import { useQuery } from "@tanstack/react-query"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { useCallback, useEffect } from "react"; + +const log = logger.scope("channel-deep-link"); + +/** + * Handles channel deep links (`://channel/{channelId}` and + * `://channel/{channelId}/tasks/{taskId}`, e.g. `posthog-code://…` in + * production and `posthog-code-dev://…` in local dev) and opens the channel — + * or a thread inside it — in the Channels space. These arrive from a shareable + * https link's web interstitial, so a teammate can open a channel straight in + * the app. + * + * Mirrors `useCanvasDeepLink`: drains any link that arrived before the renderer + * was ready (the main process clears its pending entry on read) and also + * subscribes for links delivered while the app is already running. The live + * subscription acts on every link unconditionally — gating it behind the + * project-bluebird flag would drop a link that arrives before the flag resolves + * (the main process emits rather than queues once a listener is attached, so a + * discarded payload is unrecoverable). Navigation is safe regardless: the + * Channels space is flag-gated at the route, which redirects out when off. + */ +export function useChannelDeepLink() { + const trpcReact = useHostTRPC(); + const isAuthenticated = useAuthStateValue( + (s) => s.status === "authenticated", + ); + + const pendingDeepLink = useQuery( + trpcReact.deepLink.getPendingChannelLink.queryOptions(undefined, { + enabled: isAuthenticated, + // Drain once per session – the main process clears its pending entry on read. + staleTime: Number.POSITIVE_INFINITY, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }), + ); + + const openChannel = useCallback((channelId: string, taskId?: string) => { + log.info( + `Opening channel from deep link: channelId=${channelId} taskId=${taskId ?? "-"}`, + ); + track(ANALYTICS_EVENTS.DEEP_LINK_CHANNEL, { + channel_id: channelId, + task_id: taskId, + }); + if (taskId) { + navigateToChannelTask(channelId, taskId); + } else { + navigateToChannel(channelId); + } + }, []); + + useEffect(() => { + const pending = pendingDeepLink.data; + if (pending?.channelId) { + openChannel(pending.channelId, pending.taskId); + } + }, [pendingDeepLink.data, openChannel]); + + useSubscription( + trpcReact.deepLink.onOpenChannel.subscriptionOptions(undefined, { + onData: (data) => { + if (data?.channelId) { + openChannel(data.channelId, data.taskId); + } + }, + }), + ); +} diff --git a/packages/ui/src/features/canvas/utils/copyChannelLink.ts b/packages/ui/src/features/canvas/utils/copyChannelLink.ts new file mode 100644 index 0000000000..534dfb0404 --- /dev/null +++ b/packages/ui/src/features/canvas/utils/copyChannelLink.ts @@ -0,0 +1,52 @@ +import { + ANALYTICS_EVENTS, + type ChannelsSurface, +} from "@posthog/shared/analytics-events"; +import { toast } from "@posthog/ui/primitives/toast"; +import { track } from "@posthog/ui/shell/analytics"; +import { channelShareUrl } from "@posthog/ui/utils/posthogLinks"; + +/** + * Copy a channel's — or, with `taskId`, a thread's — shareable https link + * (`/code/channel/[/tasks/]`) to the clipboard, + * toasting success or failure. Mirrors `copyCanvasLink`: the https link + * resolves to a web interstitial that deep-links into the desktop app, so it + * opens for anyone whether or not they have the app installed. + */ +export async function copyChannelLink( + channelId: string, + surface: ChannelsSurface, + taskId?: string, +): Promise { + const url = channelShareUrl(channelId, taskId); + if (!url) { + toast.error("Couldn't build a shareable link"); + return; + } + + const target = taskId ? "thread" : "channel"; + try { + await navigator.clipboard.writeText(url); + toast.success("Link copied", { + description: `Anyone with the link can open this ${target}.`, + }); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "copy_link", + surface, + channel_id: channelId, + task_id: taskId, + success: true, + }); + } catch (error) { + toast.error("Couldn't copy link", { + description: error instanceof Error ? error.message : String(error), + }); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "copy_link", + surface, + channel_id: channelId, + task_id: taskId, + success: false, + }); + } +} diff --git a/packages/ui/src/features/task-detail/components/TaskDetail.tsx b/packages/ui/src/features/task-detail/components/TaskDetail.tsx index 3400bfabd5..492de65daa 100644 --- a/packages/ui/src/features/task-detail/components/TaskDetail.tsx +++ b/packages/ui/src/features/task-detail/components/TaskDetail.tsx @@ -6,6 +6,7 @@ import { useBlurOnEscape } from "../../../hooks/useBlurOnEscape"; import { useSetHeaderContent } from "../../../hooks/useSetHeaderContent"; import { logger } from "../../../shell/logger"; import { ChannelBreadcrumb } from "../../canvas/components/ChannelBreadcrumb"; +import { CopyThreadLinkButton } from "../../canvas/components/CopyThreadLinkButton"; import { LazyCloudReviewPage as CloudReviewPage, LazyReviewPage as ReviewPage, @@ -120,9 +121,20 @@ export function TaskDetail({ const handleTitleEditCancel = useCallback(() => { setIsEditingTitle(false); }, []); - const trailing = openTargetPath ? ( - - ) : null; + // Inside a channel the thread also gets a "copy link" share affordance. + // Memoized so the headerContent memo below isn't busted by unrelated renders. + const trailing = useMemo( + () => + channelId || openTargetPath ? ( + + {channelId && ( + + )} + {openTargetPath && } + + ) : null, + [channelId, taskId, openTargetPath], + ); const workspace = useWorkspace(taskId); const workspaceMode = workspace?.mode; const headerContent = useMemo( diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index 6fcb344ca0..258f42e5ce 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -28,6 +28,7 @@ import { type FeedbackModalMode, } from "@posthog/ui/features/canvas/components/FeedbackModal"; import { useCanvasDeepLink } from "@posthog/ui/features/canvas/hooks/useCanvasDeepLink"; +import { useChannelDeepLink } from "@posthog/ui/features/canvas/hooks/useChannelDeepLink"; import { CommandMenu } from "@posthog/ui/features/command/CommandMenu"; import { GlobalFilePicker } from "@posthog/ui/features/command/GlobalFilePicker"; import { KeyboardShortcutsSheet } from "@posthog/ui/features/command/KeyboardShortcutsSheet"; @@ -177,6 +178,7 @@ function RootLayout() { useInboxDeepLink(); useScoutDeepLink(); useCanvasDeepLink(); + useChannelDeepLink(); const approvalDeepLink = useApprovalDeepLink(); useSetupDiscovery(); useNewTaskDeepLink(); diff --git a/packages/ui/src/utils/posthogLinks.ts b/packages/ui/src/utils/posthogLinks.ts index c1f5d0a7e5..b5df06abf1 100644 --- a/packages/ui/src/utils/posthogLinks.ts +++ b/packages/ui/src/utils/posthogLinks.ts @@ -99,6 +99,24 @@ export function canvasShareUrl( ); } +/** + * The shareable https link for a channel — or a thread (channel-filed task) + * inside it: `/code/channel/[/tasks/]`. Opening + * it in a browser hits a web interstitial that deep-links into the desktop app + * (or offers the download), so the link works for anyone — app installed or + * not. Not project-scoped: the ids are globally-unique row ids. The inbound + * desktop side lives in `ChannelLinkService` / `useChannelDeepLink`. + */ +export function channelShareUrl( + channelId: string, + taskId?: string, +): string | null { + const base = `/code/channel/${encodeURIComponent(channelId)}`; + return getPostHogUrl( + taskId ? `${base}/tasks/${encodeURIComponent(taskId)}` : base, + ); +} + export function errorTrackingIssueUrl( issueId: string, overrides?: ErrorTrackingIssueLinkOverrides,