From 0f61f24ad659abf44a7a4fcde6a0a2cbcf78f13b Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 13 Aug 2026 20:13:06 -0600 Subject: [PATCH 01/33] Fix channel list scroll interruption (#5815) ## Summary - keep transparent channel-list gaps in Flutter's gesture arena - allow a new drag to interrupt active ballistic scrolling immediately - add a behavioral fling-and-counter-drag regression test ## Scope audit - audited mobile list and scroll constructors across `mobile/lib` - channels is the only app scrollable overriding `hitTestBehavior` - all other lists retain Flutter's default opaque hit testing and do not share this defect ## Verification - regression test fails before the production change: ballistic offset continues from `271.17` to `345.56` - focused interruption regression passes with the fix - profile/community control test passes - pre-commit: Dart formatting and Flutter analyzer pass - pre-push: complete mobile suite passes, 1323 tests - simulator: immediate counter-drag from the transparent gutter interrupts deceleration Simulator evidence: `/Users/wesb/.buzz/.scratch/mobile-scroll-videos/interruption-verified.mp4` Signed-off-by: Wes Co-authored-by: Carl --- .../features/channels/channels_page/body.dart | 7 +-- .../features/channels/channels_page_test.dart | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 2d68a26df8..34faa068eb 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -45,9 +45,10 @@ class _ChannelsBody extends StatelessWidget { onRefresh: onRefresh, child: CustomScrollView( controller: scrollController, - // The transparent gap shows the top section and must not absorb - // taps meant for the community or profile controls beneath it. - hitTestBehavior: HitTestBehavior.deferToChild, + // Transparent list gaps must remain hit-testable so a new drag + // can interrupt ballistic scrolling. The app bar is painted + // later and retains its community and profile controls. + hitTestBehavior: HitTestBehavior.translucent, slivers: [ SliverToBoxAdapter(child: SizedBox(height: barHeight)), if (usesPinnedGradient) diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index f925724b54..b49ee78423 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -232,6 +232,55 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('interrupts a ballistic scroll from a transparent list gap', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final channels = List.generate( + 40, + (index) => Channel( + id: 'channel-$index', + name: 'channel-$index', + channelType: 'stream', + visibility: 'open', + description: 'Channel $index', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 10, + isMember: true, + ), + ); + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(channels)), + ], + ), + ); + await tester.pumpAndSettle(); + + final scrollView = find.byType(CustomScrollView); + final scrollable = tester.state( + find.descendant(of: scrollView, matching: find.byType(Scrollable)).first, + ); + await tester.fling(scrollView, const Offset(0, -300), 2400); + await tester.pump(const Duration(milliseconds: 32)); + final ballisticOffset = scrollable.position.pixels; + await tester.pump(const Duration(milliseconds: 32)); + expect(scrollable.position.pixels, greaterThan(ballisticOffset)); + + // x=1 is inside the scroll viewport but outside the padded section rows. + // A drag beginning here must still enter the scrollable's gesture arena. + final interruptingDrag = await tester.startGesture(const Offset(1, 300)); + await interruptingDrag.moveBy(const Offset(0, 80)); + await tester.pump(); + + expect(scrollable.position.pixels, lessThan(ballisticOffset)); + await interruptingDrag.up(); + }); + testWidgets('keeps the last channel above the floating tab bar', ( tester, ) async { From 57435628961d25bd24689cee82f1373e7a074040 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:26:59 -0400 Subject: [PATCH 02/33] fix(huddle): stop 20 Hz speaker-level churn from re-rendering the whole app (#5825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem With a huddle open, Buzz Desktop becomes extremely slow and laggy (Tyler, live report, 2026-08-14). Root-caused and runtime-convicted on the instrumented rig in #buzz-conversational-agents: - The Rust playout loop emits `huddle-speaker-levels` over Tauri IPC every 50 ms, unconditionally, for the whole life of a huddle (`playout.rs` `SPEAKER_LEVEL_TICK_MS = 50`). - Each event deserializes to a fresh object, so `setRemoteSpeakerLevels` updates state at 20 Hz even in silence. - `HuddleProvider` wraps the entire main app and its context value was an inline object literal — never memoized. Every level tick minted a new context identity, re-rendering **every** `useHuddle()` consumer, including `ChannelScreen` and message rows. **Measured (A/B, silent one-participant huddle, same channel/state):** ~41 sustained ChannelScreen renders/sec unsuppressed vs ~4/sec with only the speaker-level setState suppressed — the 20 Hz path is ~90% of the load. Receipts: `driver-render-counter-unsuppressed.jsonl` / `-suppressed.jsonl` on the rig, verified independently. The same main-thread churn starves the relay client's 16 ms event-flush timer, which is the delayed/bursty message hydration and thread-panel stalls seen alongside the lag. ## Fix (minimal, no behavior change for meters) 1. **Split the high-frequency fields** (`micLevel`, `activeSpeakers`, `speakerLevels`) out of `HuddleContextValue` into a new `HuddleLevelsContext`, consumed via `useHuddleLevels()` only by the three meter components (`HuddleBar`, `HuddleRoomHeader`, `HuddleProfileControl`). 2. **Memoize the main context value** so provider re-renders no longer mint a new identity for the ~everything that consumes `useHuddle()`. 3. **Extract the mic-level analyser** into `useMicLevelAnalyser` — the level pipeline now lives in one place, and `HuddleContext.tsx` stays under the file-size ratchet (977 lines). Level meters keep their 20-30 Hz updates. Everything else re-renders only when a value it actually consumes changes. ## Acceptance bar With this fix, a silent open huddle should hold `ChannelScreen` at idle render rates (single digits/sec), and message hydration should stay live during huddles. The rig's render-counter + four-clock instrumentation can verify on this branch. ## Validation - `pnpm typecheck` clean - `biome check` clean (repo leftovers in sidebar tests are preexisting on main) - full desktop suite: **4,775 passed, 0 failed** at the final tree - file-size ratchet passes (was the reason for the analyser extraction) - lefthook pre-commit (desktop-fix + signoff) passed on commit Not yet done: live-local A/B rerun on this branch — the rig (Wren/Max) has the instrumentation ready and can convict/acquit the fix with the same probe that convicted the bug. Base: `068a83b0` (main). Co-developed with runtime evidence from Wren and instrumentation by Max. Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- desktop/src/features/huddle/HuddleContext.tsx | 222 ++++++++---------- .../features/huddle/HuddleContext.types.ts | 14 +- .../features/huddle/components/HuddleBar.tsx | 6 +- .../components/HuddleProfileControl.tsx | 4 +- .../huddle/components/HuddleRoomHeader.tsx | 12 +- desktop/src/features/huddle/index.ts | 6 +- .../huddle/lib/useMicLevelAnalyser.ts | 100 ++++++++ 7 files changed, 223 insertions(+), 141 deletions(-) create mode 100644 desktop/src/features/huddle/lib/useMicLevelAnalyser.ts diff --git a/desktop/src/features/huddle/HuddleContext.tsx b/desktop/src/features/huddle/HuddleContext.tsx index ad0bc1e9cc..8e6ccbbd89 100644 --- a/desktop/src/features/huddle/HuddleContext.tsx +++ b/desktop/src/features/huddle/HuddleContext.tsx @@ -11,8 +11,12 @@ import { useHuddlePttState, } from "./lib/useHuddlePttState"; import { useHuddleSpeakerActivity } from "./lib/useHuddleSpeakerActivity"; +import { useMicLevelAnalyser } from "./lib/useMicLevelAnalyser"; import { useTtsSubscription } from "./lib/useTtsSubscription"; -import type { HuddleContextValue } from "./HuddleContext.types"; +import type { + HuddleContextValue, + HuddleLevelsValue, +} from "./HuddleContext.types"; /** * Huddle lifecycle (React context): @@ -47,29 +51,16 @@ const HUDDLE_AUDIO_COMMAND_EVENT = "huddle-audio-command"; const HUDDLE_AUDIO_STATE_EVENT = "huddle-audio-state"; const HUDDLE_AUDIO_LEVEL_EVENT = "huddle-audio-level"; -const MIC_ANALYSER_UPDATE_INTERVAL_MS = 33; -const MIC_INITIAL_NOISE_FLOOR = 0.01; -const MIC_VOICE_GATE_ON_RMS = 0.018; -const MIC_VOICE_GATE_OFF_RMS = 0.012; -const MIC_VOICE_GATE_MARGIN_RMS = 0.012; -const MIC_LEVEL_ACTIVE_RANGE_RMS = 0.11; -const MIC_MIN_ACTIVE_LEVEL = 0.18; -const MIC_LEVEL_ATTACK = 0.58; -const MIC_ACTIVE_NOISE_FLOOR_RISE = 0.006; - function isRedundantHuddlePhaseError(message: string): boolean { return /^cannot (?:start|join) huddle: already in phase /i.test(message); } -function clamp01(value: number): number { - return Math.min(1, Math.max(0, value)); -} - function interruptAgentSpeech(agentPubkey: string) { return invoke("interrupt_huddle_speech", { agentPubkey }); } const HuddleContext = React.createContext(null); +const HuddleLevelsContext = React.createContext(null); export function HuddleProvider({ children, @@ -110,7 +101,6 @@ export function HuddleProvider({ const [mirroredAudioState, setMirroredAudioState] = React.useState(null); const [mirroredMicLevel, setMirroredMicLevel] = React.useState(0); - const [micLevel, setMicLevel] = React.useState(0); const { getVoiceInputMode, pttActive, @@ -790,77 +780,7 @@ export function HuddleProvider({ usePipelineHotstart(ephemeralChannelId); // Mic level analyser — drives the voice activity indicator - React.useEffect(() => { - if (!localAudioTrack || !micConnected) { - setMicLevel(0); - return; - } - - const ctx = new AudioContext(); - const analyser = ctx.createAnalyser(); - analyser.fftSize = 512; - const source = ctx.createMediaStreamSource( - new MediaStream([localAudioTrack]), - ); - source.connect(analyser); - const buf = new Float32Array(analyser.fftSize); - - let raf = 0; - let lastUpdate = 0; - let voiceActive = false; - let noiseFloor = MIC_INITIAL_NOISE_FLOOR; - let smoothedLevel = 0; - function tick(now: number) { - raf = requestAnimationFrame(tick); - if (now - lastUpdate < MIC_ANALYSER_UPDATE_INTERVAL_MS) return; - lastUpdate = now; - analyser.getFloatTimeDomainData(buf); - - let sumSquares = 0; - for (let i = 0; i < buf.length; i += 1) { - sumSquares += buf[i] * buf[i]; - } - - const rms = Math.sqrt(sumSquares / buf.length); - const activeThreshold = Math.max( - MIC_VOICE_GATE_ON_RMS, - noiseFloor + MIC_VOICE_GATE_MARGIN_RMS, - ); - const idleThreshold = Math.max( - MIC_VOICE_GATE_OFF_RMS, - noiseFloor + MIC_VOICE_GATE_MARGIN_RMS * 0.55, - ); - voiceActive = voiceActive ? rms > idleThreshold : rms > activeThreshold; - - const floorRate = - rms < noiseFloor - ? 0.18 - : voiceActive - ? MIC_ACTIVE_NOISE_FLOOR_RISE - : 0.025; - noiseFloor += (rms - noiseFloor) * floorRate; - - if (!voiceActive) { - smoothedLevel = 0; - setMicLevel(0); - return; - } - - const normalized = clamp01( - (rms - noiseFloor) / MIC_LEVEL_ACTIVE_RANGE_RMS, - ); - const targetLevel = Math.max(normalized, MIC_MIN_ACTIVE_LEVEL); - smoothedLevel += (targetLevel - smoothedLevel) * MIC_LEVEL_ATTACK; - setMicLevel(smoothedLevel); - } - raf = requestAnimationFrame(tick); - - return () => { - cancelAnimationFrame(raf); - source.disconnect(); - void ctx.close(); - }; - }, [localAudioTrack, micConnected]); + const micLevel = useMicLevelAnalyser(localAudioTrack, micConnected); React.useEffect(() => { if (ownsAudioSession) { @@ -950,42 +870,87 @@ export function HuddleProvider({ }; }, [ownsAudioSession]); + // High-frequency (20-30 Hz) audio levels live in their own context so their + // churn re-renders only the meter components, not every useHuddle consumer. + const levelsValue = React.useMemo( + () => ({ + micLevel: ownsAudioSession ? micLevel : mirroredMicLevel, + activeSpeakers, + speakerLevels, + }), + [ + activeSpeakers, + micLevel, + mirroredMicLevel, + ownsAudioSession, + speakerLevels, + ], + ); + + const effectiveMicConnected = ownsAudioSession + ? micConnected + : (mirroredAudioState?.micConnected ?? false); + const contextValue = React.useMemo( + () => ({ + localAudioTrack, + isStarting, + huddleError, + clearHuddleError, + micConnected: effectiveMicConnected, + isMuted: effectiveIsMuted, + toggleMute, + interruptAgentSpeech, + pttActive, + voiceInputMode: effectiveVoiceInputMode, + setVoiceInputMode, + audioDevices, + selectedDeviceId, + setSelectedDeviceId, + micGain, + setMicGain, + outputDevices, + selectedOutputDevice, + setSelectedOutputDevice, + activeEphemeralChannelId: ephemeralChannelId, + showHuddleInMainApp, + viewHuddleChannel, + startHuddle, + joinHuddle, + leaveHuddle, + }), + [ + audioDevices, + clearHuddleError, + effectiveIsMuted, + effectiveMicConnected, + effectiveVoiceInputMode, + ephemeralChannelId, + huddleError, + isStarting, + joinHuddle, + leaveHuddle, + localAudioTrack, + micGain, + outputDevices, + pttActive, + selectedDeviceId, + selectedOutputDevice, + setMicGain, + setSelectedDeviceId, + setSelectedOutputDevice, + setVoiceInputMode, + showHuddleInMainApp, + startHuddle, + toggleMute, + viewHuddleChannel, + ], + ); + return ( - - {children} + + + {children} + ); } @@ -997,3 +962,16 @@ export function useHuddle(): HuddleContextValue { } return ctx; } + +/** + * High-frequency (20-30 Hz) mic/speaker levels. Consume only from components + * that render audio meters; everything else should use {@link useHuddle} so it + * is insulated from level churn. + */ +export function useHuddleLevels(): HuddleLevelsValue { + const ctx = React.useContext(HuddleLevelsContext); + if (!ctx) { + throw new Error("useHuddleLevels must be used within a HuddleProvider"); + } + return ctx; +} diff --git a/desktop/src/features/huddle/HuddleContext.types.ts b/desktop/src/features/huddle/HuddleContext.types.ts index 311366cb86..e3e9458a9d 100644 --- a/desktop/src/features/huddle/HuddleContext.types.ts +++ b/desktop/src/features/huddle/HuddleContext.types.ts @@ -1,6 +1,17 @@ import type { AudioInputDevice } from "./lib/useAudioDevices"; import type { VoiceInputMode } from "./lib/useHuddlePttState"; +/** + * High-frequency audio-level fields, split from {@link HuddleContextValue} so + * their 20-30 Hz updates only re-render the meter components that consume + * them — not every `useHuddle()` consumer across the app. + */ +export interface HuddleLevelsValue { + micLevel: number; + activeSpeakers: string[]; + speakerLevels: Record; +} + export interface HuddleContextValue { localAudioTrack: MediaStreamTrack | null; isStarting: boolean; @@ -11,12 +22,9 @@ export interface HuddleContextValue { toggleMute: () => void; /** Interrupt this agent only if it still owns the active utterance. */ interruptAgentSpeech: (agentPubkey: string) => Promise; - micLevel: number; pttActive: boolean; voiceInputMode: VoiceInputMode; setVoiceInputMode: (mode: VoiceInputMode) => Promise; - activeSpeakers: string[]; - speakerLevels: Record; audioDevices: AudioInputDevice[]; selectedDeviceId: string; setSelectedDeviceId: (id: string) => void; diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index 81920e5ea9..16641f4cc3 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -27,7 +27,7 @@ import { Button } from "@/shared/ui/button"; import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; -import { useHuddle } from "../HuddleContext"; +import { useHuddle, useHuddleLevels } from "../HuddleContext"; import { AddAgentDialog, type AgentAddResult } from "./AddAgentDialog"; import type { HuddleAgentVoiceSettings } from "./AgentVoiceMenu"; import { MicControls, SpeakerControls } from "./MicControls"; @@ -157,11 +157,8 @@ export function HuddleBar({ micConnected, isMuted, toggleMute, - micLevel, voiceInputMode, setVoiceInputMode, - activeSpeakers, - speakerLevels, huddleError, clearHuddleError, audioDevices, @@ -173,6 +170,7 @@ export function HuddleBar({ selectedOutputDevice, setSelectedOutputDevice, } = useHuddle(); + const { activeSpeakers, micLevel, speakerLevels } = useHuddleLevels(); const customEmoji = useCustomEmoji(); const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); diff --git a/desktop/src/features/huddle/components/HuddleProfileControl.tsx b/desktop/src/features/huddle/components/HuddleProfileControl.tsx index 6a09797ba2..dbf3d5f6b8 100644 --- a/desktop/src/features/huddle/components/HuddleProfileControl.tsx +++ b/desktop/src/features/huddle/components/HuddleProfileControl.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import type { Channel } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; -import { useHuddle } from "../HuddleContext"; +import { useHuddle, useHuddleLevels } from "../HuddleContext"; import { MicControls } from "./MicControls"; type HuddleProfileState = { @@ -42,7 +42,6 @@ export function HuddleProfileControl({ leaveHuddle, micConnected, micGain, - micLevel, selectedDeviceId, setMicGain, setSelectedDeviceId, @@ -50,6 +49,7 @@ export function HuddleProfileControl({ toggleMute, voiceInputMode, } = useHuddle(); + const { micLevel } = useHuddleLevels(); const [isLeaving, setIsLeaving] = React.useState(false); const [state, setState] = React.useState(null); const lastHuddleChannelIdRef = React.useRef(null); diff --git a/desktop/src/features/huddle/components/HuddleRoomHeader.tsx b/desktop/src/features/huddle/components/HuddleRoomHeader.tsx index 17e2bcfacd..a356c6f2a9 100644 --- a/desktop/src/features/huddle/components/HuddleRoomHeader.tsx +++ b/desktop/src/features/huddle/components/HuddleRoomHeader.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import { useProfileQuery, useSelfProfileCache } from "@/features/profile/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; -import { useHuddle } from "../HuddleContext"; +import { useHuddle, useHuddleLevels } from "../HuddleContext"; import type { HuddleAgentVoiceSettings } from "./AgentVoiceMenu"; import { HuddleParticipantsControl } from "./ParticipantList"; @@ -28,14 +28,8 @@ function isVisible(state: HuddleRosterState | null) { /** Larger, persistent roster for the companion huddle room window. */ export function HuddleRoomHeader() { - const { - activeSpeakers, - interruptAgentSpeech, - isMuted, - micConnected, - micLevel, - speakerLevels, - } = useHuddle(); + const { interruptAgentSpeech, isMuted, micConnected } = useHuddle(); + const { activeSpeakers, micLevel, speakerLevels } = useHuddleLevels(); const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); const selfProfileCache = useSelfProfileCache(); diff --git a/desktop/src/features/huddle/index.ts b/desktop/src/features/huddle/index.ts index cda38e31b6..c25b4e9de7 100644 --- a/desktop/src/features/huddle/index.ts +++ b/desktop/src/features/huddle/index.ts @@ -1,4 +1,8 @@ -export { HuddleProvider, useHuddle } from "./HuddleContext"; +export { + HuddleProvider, + useHuddle, + useHuddleLevels, +} from "./HuddleContext"; export { setupAudioWorklet } from "./lib/audioWorklet"; export { HuddleBar } from "./components/HuddleBar"; export { HuddleProfileControl } from "./components/HuddleProfileControl"; diff --git a/desktop/src/features/huddle/lib/useMicLevelAnalyser.ts b/desktop/src/features/huddle/lib/useMicLevelAnalyser.ts new file mode 100644 index 0000000000..402054befa --- /dev/null +++ b/desktop/src/features/huddle/lib/useMicLevelAnalyser.ts @@ -0,0 +1,100 @@ +import * as React from "react"; + +const MIC_ANALYSER_UPDATE_INTERVAL_MS = 33; +const MIC_INITIAL_NOISE_FLOOR = 0.01; +const MIC_VOICE_GATE_ON_RMS = 0.018; +const MIC_VOICE_GATE_OFF_RMS = 0.012; +const MIC_VOICE_GATE_MARGIN_RMS = 0.012; +const MIC_LEVEL_ACTIVE_RANGE_RMS = 0.11; +const MIC_MIN_ACTIVE_LEVEL = 0.18; +const MIC_LEVEL_ATTACK = 0.58; +const MIC_ACTIVE_NOISE_FLOOR_RISE = 0.006; + +function clamp01(value: number): number { + return Math.min(1, Math.max(0, value)); +} + +/** + * Mic level analyser — drives the voice activity indicator. Emits a smoothed + * 0..1 level at up to ~30 Hz while the local track is live; 0 when idle. + */ +export function useMicLevelAnalyser( + localAudioTrack: MediaStreamTrack | null, + micConnected: boolean, +): number { + const [micLevel, setMicLevel] = React.useState(0); + + React.useEffect(() => { + if (!localAudioTrack || !micConnected) { + setMicLevel(0); + return; + } + + const ctx = new AudioContext(); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 512; + const source = ctx.createMediaStreamSource( + new MediaStream([localAudioTrack]), + ); + source.connect(analyser); + const buf = new Float32Array(analyser.fftSize); + + let raf = 0; + let lastUpdate = 0; + let voiceActive = false; + let noiseFloor = MIC_INITIAL_NOISE_FLOOR; + let smoothedLevel = 0; + function tick(now: number) { + raf = requestAnimationFrame(tick); + if (now - lastUpdate < MIC_ANALYSER_UPDATE_INTERVAL_MS) return; + lastUpdate = now; + analyser.getFloatTimeDomainData(buf); + + let sumSquares = 0; + for (let i = 0; i < buf.length; i += 1) { + sumSquares += buf[i] * buf[i]; + } + + const rms = Math.sqrt(sumSquares / buf.length); + const activeThreshold = Math.max( + MIC_VOICE_GATE_ON_RMS, + noiseFloor + MIC_VOICE_GATE_MARGIN_RMS, + ); + const idleThreshold = Math.max( + MIC_VOICE_GATE_OFF_RMS, + noiseFloor + MIC_VOICE_GATE_MARGIN_RMS * 0.55, + ); + voiceActive = voiceActive ? rms > idleThreshold : rms > activeThreshold; + + const floorRate = + rms < noiseFloor + ? 0.18 + : voiceActive + ? MIC_ACTIVE_NOISE_FLOOR_RISE + : 0.025; + noiseFloor += (rms - noiseFloor) * floorRate; + + if (!voiceActive) { + smoothedLevel = 0; + setMicLevel(0); + return; + } + + const normalized = clamp01( + (rms - noiseFloor) / MIC_LEVEL_ACTIVE_RANGE_RMS, + ); + const targetLevel = Math.max(normalized, MIC_MIN_ACTIVE_LEVEL); + smoothedLevel += (targetLevel - smoothedLevel) * MIC_LEVEL_ATTACK; + setMicLevel(smoothedLevel); + } + raf = requestAnimationFrame(tick); + + return () => { + cancelAnimationFrame(raf); + source.disconnect(); + void ctx.close(); + }; + }, [localAudioTrack, micConnected]); + + return micLevel; +} From b30f1f61299f6f559777f797be27f193a6a4f0b3 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 14 Aug 2026 04:16:04 +0100 Subject: [PATCH 03/33] Polish mobile profiles, DMs, and sheets (#5401) ## Summary - add poster-first, tap-to-toggle animated avatars on profile surfaces while preserving transparent/static behavior elsewhere - align mobile DM headers, membership actions, and invisible agent recipient addressing with established desktop semantics - polish titled sheets and status editing, preserve native iOS sheet corners, and batch relay reads to improve review-build responsiveness ## Snapshots
Profile avatar Agent DM header and composer
Mobile profile settings with animated avatar
surface Agent direct message with masked presence and normal
composer
Members sheet Status editor
Members bottom sheet with centered title and padded
content Status editor bottom sheet with duration and quick
statuses
Switch Community
Switch Community bottom sheet with centered title and
aligned Edit action
## Validation - `just mobile-check` - `just mobile-test` (1,283 tests) - installed and reviewed isolated debug builds on iPhone and Pixel --------- Signed-off-by: kenny lopez Signed-off-by: Princess Donut Signed-off-by: Kenny Lopez Signed-off-by: Wes Co-authored-by: Princess Donut Co-authored-by: Wes Co-authored-by: Carl --- .../activity/activity_page/inbox_row.dart | 21 +- .../features/activity/activity_provider.dart | 142 +++-- .../channels/channel_actions_sheet.dart | 1 + .../channels/channel_detail_page.dart | 14 +- .../channels/channel_detail_page/app_bar.dart | 102 ++-- .../channel_detail_page/message_bubble.dart | 18 +- .../channels/channel_typing_indicator.dart | 13 +- .../lib/features/channels/channels_page.dart | 4 +- .../channels/channels_page/channel_tile.dart | 19 +- .../channels/channels_page/community.dart | 230 ++++---- .../channels_page/quick_actions_launcher.dart | 2 + .../channels/channels_page/sheets.dart | 16 - .../features/channels/channels_provider.dart | 203 +++++--- .../channels/manage_channel_sheet.dart | 2 - .../lib/features/channels/members_sheet.dart | 155 +++--- .../channels/message_mention_pubkeys.dart | 26 + .../channels/send_message_provider.dart | 46 +- .../features/channels/thread_detail_page.dart | 4 + mobile/lib/features/home/home_page.dart | 12 +- .../lib/features/profile/profile_avatar.dart | 9 +- .../features/profile/set_status_sheet.dart | 493 +++++++++++++----- .../profile/settings_profile_header.dart | 63 ++- .../features/profile/user_profile_sheet.dart | 33 +- mobile/lib/features/profile/user_status.dart | 25 + .../profile/user_status_cache_provider.dart | 81 ++- .../profile/user_status_provider.dart | 75 ++- .../settings_page/appearance_section.dart | 10 +- mobile/lib/shared/animated_avatar.dart | 58 +++ mobile/lib/shared/relay/media_image.dart | 3 + .../shared/relay/relay_http_query_client.dart | 97 ++++ mobile/lib/shared/relay/relay_session.dart | 47 +- .../reminders/remind_me_later_sheet.dart | 11 +- mobile/lib/shared/widgets/avatar_image.dart | 13 +- .../lib/shared/widgets/buzz_sheet_header.dart | 121 +++++ .../widgets/buzz_titled_sheet_layout.dart | 67 +++ .../widgets/concentric_sheet_surface.dart | 39 +- .../shared/widgets/modal_presentation.dart | 107 ++-- .../widgets/progressive_animated_avatar.dart | 77 +++ .../activity/activity_provider_test.dart | 74 ++- .../channels/channel_detail_page_test.dart | 300 +++++++++++ .../features/channels/channels_page_test.dart | 11 +- .../channels/channels_provider_test.dart | 107 ++++ .../message_mention_pubkeys_test.dart | 82 +++ .../channels/send_message_provider_test.dart | 161 ++++++ .../features/profile/profile_avatar_test.dart | 52 +- .../profile/set_status_sheet_test.dart | 188 ++++++- .../profile/settings_profile_header_test.dart | 123 ++++- .../profile/user_status_provider_test.dart | 235 +++++++++ mobile/test/shared/animated_avatar_test.dart | 36 ++ .../test/shared/relay/relay_session_test.dart | 138 +++++ .../shared/widgets/avatar_image_test.dart | 36 +- .../widgets/modal_presentation_test.dart | 146 +++++- 52 files changed, 3428 insertions(+), 720 deletions(-) create mode 100644 mobile/lib/features/channels/message_mention_pubkeys.dart create mode 100644 mobile/lib/shared/animated_avatar.dart create mode 100644 mobile/lib/shared/relay/relay_http_query_client.dart create mode 100644 mobile/lib/shared/widgets/buzz_sheet_header.dart create mode 100644 mobile/lib/shared/widgets/buzz_titled_sheet_layout.dart create mode 100644 mobile/lib/shared/widgets/progressive_animated_avatar.dart create mode 100644 mobile/test/features/channels/message_mention_pubkeys_test.dart create mode 100644 mobile/test/features/profile/user_status_provider_test.dart create mode 100644 mobile/test/shared/animated_avatar_test.dart diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index fe7869299d..6d7f8f2985 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -72,23 +72,28 @@ class _InboxRow extends HookConsumerWidget { final revealAmount = useState(0.0); final isDragging = useState(false); final labelHapticFired = useRef(false); - final userCache = ref.watch(userCacheProvider); - final profile = userCache[item.item.pubkey.toLowerCase()]; + final senderPubkey = item.item.pubkey.toLowerCase(); + final mentionPubkeys = mentionedPubkeysFromTags(item.item.tags); + final relevantPubkeys = {senderPubkey, ...mentionPubkeys}; + final profiles = { + for (final pubkey in relevantPubkeys) + pubkey: ref.watch(userCacheProvider.select((cache) => cache[pubkey])), + }; + final profile = profiles[senderPubkey]; final senderLabel = profile?.displayName ?? shortPubkey(item.item.pubkey); final profileMentionNames = { - for (final pubkey in mentionedPubkeysFromTags(item.item.tags)) - if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true) - pubkey: userCache[pubkey]!.displayName!.trim(), + for (final pubkey in mentionPubkeys) + if (profiles[pubkey]?.displayName?.trim().isNotEmpty == true) + pubkey: profiles[pubkey]!.displayName!.trim(), }; - final mentionPubkeys = mentionedPubkeysFromTags(item.item.tags); final knownAgentPubkeys = channel == null ? ref.watch(knownAgentPubkeysProvider) : ref.watch(agentMentionPubkeysProvider(channel!.id)); final agentMentionPubkeys = agentPubkeysWithProfileOwners( knownAgentPubkeys: knownAgentPubkeys, profileOwnedAgentPubkeys: [ - for (final profile in userCache.values) - if (profile.ownerPubkey != null) profile.pubkey, + for (final pubkey in mentionPubkeys) + if (profiles[pubkey]?.ownerPubkey != null) pubkey, ], ); final mentionNames = mentionNamesWithDirectoryLabels( diff --git a/mobile/lib/features/activity/activity_provider.dart b/mobile/lib/features/activity/activity_provider.dart index 3581cbd551..a1badf2045 100644 --- a/mobile/lib/features/activity/activity_provider.dart +++ b/mobile/lib/features/activity/activity_provider.dart @@ -214,48 +214,55 @@ class ActivityNotifier extends AsyncNotifier { if (channel.isDm && channel.isMember) channel.id, ]; - final results = await Future.wait([ + final filters = [ // Mentions of me on user-visible channel content. - session.fetchHistory( - NostrFilter( - kinds: const [9, 40002, 1, 45001, 45003], - tags: { - '#p': [myPk], - }, - limit: 50, - ), + NostrFilter( + kinds: const [9, 40002, 1, 45001, 45003], + tags: { + '#p': [myPk], + }, + limit: 50, ), // Workflow approvals addressed to me. - session.fetchHistory( - NostrFilter( - kinds: const [46010, 46011, 46012], - tags: { - '#p': [myPk], - }, - limit: 20, - ), + NostrFilter( + kinds: const [46010, 46011, 46012], + tags: { + '#p': [myPk], + }, + limit: 20, ), // Agent job lifecycle events addressed to me. - session.fetchHistory( - NostrFilter( - kinds: const [43001, 43002, 43003, 43004, 43005, 43006], - tags: { - '#p': [myPk], - }, - limit: 20, - ), + NostrFilter( + kinds: const [43001, 43002, 43003, 43004, 43005, 43006], + tags: { + '#p': [myPk], + }, + limit: 20, ), // Recent DM traffic (filtered to other senders below). - if (dmChannelIds.isEmpty) - Future.value(const []) - else - session.fetchHistory( - NostrFilter(kinds: const [9], tags: {'#h': dmChannelIds}, limit: 30), - ), - ]); + if (dmChannelIds.isNotEmpty) + NostrFilter(kinds: const [9], tags: {'#h': dmChannelIds}, limit: 30), + ]; + + // The HTTP bridge keeps each NIP-01 filter's independent limit while + // executing the batch with bounded server-side concurrency. One request + // here replaces the four simultaneous websocket history subscriptions that + // otherwise compete with channel and preference startup sync. + final events = await _queryWithWebSocketFallback(session, filters); bool isFromOther(NostrEvent e) => e.pubkey.toLowerCase() != myPk.toLowerCase(); + bool isAddressedToMe(NostrEvent event) => event.tags.any( + (tag) => + tag.length > 1 && + tag[0] == 'p' && + tag[1].toLowerCase() == myPk.toLowerCase(), + ); + + const mentionKinds = {9, 40002, 1, 45001, 45003}; + const needsActionKinds = {46010, 46011, 46012}; + const agentActivityKinds = {43001, 43002, 43003, 43004, 43005, 43006}; + final dmChannelIdSet = dmChannelIds.toSet(); // Dedupe across sources by event id, keeping the higher-priority // category (needs_action > mention > agent_activity > activity). @@ -271,10 +278,38 @@ class ActivityNotifier extends AsyncNotifier { } } - add(results[1], 'needs_action'); - add(results[0].where(isFromOther), 'mention'); - add(results[2], 'agent_activity'); - add(results[3].where(isFromOther), 'activity'); + add( + events.where( + (event) => + needsActionKinds.contains(event.kind) && isAddressedToMe(event), + ), + 'needs_action', + ); + add( + events.where( + (event) => + mentionKinds.contains(event.kind) && + isAddressedToMe(event) && + isFromOther(event), + ), + 'mention', + ); + add( + events.where( + (event) => + agentActivityKinds.contains(event.kind) && isAddressedToMe(event), + ), + 'agent_activity', + ); + add( + events.where( + (event) => + event.kind == 9 && + dmChannelIdSet.contains(event.channelId) && + isFromOther(event), + ), + 'activity', + ); final items = byId.values.toList() ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); @@ -299,6 +334,41 @@ class ActivityNotifier extends AsyncNotifier { ); } + Future> _queryWithWebSocketFallback( + RelaySessionNotifier session, + List filters, + ) async { + try { + return await session.queryRelay(filters); + } catch (error) { + debugPrint( + '[ActivityNotifier] batched history query failed; ' + 'using bounded websocket fallback: $error', + ); + } + + const fallbackConcurrency = 4; + final events = []; + for (var start = 0; start < filters.length; start += fallbackConcurrency) { + final end = start + fallbackConcurrency < filters.length + ? start + fallbackConcurrency + : filters.length; + final results = await Future.wait( + filters.sublist(start, end).map((filter) async { + try { + return await session.fetchHistory(filter); + } catch (_) { + return const []; + } + }), + ); + for (final result in results) { + events.addAll(result); + } + } + return events; + } + FeedItem _feedItem(NostrEvent event, {required String category}) { return FeedItem( id: event.id, diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index 4e821ecc07..99dc2ebf47 100644 --- a/mobile/lib/features/channels/channel_actions_sheet.dart +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -200,6 +200,7 @@ class ChannelActionsSheet extends ConsumerWidget { onTap: () async { final shouldClose = await showBuzzModalBottomSheet( context: context, + title: 'Manage channel', isScrollControlled: true, showDragHandle: true, constraints: BoxConstraints( diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index c0b64f3e40..7cf3532ac4 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import '../../shared/animated_avatar.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; @@ -17,6 +18,7 @@ import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/keyboard_dismiss_on_drag.dart'; +import '../../shared/widgets/masked_avatar_badge.dart'; import '../../shared/widgets/message_author_meta.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/skeleton.dart'; @@ -338,11 +340,12 @@ class ChannelDetailPage extends HookConsumerWidget { ], ), actions: [ - _MembersButton( - channelId: resolvedChannel.id, - channel: resolvedChannel, - currentPubkey: currentPubkey, - ), + if (_showsMembersAction(resolvedChannel)) + _MembersButton( + channelId: resolvedChannel.id, + channel: resolvedChannel, + currentPubkey: currentPubkey, + ), IconButton( color: context.colors.primary, onPressed: () async { @@ -524,6 +527,7 @@ class ChannelDetailPage extends HookConsumerWidget { channelId: channel.id, content: content, mentionPubkeys: mentionPubkeys, + channel: resolvedChannel, mediaTags: mediaTags, ), ), diff --git a/mobile/lib/features/channels/channel_detail_page/app_bar.dart b/mobile/lib/features/channels/channel_detail_page/app_bar.dart index fe861bf0b9..b3e1271e98 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -1,5 +1,16 @@ part of '../channel_detail_page.dart'; +const _dmHeaderAvatarSize = 32.0; +const _dmPresenceDotRatio = 8 / 14; + +bool _showsMembersAction(Channel channel) { + if (!channel.isDm) return true; + final participants = channel.participantPubkeys + .map((pubkey) => pubkey.toLowerCase()) + .toSet(); + return participants.length != 2; +} + double _scaledTextHeight(BuildContext context, TextStyle style) { final scaledFontSize = MediaQuery.textScalerOf( context, @@ -8,15 +19,15 @@ double _scaledTextHeight(BuildContext context, TextStyle style) { } double _dmAppBarTitleContentHeight(BuildContext context) { - const titleStyle = channelTitleTextStyle; - final presenceStyle = context.textTheme.bodySmall; - if (presenceStyle == null) { - return 30; + final titleStyle = context.textTheme.titleSmall; + final presenceStyle = context.textTheme.bodyMedium; + if (titleStyle == null || presenceStyle == null) { + return _dmHeaderAvatarSize; } final textHeight = _scaledTextHeight(context, titleStyle) + _scaledTextHeight(context, presenceStyle); - return textHeight > 30 ? textHeight : 30; + return textHeight > _dmHeaderAvatarSize ? textHeight : _dmHeaderAvatarSize; } class _MembersButton extends ConsumerWidget { @@ -41,6 +52,7 @@ class _MembersButton extends ConsumerWidget { onPressed: () { showBuzzModalBottomSheet( context: context, + title: 'Members', isScrollControlled: true, showDragHandle: true, builder: (_) => @@ -80,8 +92,6 @@ class _DmAppBarTitle extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final profiles = ref.watch(userCacheProvider); - final presenceMap = ref.watch(presenceCacheProvider); final normalizedCurrent = currentPubkey?.toLowerCase(); String? otherPubkey; @@ -92,7 +102,18 @@ class _DmAppBarTitle extends ConsumerWidget { } } - final profile = otherPubkey != null ? profiles[otherPubkey] : null; + final profile = ref.watch( + userCacheProvider.select( + (profiles) => otherPubkey == null ? null : profiles[otherPubkey], + ), + ); + final presence = ref.watch( + presenceCacheProvider.select( + (presenceMap) => otherPubkey == null + ? 'offline' + : (presenceMap[otherPubkey] ?? 'offline'), + ), + ); if (otherPubkey != null) { if (profile == null) { @@ -102,14 +123,12 @@ class _DmAppBarTitle extends ConsumerWidget { } final avatarUrl = profile?.avatarUrl; + final animatedAvatar = parseAnimatedAvatarUrl(avatarUrl); final initial = profile?.initial ?? (channel.participants.isNotEmpty ? channel.participants.first[0].toUpperCase() : '?'); - final presence = otherPubkey != null - ? (presenceMap[otherPubkey] ?? 'offline') - : 'offline'; final presenceLabel = switch (presence) { 'online' => 'Online', 'away' => 'Away', @@ -118,16 +137,17 @@ class _DmAppBarTitle extends ConsumerWidget { return Row( children: [ - SizedBox( - width: 30, - height: 30, - child: Stack( - clipBehavior: Clip.none, - children: [ - AvatarImage( - imageUrl: avatarUrl, - radius: 14, - backgroundColor: context.colors.primaryContainer, + MaskedAvatarBadge( + key: const ValueKey('dm-header-avatar'), + size: _dmHeaderAvatarSize, + geometry: AvatarBadgeMaskGeometry.presenceDot, + avatar: ClipOval( + child: ColoredBox( + color: animatedAvatar == null + ? context.colors.primaryContainer + : Colors.transparent, + child: AvatarImageContent( + imageUrl: animatedAvatar?.posterUrl ?? avatarUrl, fallback: Text( initial, style: context.textTheme.labelSmall?.copyWith( @@ -136,27 +156,23 @@ class _DmAppBarTitle extends ConsumerWidget { ), ), ), - Positioned( - right: -1, - bottom: -1, - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: switch (presence) { - 'online' => context.appColors.success, - 'away' => context.appColors.warning, - _ => context.colors.outline, - }, - shape: BoxShape.circle, - border: Border.all( - color: context.colors.surface, - width: 1.5, - ), - ), + ), + ), + badge: Center( + child: FractionallySizedBox( + widthFactor: _dmPresenceDotRatio, + heightFactor: _dmPresenceDotRatio, + child: DecoratedBox( + decoration: BoxDecoration( + color: switch (presence) { + 'online' => context.appColors.success, + 'away' => context.appColors.warning, + _ => context.colors.outline, + }, + shape: BoxShape.circle, ), ), - ], + ), ), ), const SizedBox(width: Grid.xxs), @@ -176,7 +192,8 @@ class _DmAppBarTitle extends ConsumerWidget { ), maxLines: 1, overflow: TextOverflow.ellipsis, - style: channelTitleTextStyle, + key: const ValueKey('dm-header-name'), + style: context.textTheme.titleSmall, ), ), if (channel.isEphemeral) ...[ @@ -187,7 +204,8 @@ class _DmAppBarTitle extends ConsumerWidget { ), Text( presenceLabel, - style: context.textTheme.bodySmall?.copyWith( + key: const ValueKey('dm-header-presence'), + style: context.textTheme.bodyMedium?.copyWith( color: context.colors.onSurfaceVariant, ), ), diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index ac14f1d0a1..87673b3cbe 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -34,22 +34,30 @@ class _MessageBubble extends ConsumerWidget { (profile?.ownerPubkey != null && profile?.ownerPubkey == currentPubkey?.toLowerCase()); - // Build mention names map from event p-tags. - final userCache = ref.watch(userCacheProvider); + // Watch only profiles referenced by this message. A batched profile fetch + // should not rebuild every visible message just because an unrelated user + // was added to the shared cache. + final normalizedMentionPubkeys = { + for (final pubkey in message.mentionPubkeys) pubkey.toLowerCase(), + }; + final mentionProfiles = { + for (final pubkey in normalizedMentionPubkeys) + pubkey: ref.watch(userCacheProvider.select((cache) => cache[pubkey])), + }; final knownAgentPubkeys = agentPubkeysWithProfileOwners( knownAgentPubkeys: ref.watch( agentMentionPubkeysProvider(currentChannelId), ), profileOwnedAgentPubkeys: [ - for (final profile in userCache.values) - if (profile.ownerPubkey != null) profile.pubkey, + for (final entry in mentionProfiles.entries) + if (entry.value?.ownerPubkey != null) entry.key, ], ); final mentionNames = {}; final agentMentionPubkeys = {}; for (final mpk in message.mentionPubkeys) { final normalizedPubkey = mpk.toLowerCase(); - final p = userCache[normalizedPubkey]; + final p = mentionProfiles[normalizedPubkey]; if (p?.displayName != null) { mentionNames[normalizedPubkey] = p!.displayName!; } diff --git a/mobile/lib/features/channels/channel_typing_indicator.dart b/mobile/lib/features/channels/channel_typing_indicator.dart index 0543f13b06..698144ce72 100644 --- a/mobile/lib/features/channels/channel_typing_indicator.dart +++ b/mobile/lib/features/channels/channel_typing_indicator.dart @@ -16,10 +16,19 @@ class ChannelTypingIndicator extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final userCache = ref.watch(userCacheProvider); + final normalizedPubkeys = { + for (final entry in entries) entry.pubkey.toLowerCase(), + }; + final profiles = { + for (final pubkey in normalizedPubkeys) + pubkey: ref.watch(userCacheProvider.select((cache) => cache[pubkey])), + }; + final userCache = { + for (final entry in profiles.entries) entry.key: ?entry.value, + }; final names = entries.map((entry) { final profile = - userCache[entry.pubkey.toLowerCase()] ?? + profiles[entry.pubkey.toLowerCase()] ?? ref.read(userCacheProvider.notifier).get(entry.pubkey.toLowerCase()); return profile?.label ?? shortPubkey(entry.pubkey); }).toList(); diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 5ecc21a73a..c77ef278ec 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -19,6 +19,7 @@ import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/bee_refresh_indicator.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/buzz_titled_sheet_layout.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/modal_presentation.dart'; @@ -303,7 +304,8 @@ class ChannelsPage extends HookConsumerWidget { ref.invalidate(communityIconProvider); showBuzzModalBottomSheet( context: context, - showDragHandle: true, + showCloseButton: false, + showDragHandle: false, builder: (_) => const _CommunitySwitcherSheet(), ); } diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index f7344086e5..04d6b38d73 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -137,8 +137,6 @@ class _DmAvatar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final profiles = ref.watch(userCacheProvider); - final presenceMap = ref.watch(presenceCacheProvider); final normalizedCurrent = currentPubkey?.toLowerCase(); final otherPubkeys = [ for (final pk in channel.participantPubkeys) @@ -171,7 +169,18 @@ class _DmAvatar extends ConsumerWidget { } final otherPubkey = visiblePubkeys.isNotEmpty ? visiblePubkeys.first : null; - final profile = otherPubkey != null ? profiles[otherPubkey] : null; + final profile = ref.watch( + userCacheProvider.select( + (profiles) => otherPubkey == null ? null : profiles[otherPubkey], + ), + ); + final presence = ref.watch( + presenceCacheProvider.select( + (presenceMap) => otherPubkey == null + ? 'offline' + : (presenceMap[otherPubkey] ?? 'offline'), + ), + ); // Trigger fetches if not cached yet. if (otherPubkey != null) { @@ -187,10 +196,6 @@ class _DmAvatar extends ConsumerWidget { (channel.participants.isNotEmpty ? channel.participants.first[0].toUpperCase() : '?'); - final presence = otherPubkey != null - ? (presenceMap[otherPubkey] ?? 'offline') - : 'offline'; - return SizedBox( width: _kDmAvatarSize, height: _kDmAvatarSize, diff --git a/mobile/lib/features/channels/channels_page/community.dart b/mobile/lib/features/channels/channels_page/community.dart index 19f0efe5da..bd64ccc726 100644 --- a/mobile/lib/features/channels/channels_page/community.dart +++ b/mobile/lib/features/channels/channels_page/community.dart @@ -10,149 +10,109 @@ class _CommunitySwitcherSheet extends HookConsumerWidget { final isEditing = useState(false); return SafeArea( - child: Column( + child: BuzzTitledSheetLayout( key: const Key('community-switcher-sheet'), - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - Grid.xxs, - Grid.gutter, - Grid.xxs, + title: 'Switch Community', + titleKey: const Key('community-switcher-title'), + showDragHandle: true, + trailing: SizedBox( + key: const Key('community-switcher-edit'), + width: 56, + height: 44, + child: TextButton( + onPressed: () => isEditing.value = !isEditing.value, + style: TextButton.styleFrom( + minimumSize: const Size(56, 44), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 32), - child: Row( - children: [ - Expanded( - child: Text( - 'Switch Community', - key: const Key('community-switcher-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - Padding( - padding: const EdgeInsets.only(right: Grid.half), - child: ConstrainedBox( - key: const Key('community-switcher-edit'), - constraints: const BoxConstraints( - minWidth: 48, - minHeight: 32, - ), - child: TextButton( - onPressed: () => isEditing.value = !isEditing.value, - style: TextButton.styleFrom( - minimumSize: Size.zero, - padding: const EdgeInsets.symmetric( - horizontal: Grid.half, - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text(isEditing.value ? 'Done' : 'Edit'), - ), - ), - ), - ], + child: Text(isEditing.value ? 'Done' : 'Edit'), + ), + ), + child: communitiesAsync.when( + loading: () => const SizedBox( + height: 120, + child: Center( + child: BuzzLoadingIndicator( + size: 40, + semanticLabel: 'Loading communities', ), ), ), - Flexible( - child: communitiesAsync.when( - loading: () => const SizedBox( - height: 120, - child: Center( - child: BuzzLoadingIndicator( - size: 40, - semanticLabel: 'Loading communities', - ), - ), - ), - error: (e, _) => Padding( - padding: const EdgeInsets.all(Grid.xs), - child: Text('Error loading communities: $e'), - ), - data: (communities) { - final activeId = activeAsync.value?.id; - return SingleChildScrollView( - padding: const EdgeInsets.only(bottom: Grid.xs), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - ), - child: Material( - key: const Key('community-switcher-options'), - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.card), - clipBehavior: Clip.antiAlias, - child: Column( - children: [ - for ( - var index = 0; - index < communities.length; - index++ - ) ...[ - if (index > 0) const _CommunitySwitcherDivider(), - _CommunitySwitcherTile( - community: communities[index], - isActive: communities[index].id == activeId, - isEditing: isEditing.value, - onTap: isEditing.value - ? null - : () async { - final community = communities[index]; - if (community.id != activeId) { - await ref - .read( - communityListProvider.notifier, - ) - .switchCommunity(community.id); - } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - onRemove: () => _confirmRemoveCommunity( - context, - ref, - communities[index], - closeSheetAfterRemoval: - communities[index].id == activeId, - ), - ), - ], - if (communities.isNotEmpty) - const _CommunitySwitcherDivider(), - _AddCommunityTile( - onTap: () { - final nav = Navigator.of( - context, - rootNavigator: true, - ); - ref.read(pairingProvider.notifier).reset(); - Navigator.of(context).pop(); - nav.push( - MaterialPageRoute( - builder: (_) => - const PairingPage(addingCommunity: true), - ), - ); - }, + error: (e, _) => Padding( + padding: const EdgeInsets.all(Grid.xs), + child: Text('Error loading communities: $e'), + ), + data: (communities) { + final activeId = activeAsync.value?.id; + return SingleChildScrollView( + padding: const EdgeInsets.only(bottom: Grid.xs), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + child: Material( + key: const Key('community-switcher-options'), + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.card), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for ( + var index = 0; + index < communities.length; + index++ + ) ...[ + if (index > 0) const _CommunitySwitcherDivider(), + _CommunitySwitcherTile( + community: communities[index], + isActive: communities[index].id == activeId, + isEditing: isEditing.value, + onTap: isEditing.value + ? null + : () async { + final community = communities[index]; + if (community.id != activeId) { + await ref + .read(communityListProvider.notifier) + .switchCommunity(community.id); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + onRemove: () => _confirmRemoveCommunity( + context, + ref, + communities[index], + closeSheetAfterRemoval: + communities[index].id == activeId, ), - ], + ), + ], + if (communities.isNotEmpty) + const _CommunitySwitcherDivider(), + _AddCommunityTile( + onTap: () { + final nav = Navigator.of( + context, + rootNavigator: true, + ); + ref.read(pairingProvider.notifier).reset(); + Navigator.of(context).pop(); + nav.push( + MaterialPageRoute( + builder: (_) => + const PairingPage(addingCommunity: true), + ), + ); + }, ), - ), + ], ), - ); - }, - ), - ), - ], + ), + ), + ); + }, + ), ), ); } diff --git a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart index adf5cfd176..8517fa70b9 100644 --- a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart +++ b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart @@ -87,6 +87,7 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget { case _QuickAction.createChannel: final created = await showBuzzModalBottomSheet( context: context, + title: 'Create a new channel', constraints: _quickActionSheetConstraints(context), isScrollControlled: true, showDragHandle: true, @@ -98,6 +99,7 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget { case _QuickAction.newDm: final opened = await showBuzzModalBottomSheet( context: context, + title: 'New message', constraints: _quickActionSheetConstraints(context), isScrollControlled: true, showDragHandle: true, diff --git a/mobile/lib/features/channels/channels_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index 2738be0aa9..a17bcef476 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -91,14 +91,6 @@ class _CreateChannelSheet extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Create a new $kindLabel', - style: context.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - letterSpacing: -0.3, - ), - ), - const SizedBox(height: Grid.sm), _CreateChannelFieldLabel(label: 'Name'), const SizedBox(height: Grid.xxs), _CreateChannelFieldShell( @@ -537,14 +529,6 @@ class _NewDirectMessageSheet extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'New message', - style: context.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - letterSpacing: -0.3, - ), - ), - const SizedBox(height: Grid.xs), GestureDetector( behavior: HitTestBehavior.translucent, onTap: isSubmitting.value ? null : queryFocusNode.requestFocus, diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 094f37b3f7..2f8dddf2d9 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -279,53 +279,34 @@ class ChannelsNotifier extends AsyncNotifier> { // see every channel as having no messages. Skipped on backstop refreshes since // live subscriptions keep lastMessageAt current after the initial load. if (fetchLastMessage) { - final lastMessageResults = await Future.wait( - channels.map((channel) async { - if (!channel.isMember || channel.isArchived) return null; - try { - if (channel.isDm) { - final events = await session.fetchHistory( - NostrFilter( - kinds: EventKind.channelMessageEventKinds, - tags: { - '#h': [channel.id], - }, - limit: 1, - ), - ); - if (events.isEmpty) return null; - return MapEntry(channel.id, events.first.createdAt); - } - final events = await session.fetchHistory( - NostrFilter( - kinds: EventKind.channelMessageEventKinds, - tags: { - '#h': [channel.id], - }, - limit: 20, - ), - ); - for (final event in events) { - if (shouldNotifyForEvent( - event, - myPk, - mutedChannelIds: _mutedChannelIds(), - channelId: channel.id, - )) { - return MapEntry(channel.id, event.createdAt); - } - } - return null; - } catch (_) { - return null; - } - }), - ); - + final activeChannels = [ + for (final channel in channels) + if (channel.isMember && !channel.isArchived) channel, + ]; + final channelById = { + for (final channel in activeChannels) channel.id: channel, + }; + final events = await _fetchLastMessageEvents(session, activeChannels); final lastMessageMap = {}; - for (final entry - in lastMessageResults.whereType>()) { - lastMessageMap[entry.key] = entry.value; + final mutedChannelIds = _mutedChannelIds(); + for (final event in events) { + final channelId = event.channelId; + if (channelId == null) continue; + final channel = channelById[channelId]; + if (channel == null) continue; + if (!channel.isDm && + !shouldNotifyForEvent( + event, + myPk, + mutedChannelIds: mutedChannelIds, + channelId: channelId, + )) { + continue; + } + final current = lastMessageMap[channelId]; + if (current == null || event.createdAt > current) { + lastMessageMap[channelId] = event.createdAt; + } } for (var i = 0; i < channels.length; i++) { @@ -410,6 +391,70 @@ class ChannelsNotifier extends AsyncNotifier> { _memberSnapshotsByChannelId = Map.unmodifiable(snapshots); } + /// Fetches each channel's independent latest-message window in one HTTP + /// bridge request. The relay preserves NIP-01 per-filter limits while + /// executing the filters with bounded concurrency, avoiding an unbounded + /// burst of websocket REQs on communities with many channels. + Future> _fetchLastMessageEvents( + RelaySessionNotifier session, + List channels, + ) async { + if (channels.isEmpty) return const []; + + final filters = [ + for (final channel in channels) + NostrFilter( + kinds: EventKind.channelMessageEventKinds, + tags: { + '#h': [channel.id], + }, + limit: channel.isDm ? 1 : 20, + ), + ]; + + return _fetchChannelHistoryBatch( + session, + filters, + operation: 'latest-message query', + ); + } + + Future> _fetchChannelHistoryBatch( + RelaySessionNotifier session, + List filters, { + required String operation, + }) async { + if (filters.isEmpty) return const []; + + try { + return await session.queryRelay(filters); + } catch (error) { + debugPrint( + '[ChannelsNotifier] batched $operation failed; ' + 'using bounded websocket fallback: $error', + ); + } + + const fallbackConcurrency = 4; + final events = []; + for (var start = 0; start < filters.length; start += fallbackConcurrency) { + final end = min(start + fallbackConcurrency, filters.length); + final results = await Future.wait( + filters.sublist(start, end).map((filter) async { + try { + return await session.fetchHistory(filter); + } catch (_) { + return const []; + } + }), + ); + for (final result in results) { + events.addAll(result); + } + } + return events; + } + Future> _fetchHiddenDmIds( RelaySessionNotifier session, String myPk, @@ -615,47 +660,34 @@ class ChannelsNotifier extends AsyncNotifier> { debugPrint('[ChannelsNotifier] unread catch-up skipped: $error'); return; } - final futures = >[]; - - for (final channel in channels) { - if (!channel.isMember || channel.isArchived) continue; - final readAt = readState.effectiveTimestamp(channel.id); - futures.add( - _catchUpUnreadEventsForChannel( - session, - channel, - myPk, - readAt, - mutedChannelIds, - ), - ); - } - - const batchSize = 5; - for (var i = 0; i < futures.length; i += batchSize) { - await Future.wait(futures.sublist(i, min(i + batchSize, futures.length))); - } - - state = state.whenData((channels) => List.of(channels)); - } - - Future _catchUpUnreadEventsForChannel( - RelaySessionNotifier session, - Channel channel, - String myPk, - int? readAt, - Set mutedChannelIds, - ) async { - try { - final events = await session.fetchHistory( + final activeChannels = [ + for (final channel in channels) + if (channel.isMember && !channel.isArchived) channel, + ]; + final channelById = { + for (final channel in activeChannels) channel.id: channel, + }; + final readAtByChannel = { + for (final channel in activeChannels) + channel.id: readState.effectiveTimestamp(channel.id), + }; + final filters = [ + for (final channel in activeChannels) NostrFilter( kinds: EventKind.channelMessageEventKinds, tags: { '#h': [channel.id], }, - since: readAt == null ? 0 : readAt + 1, + since: (readAtByChannel[channel.id] ?? -1) + 1, limit: _unreadCatchUpLimit, ), + ]; + + try { + final events = await _fetchChannelHistoryBatch( + session, + filters, + operation: 'unread catch-up', ); for (final event in events) { @@ -665,6 +697,11 @@ class ChannelsNotifier extends AsyncNotifier> { } for (final event in events) { + final channelId = event.channelId; + if (channelId == null) continue; + final channel = channelById[channelId]; + if (channel == null) continue; + final readAt = readAtByChannel[channelId]; if (event.pubkey.toLowerCase() == myPk.toLowerCase()) continue; if (readAt != null && event.createdAt <= readAt) continue; if (!shouldNotifyForEvent( @@ -681,10 +718,10 @@ class ChannelsNotifier extends AsyncNotifier> { _recordUnreadEvent(channel, event, myPk); } } catch (error) { - debugPrint( - '[ChannelsNotifier] unread catch-up failed for ${channel.id}: $error', - ); + debugPrint('[ChannelsNotifier] unread catch-up failed: $error'); } + + state = state.whenData((channels) => List.of(channels)); } void _handleLiveEvent(NostrEvent event) { diff --git a/mobile/lib/features/channels/manage_channel_sheet.dart b/mobile/lib/features/channels/manage_channel_sheet.dart index 15ad07b0cd..3f0d612745 100644 --- a/mobile/lib/features/channels/manage_channel_sheet.dart +++ b/mobile/lib/features/channels/manage_channel_sheet.dart @@ -109,8 +109,6 @@ class ManageChannelSheet extends HookConsumerWidget { child: ListView( shrinkWrap: true, children: [ - Text('Manage channel', style: context.textTheme.titleMedium), - const SizedBox(height: Grid.xxs), Text( 'Basic management for ${channel.name}.', style: context.textTheme.bodySmall?.copyWith( diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index 24c80daa5f..54215ccd75 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -35,6 +35,7 @@ class MembersSheet extends HookConsumerWidget { final userCache = ref.watch(userCacheProvider); final typingBotPubkeys = ref.watch(workingBotPubkeysProvider(channel.id)); final statusCache = ref.watch(userStatusCacheProvider); + final bottomClearance = Grid.md + MediaQuery.viewPaddingOf(context).bottom; // Determine if the current user can manage members. final currentMember = allMembers.cast().firstWhere( @@ -82,93 +83,78 @@ class MembersSheet extends HookConsumerWidget { }, [allMembers.length]); return Padding( - padding: EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - MediaQuery.viewInsetsOf(context).bottom, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Members', style: context.textTheme.titleMedium), - const SizedBox(height: Grid.xxs), - if (!channel.isDm) ...[const Divider(height: 1)], - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 400), - child: membersAsync.when( - data: (_) => ListView( - shrinkWrap: true, - padding: const EdgeInsets.only(top: Grid.xxs), - children: [ - if (people.isNotEmpty) ...[ - _SectionLabel(label: 'People — ${people.length}'), - for (final member in people) - _MemberTile( - member: member, - currentPubkey: currentPubkey, - profile: userCache[member.pubkey.toLowerCase()], - canManage: canManage, - isSelf: - member.pubkey.toLowerCase() == - currentPubkey?.toLowerCase(), - channelId: channel.id, - userStatus: statusCache[member.pubkey.toLowerCase()], - ), - ], - if (bots.isNotEmpty) ...[ - const SizedBox(height: Grid.xxs), - _SectionLabel(label: 'Bots — ${bots.length}'), - for (final bot in bots) - _MemberTile( - member: bot, - currentPubkey: currentPubkey, - profile: userCache[bot.pubkey.toLowerCase()], - canManage: canManage, - isSelf: false, - channelId: channel.id, - isWorking: typingBotPubkeys.contains( - bot.pubkey.toLowerCase(), - ), - onViewActivity: () => openActivity(bot), - onActivityTap: - typingBotPubkeys.contains( - bot.pubkey.toLowerCase(), - ) - ? () => openActivity(bot) - : null, - ), - ], - if (people.isEmpty && bots.isEmpty) - Center( - child: Text( - 'No members found.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ), - ], - ), - loading: () => const Center( - child: BuzzLoadingIndicator( - size: 44, - semanticLabel: 'Loading members', + key: const ValueKey('members-sheet-content-padding'), + padding: EdgeInsets.fromLTRB(Grid.gutter, 0, Grid.gutter, 0), + child: ConstrainedBox( + key: const ValueKey('members-sheet-viewport'), + constraints: BoxConstraints(maxHeight: 400 + bottomClearance), + child: membersAsync.when( + data: (_) => ListView( + key: const ValueKey('members-sheet-list'), + shrinkWrap: true, + padding: EdgeInsets.only(top: Grid.xxs, bottom: bottomClearance), + children: [ + if (people.isNotEmpty) ...[ + _SectionLabel(label: 'People · ${people.length}'), + for (final member in people) + _MemberTile( + member: member, + currentPubkey: currentPubkey, + profile: userCache[member.pubkey.toLowerCase()], + canManage: canManage, + isSelf: + member.pubkey.toLowerCase() == + currentPubkey?.toLowerCase(), + channelId: channel.id, + userStatus: statusCache[member.pubkey.toLowerCase()], ), - ), - error: (error, _) => Center( + ], + if (bots.isNotEmpty) ...[ + const SizedBox(height: Grid.xxs), + _SectionLabel(label: 'Agents · ${bots.length}'), + for (final bot in bots) + _MemberTile( + member: bot, + currentPubkey: currentPubkey, + profile: userCache[bot.pubkey.toLowerCase()], + canManage: canManage, + isSelf: false, + channelId: channel.id, + isWorking: typingBotPubkeys.contains( + bot.pubkey.toLowerCase(), + ), + onViewActivity: () => openActivity(bot), + onActivityTap: + typingBotPubkeys.contains(bot.pubkey.toLowerCase()) + ? () => openActivity(bot) + : null, + ), + ], + if (people.isEmpty && bots.isEmpty) + Center( child: Text( - error.toString(), + 'No members found.', style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, + color: context.colors.onSurfaceVariant, ), ), ), + ], + ), + loading: () => const Center( + child: BuzzLoadingIndicator( + size: 44, + semanticLabel: 'Loading members', + ), + ), + error: (error, _) => Center( + child: Text( + error.toString(), + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, ), ), - ], + ), ), ), ); @@ -185,11 +171,10 @@ class _SectionLabel extends StatelessWidget { return Padding( padding: const EdgeInsets.only(top: Grid.half, bottom: Grid.half), child: Text( - label.toUpperCase(), - style: context.textTheme.labelSmall?.copyWith( + label, + style: context.textTheme.labelMedium?.copyWith( color: context.colors.onSurfaceVariant, fontWeight: FontWeight.w600, - letterSpacing: 0.8, ), ), ); @@ -305,17 +290,13 @@ class _MemberTile extends ConsumerWidget { final canChangeRole = showManagementActions && !member.isBot; showBuzzModalBottomSheet( context: context, + title: label, showDragHandle: true, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), - child: Text(label, style: context.textTheme.titleSmall), - ), - const SizedBox(height: Grid.xxs), if (onViewActivity != null) ListTile( leading: Icon( diff --git a/mobile/lib/features/channels/message_mention_pubkeys.dart b/mobile/lib/features/channels/message_mention_pubkeys.dart new file mode 100644 index 0000000000..59f28b903d --- /dev/null +++ b/mobile/lib/features/channels/message_mention_pubkeys.dart @@ -0,0 +1,26 @@ +import 'channel.dart'; + +/// Semantic recipients for an outgoing mobile message. +/// +/// Explicit mentions are always preserved. In a DM, every current recipient +/// is also addressed with a `p` tag without inserting visible `@mentions` into +/// the composer. Non-DM channels remain explicit-only. +List messageMentionPubkeys({ + required Channel channel, + required String? senderPubkey, + required Iterable explicitMentions, + required Iterable dmRecipientPubkeys, +}) { + final sender = senderPubkey?.toLowerCase(); + final candidates = [ + ...explicitMentions, + if (channel.isDm) ...dmRecipientPubkeys, + ]; + + final seen = {?sender}; + return [ + for (final candidate in candidates) + if (candidate.trim().isNotEmpty && seen.add(candidate.toLowerCase())) + candidate.toLowerCase(), + ]; +} diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 730546807f..736ce0821e 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -4,7 +4,9 @@ import '../../shared/relay/relay.dart'; import '../channels/channel_management_provider.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; +import 'channel.dart'; import 'channel_messages_provider.dart'; +import 'message_mention_pubkeys.dart'; /// Sends messages by signing an event with the user's nsec and publishing it /// over the relay's NIP-42-authenticated WebSocket session. @@ -48,14 +50,26 @@ class SendMessage { String? parentEventId, String? rootEventId, List? mentionPubkeys, + Channel? channel, List> mediaTags = const [], }) async { _ensureDeliveryValid(); // Use explicitly passed pubkeys, or resolve @mentions against // channel members to avoid matching the wrong user. - final resolvedMentions = + final explicitMentions = mentionPubkeys ?? await _resolveMentions(content, channelId); final authorPubkey = _signedEventRelay.pubkey; + final dmRecipientPubkeys = channel?.isDm == true + ? await _fetchDmRecipientPubkeys(channelId, channel!, authorPubkey) + : null; + final resolvedMentions = dmRecipientPubkeys != null + ? messageMentionPubkeys( + channel: channel!, + senderPubkey: authorPubkey, + explicitMentions: explicitMentions, + dmRecipientPubkeys: dmRecipientPubkeys, + ) + : explicitMentions; // Normalize mentions: lowercase, deduplicate, exclude self (matching // the desktop's normalizeMentionPubkeys). @@ -94,6 +108,36 @@ class SendMessage { } } + /// Resolve every identity that is actually a current member of this DM. + /// + /// Membership is authoritative for delivery. The channel metadata's `p` + /// tags can lag membership changes, so they are only used when the membership + /// snapshot is unavailable. + Future> _fetchDmRecipientPubkeys( + String channelId, + Channel channel, + String? authorPubkey, + ) async { + List? members; + try { + members = await _fetchMembers(channelId); + } catch (_) { + // Fall back to metadata below so an unavailable membership query does + // not block ordinary DM sends. + } + + final author = authorPubkey?.toLowerCase(); + final participants = members != null && members.isNotEmpty + ? members.map((member) => member.pubkey) + : channel.participantPubkeys; + return { + for (final participant in participants) + if (participant.trim().isNotEmpty && + participant.toLowerCase() != author) + participant.toLowerCase(), + }; + } + void _ensureDeliveryValid() { if (_isDeliveryValid?.call() == false) { throw StateError( diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 9de8dc861f..fcd89b767a 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -375,6 +375,9 @@ class ThreadDetailPage extends HookConsumerWidget { }, [itemScrollController, replies.length]); final channelsAsync = ref.watch(channelsProvider); + final channel = channelsAsync.value + ?.where((candidate) => candidate.id == channelId) + .firstOrNull; final channelNamesMap = {}; channelsAsync.whenData((channels) { for (final ch in channels) { @@ -583,6 +586,7 @@ class ThreadDetailPage extends HookConsumerWidget { channelId: channelId, content: content, mentionPubkeys: mentionPubkeys, + channel: channel, parentEventId: threadHead.id, rootEventId: effectiveRootId, mediaTags: mediaTags, diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index c691310df5..1026a51a01 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -64,6 +64,7 @@ class HomePage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final tabIndex = useState(0); + final visitedTabs = useRef({0}); final tabContentTransitionDirection = useRef(1.0); final tabContentTransitionController = useAnimationController( duration: _tabContentTransitionDuration, @@ -96,8 +97,14 @@ class HomePage extends HookConsumerWidget { } }, ), - ActivityPage(tabReselection: activityReselection), - SearchPage(tabReselection: searchReselection), + if (visitedTabs.value.contains(1)) + ActivityPage(tabReselection: activityReselection) + else + const SizedBox.shrink(), + if (visitedTabs.value.contains(2)) + SearchPage(tabReselection: searchReselection) + else + const SizedBox.shrink(), ]; final settingsTransitionGradient = tabIndex.value == 0 @@ -195,6 +202,7 @@ class HomePage extends HookConsumerWidget { ? 1 : -1; unawaited(HapticFeedback.selectionClick()); + visitedTabs.value.add(i); tabIndex.value = i; if (reducedMotion) { tabContentTransitionController.value = 1; diff --git a/mobile/lib/features/profile/profile_avatar.dart b/mobile/lib/features/profile/profile_avatar.dart index 087cc909aa..3e98210ffd 100644 --- a/mobile/lib/features/profile/profile_avatar.dart +++ b/mobile/lib/features/profile/profile_avatar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/animated_avatar.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/masked_avatar_badge.dart'; @@ -63,6 +64,7 @@ class ProfileAvatar extends ConsumerWidget { UserProfile? profile, String presence, ) { + final animatedAvatar = parseAnimatedAvatarUrl(profile?.avatarUrl); return GestureDetector( onTap: onTap, child: MaskedAvatarBadge( @@ -70,9 +72,12 @@ class ProfileAvatar extends ConsumerWidget { geometry: AvatarBadgeMaskGeometry.presenceDot, avatar: ClipOval( child: ColoredBox( - color: context.colors.primaryContainer, + key: const ValueKey('profile-avatar-background'), + color: animatedAvatar == null + ? context.colors.primaryContainer + : Colors.transparent, child: AvatarImageContent( - imageUrl: profile?.avatarUrl, + imageUrl: animatedAvatar?.posterUrl ?? profile?.avatarUrl, fallback: Text( profile?.initial ?? '?', style: context.textTheme.labelMedium?.copyWith( diff --git a/mobile/lib/features/profile/set_status_sheet.dart b/mobile/lib/features/profile/set_status_sheet.dart index 7419548b62..b3d2b4b0af 100644 --- a/mobile/lib/features/profile/set_status_sheet.dart +++ b/mobile/lib/features/profile/set_status_sheet.dart @@ -1,40 +1,62 @@ -import 'dart:math' as math; - +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/app_list.dart'; +import '../../shared/widgets/app_list_card.dart'; +import '../../shared/widgets/buzz_titled_sheet_layout.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../channels/emoji_picker.dart'; import 'user_status.dart'; import 'user_status_provider.dart'; -/// The emoji well and the text field are sized to be the two things you reach -/// for, so they carry no borders — the sheet has no other controls to compete -/// with them. -const _emojiWellSize = 56.0; -const _emojiGlyphSize = 32.0; -const _saveButtonHeight = 52.0; +const _emojiWellSize = 48.0; +const _emojiGlyphSize = 28.0; + +const _statusPresets = [ + (text: 'In a meeting', emoji: '\u{1F5E3}\u{FE0F}'), + (text: 'Commuting', emoji: '\u{1F68C}'), + (text: 'Out sick', emoji: '\u{1F912}'), + (text: 'Vacationing', emoji: '\u{1F3D6}\u{FE0F}'), + (text: 'Working remotely', emoji: '\u{1F3E0}'), +]; + +enum _StatusDuration { + oneHour('1 hour', Duration(hours: 1)), + eightHours('8 hours', Duration(hours: 8)), + oneDay('1 day', Duration(days: 1)), + oneWeek('1 week', Duration(days: 7)), + custom('Custom', null); + + const _StatusDuration(this.label, this.duration); + + final String label; + final Duration? duration; +} void showSetStatusSheet(BuildContext context, {UserStatus? currentStatus}) { showBuzzModalBottomSheet( context: context, isScrollControlled: true, - showDragHandle: true, + showCloseButton: false, + showDragHandle: false, builder: (_) => _SetStatusSheet(currentStatus: currentStatus), ); } class _SetStatusSheet extends HookConsumerWidget { - final UserStatus? currentStatus; - const _SetStatusSheet({this.currentStatus}); + final UserStatus? currentStatus; + @override Widget build(BuildContext context, WidgetRef ref) { final textController = useTextEditingController( @@ -42,6 +64,15 @@ class _SetStatusSheet extends HookConsumerWidget { ); final emoji = useState(currentStatus?.emoji ?? ''); final text = useState(currentStatus?.text ?? ''); + final currentExpiration = currentStatus?.expirationDateTime; + final duration = useState( + currentExpiration == null + ? _StatusDuration.oneDay + : _StatusDuration.custom, + ); + final customUntil = useState( + currentExpiration ?? DateTime.now().add(_StatusDuration.oneDay.duration!), + ); final isSaving = useState(false); useEffect(() { @@ -53,13 +84,18 @@ class _SetStatusSheet extends HookConsumerWidget { final hasContent = text.value.trim().isNotEmpty || emoji.value.isNotEmpty; final hasExistingStatus = currentStatus != null && !currentStatus!.isEmpty; + DateTime expiresAt() => switch (duration.value) { + _StatusDuration.custom => customUntil.value, + final preset => DateTime.now().add(preset.duration!), + }; + Future handleSave() async { - if (isSaving.value) return; + if (isSaving.value || !hasContent) return; isSaving.value = true; try { await ref .read(userStatusProvider.notifier) - .setStatus(text.value, emoji.value); + .setStatus(text.value, emoji.value, expiresAt: expiresAt()); if (context.mounted) Navigator.of(context).pop(); } finally { isSaving.value = false; @@ -77,135 +113,244 @@ class _SetStatusSheet extends HookConsumerWidget { } } - return Padding( - padding: EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - // The sheet ends in the Save button, so it owns its own breathing room - // above whichever is taller: the keyboard, or the home indicator. - Grid.gutter + - math.max( - MediaQuery.viewInsetsOf(context).bottom, - MediaQuery.viewPaddingOf(context).bottom, - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Set a status', style: context.textTheme.titleMedium), - const SizedBox(height: Grid.half), - Text( - 'Let others know what you\u2019re up to.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), + Future pickCustomUntil() async { + final picked = await _showNativeDateTimePicker( + context, + initial: customUntil.value, + ); + if (picked == null) return; + duration.value = _StatusDuration.custom; + customUntil.value = picked; + } + + Future chooseDuration() async { + FocusScope.of(context).unfocus(); + final selected = await _showStatusDurationSheet( + context, + selected: duration.value, + ); + if (selected == null || !context.mounted) return; + if (selected == _StatusDuration.custom) { + await pickCustomUntil(); + } else { + duration.value = selected; + } + } + + final headerAction = SizedBox.square( + dimension: 44, + child: IconButton( + key: const ValueKey('save-status-button'), + tooltip: 'Save status', + onPressed: hasContent && !isSaving.value ? handleSave : null, + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: context.colors.surfaceContainerHighest, + foregroundColor: context.colors.onSurface, + disabledForegroundColor: context.colors.onSurfaceVariant.withValues( + alpha: 0.38, ), - const SizedBox(height: Grid.twelve), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.dialog), + ), + ), + icon: const Icon(LucideIcons.check, size: 22), + ), + ); - // The emoji well doubles as the picker's entry point, which is why - // there is no separate row of emoji suggestions below. - Row( + return SafeArea( + top: false, + child: BuzzTitledSheetLayout( + title: 'Set a status', + showDragHandle: true, + leading: headerAction, + child: SingleChildScrollView( + padding: EdgeInsets.only( + // Keep the sheet at its resting height while the keyboard + // overlays its lower portion. Adding viewInsets here makes the + // modal itself grow upward by the full keyboard height. + bottom: Grid.gutter + MediaQuery.viewPaddingOf(context).bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Stack( - clipBehavior: Clip.none, + Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: _StatusInput( + controller: textController, + emoji: emoji.value, + enabled: !isSaving.value, + onChooseEmoji: () => showEmojiPicker( + context: context, + onSelect: (value) => emoji.value = value, + ), + onRemoveEmoji: () => emoji.value = '', + onSubmitted: handleSave, + ), + ), + AppListCard( + label: 'Duration', children: [ - Semantics( - button: true, - label: 'Choose a status emoji', - child: InkWell( - borderRadius: BorderRadius.circular(Radii.lg), - onTap: () => showEmojiPicker( - context: context, - onSelect: (value) => emoji.value = value, - ), - child: SizedBox.square( - dimension: _emojiWellSize, - child: Center( - child: _StatusEmojiPreview(emoji: emoji.value), - ), - ), - ), + AppListRow( + icon: LucideIcons.clock3, + title: 'Duration', + value: duration.value.label, + trailing: const Icon(LucideIcons.chevronDown, size: 18), + onTap: chooseDuration, ), - if (emoji.value.isNotEmpty) - Positioned( - top: -Grid.quarter, - right: -Grid.quarter, - child: SizedBox.square( - dimension: Grid.sm, - child: IconButton( - onPressed: isSaving.value - ? null - : () => emoji.value = '', - tooltip: 'Remove status emoji', - visualDensity: VisualDensity.compact, - style: IconButton.styleFrom( - backgroundColor: context.colors.surface, - minimumSize: const Size.square(Grid.sm), - maximumSize: const Size.square(Grid.sm), - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - icon: Icon( - LucideIcons.x, - size: 14, - color: context.colors.onSurface, + if (duration.value == _StatusDuration.custom) + AppListRow( + icon: LucideIcons.calendarClock, + title: 'Until', + value: _formatUntil(customUntil.value), + onTap: pickCustomUntil, + ), + ], + ), + AppListCard( + label: 'Quick statuses', + children: [ + for (final preset in _statusPresets) + AppListRowRaw( + leading: SizedBox( + width: 22, + child: Center( + child: Text( + preset.emoji, + style: const TextStyle(fontSize: 20), ), ), ), + title: Text( + preset.text, + style: context.textTheme.bodyLarge, + ), + onTap: () { + textController.text = preset.text; + emoji.value = preset.emoji; + }, ), ], ), - const SizedBox(width: Grid.half), - Expanded( - child: TextField( - controller: textController, - autofocus: true, - style: context.textTheme.titleMedium, - decoration: InputDecoration( - hintText: 'What\u2019s your status?', - hintStyle: context.textTheme.titleMedium?.copyWith( - color: context.colors.onSurfaceVariant, + if (hasExistingStatus) + AppListCard( + children: [ + AppListRow( + icon: LucideIcons.trash2, + title: 'Clear status', + titleColor: context.colors.error, + onTap: isSaving.value ? null : handleClear, ), - // The theme outlines inputs; this one is the sheet's whole - // content, so every border state is cleared explicitly. - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - isDense: false, - contentPadding: EdgeInsets.zero, - ), - textInputAction: TextInputAction.done, - onSubmitted: (_) { - if (hasContent) handleSave(); - }, + ], ), - ), ], ), - const SizedBox(height: Grid.gutter), - - SizedBox( - width: double.infinity, - height: _saveButtonHeight, - child: FilledButton( - onPressed: hasContent && !isSaving.value ? handleSave : null, - child: const Text('Save'), + ), + ), + ); + } +} + +class _StatusInput extends ConsumerWidget { + const _StatusInput({ + required this.controller, + required this.emoji, + required this.enabled, + required this.onChooseEmoji, + required this.onRemoveEmoji, + required this.onSubmitted, + }); + + final TextEditingController controller; + final String emoji; + final bool enabled; + final VoidCallback onChooseEmoji; + final VoidCallback onRemoveEmoji; + final VoidCallback onSubmitted; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DecoratedBox( + key: const ValueKey('status-input-outline'), + decoration: BoxDecoration( + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.8), + ), + borderRadius: BorderRadius.circular(Radii.card), + ), + child: Row( + children: [ + SizedBox.square( + dimension: _emojiWellSize, + child: Stack( + clipBehavior: Clip.none, + children: [ + Semantics( + button: true, + label: 'Choose a status emoji', + child: InkWell( + borderRadius: BorderRadius.circular(Radii.card), + onTap: enabled ? onChooseEmoji : null, + child: Center(child: _StatusEmojiPreview(emoji: emoji)), + ), + ), + if (emoji.isNotEmpty) + Positioned( + top: -Grid.half, + right: -Grid.half, + child: SizedBox.square( + dimension: Grid.sm, + child: IconButton( + onPressed: enabled ? onRemoveEmoji : null, + tooltip: 'Remove status emoji', + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + backgroundColor: context.colors.surface, + minimumSize: const Size.square(Grid.sm), + maximumSize: const Size.square(Grid.sm), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: Icon( + LucideIcons.x, + size: 14, + color: context.colors.onSurface, + ), + ), + ), + ), + ], ), ), - // No Cancel \u2014 the sheet dismisses by swiping down. - if (hasExistingStatus) - Padding( - padding: const EdgeInsets.only(top: Grid.half), - child: SizedBox( - width: double.infinity, - child: TextButton( - onPressed: isSaving.value ? null : handleClear, - child: const Text('Clear status'), + Expanded( + child: TextField( + controller: controller, + enabled: enabled, + autofocus: false, + style: context.textTheme.bodyLarge, + decoration: InputDecoration( + hintText: 'What\u2019s your status?', + hintStyle: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.only( + left: Grid.xxs, + right: Grid.xs, ), ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => onSubmitted(), ), + ), ], ), ); @@ -213,10 +358,10 @@ class _SetStatusSheet extends HookConsumerWidget { } class _StatusEmojiPreview extends ConsumerWidget { - final String emoji; - const _StatusEmojiPreview({required this.emoji}); + final String emoji; + @override Widget build(BuildContext context, WidgetRef ref) { if (emoji.isEmpty) { @@ -242,3 +387,103 @@ class _StatusEmojiPreview extends ConsumerWidget { return Text(emoji, style: const TextStyle(fontSize: _emojiGlyphSize)); } } + +Future<_StatusDuration?> _showStatusDurationSheet( + BuildContext context, { + required _StatusDuration selected, +}) { + return showBuzzModalBottomSheet<_StatusDuration>( + context: context, + title: 'Duration', + isScrollControlled: true, + showDragHandle: true, + builder: (sheetContext) => SingleChildScrollView( + child: SafeArea( + top: false, + child: AppListCard( + children: [ + for (final option in _StatusDuration.values) + AppListRow( + title: option.label, + trailing: option == selected + ? const Icon(LucideIcons.check, size: 18) + : null, + onTap: () => Navigator.of(sheetContext).pop(option), + ), + ], + ), + ), + ), + ); +} + +Future _showNativeDateTimePicker( + BuildContext context, { + required DateTime initial, +}) async { + final now = DateTime.now(); + final minimum = now.add(const Duration(minutes: 5)); + final safeInitial = initial.isAfter(minimum) ? initial : minimum; + + if (defaultTargetPlatform == TargetPlatform.iOS) { + var selected = safeInitial; + return showCupertinoModalPopup( + context: context, + builder: (pickerContext) => Material( + color: context.colors.surface, + child: SafeArea( + top: false, + child: SizedBox( + height: 300, + child: Column( + children: [ + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => Navigator.of(pickerContext).pop(selected), + child: const Text('Done'), + ), + ), + Expanded( + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.dateAndTime, + minimumDate: minimum, + initialDateTime: safeInitial, + use24hFormat: MediaQuery.alwaysUse24HourFormatOf(context), + onDateTimeChanged: (value) => selected = value, + ), + ), + ], + ), + ), + ), + ), + ); + } + + final lastDate = now.add(const Duration(days: 365)); + final androidInitial = safeInitial.isAfter(lastDate) ? lastDate : safeInitial; + final date = await showDatePicker( + context: context, + initialDate: androidInitial, + firstDate: now, + lastDate: lastDate, + ); + if (date == null || !context.mounted) return null; + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(androidInitial), + ); + if (time == null) return null; + final selected = DateTime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + ); + return selected.isBefore(minimum) ? minimum : selected; +} + +String _formatUntil(DateTime value) => + DateFormat('EEE, MMM d \u00B7 h:mm a').format(value); diff --git a/mobile/lib/features/profile/settings_profile_header.dart b/mobile/lib/features/profile/settings_profile_header.dart index d33e0ce2f7..59f424cb7a 100644 --- a/mobile/lib/features/profile/settings_profile_header.dart +++ b/mobile/lib/features/profile/settings_profile_header.dart @@ -1,15 +1,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/animated_avatar.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/masked_avatar_badge.dart'; +import '../../shared/widgets/progressive_animated_avatar.dart'; import 'profile_provider.dart'; import 'set_status_sheet.dart'; import 'user_status_provider.dart'; @@ -19,7 +22,7 @@ import 'user_status_provider.dart'; /// puts an edit-photo pencil in that badge; here it carries the status glyph and /// opens the status sheet instead. The notch shape — including the fillets where /// it meets the avatar's edge — comes from [AvatarBadgeMaskGeometry.badge]. -class SettingsProfileHeader extends ConsumerWidget { +class SettingsProfileHeader extends HookConsumerWidget { const SettingsProfileHeader({super.key}); static const _avatarSize = 128.0; @@ -30,6 +33,13 @@ class SettingsProfileHeader extends ConsumerWidget { final status = ref.watch(userStatusProvider).asData?.value; final hasStatus = status != null && !status.isEmpty; final presence = ref.watch(presenceProvider).value ?? 'offline'; + final animatedAvatar = parseAnimatedAvatarUrl(profile?.avatarUrl); + final stoppedAnimationUrl = useState(null); + final avatarUrl = animatedAvatar == null + ? profile?.avatarUrl + : stoppedAnimationUrl.value == animatedAvatar.animationUrl + ? animatedAvatar.posterUrl + : null; void openStatusSheet() => showSetStatusSheet(context, currentStatus: status); @@ -40,16 +50,31 @@ class SettingsProfileHeader extends ConsumerWidget { children: [ MaskedAvatarBadge( size: _avatarSize, - avatar: ColoredBox( - color: context.colors.primaryContainer, - child: AvatarImageContent( - imageUrl: profile?.avatarUrl, - fallback: Text( - profile?.initial ?? '?', - style: context.textTheme.displaySmall?.copyWith( - color: context.colors.onPrimaryContainer, - ), - ), + avatar: GestureDetector( + key: const ValueKey('settings-profile-avatar'), + onTap: animatedAvatar == null + ? null + : () => stoppedAnimationUrl.value = + stoppedAnimationUrl.value == animatedAvatar.animationUrl + ? null + : animatedAvatar.animationUrl, + child: ColoredBox( + key: const ValueKey('settings-profile-avatar-background'), + color: animatedAvatar == null + ? context.colors.primaryContainer + : Colors.transparent, + child: + animatedAvatar != null && + stoppedAnimationUrl.value != animatedAvatar.animationUrl + ? ProgressiveAnimatedAvatar( + key: ValueKey(animatedAvatar.animationUrl), + descriptor: animatedAvatar, + fallback: _AvatarFallback(initial: profile?.initial), + ) + : AvatarImageContent( + imageUrl: avatarUrl, + fallback: _AvatarFallback(initial: profile?.initial), + ), ), ), badge: _StatusBadge( @@ -98,6 +123,22 @@ class SettingsProfileHeader extends ConsumerWidget { } } +class _AvatarFallback extends StatelessWidget { + const _AvatarFallback({required this.initial}); + + final String? initial; + + @override + Widget build(BuildContext context) { + return Text( + initial ?? '?', + style: context.textTheme.displaySmall?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ); + } +} + class _PresencePill extends StatelessWidget { const _PresencePill({required this.presence, required this.onSelected}); diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index d89c64a947..e2dbb86725 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -6,12 +6,14 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/animated_avatar.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/utils/string_utils.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_action_tile.dart'; import '../../shared/widgets/modal_presentation.dart'; +import '../../shared/widgets/progressive_animated_avatar.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; import '../channels/channel_management_provider.dart'; @@ -372,7 +374,7 @@ class _ProfilePresenceChip extends StatelessWidget { } } -class _ProfileAvatar extends StatelessWidget { +class _ProfileAvatar extends HookWidget { final String? avatarUrl; final String initial; @@ -380,12 +382,33 @@ class _ProfileAvatar extends StatelessWidget { @override Widget build(BuildContext context) { + final animatedAvatar = parseAnimatedAvatarUrl(avatarUrl); + final stoppedAnimationUrl = useState(null); + final isPlaying = + animatedAvatar != null && + stoppedAnimationUrl.value != animatedAvatar.animationUrl; + return AspectRatio( aspectRatio: 1, - child: ClipOval( - child: AvatarImageContent( - imageUrl: avatarUrl, - fallback: _AvatarFallback(initial: initial), + child: GestureDetector( + key: const ValueKey('selected-profile-avatar'), + onTap: animatedAvatar == null + ? null + : () => stoppedAnimationUrl.value = + stoppedAnimationUrl.value == animatedAvatar.animationUrl + ? null + : animatedAvatar.animationUrl, + child: ClipOval( + child: isPlaying + ? ProgressiveAnimatedAvatar( + key: ValueKey(animatedAvatar.animationUrl), + descriptor: animatedAvatar, + fallback: _AvatarFallback(initial: initial), + ) + : AvatarImageContent( + imageUrl: animatedAvatar?.posterUrl ?? avatarUrl, + fallback: _AvatarFallback(initial: initial), + ), ), ), ); diff --git a/mobile/lib/features/profile/user_status.dart b/mobile/lib/features/profile/user_status.dart index 8b28641d4f..b487c54afa 100644 --- a/mobile/lib/features/profile/user_status.dart +++ b/mobile/lib/features/profile/user_status.dart @@ -2,29 +2,54 @@ import 'package:flutter/foundation.dart'; import '../../shared/relay/nostr_models.dart'; +const _minDateTimeUnixSeconds = -8640000000000; +const _maxDateTimeUnixSeconds = 8640000000000; + /// A user's NIP-38 status (kind:30315, d=general). @immutable class UserStatus { final String text; final String emoji; final int updatedAt; + final int? expiresAt; const UserStatus({ required this.text, required this.emoji, required this.updatedAt, + this.expiresAt, }); factory UserStatus.fromEvent(NostrEvent event) { final emojiTag = event.tags .where((t) => t.length >= 2 && t[0] == 'emoji') .firstOrNull; + final expirationTag = event.tags + .where((t) => t.length >= 2 && t[0] == 'expiration') + .firstOrNull; return UserStatus( text: event.content, emoji: emojiTag?[1] ?? '', updatedAt: event.createdAt, + expiresAt: int.tryParse(expirationTag?[1] ?? ''), ); } + /// Converts [expiresAt] when it falls within Dart's supported date range. + /// + /// NIP-40 expiration tags are untrusted integers and may exceed that range. + DateTime? get expirationDateTime { + final unixSeconds = expiresAt; + if (unixSeconds == null || + unixSeconds < _minDateTimeUnixSeconds || + unixSeconds > _maxDateTimeUnixSeconds) { + return null; + } + return DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000); + } + bool get isEmpty => text.isEmpty && emoji.isEmpty; + + bool isExpiredAt(int unixSeconds) => + expiresAt != null && expiresAt! <= unixSeconds; } diff --git a/mobile/lib/features/profile/user_status_cache_provider.dart b/mobile/lib/features/profile/user_status_cache_provider.dart index e8873b08e8..0d5b8ee5d6 100644 --- a/mobile/lib/features/profile/user_status_cache_provider.dart +++ b/mobile/lib/features/profile/user_status_cache_provider.dart @@ -17,6 +17,7 @@ class UserStatusCacheNotifier extends Notifier> { final Set _pending = {}; Timer? _batchTimer; Timer? _refreshTimer; + Timer? _expirationTimer; void Function()? _statusUnsub; int _subscriptionVersion = 0; @@ -30,6 +31,8 @@ class UserStatusCacheNotifier extends Notifier> { _batchTimer = null; _refreshTimer?.cancel(); _refreshTimer = null; + _expirationTimer?.cancel(); + _expirationTimer = null; _statusUnsub?.call(); _statusUnsub = null; }); @@ -101,13 +104,20 @@ class UserStatusCacheNotifier extends Notifier> { final parsed = UserStatus.fromEvent(event); final existing = state[pubkey]; - // Staleness guard: discard if we already have a newer-or-equal event. - if (existing != null && existing.updatedAt >= parsed.updatedAt) return; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + // An equal event still needs its expiration re-evaluated. Otherwise the + // periodic refresh keeps an expired status alive forever. + if (existing != null && existing.updatedAt > parsed.updatedAt) return; + if (existing != null && + existing.updatedAt == parsed.updatedAt && + !parsed.isExpiredAt(now)) { + return; + } - final status = parsed.isEmpty ? null : parsed; + final status = parsed.isEmpty || parsed.isExpiredAt(now) ? null : parsed; final updated = Map.from(state); updated[pubkey] = status; - state = updated; + _replaceState(updated); } /// Directly update a pubkey's cached status. Used by [UserStatusNotifier] @@ -116,7 +126,7 @@ class UserStatusCacheNotifier extends Notifier> { final pk = pubkey.toLowerCase(); final updated = Map.from(state); updated[pk] = status; - state = updated; + _replaceState(updated); } Future _refreshAll() async { @@ -161,17 +171,72 @@ class UserStatusCacheNotifier extends Notifier> { final pk = event.pubkey.toLowerCase(); final parsed = UserStatus.fromEvent(event); final existing = updated[pk]; - if (existing != null && existing.updatedAt >= parsed.updatedAt) { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (existing != null && existing.updatedAt > parsed.updatedAt) { continue; } - updated[pk] = parsed.isEmpty ? null : parsed; + if (existing != null && + existing.updatedAt == parsed.updatedAt && + !parsed.isExpiredAt(now)) { + continue; + } + updated[pk] = parsed.isEmpty || parsed.isExpiredAt(now) ? null : parsed; } - state = updated; + _replaceState(updated); } catch (_) { // Silently fail — backstop will retry. } } + + void _replaceState(Map updated) { + state = updated; + _scheduleNextExpiration(); + } + + void _scheduleNextExpiration() { + _expirationTimer?.cancel(); + _expirationTimer = null; + + DateTime? nextDeadline; + for (final status in state.values) { + final deadline = status?.expirationDateTime; + if (deadline == null) continue; + if (nextDeadline == null || deadline.isBefore(nextDeadline)) { + nextDeadline = deadline; + } + } + if (nextDeadline == null) return; + + final remaining = nextDeadline.difference(DateTime.now()); + _expirationTimer = Timer( + remaining.isNegative ? Duration.zero : remaining, + _expireDueStatuses, + ); + } + + void _expireDueStatuses() { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + var changed = false; + final updated = Map.from(state); + for (final entry in state.entries) { + final status = entry.value; + if (status != null && status.isExpiredAt(now)) { + updated[entry.key] = null; + changed = true; + } + } + + if (changed) { + _replaceState(updated); + } else { + // Timer callbacks can land just before the Unix-second boundary. + _expirationTimer = Timer( + const Duration(milliseconds: 50), + _expireDueStatuses, + ); + } + } } final userStatusCacheProvider = diff --git a/mobile/lib/features/profile/user_status_provider.dart b/mobile/lib/features/profile/user_status_provider.dart index bdeecfdcea..1ed3c9218d 100644 --- a/mobile/lib/features/profile/user_status_provider.dart +++ b/mobile/lib/features/profile/user_status_provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -11,11 +13,20 @@ import 'user_status_cache_provider.dart'; /// for publishing. Publishes via WebSocket (triggers fan-out). No heartbeat /// needed — user status events are parameterised replaceable, not ephemeral. class UserStatusNotifier extends AsyncNotifier { + Timer? _expirationTimer; + @override - Future build() { + Future build() async { ref.watch(relayClientProvider); ref.watch(relaySessionProvider); - return _fetch(); + ref.onDispose(() { + _expirationTimer?.cancel(); + _expirationTimer = null; + }); + + final status = await _fetch(); + _scheduleExpiration(status); + return status; } Future _fetch() async { @@ -55,13 +66,18 @@ class UserStatusNotifier extends AsyncNotifier { (a, b) => a.createdAt >= b.createdAt ? a : b, ); final status = UserStatus.fromEvent(latest); - return status.isEmpty ? null : status; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return status.isEmpty || status.isExpiredAt(now) ? null : status; } catch (_) { return null; } } - Future setStatus(String text, String emoji) async { + Future setStatus( + String text, + String emoji, { + DateTime? expiresAt, + }) async { final trimmed = text.trim(); final config = ref.read(relayConfigProvider); final nsec = config.nsec; @@ -73,6 +89,9 @@ class UserStatusNotifier extends AsyncNotifier { if (emoji.isNotEmpty) { tags.add(['emoji', emoji]); } + if (expiresAt != null) { + tags.add(['expiration', '${expiresAt.millisecondsSinceEpoch ~/ 1000}']); + } final privkeyHex = nostr.Nip19.decode(payload: nsec).data; final event = nostr.Event.from( @@ -92,9 +111,13 @@ class UserStatusNotifier extends AsyncNotifier { text: trimmed, emoji: emoji, updatedAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + expiresAt: expiresAt == null + ? null + : expiresAt.millisecondsSinceEpoch ~/ 1000, ) : null; state = AsyncValue.data(newStatus); + _scheduleExpiration(newStatus); // Also update the shared cache so other UI reads stay consistent. final keyPair = nostr.Keys(privkeyHex); @@ -103,6 +126,50 @@ class UserStatusNotifier extends AsyncNotifier { } Future clearStatus() => setStatus('', ''); + + void _scheduleExpiration(UserStatus? status) { + _expirationTimer?.cancel(); + _expirationTimer = null; + final deadline = status?.expirationDateTime; + if (deadline == null) return; + + final remaining = deadline.difference(DateTime.now()); + _expirationTimer = Timer( + remaining.isNegative ? Duration.zero : remaining, + _expireCurrentStatus, + ); + } + + void _expireCurrentStatus() { + final current = state.asData?.value; + if (current == null) return; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (!current.isExpiredAt(now)) { + // Timer callbacks can land just before the Unix-second boundary. + _expirationTimer = Timer( + const Duration(milliseconds: 50), + _expireCurrentStatus, + ); + return; + } + + state = const AsyncValue.data(null); + final pubkey = _currentPubkey(); + if (pubkey != null) { + ref.read(userStatusCacheProvider.notifier).updateStatus(pubkey, null); + } + } + + String? _currentPubkey() { + final nsec = ref.read(relayConfigProvider).nsec; + if (nsec == null || nsec.isEmpty) return null; + try { + final privkeyHex = nostr.Nip19.decode(payload: nsec).data; + return nostr.Keys(privkeyHex).public.toLowerCase(); + } catch (_) { + return null; + } + } } final userStatusProvider = diff --git a/mobile/lib/features/settings/settings_page/appearance_section.dart b/mobile/lib/features/settings/settings_page/appearance_section.dart index 9179003180..a5c3a5142d 100644 --- a/mobile/lib/features/settings/settings_page/appearance_section.dart +++ b/mobile/lib/features/settings/settings_page/appearance_section.dart @@ -61,6 +61,7 @@ class _AppearanceSection extends ConsumerWidget { void _showAppearanceModeSheet(BuildContext context) { showBuzzModalBottomSheet( context: context, + title: 'Appearance', showDragHandle: true, builder: (_) => const _AppearanceModeSheet(), ); @@ -80,15 +81,6 @@ class _AppearanceModeSheet extends ConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.xxs, - ), - child: Text('Appearance', style: context.textTheme.titleMedium), - ), for (final option in _modeOptions) AppListRow( icon: option.icon, diff --git a/mobile/lib/shared/animated_avatar.dart b/mobile/lib/shared/animated_avatar.dart new file mode 100644 index 0000000000..41efcecb1e --- /dev/null +++ b/mobile/lib/shared/animated_avatar.dart @@ -0,0 +1,58 @@ +// Animated avatar URL scheme shared with the desktop client. +// +// The static poster is the normal selected frame. The animated PNG URL lives +// in the fragment so clients that do not understand the scheme render only +// the poster: +// +// posterUrl#buzz-anim=encodedAnimationUrl +const _animatedAvatarSeparator = '#buzz-anim='; + +/// The static poster and animated image URLs encoded in an avatar URL. +class AnimatedAvatarDescriptor { + const AnimatedAvatarDescriptor({ + required this.posterUrl, + required this.animationUrl, + }); + + final String posterUrl; + final String animationUrl; +} + +/// Parses the Buzz animated-avatar fragment scheme from [url]. +/// +/// Returns `null` when the poster or animation URL is missing, malformed, or +/// does not use HTTP(S). +AnimatedAvatarDescriptor? parseAnimatedAvatarUrl(String? url) { + if (url == null || url.isEmpty) return null; + + final separatorIndex = url.indexOf(_animatedAvatarSeparator); + if (separatorIndex <= 0) return null; + + final posterUrl = url.substring(0, separatorIndex); + final encodedAnimationUrl = url.substring( + separatorIndex + _animatedAvatarSeparator.length, + ); + if (encodedAnimationUrl.isEmpty) return null; + + final String animationUrl; + try { + animationUrl = Uri.decodeComponent(encodedAnimationUrl); + } on ArgumentError { + return null; + } on FormatException { + return null; + } + + if (!_isHttpUrl(posterUrl) || !_isHttpUrl(animationUrl)) return null; + return AnimatedAvatarDescriptor( + posterUrl: posterUrl, + animationUrl: animationUrl, + ); +} + +bool _isHttpUrl(String value) { + final uri = Uri.tryParse(value); + return uri != null && + (uri.scheme == 'http' || uri.scheme == 'https') && + uri.host.isNotEmpty; +} diff --git a/mobile/lib/shared/relay/media_image.dart b/mobile/lib/shared/relay/media_image.dart index e8838b65af..14187df081 100644 --- a/mobile/lib/shared/relay/media_image.dart +++ b/mobile/lib/shared/relay/media_image.dart @@ -177,6 +177,7 @@ class MediaImage extends ConsumerWidget { final double? height; final String? semanticLabel; final ImageErrorWidgetBuilder? errorBuilder; + final ImageFrameBuilder? frameBuilder; final FilterQuality filterQuality; final double? decodeWidth; final bool boundDecodeToLayout; @@ -189,6 +190,7 @@ class MediaImage extends ConsumerWidget { this.height, this.semanticLabel, this.errorBuilder, + this.frameBuilder, this.filterQuality = FilterQuality.medium, this.decodeWidth, this.boundDecodeToLayout = true, @@ -231,6 +233,7 @@ class MediaImage extends ConsumerWidget { height: height, semanticLabel: semanticLabel, errorBuilder: errorBuilder, + frameBuilder: frameBuilder, filterQuality: filterQuality, gaplessPlayback: true, ); diff --git a/mobile/lib/shared/relay/relay_http_query_client.dart b/mobile/lib/shared/relay/relay_http_query_client.dart new file mode 100644 index 0000000000..615b28f64c --- /dev/null +++ b/mobile/lib/shared/relay/relay_http_query_client.dart @@ -0,0 +1,97 @@ +import 'dart:async'; + +import 'package:http/http.dart' as http; + +/// Owns the reusable HTTP transport used for relay `/query` requests. +class RelayHttpQueryClient { + RelayHttpQueryClient({ + http.Client? client, + http.Client Function()? clientFactory, + }) : _injectedClient = client, + _clientFactory = clientFactory ?? (() => http.Client()); + + final http.Client? _injectedClient; + final http.Client Function() _clientFactory; + _ClientGeneration? _currentGeneration; + final Set<_ClientGeneration> _generations = {}; + + Future post( + Uri url, { + required Map headers, + required List body, + required Duration timeout, + }) async { + final generation = _injectedClient == null + ? (_currentGeneration ??= _createGeneration()) + : null; + generation?.acquire(); + try { + return await (_injectedClient ?? generation!.client) + .post(url, headers: headers, body: body) + .timeout(timeout); + } on TimeoutException { + if (identical(_currentGeneration, generation)) { + _currentGeneration = null; + } + generation?.retire(); + rethrow; + } finally { + generation?.release(); + } + } + + void close() { + for (final generation in _generations.toList()) { + generation.close(); + } + _currentGeneration = null; + _injectedClient?.close(); + } + + _ClientGeneration _createGeneration() { + late final _ClientGeneration generation; + generation = _ClientGeneration( + _clientFactory(), + onClosed: () => _generations.remove(generation), + ); + _generations.add(generation); + return generation; + } +} + +class _ClientGeneration { + _ClientGeneration(this.client, {required this.onClosed}); + + final http.Client client; + final void Function() onClosed; + int _activeRequests = 0; + bool _retired = false; + bool _closed = false; + + void acquire() { + assert(!_closed); + _activeRequests++; + } + + void release() { + assert(_activeRequests > 0); + _activeRequests--; + _closeIfIdle(); + } + + void retire() { + _retired = true; + _closeIfIdle(); + } + + void close() { + if (_closed) return; + _closed = true; + client.close(); + onClosed(); + } + + void _closeIfIdle() { + if (_retired && _activeRequests == 0) close(); + } +} diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 1c5c305b4c..d6c094d82e 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -14,6 +14,7 @@ import '../auth/auth.dart'; import 'nostr_models.dart'; import 'relay_client.dart'; import 'relay_closed_policy.dart'; +import 'relay_http_query_client.dart'; import 'relay_provider.dart'; import 'relay_rate_limit_gate.dart'; import 'relay_socket.dart'; @@ -88,19 +89,23 @@ typedef RelaySocketFactory = class RelaySessionNotifier extends Notifier { RelaySessionNotifier({ http.Client? httpClient, + http.Client Function()? httpClientFactory, RelaySocketFactory socketFactory = RelaySocket.new, DateTime Function()? now, RelayRateLimitGate? rateLimitGate, RelayTimerFactory retryTimerFactory = Timer.new, Future Function(Duration) replayDelay = Future.delayed, - }) : _httpClient = httpClient, + }) : _httpQueryClient = RelayHttpQueryClient( + client: httpClient, + clientFactory: httpClientFactory, + ), _socketFactory = socketFactory, _now = now ?? DateTime.now, _rateLimitGate = rateLimitGate ?? RelayRateLimitGate(), _retryTimerFactory = retryTimerFactory, _replayDelay = replayDelay; - final http.Client? _httpClient; + final RelayHttpQueryClient _httpQueryClient; final RelaySocketFactory _socketFactory; final DateTime Function() _now; final RelayRateLimitGate _rateLimitGate; @@ -168,26 +173,22 @@ class RelaySessionNotifier extends Notifier { final bodyBytes = utf8.encode( jsonEncode(filters.map((filter) => filter.toJson()).toList()), ); - final client = _httpClient ?? http.Client(); - final shouldCloseClient = _httpClient == null; - final response = await client - .post( - Uri.parse(url), - headers: { - 'Authorization': buildNip98AuthHeader( - method: 'POST', - url: url, - bodyBytes: bodyBytes, - nsec: config.nsec, - ), - 'Content-Type': 'application/json', - }, - body: bodyBytes, - ) - .timeout(timeout) - .whenComplete(() { - if (shouldCloseClient) client.close(); - }); + // Reuse the session transport on success. A timeout rotates immediately + // for new queries, then closes the retired client after its peers finish. + final response = await _httpQueryClient.post( + Uri.parse(url), + headers: { + 'Authorization': buildNip98AuthHeader( + method: 'POST', + url: url, + bodyBytes: bodyBytes, + nsec: config.nsec, + ), + 'Content-Type': 'application/json', + }, + body: bodyBytes, + timeout: timeout, + ); if (response.statusCode < 200 || response.statusCode >= 300) { _activateRateLimitGateFromHttpError(response.body); throw RelayException(response.statusCode, response.body); @@ -936,7 +937,7 @@ class RelaySessionNotifier extends Notifier { _recentDeliveryKeys.clear(); _socket?.dispose(); _socket = null; - _httpClient?.close(); + _httpQueryClient.close(); } } diff --git a/mobile/lib/shared/reminders/remind_me_later_sheet.dart b/mobile/lib/shared/reminders/remind_me_later_sheet.dart index a2fb7c59e9..7785f05448 100644 --- a/mobile/lib/shared/reminders/remind_me_later_sheet.dart +++ b/mobile/lib/shared/reminders/remind_me_later_sheet.dart @@ -40,6 +40,7 @@ void showRemindMeLaterSheet({ showBuzzModalBottomSheet( context: context, + title: 'Remind me about this message', showDragHandle: true, builder: (sheetContext) => SafeArea( child: IconTheme.merge( @@ -56,16 +57,6 @@ void showRemindMeLaterSheet({ mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.only( - left: Grid.half, - bottom: Grid.xxs, - ), - child: Text( - 'Remind me about this message', - style: Theme.of(sheetContext).textTheme.titleSmall, - ), - ), for (final preset in reminderTimePresets) ListTile( leading: const Icon(LucideIcons.clock), diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index e12a03ad61..7b40739c4c 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import '../animated_avatar.dart'; import '../relay/relay.dart'; /// A circular avatar that supports both remote URLs and inline image data. @@ -27,13 +28,21 @@ class AvatarImage extends StatelessWidget { @override Widget build(BuildContext context) { + final animatedAvatar = parseAnimatedAvatarUrl(imageUrl); return CircleAvatar( radius: radius, - backgroundColor: backgroundColor, + // Animated avatar posters carry their own backdrop disc; preserve their + // transparent surroundings on static/list surfaces, matching desktop. + backgroundColor: animatedAvatar == null + ? backgroundColor + : Colors.transparent, child: ClipOval( child: SizedBox.square( dimension: radius * 2, - child: AvatarImageContent(imageUrl: imageUrl, fallback: fallback), + child: AvatarImageContent( + imageUrl: animatedAvatar?.posterUrl ?? imageUrl, + fallback: fallback, + ), ), ), ); diff --git a/mobile/lib/shared/widgets/buzz_sheet_header.dart b/mobile/lib/shared/widgets/buzz_sheet_header.dart new file mode 100644 index 0000000000..3168ad1d53 --- /dev/null +++ b/mobile/lib/shared/widgets/buzz_sheet_header.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../theme/theme.dart'; + +/// A titled sheet header with balanced actions and an exactly centered title. +class BuzzSheetHeader extends StatelessWidget { + const BuzzSheetHeader({ + super.key, + this.title, + this.titleKey, + this.leading, + this.trailing, + this.showDragHandle = false, + }); + + final String? title; + final Key? titleKey; + final Widget? leading; + final Widget? trailing; + final bool showDragHandle; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only( + top: Grid.xxs, + left: Grid.gutter, + right: Grid.gutter, + bottom: Grid.xs, + ), + child: SizedBox( + height: 56, + child: Stack( + alignment: Alignment.topCenter, + children: [ + if (showDragHandle) const _SheetDragHandle(), + if (title case final title?) + Positioned( + left: 64, + right: 64, + bottom: 0, + height: 44, + child: Center( + child: Text( + title, + key: titleKey ?? const ValueKey('buzz-sheet-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + Align( + alignment: Alignment.bottomRight, + child: trailing ?? const _SheetCloseButton(), + ), + if (leading case final leading?) + Align(alignment: Alignment.bottomLeft, child: leading), + ], + ), + ), + ); + } +} + +class _SheetCloseButton extends StatelessWidget { + const _SheetCloseButton(); + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 44, + child: IconButton( + tooltip: 'Close sheet', + onPressed: () { + unawaited(HapticFeedback.lightImpact()); + Navigator.of(context).pop(); + }, + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: context.colors.surfaceContainerHighest, + foregroundColor: context.colors.onSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.dialog), + ), + ), + icon: const Icon(LucideIcons.x, size: 22), + ), + ); + } +} + +class _SheetDragHandle extends StatelessWidget { + const _SheetDragHandle(); + + @override + Widget build(BuildContext context) { + return Semantics( + label: MaterialLocalizations.of(context).modalBarrierDismissLabel, + container: true, + button: true, + onTap: () => Navigator.of(context).pop(), + child: Container( + key: const ValueKey('buzz-sheet-drag-handle'), + width: 32, + height: 4, + decoration: BoxDecoration( + color: context.colors.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(Radii.full), + ), + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/buzz_titled_sheet_layout.dart b/mobile/lib/shared/widgets/buzz_titled_sheet_layout.dart new file mode 100644 index 0000000000..fad8a4271a --- /dev/null +++ b/mobile/lib/shared/widgets/buzz_titled_sheet_layout.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme.dart'; +import 'buzz_sheet_header.dart'; +import 'concentric_sheet_surface.dart'; + +/// Shared solid sheet surface with a centered navigation row. +class BuzzTitledSheetLayout extends StatelessWidget { + const BuzzTitledSheetLayout({ + super.key, + required this.title, + required this.child, + this.leading, + this.trailing, + this.titleKey, + this.showDragHandle = false, + this.surfaceColor, + }); + + final String title; + final Widget child; + final Widget? leading; + final Widget? trailing; + final Key? titleKey; + final bool showDragHandle; + final Color? surfaceColor; + + @override + Widget build(BuildContext context) { + final color = surfaceColor ?? context.colors.surface; + final paintsSurface = !ConcentricSheetSurface.providesSurfaceOf(context); + + final sheet = SizedBox( + width: double.infinity, + child: ColoredBox( + key: const ValueKey('buzz-sheet-surface'), + color: paintsSurface ? color : Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + BuzzSheetHeader( + title: title, + titleKey: titleKey, + leading: leading, + trailing: trailing, + showDragHandle: showDragHandle, + ), + Flexible(child: child), + ], + ), + ), + ); + + // The native iOS surface owns its exact iOS 26 container-concentric mask. + // A second fixed Flutter radius would visibly square off those corners. + if (!paintsSurface) return sheet; + + return ClipRRect( + key: const ValueKey('buzz-sheet-surface-clip'), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(Radii.dialog), + ), + clipBehavior: Clip.antiAlias, + child: sheet, + ); + } +} diff --git a/mobile/lib/shared/widgets/concentric_sheet_surface.dart b/mobile/lib/shared/widgets/concentric_sheet_surface.dart index 12e7e002fc..1cf7a67220 100644 --- a/mobile/lib/shared/widgets/concentric_sheet_surface.dart +++ b/mobile/lib/shared/widgets/concentric_sheet_surface.dart @@ -20,7 +20,14 @@ class ConcentricSheetSurface extends HookWidget { final bool enabled; final Color? color; + static bool providesSurfaceOf(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType<_ConcentricSheetSurfaceScope>() + ?.providesSurface ?? + false; + static const _surfaceChannel = MethodChannel('buzz/concentric_sheet_surface'); + static const _nativeContentClipRadius = Radii.dialog * 2; Future _checkNativeSurfaceSupport() async { try { @@ -48,7 +55,7 @@ class ConcentricSheetSurface extends HookWidget { final nativeSurfaceSupported = useFuture(supportFuture).data ?? false; if (!shouldCheckNativeSurface) { - return child; + return _ConcentricSheetSurfaceScope(providesSurface: false, child: child); } final surfaceColor = color ?? context.colors.surface; @@ -83,9 +90,37 @@ class ConcentricSheetSurface extends HookWidget { clipBehavior: Clip.antiAlias, ), ), - child, + ClipRSuperellipse( + key: const ValueKey('concentric-sheet-content-clip'), + // UIKit's container-concentric corner resolves substantially + // larger than its minimum radius on an edge-inset iOS sheet. Keep + // the Flutter content inside that continuous outline; a 24pt + // circular clip still lets scrolling rows show through the native + // corner cutouts. + borderRadius: BorderRadius.circular( + nativeSurfaceSupported ? _nativeContentClipRadius : Radii.dialog, + ), + clipBehavior: Clip.antiAlias, + child: _ConcentricSheetSurfaceScope( + providesSurface: true, + child: child, + ), + ), ], ), ); } } + +class _ConcentricSheetSurfaceScope extends InheritedWidget { + const _ConcentricSheetSurfaceScope({ + required this.providesSurface, + required super.child, + }); + + final bool providesSurface; + + @override + bool updateShouldNotify(_ConcentricSheetSurfaceScope oldWidget) => + oldWidget.providesSurface != providesSurface; +} diff --git a/mobile/lib/shared/widgets/modal_presentation.dart b/mobile/lib/shared/widgets/modal_presentation.dart index 3793774a2d..735be1770b 100644 --- a/mobile/lib/shared/widgets/modal_presentation.dart +++ b/mobile/lib/shared/widgets/modal_presentation.dart @@ -1,11 +1,9 @@ -import 'dart:async'; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../theme/theme.dart'; +import 'buzz_sheet_header.dart'; +import 'buzz_titled_sheet_layout.dart'; import 'concentric_sheet_surface.dart'; /// Shared motion for occasional modal UI. @@ -23,10 +21,12 @@ const buzzModalAnimationStyle = AnimationStyle( /// /// Sheets include the shared close control by default. On iOS, the surface /// uses native concentric corners when available and paints a requested drag -/// handle inside that inset surface; other platforms retain Flutter's handle. +/// handle inside the shared header so its spacing is consistent on every +/// platform. Future showBuzzModalBottomSheet({ required BuildContext context, required WidgetBuilder builder, + String? title, Color? backgroundColor, String? barrierLabel, double? elevation, @@ -63,12 +63,16 @@ Future showBuzzModalBottomSheet({ enabled: isIos, color: surfaceColor, child: _SheetContent( + title: title, showCloseButton: showCloseButton, - showDragHandle: isIos && showDragHandle == true, + showDragHandle: showDragHandle == true, + surfaceColor: surfaceColor, child: builder(sheetContext), ), ), - backgroundColor: isIos ? Colors.transparent : backgroundColor, + backgroundColor: isIos || title != null + ? Colors.transparent + : backgroundColor, barrierLabel: barrierLabel, elevation: elevation, shape: shape, @@ -80,9 +84,9 @@ Future showBuzzModalBottomSheet({ useRootNavigator: useRootNavigator, isDismissible: isDismissible, enableDrag: enableDrag, - // The iOS route is transparent so its stock handle sits outside the inset - // concentric surface. Paint it inside the surface above instead. - showDragHandle: isIos ? false : showDragHandle, + // The shared header owns the handle so Android does not reserve a second + // handle band above the title row and iOS keeps it inside its native inset. + showDragHandle: false, useSafeArea: useSafeArea, routeSettings: routeSettings, transitionAnimationController: transitionAnimationController, @@ -97,78 +101,55 @@ Future showBuzzModalBottomSheet({ class _SheetContent extends StatelessWidget { const _SheetContent({ required this.child, + required this.title, required this.showCloseButton, required this.showDragHandle, + required this.surfaceColor, }); final Widget child; + final String? title; final bool showCloseButton; final bool showDragHandle; + final Color surfaceColor; @override Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (showCloseButton) - Padding( - padding: const EdgeInsets.only( - top: Grid.xxs, - left: Grid.gutter, - right: Grid.gutter, - bottom: Grid.xs, - ), - child: SizedBox( - height: 56, - child: Stack( - alignment: Alignment.topCenter, - children: [ - if (showDragHandle) const _SheetDragHandle(), - Align( - alignment: Alignment.bottomRight, - child: SizedBox.square( - dimension: 44, - child: IconButton( - tooltip: 'Close sheet', - onPressed: () { - unawaited(HapticFeedback.lightImpact()); - Navigator.of(context).pop(); - }, - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - backgroundColor: - context.colors.surfaceContainerHighest, - foregroundColor: context.colors.onSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.dialog), - ), - ), - icon: const Icon(LucideIcons.x, size: 22), - ), - ), - ), - ], - ), + if (title == null || !showCloseButton) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showCloseButton) + BuzzSheetHeader(title: title, showDragHandle: showDragHandle) + else if (showDragHandle) + const Padding( + padding: EdgeInsets.only(top: Grid.xxs, bottom: Grid.xs), + child: _StandaloneSheetDragHandle(), ), - ) - else if (showDragHandle) - const Padding( - padding: EdgeInsets.only(top: Grid.xxs, bottom: Grid.xs), - child: _SheetDragHandle(), - ), - Flexible(child: child), - ], + Flexible(child: child), + ], + ); + } + + return BuzzTitledSheetLayout( + title: title!, + showDragHandle: showDragHandle, + surfaceColor: surfaceColor, + child: child, ); } } -class _SheetDragHandle extends StatelessWidget { - const _SheetDragHandle(); +class _StandaloneSheetDragHandle extends StatelessWidget { + const _StandaloneSheetDragHandle(); @override Widget build(BuildContext context) { return Semantics( - label: 'Drag handle', + label: MaterialLocalizations.of(context).modalBarrierDismissLabel, + container: true, + button: true, + onTap: () => Navigator.of(context).pop(), child: Container( key: const ValueKey('buzz-sheet-drag-handle'), width: 32, diff --git a/mobile/lib/shared/widgets/progressive_animated_avatar.dart b/mobile/lib/shared/widgets/progressive_animated_avatar.dart new file mode 100644 index 0000000000..94a4785fdf --- /dev/null +++ b/mobile/lib/shared/widgets/progressive_animated_avatar.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../animated_avatar.dart'; +import '../relay/media_image.dart'; +import 'avatar_image.dart'; + +/// Paints an animated avatar's persisted poster until its first frame is ready. +/// +/// Callers own playback policy, which keeps animation opt-in at individual +/// profile surfaces. +class ProgressiveAnimatedAvatar extends HookWidget { + const ProgressiveAnimatedAvatar({ + super.key, + required this.descriptor, + required this.fallback, + this.fit = BoxFit.cover, + }); + + final AnimatedAvatarDescriptor descriptor; + final Widget fallback; + final BoxFit fit; + + @override + Widget build(BuildContext context) { + final readyAnimationUrl = useState(null); + final scheduledAnimationUrl = useRef(null); + final animationKey = useMemoized(GlobalKey.new, [descriptor.animationUrl]); + final isReady = readyAnimationUrl.value == descriptor.animationUrl; + final animation = KeyedSubtree( + key: const ValueKey('progressive-animated-avatar-animation'), + child: MediaImage( + key: animationKey, + url: descriptor.animationUrl, + fit: fit, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if ((wasSynchronouslyLoaded || frame != null) && + !isReady && + scheduledAnimationUrl.value != descriptor.animationUrl) { + scheduledAnimationUrl.value = descriptor.animationUrl; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + readyAnimationUrl.value = descriptor.animationUrl; + } + }); + } + return child; + }, + ), + ); + + if (isReady) { + return KeyedSubtree( + key: const ValueKey('progressive-animated-avatar-animation-ready'), + child: animation, + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + AvatarImageContent( + key: const ValueKey('progressive-animated-avatar-poster'), + imageUrl: descriptor.posterUrl, + fallback: fallback, + fit: fit, + ), + Offstage( + key: const ValueKey('progressive-animated-avatar-animation-loading'), + offstage: true, + child: animation, + ), + ], + ); + } +} diff --git a/mobile/test/features/activity/activity_provider_test.dart b/mobile/test/features/activity/activity_provider_test.dart index 351e08b4e1..820a6d1560 100644 --- a/mobile/test/features/activity/activity_provider_test.dart +++ b/mobile/test/features/activity/activity_provider_test.dart @@ -10,11 +10,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; /// Records subscriptions and DM history queries for Activity projection tests. class _RecordingSessionNotifier extends RelaySessionNotifier { final List> dmQueries = []; + final List queryFilterCounts = []; final List _history = []; final List<({NostrFilter filter, void Function(NostrEvent) onEvent})> _subscriptions = []; Completer? mentionFetchGate; bool failNextMentionFetch = false; + bool failNextQueryRelay = false; int mentionFetchCount = 0; int activeMentionFetches = 0; int maxActiveMentionFetches = 0; @@ -51,6 +53,45 @@ class _RecordingSessionNotifier extends RelaySessionNotifier { return _history.where((event) => _matches(filter, event)).toList(); } + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + queryFilterCounts.add(filters.length); + if (failNextQueryRelay) { + failNextQueryRelay = false; + throw StateError('transient HTTP query failure'); + } + for (final filter in filters) { + final h = filter.tags['#h']; + if (h != null) dmQueries.add(h); + } + final isMentionFetch = filters.any( + (filter) => filter.tags.containsKey('#p') && filter.kinds.contains(40002), + ); + if (isMentionFetch) { + mentionFetchCount += 1; + activeMentionFetches += 1; + if (activeMentionFetches > maxActiveMentionFetches) { + maxActiveMentionFetches = activeMentionFetches; + } + try { + final gate = mentionFetchGate; + if (gate != null) await gate.future; + if (failNextMentionFetch) { + failNextMentionFetch = false; + throw StateError('transient mention history failure'); + } + } finally { + activeMentionFetches -= 1; + } + } + return _history + .where((event) => filters.any((filter) => _matches(filter, event))) + .toList(); + } + @override Future subscribe( NostrFilter filter, @@ -159,6 +200,7 @@ void main() { // Cold start: channels still loading, so the first fetch has no DM ids. await container.read(activityProvider.future); expect(session.dmQueries, isEmpty); + expect(session.queryFilterCounts, [3]); // Channel list resolves with a DM → Activity must rebuild and query it. channels.resolve([_dmChannel('dm1')]); @@ -167,6 +209,7 @@ void main() { expect(session.dmQueries, hasLength(1)); expect(session.dmQueries.single, ['dm1']); + expect(session.queryFilterCounts, [3, 4]); }); test('does not query DMs when the resolved channel list has none', () async { @@ -190,6 +233,30 @@ void main() { expect(session.dmQueries, isEmpty); }); + test('falls back to websocket history when the HTTP batch fails', () async { + final session = _RecordingSessionNotifier() + ..seed(_mentionEvent('fallback-mention', 1_700_000_001)) + ..failNextQueryRelay = true; + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new), + myPubkeyProvider.overrideWithValue('me_pk'), + relaySessionProvider.overrideWith(() => session), + channelsProvider.overrideWith( + () => _FixedChannelsNotifier(const []), + ), + ], + ); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + final feed = await container.read(activityProvider.future); + + expect(session.queryFilterCounts, [3]); + expect(session.mentionFetchCount, 1); + expect(feed.mentions.map((item) => item.id), ['fallback-mention']); + }); + test( 'refreshes the inbox projection when addressed activity arrives', () async { @@ -308,7 +375,7 @@ void main() { expect(session.maxActiveMentionFetches, 1); }); - test('retains the loaded inbox when a live refresh fails', () async { + test('recovers a live refresh through websocket history fallback', () async { final session = _RecordingSessionNotifier() ..seed(_mentionEvent('existing', 1_700_000_001)); final container = ProviderContainer( @@ -333,7 +400,10 @@ void main() { await _waitFor(() => session.mentionFetchCount >= 2); await Future.delayed(const Duration(milliseconds: 10)); - expect(container.read(inboxItemsProvider).single.id, 'existing'); + expect( + container.read(inboxItemsProvider).map((item) => item.id), + containsAll(['existing', 'newer']), + ); }); } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index fbb12f7177..a0a832c866 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -7,6 +7,8 @@ import 'package:flutter/rendering.dart' show ScrollDirection; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:buzz/features/channels/channel.dart'; @@ -35,6 +37,7 @@ import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/keyboard_dismiss_on_drag.dart'; +import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -187,6 +190,7 @@ Widget _buildTestable({ TextScaler textScaler = TextScaler.noScaling, bool disableAnimations = false, RelaySessionNotifier? relaySessionNotifier, + http.Client? mediaClient, }) { final resolvedChannel = channel ?? _testChannel; final fakeChannelsNotifier = @@ -238,6 +242,12 @@ Widget _buildTestable({ relayClientProvider.overrideWithValue( RelayClient(baseUrl: 'http://localhost:3000'), ), + if (mediaClient != null) ...[ + mediaGetAuthServiceProvider.overrideWithValue( + MediaGetAuthService(baseUrl: 'https://relay.example', nsec: null), + ), + mediaHttpClientProvider.overrideWithValue(mediaClient), + ], if (relaySessionNotifier != null) relaySessionProvider.overrideWith(() => relaySessionNotifier), // Compose bar drafts persist through SharedPreferences. @@ -344,6 +354,86 @@ void main() { }); group('ChannelDetailPage', () { + testWidgets('uses the shared 32px masked presence avatar in DM headers', ( + tester, + ) async { + final dmChannel = Channel( + id: _channelId, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Alice'], + participantPubkeys: const ['self', 'alice'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + final avatarFinder = find.byKey(const ValueKey('dm-header-avatar')); + final avatar = tester.widget(avatarFinder); + expect(tester.getSize(avatarFinder), const Size.square(32)); + expect(avatar.geometry, AvatarBadgeMaskGeometry.presenceDot); + expect(avatar.badge, isNotNull); + expect( + find.descendant(of: avatarFinder, matching: find.byType(ClipPath)), + findsOneWidget, + ); + final name = tester.widget( + find.byKey(const ValueKey('dm-header-name')), + ); + final presence = tester.widget( + find.byKey(const ValueKey('dm-header-presence')), + ); + expect(name.style?.fontSize, 16); + expect(name.style?.fontWeight, FontWeight.w500); + expect(presence.style?.fontSize, 14); + expect(presence.style?.fontWeight, FontWeight.w400); + expect(find.byTooltip('View members'), findsNothing); + }); + + testWidgets('keeps the Members action for group DMs', (tester) async { + final dmChannel = Channel( + id: _channelId, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: 'Group direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 3, + participants: const ['Self', 'Alice', 'Bob'], + participantPubkeys: const ['self', 'alice', 'bob'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('View members'), findsOneWidget); + }); + testWidgets( 'restores the previous channel replay priority after a nested pop', (tester) async { @@ -732,6 +822,12 @@ void main() { joinedAt: DateTime(2025), displayName: 'Alice', ), + ChannelMember( + pubkey: 'agent', + role: 'bot', + joinedAt: DateTime(2025), + displayName: 'Agent', + ), ], ), ); @@ -743,6 +839,65 @@ void main() { expect(find.text('Alice'), findsOneWidget); expect(find.text('Member'), findsOneWidget); expect(find.text('Owner'), findsOneWidget); + expect(find.text('People · 2'), findsOneWidget); + expect(find.text('Agents · 1'), findsOneWidget); + expect(find.text('PEOPLE — 2'), findsNothing); + expect(find.text('BOTS — 1'), findsNothing); + }); + + testWidgets('members sheet has no divider and pads a one-member list', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: const [], + members: [ + ChannelMember( + pubkey: 'self', + role: 'owner', + joinedAt: DateTime(2025), + displayName: 'Self', + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('View members')); + await tester.pumpAndSettle(); + + final contentPadding = find.byKey( + const ValueKey('members-sheet-content-padding'), + ); + expect(contentPadding, findsOneWidget); + expect( + find.descendant(of: contentPadding, matching: find.byType(Divider)), + findsNothing, + ); + expect( + (tester.widget(contentPadding).padding as EdgeInsets).bottom, + 0, + ); + final viewport = tester.widget( + find.byKey(const ValueKey('members-sheet-viewport')), + ); + expect(viewport.constraints.maxHeight, 400 + Grid.md); + expect( + (tester + .widget( + find.byKey(const ValueKey('members-sheet-list')), + ) + .padding! + as EdgeInsets) + .bottom, + Grid.md, + ); + expect( + find.byKey(const ValueKey('buzz-sheet-surface-margin')), + findsNothing, + ); + expect(find.text('People · 1'), findsOneWidget); + expect(tester.widget(find.text('People · 1')).style?.fontSize, 14); }); testWidgets('hides composer for archived channels', (tester) async { @@ -1029,6 +1184,46 @@ void main() { ); }); + testWidgets('keeps animated message avatars static and transparent', ( + tester, + ) async { + const posterUrl = 'https://relay.example/media/alice-poster.png'; + const animationUrl = 'https://relay.example/media/alice-avatar.png'; + final profileUrl = + '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}'; + final mediaClient = http_testing.MockClient( + (_) async => http.Response.bytes(_transparentPng, 200), + ); + addTearDown(mediaClient.close); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg(id: 'animated-avatar', pubkey: 'alice', content: 'Hello'), + ], + users: { + 'alice': UserProfile( + pubkey: 'alice', + displayName: 'Alice', + avatarUrl: profileUrl, + ), + }, + mediaClient: mediaClient, + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byType(CircleAvatar)).backgroundColor, + Colors.transparent, + ); + expect(tester.widget(find.byType(MediaImage)).url, posterUrl); + expect( + find.byKey(const ValueKey('progressive-animated-avatar-animation')), + findsNothing, + ); + }); + testWidgets('long press opens the anchored reaction popover', ( tester, ) async { @@ -2891,6 +3086,107 @@ void main() { expect(find.byType(UserProfileSheet), findsOneWidget); }); + testWidgets( + 'profile sheet shows the poster before autoplay and restores it on tap', + (tester) async { + const posterUrl = 'https://relay.example/media/alice-poster.png'; + const animationUrl = 'https://relay.example/media/alice-avatar.png'; + final profileUrl = + '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}'; + final animationResponse = Completer(); + final mediaClient = http_testing.MockClient( + (request) => request.url.toString() == animationUrl + ? animationResponse.future + : Future.value(http.Response.bytes(_transparentPng, 200)), + ); + addTearDown(mediaClient.close); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'sys-huddle-animated-avatar', + kind: EventKind.huddleStarted, + pubkey: 'alice', + ), + ], + users: { + 'alice': UserProfile( + pubkey: 'alice', + displayName: 'Alice', + avatarUrl: profileUrl, + ), + }, + mediaClient: mediaClient, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(CircleAvatar)); + await tester.pumpAndSettle(); + + expect(find.byType(UserProfileSheet), findsOneWidget); + expect( + find.byKey(const ValueKey('progressive-animated-avatar-poster')), + findsOneWidget, + ); + expect( + find.byKey( + const ValueKey('progressive-animated-avatar-animation-loading'), + ), + findsOneWidget, + ); + + animationResponse.complete(http.Response.bytes(_transparentPng, 200)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey( + const ValueKey('progressive-animated-avatar-animation-ready'), + ), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('progressive-animated-avatar-poster')), + findsNothing, + ); + + await tester.tap(find.byKey(const ValueKey('selected-profile-avatar'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('progressive-animated-avatar-animation')), + findsNothing, + ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('selected-profile-avatar')), + matching: find.byType(MediaImage), + ), + ) + .url, + posterUrl, + ); + + await tester.tap(find.byKey(const ValueKey('selected-profile-avatar'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('progressive-animated-avatar-animation')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('progressive-animated-avatar-poster')), + findsNothing, + ); + }, + ); + testWidgets('opens a profile sheet from a generic system avatar', ( tester, ) async { @@ -6265,6 +6561,10 @@ List _replayedChannelIds(_RecordingRelaySocket socket) => socket ) .toList(); +final _transparentPng = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', +); + class _TestNavigatorObserver extends NavigatorObserver { int pushCount = 0; diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index b49ee78423..b0387b54f8 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -909,11 +909,12 @@ void main() { final options = find.byKey(const Key('community-switcher-options')); expect(options, findsOneWidget); final editButton = find.byKey(const Key('community-switcher-edit')); + expect(tester.getSize(editButton), const Size(56, 44)); + expect(find.byTooltip('Close sheet'), findsNothing); expect( - tester.getRect(options).top - tester.getRect(editButton).bottom, - closeTo(8, 0.01), + tester.getCenter(editButton).dx, + greaterThan(tester.getCenter(find.text('Switch Community')).dx), ); - expect(tester.getSize(editButton).height, 32); expect(find.text('alpha.example.com'), findsOneWidget); expect(find.text('bravo.example.com'), findsOneWidget); expect(find.text('Rename'), findsNothing); @@ -943,6 +944,10 @@ void main() { final inactiveSelection = find.byKey( const Key('community-switcher-selection-bravo'), ); + expect( + tester.getCenter(editButton).dx, + closeTo(tester.getCenter(activeSelection).dx, 0.01), + ); expect(tester.getSize(activeSelection), const Size.square(40)); expect( tester.getSize(find.byKey(const Key('community-switcher-circle-alpha'))), diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 116a977852..ea33fb7944 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -306,6 +306,83 @@ void main() { expect(channels.single.lastMessageAt?.millisecondsSinceEpoch, 20 * 1000); }); + test( + 'loads all channel timestamps through one batched relay query', + () async { + final session = _FakeRelaySession( + memberships: [ + _membership(_channelA, myPk), + _membership(_channelB, myPk), + ], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'direct', channelType: 'dm'), + ], + recentMessages: const [ + NostrEvent( + id: 'stream-message', + pubkey: 'alice', + createdAt: 30, + kind: EventKind.streamMessageV2, + tags: [ + ['h', _channelA], + ], + content: 'hello', + sig: 'sig', + ), + NostrEvent( + id: 'dm-message', + pubkey: 'alice', + createdAt: 40, + kind: 9, + tags: [ + ['h', _channelB], + ], + content: 'hello privately', + sig: 'sig', + ), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final channels = await container.read(channelsProvider.future); + + await _waitUntil(() => session.queryBatches.length == 2); + expect(session.queryBatches, hasLength(2)); + expect(session.queryBatches.first, hasLength(2)); + expect( + session.queryBatches.first + .map((filter) => filter.tags['#h']!.single) + .toSet(), + {_channelA, _channelB}, + ); + expect(session.queryBatches.last, hasLength(2)); + expect( + session.queryBatches.last.every( + (filter) => filter.limit == 1000 && filter.since == 0, + ), + isTrue, + ); + expect( + session.historyFilters.where((filter) { + final kinds = filter.kinds.toSet(); + return kinds.length == EventKind.channelMessageEventKinds.length && + kinds.containsAll(EventKind.channelMessageEventKinds); + }), + isEmpty, + ); + expect( + channels.firstWhere((channel) => channel.id == _channelA).lastMessageAt, + DateTime.fromMillisecondsSinceEpoch(30 * 1000, isUtc: true), + ); + expect( + channels.firstWhere((channel) => channel.id == _channelB).lastMessageAt, + DateTime.fromMillisecondsSinceEpoch(40 * 1000, isUtc: true), + ); + }, + ); + test('ephemeral (TTL) channels appear in the list', () async { // Regression: previously the provider unconditionally dropped any channel // with a `ttl` tag, which made TTL channels invisible on iOS even when the @@ -668,15 +745,18 @@ class _FakeRelaySession extends RelaySessionNotifier { required this.memberships, required this.metadata, this.hiddenDmEvents = const [], + this.recentMessages = const [], this.membershipFailures = 0, }); List memberships; List metadata; final List hiddenDmEvents; + final List recentMessages; int membershipFailures; final List historyFilters = []; + final List> queryBatches = []; final List subscribeFilters = []; final Map _subscriptions = {}; int _nextSubscriptionKey = 0; @@ -747,6 +827,33 @@ class _FakeRelaySession extends RelaySessionNotifier { return const []; } + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + queryBatches.add(filters); + return recentMessages.where((event) { + return filters.any((filter) { + if (!filter.kinds.contains(event.kind)) return false; + for (final entry in filter.tags.entries) { + final tagName = entry.key.startsWith('#') + ? entry.key.substring(1) + : entry.key; + if (!event.tags.any( + (tag) => + tag.length > 1 && + tag[0] == tagName && + entry.value.contains(tag[1]), + )) { + return false; + } + } + return true; + }); + }).toList(); + } + @override Future subscribe( NostrFilter filter, diff --git a/mobile/test/features/channels/message_mention_pubkeys_test.dart b/mobile/test/features/channels/message_mention_pubkeys_test.dart new file mode 100644 index 0000000000..8bc260a7ac --- /dev/null +++ b/mobile/test/features/channels/message_mention_pubkeys_test.dart @@ -0,0 +1,82 @@ +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/message_mention_pubkeys.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _self = 'self'; +const _agent = 'agent'; +const _human = 'human'; + +void main() { + test('implicitly addresses every participating DM recipient', () { + expect( + messageMentionPubkeys( + channel: _channel( + type: 'dm', + participantPubkeys: const [_self, _agent, _human], + ), + senderPubkey: _self, + explicitMentions: const [], + dmRecipientPubkeys: const [_agent, _human], + ), + [_agent, _human], + ); + }); + + test('preserves and deduplicates explicit mentions with DM recipients', () { + expect( + messageMentionPubkeys( + channel: _channel( + type: 'dm', + participantPubkeys: const [_self, _agent, _human], + ), + senderPubkey: _self, + explicitMentions: const [_human, _agent], + dmRecipientPubkeys: const [_agent], + ), + [_human, _agent], + ); + }); + + test('addresses human DMs but not ordinary channel members', () { + expect( + messageMentionPubkeys( + channel: _channel( + type: 'dm', + participantPubkeys: const [_self, _human], + ), + senderPubkey: _self, + explicitMentions: const [], + dmRecipientPubkeys: const [_human], + ), + [_human], + ); + expect( + messageMentionPubkeys( + channel: _channel( + type: 'stream', + participantPubkeys: const [_self, _agent], + ), + senderPubkey: _self, + explicitMentions: const [], + dmRecipientPubkeys: const [_agent], + ), + isEmpty, + ); + }); +} + +Channel _channel({ + required String type, + required List participantPubkeys, +}) => Channel( + id: 'channel', + name: 'Conversation', + channelType: type, + visibility: 'private', + description: '', + createdBy: _self, + createdAt: DateTime(2025), + memberCount: participantPubkeys.length, + participantPubkeys: participantPubkeys, + isMember: true, +); diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart index 273336f283..2361360d5c 100644 --- a/mobile/test/features/channels/send_message_provider_test.dart +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/send_message_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -68,6 +70,149 @@ void main() { expect(removedIds, [localMessages.single.id]); }); + test('final signed event addresses the current DM agent member', () async { + final session = _PendingPublishRelaySession(); + final signingKey = nostr.Keys.generate().nsec; + final sender = nostr.Keys( + nostr.Nip19.decode(payload: signingKey).data, + ).public; + final staleAgent = 'a' * 64; + final activeAgent = 'c' * 64; + final human = 'b' * 64; + final send = SendMessage( + signedEventRelay: SignedEventRelay(session: session, nsec: signingKey), + fetchMembers: (_) async => [ + _member(sender), + _member(activeAgent), + _member(human), + ], + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _channelId, + content: 'hello without a visible mention', + // Metadata still names the replaced agent. Delivery must follow the + // authoritative current membership snapshot instead. + channel: _dmChannel([sender, staleAgent, human]), + mentionPubkeys: const [], + ); + await session.published; + + expect(session.event.content, 'hello without a visible mention'); + expect(session.event.tags.where((tag) => tag.first == 'p').toList(), [ + ['p', activeAgent], + ['p', human], + ]); + + session.accept(); + await result; + }); + + test('final signed event addresses a human DM recipient', () async { + final session = _PendingPublishRelaySession(); + final signingKey = nostr.Keys.generate().nsec; + final sender = nostr.Keys( + nostr.Nip19.decode(payload: signingKey).data, + ).public; + final human = 'b' * 64; + final send = SendMessage( + signedEventRelay: SignedEventRelay(session: session, nsec: signingKey), + fetchMembers: (_) async => [_member(sender), _member(human)], + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _channelId, + content: 'hello human', + channel: _dmChannel([sender, human]), + mentionPubkeys: const [], + ); + await session.published; + + expect(session.event.tags.where((tag) => tag.first == 'p').toList(), [ + ['p', human], + ]); + + session.accept(); + await result; + }); + + test( + 'falls back to metadata DM recipients when membership is empty', + () async { + final session = _PendingPublishRelaySession(); + final signingKey = nostr.Keys.generate().nsec; + final sender = nostr.Keys( + nostr.Nip19.decode(payload: signingKey).data, + ).public; + final recipient = 'b' * 64; + final send = SendMessage( + signedEventRelay: SignedEventRelay(session: session, nsec: signingKey), + fetchMembers: (_) async => const [], + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _channelId, + content: 'hello from an unavailable roster', + channel: _dmChannel([sender, recipient]), + mentionPubkeys: const [], + ); + await session.published; + + expect(session.event.tags.where((tag) => tag.first == 'p').toList(), [ + ['p', recipient], + ]); + + session.accept(); + await result; + }, + ); + + test('falls back to metadata DM recipients when membership fails', () async { + final session = _PendingPublishRelaySession(); + final signingKey = nostr.Keys.generate().nsec; + final sender = nostr.Keys( + nostr.Nip19.decode(payload: signingKey).data, + ).public; + final recipientOne = 'b' * 64; + final recipientTwo = 'c' * 64; + final send = SendMessage( + signedEventRelay: SignedEventRelay(session: session, nsec: signingKey), + fetchMembers: (_) async => throw StateError('membership unavailable'), + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _channelId, + content: 'hello group', + channel: _dmChannel([sender, recipientOne, recipientTwo]), + mentionPubkeys: [recipientOne.toUpperCase()], + ); + await session.published; + + expect(session.event.tags.where((tag) => tag.first == 'p').toList(), [ + ['p', recipientOne], + ['p', recipientTwo], + ]); + + session.accept(); + await result; + }); + test('cancels delivery after the active community changes', () async { final container = ProviderContainer(); addTearDown(container.dispose); @@ -95,6 +240,22 @@ void main() { const _channelId = '11111111-1111-4111-8111-111111111111'; +Channel _dmChannel(List participantPubkeys) => Channel( + id: _channelId, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: '', + createdBy: participantPubkeys.first, + createdAt: DateTime(2025), + memberCount: participantPubkeys.length, + participantPubkeys: participantPubkeys, + isMember: true, +); + +ChannelMember _member(String pubkey, {String role = 'member'}) => + ChannelMember(pubkey: pubkey, role: role, joinedAt: DateTime(2025)); + class _PendingPublishRelaySession extends RelaySessionNotifier { final Completer _result = Completer(); final Completer _published = Completer(); diff --git a/mobile/test/features/profile/profile_avatar_test.dart b/mobile/test/features/profile/profile_avatar_test.dart index 4562e4166c..f1207e27de 100644 --- a/mobile/test/features/profile/profile_avatar_test.dart +++ b/mobile/test/features/profile/profile_avatar_test.dart @@ -5,13 +5,20 @@ import 'package:buzz/features/profile/profile_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/user_profile.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; void main() { - Widget harness({bool showPresence = true, String presence = 'online'}) { + Widget harness({ + bool showPresence = true, + String presence = 'online', + String? avatarUrl, + }) { return ProviderScope( overrides: [ - profileProvider.overrideWith(_FakeProfileNotifier.new), + profileProvider.overrideWith( + () => _FakeProfileNotifier(avatarUrl: avatarUrl), + ), presenceProvider.overrideWith(() => _FakePresenceNotifier(presence)), ], child: MaterialApp( @@ -31,6 +38,14 @@ void main() { find.byType(MaskedAvatarBadge), ); expect(badge.geometry, AvatarBadgeMaskGeometry.presenceDot); + expect( + tester + .widget( + find.byKey(const ValueKey('profile-avatar-background')), + ) + .color, + AppTheme.light().colorScheme.primaryContainer, + ); expect( tester.widget(find.byType(ClipPath).last).clipper, isA(), @@ -86,12 +101,43 @@ void main() { findsNothing, ); }); + + testWidgets('keeps an animated avatar poster background transparent', ( + tester, + ) async { + const posterUrl = 'https://relay.example/media/poster.png'; + const animationUrl = 'https://relay.example/media/animation.png'; + final avatarUrl = + '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}'; + + await tester.pumpWidget(harness(avatarUrl: avatarUrl)); + await tester.pump(); + + expect( + tester + .widget( + find.byKey(const ValueKey('profile-avatar-background')), + ) + .color, + Colors.transparent, + ); + expect( + tester + .widget(find.byType(AvatarImageContent)) + .imageUrl, + posterUrl, + ); + }); } class _FakeProfileNotifier extends ProfileNotifier { + _FakeProfileNotifier({this.avatarUrl}); + + final String? avatarUrl; + @override Future build() async => - const UserProfile(pubkey: 'aabb', displayName: 'Test'); + UserProfile(pubkey: 'aabb', displayName: 'Test', avatarUrl: avatarUrl); } class _FakePresenceNotifier extends PresenceNotifier { diff --git a/mobile/test/features/profile/set_status_sheet_test.dart b/mobile/test/features/profile/set_status_sheet_test.dart index 53563758d9..5ae424dfb0 100644 --- a/mobile/test/features/profile/set_status_sheet_test.dart +++ b/mobile/test/features/profile/set_status_sheet_test.dart @@ -2,6 +2,8 @@ import 'package:buzz/features/profile/set_status_sheet.dart'; import 'package:buzz/features/profile/user_status.dart'; import 'package:buzz/features/profile/user_status_provider.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -9,7 +11,41 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../helpers/widget_helpers.dart'; void main() { - testWidgets('removes only the emoji from an existing status', (tester) async { + testWidgets('keeps its resting top edge when the keyboard appears', ( + tester, + ) async { + addTearDown(tester.view.reset); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + customEmojiListProvider.overrideWithValue(const []), + userStatusProvider.overrideWith(() => _RecordingUserStatusNotifier()), + ], + child: Builder( + builder: (context) => FilledButton( + onPressed: () => showSetStatusSheet(context), + child: const Text('Open status editor'), + ), + ), + ), + ); + + await tester.tap(find.text('Open status editor')); + await tester.pumpAndSettle(); + final restingTop = tester.getTopLeft(find.byType(BottomSheet)).dy; + + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + + expect( + tester.getTopLeft(find.byType(BottomSheet)).dy, + closeTo(restingTop, 0.01), + ); + }); + + testWidgets('uses Buzz rows, desktop presets, and a real duration', ( + tester, + ) async { final statusNotifier = _RecordingUserStatusNotifier(); await tester.pumpWidget( WidgetHelpers.testable( @@ -38,6 +74,25 @@ void main() { expect(find.text('\u{1F3AF}'), findsOneWidget); expect(find.byTooltip('Remove status emoji'), findsOneWidget); + expect(find.byKey(const ValueKey('status-input-outline')), findsOneWidget); + expect(find.text('Visible in chats to everyone'), findsNothing); + expect(find.text('Let others know what you\u2019re up to.'), findsNothing); + expect(find.text('1 day'), findsOneWidget); + expect(find.text('In a meeting'), findsOneWidget); + expect(find.text('Commuting'), findsOneWidget); + expect(find.text('Out sick'), findsOneWidget); + expect(find.text('Vacationing'), findsOneWidget); + expect(find.text('Working remotely'), findsOneWidget); + + await tester.tap(find.text('1 day')); + await tester.pumpAndSettle(); + expect(find.text('1 hour'), findsOneWidget); + expect(find.text('8 hours'), findsOneWidget); + expect(find.text('1 week'), findsOneWidget); + expect(find.text('Custom'), findsOneWidget); + await tester.tap(find.text('8 hours')); + await tester.pumpAndSettle(); + expect(find.text('8 hours'), findsOneWidget); await tester.tap(find.byTooltip('Remove status emoji')); await tester.pump(); @@ -49,24 +104,151 @@ void main() { 'Focusing', ); - await tester.tap(find.text('Save')); + final beforeSave = DateTime.now(); + await tester.tap(find.byTooltip('Save status')); await tester.pumpAndSettle(); expect(statusNotifier.savedText, 'Focusing'); expect(statusNotifier.savedEmoji, isEmpty); + expect(statusNotifier.savedExpiresAt, isNotNull); + expect( + statusNotifier.savedExpiresAt!.difference(beforeSave).inSeconds, + inInclusiveRange( + const Duration(hours: 8).inSeconds - 2, + const Duration(hours: 8).inSeconds + 2, + ), + ); + }); + + testWidgets('opens with an out-of-range remote expiration', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + customEmojiListProvider.overrideWithValue(const []), + userStatusProvider.overrideWith(() => _RecordingUserStatusNotifier()), + ], + child: Builder( + builder: (context) => FilledButton( + onPressed: () => showSetStatusSheet( + context, + currentStatus: const UserStatus( + text: 'Focusing', + emoji: '', + updatedAt: 1, + expiresAt: 9_000_000_000_000, + ), + ), + child: const Text('Open status editor'), + ), + ), + ), + ); + + await tester.tap(find.text('Open status editor')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Focusing'), findsOneWidget); + expect(find.text('1 day'), findsOneWidget); + }); + + testWidgets('clamps an existing custom date to the Android picker range', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final expiresAt = DateTime.now().add(const Duration(days: 730)); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + customEmojiListProvider.overrideWithValue(const []), + userStatusProvider.overrideWith(() => _RecordingUserStatusNotifier()), + ], + child: Builder( + builder: (context) => FilledButton( + onPressed: () => showSetStatusSheet( + context, + currentStatus: UserStatus( + text: 'Sabbatical', + emoji: '\u{1F3DD}\u{FE0F}', + updatedAt: 1, + expiresAt: expiresAt.millisecondsSinceEpoch ~/ 1000, + ), + ), + child: const Text('Open status editor'), + ), + ), + ), + ); + + await tester.tap(find.text('Open status editor')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Until')); + await tester.pumpAndSettle(); + + final picker = tester.widget( + find.byType(DatePickerDialog), + ); + expect(picker.initialDate, picker.lastDate); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('custom duration reveals the native until picker', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final statusNotifier = _RecordingUserStatusNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + customEmojiListProvider.overrideWithValue(const []), + userStatusProvider.overrideWith(() => statusNotifier), + ], + child: Builder( + builder: (context) => FilledButton( + onPressed: () => showSetStatusSheet(context), + child: const Text('Open status editor'), + ), + ), + ), + ); + + await tester.tap(find.text('Open status editor')); + await tester.pumpAndSettle(); + await tester.tap(find.text('1 day')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Custom')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Custom')); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoDatePicker), findsOneWidget); + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + + expect(find.text('Custom'), findsOneWidget); + expect(find.text('Until'), findsOneWidget); + debugDefaultTargetPlatformOverride = null; }); } class _RecordingUserStatusNotifier extends UserStatusNotifier { String? savedText; String? savedEmoji; + DateTime? savedExpiresAt; @override Future build() async => null; @override - Future setStatus(String text, String emoji) async { + Future setStatus( + String text, + String emoji, { + DateTime? expiresAt, + }) async { savedText = text; savedEmoji = emoji; + savedExpiresAt = expiresAt; } } diff --git a/mobile/test/features/profile/settings_profile_header_test.dart b/mobile/test/features/profile/settings_profile_header_test.dart index a3c7b3dd9a..ca10c4726c 100644 --- a/mobile/test/features/profile/settings_profile_header_test.dart +++ b/mobile/test/features/profile/settings_profile_header_test.dart @@ -1,18 +1,128 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/settings_profile_header.dart'; import 'package:buzz/features/profile/user_profile.dart'; import 'package:buzz/features/profile/user_status.dart'; import 'package:buzz/features/profile/user_status_provider.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/relay/media_auth.dart'; +import 'package:buzz/shared/relay/media_image.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../helpers/widget_helpers.dart'; void main() { + testWidgets('shows the poster until the animated avatar is ready', ( + tester, + ) async { + const posterUrl = 'https://relay.example/media/poster.png'; + const animationUrl = 'https://relay.example/media/animation.png'; + final profileUrl = + '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}'; + final animationResponse = Completer(); + final client = http_testing.MockClient( + (request) => request.url.toString() == animationUrl + ? animationResponse.future + : Future.value(http.Response.bytes(_transparentPng, 200)), + ); + addTearDown(client.close); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith( + () => _FakeProfileNotifier(avatarUrl: profileUrl), + ), + presenceProvider.overrideWith(() => _FakePresenceNotifier('online')), + userStatusProvider.overrideWith(() => _FakeUserStatusNotifier(null)), + customEmojiListProvider.overrideWithValue(const []), + mediaGetAuthServiceProvider.overrideWithValue( + MediaGetAuthService(baseUrl: 'https://relay.example', nsec: null), + ), + mediaHttpClientProvider.overrideWithValue(client), + ], + child: const SettingsProfileHeader(), + ), + ); + await tester.pump(); + await tester.pump(); + + expect( + find.byKey(const ValueKey('progressive-animated-avatar-poster')), + findsOneWidget, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('settings-profile-avatar-background')), + ) + .color, + Colors.transparent, + ); + expect( + find.byKey( + const ValueKey('progressive-animated-avatar-animation-loading'), + ), + findsOneWidget, + ); + expect( + tester + .widgetList(find.byType(MediaImage, skipOffstage: false)) + .map((image) => image.url), + containsAll([posterUrl, animationUrl]), + ); + + animationResponse.complete(http.Response.bytes(_transparentPng, 200)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('progressive-animated-avatar-animation-ready')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('progressive-animated-avatar-poster')), + findsNothing, + ); + expect( + tester + .widgetList(find.byType(MediaImage)) + .map((image) => image.url), + [animationUrl], + ); + + await tester.tap(find.byKey(const ValueKey('settings-profile-avatar'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('progressive-animated-avatar-animation')), + findsNothing, + ); + expect(tester.widget(find.byType(MediaImage)).url, posterUrl); + + await tester.tap(find.byKey(const ValueKey('settings-profile-avatar'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('progressive-animated-avatar-animation')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('progressive-animated-avatar-poster')), + findsNothing, + ); + }); + testWidgets('uses a bounded icon for an unresolved status shortcode', ( tester, ) async { @@ -145,15 +255,19 @@ void main() { } class _FakeProfileNotifier extends ProfileNotifier { + _FakeProfileNotifier({this.avatarUrl}); + + final String? avatarUrl; + @override Future build() async => - const UserProfile(pubkey: 'aabb', displayName: 'Test'); + UserProfile(pubkey: 'aabb', displayName: 'Test', avatarUrl: avatarUrl); } class _FakeUserStatusNotifier extends UserStatusNotifier { _FakeUserStatusNotifier(this._status); - final UserStatus _status; + final UserStatus? _status; @override Future build() async => _status; @@ -173,3 +287,8 @@ class _FakePresenceNotifier extends PresenceNotifier { selected.add(status); } } + +final _transparentPng = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAA' + 'AAYAAjCB0C8AAAAASUVORK5CYII=', +); diff --git a/mobile/test/features/profile/user_status_provider_test.dart b/mobile/test/features/profile/user_status_provider_test.dart new file mode 100644 index 0000000000..e57d066a7a --- /dev/null +++ b/mobile/test/features/profile/user_status_provider_test.dart @@ -0,0 +1,235 @@ +import 'package:buzz/features/profile/user_status_cache_provider.dart'; +import 'package:buzz/features/profile/user_status.dart'; +import 'package:buzz/features/profile/user_status_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + test( + 'publishes the selected status expiration on the final signed event', + () async { + final keys = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final expiresAt = DateTime.fromMillisecondsSinceEpoch( + 1_900_000_000 * 1000, + ); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith( + () => _FixedRelayConfigNotifier(keys.nsec), + ), + relaySessionProvider.overrideWith(() => relaySession), + userStatusCacheProvider.overrideWith(_EmptyUserStatusCache.new), + ], + ); + addTearDown(container.dispose); + + await container.read(userStatusProvider.future); + await container + .read(userStatusProvider.notifier) + .setStatus(' Focusing ', '\u{1F3AF}', expiresAt: expiresAt); + + final event = relaySession.published.single; + expect(event.kind, EventKind.userStatus); + expect(event.content, 'Focusing'); + expect(event.tags, contains(equals(['d', 'general']))); + expect(event.tags, contains(equals(['emoji', '\u{1F3AF}']))); + expect(event.tags, contains(equals(['expiration', '1900000000']))); + }, + ); + + testWidgets('clears the current status and shared cache at expiration', ( + tester, + ) async { + final keys = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final statusCache = _RecordingUserStatusCache(); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith( + () => _FixedRelayConfigNotifier(keys.nsec), + ), + relaySessionProvider.overrideWith(() => relaySession), + userStatusCacheProvider.overrideWith(() => statusCache), + ], + ); + addTearDown(container.dispose); + + await container.read(userStatusProvider.future); + await container + .read(userStatusProvider.notifier) + .setStatus('Focusing', '🎯', expiresAt: DateTime.now()); + + expect(container.read(userStatusProvider).value, isNotNull); + expect(statusCache.updates.last.$2, isNotNull); + + await tester.pump(const Duration(milliseconds: 1)); + + expect(container.read(userStatusProvider).value, isNull); + expect(statusCache.updates.last, (keys.public.toLowerCase(), null)); + }); + + testWidgets('clears another user status without a new relay event', ( + tester, + ) async { + final container = ProviderContainer( + overrides: [ + relayClientProvider.overrideWithValue( + RelayClient(baseUrl: 'https://relay.example'), + ), + relaySessionProvider.overrideWith(_DisconnectedRelaySession.new), + ], + ); + addTearDown(container.dispose); + + final cache = container.read(userStatusCacheProvider.notifier); + cache.updateStatus( + 'alice', + UserStatus( + text: 'Focusing', + emoji: '', + updatedAt: 1, + expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ); + expect(container.read(userStatusCacheProvider)['alice'], isNotNull); + + await tester.pump(const Duration(milliseconds: 1)); + + expect(container.read(userStatusCacheProvider)['alice'], isNull); + }); + + test('ignores an out-of-range expiration in the shared cache scheduler', () { + final container = ProviderContainer( + overrides: [ + relayClientProvider.overrideWithValue( + RelayClient(baseUrl: 'https://relay.example'), + ), + relaySessionProvider.overrideWith(_DisconnectedRelaySession.new), + ], + ); + addTearDown(container.dispose); + + final status = UserStatus( + text: 'Focusing', + emoji: '', + updatedAt: 1, + expiresAt: 9_000_000_000_000, + ); + expect(status.expirationDateTime, isNull); + + expect( + () => container + .read(userStatusCacheProvider.notifier) + .updateStatus('alice', status), + returnsNormally, + ); + expect(container.read(userStatusCacheProvider)['alice'], same(status)); + }); + + test( + 'ignores an out-of-range expiration in the current-user scheduler', + () async { + final keys = nostr.Keys.generate(); + final relaySession = _StatusRelaySession( + NostrEvent( + id: 'status-1', + pubkey: keys.public, + createdAt: 1, + kind: EventKind.userStatus, + tags: const [ + ['d', 'general'], + ['expiration', '9000000000000'], + ], + content: 'Focusing', + sig: 'sig', + ), + ); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith( + () => _FixedRelayConfigNotifier(keys.nsec), + ), + relaySessionProvider.overrideWith(() => relaySession), + userStatusCacheProvider.overrideWith(_EmptyUserStatusCache.new), + ], + ); + addTearDown(container.dispose); + + final status = await container.read(userStatusProvider.future); + + expect(status?.text, 'Focusing'); + expect(status?.expiresAt, 9_000_000_000_000); + expect(status?.expirationDateTime, isNull); + }, + ); +} + +class _FixedRelayConfigNotifier extends RelayConfigNotifier { + _FixedRelayConfigNotifier(this.nsec); + + final String nsec; + + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); +} + +class _RecordingRelaySession extends RelaySessionNotifier { + final List published = []; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => []; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published.add(event); + return event; + } +} + +class _StatusRelaySession extends _RecordingRelaySession { + _StatusRelaySession(this.status); + + final NostrEvent status; + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => [status]; +} + +class _DisconnectedRelaySession extends RelaySessionNotifier { + @override + SessionState build() => + const SessionState(status: SessionStatus.disconnected); +} + +class _RecordingUserStatusCache extends UserStatusCacheNotifier { + final List<(String, UserStatus?)> updates = []; + + @override + Map build() => {}; + + @override + void updateStatus(String pubkey, UserStatus? status) { + updates.add((pubkey, status)); + } +} + +class _EmptyUserStatusCache extends UserStatusCacheNotifier { + @override + Map build() => {}; +} diff --git a/mobile/test/shared/animated_avatar_test.dart b/mobile/test/shared/animated_avatar_test.dart new file mode 100644 index 0000000000..773f957a4b --- /dev/null +++ b/mobile/test/shared/animated_avatar_test.dart @@ -0,0 +1,36 @@ +import 'package:buzz/shared/animated_avatar.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const posterUrl = 'https://relay.example/media/poster.png'; + const animationUrl = 'https://relay.example/media/animation.png?loop=1'; + + test('parses the selected poster and animated PNG URLs', () { + final url = '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}'; + + final parsed = parseAnimatedAvatarUrl(url); + + expect(parsed?.posterUrl, posterUrl); + expect(parsed?.animationUrl, animationUrl); + }); + + test('rejects malformed and non-http animated avatar URLs', () { + expect(parseAnimatedAvatarUrl(posterUrl), isNull); + expect(parseAnimatedAvatarUrl('$posterUrl#buzz-anim='), isNull); + expect(parseAnimatedAvatarUrl('$posterUrl#buzz-anim=%E0%A4%A'), isNull); + expect(parseAnimatedAvatarUrl('$posterUrl#buzz-anim=%'), isNull); + expect( + parseAnimatedAvatarUrl( + '$posterUrl#buzz-anim=${Uri.encodeComponent('javascript:alert(1)')}', + ), + isNull, + ); + expect( + parseAnimatedAvatarUrl( + 'data:image/png;base64,xx#buzz-anim=' + '${Uri.encodeComponent(animationUrl)}', + ), + isNull, + ); + }); +} diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 826e234400..7332075c3f 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -105,6 +105,91 @@ void main() { ); }); + test('queryRelay rotates the client after a timeout', () async { + final clients = <_ControlledHttpClient>[]; + final session = RelaySessionNotifier( + httpClientFactory: () { + final client = _ControlledHttpClient(); + clients.add(client); + return client; + }, + ); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + ), + ), + ], + ); + addTearDown(container.dispose); + container.read(relaySessionProvider); + + await expectLater( + session.queryRelay(const [], timeout: Duration.zero), + throwsA(isA()), + ); + expect(clients.single.closed, isTrue); + + final nextQuery = session.queryRelay(const []); + expect(clients, hasLength(2)); + clients.last.complete(http.Response('[]', 200)); + + expect(await nextQuery, isEmpty); + expect(clients.last.closed, isFalse); + }); + + test( + 'queryRelay defers closing a timed-out client until peer queries finish', + () async { + final clients = <_QueuedControlledHttpClient>[]; + final session = RelaySessionNotifier( + httpClientFactory: () { + final client = _QueuedControlledHttpClient(); + clients.add(client); + return client; + }, + ); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + ), + ), + ], + ); + addTearDown(container.dispose); + container.read(relaySessionProvider); + await Future.delayed(Duration.zero); + + final timedOutQuery = session.queryRelay( + const [], + timeout: const Duration(milliseconds: 10), + ); + final peerQuery = session.queryRelay(const []); + expect(clients.single.requestCount, 2); + + await expectLater(timedOutQuery, throwsA(isA())); + expect(clients.single.closed, isFalse); + + final nextQuery = session.queryRelay(const []); + expect(clients, hasLength(2)); + clients.first.complete(1, http.Response('[]', 200)); + expect(await peerQuery, isEmpty); + expect(clients.first.closed, isTrue); + + clients.last.complete(0, http.Response('[]', 200)); + expect(await nextQuery, isEmpty); + expect(clients.last.closed, isFalse); + }, + ); + test('queryRelay arms the rate-limit gate from a 429 retry hint', () async { final gateTimers = <_ManualTimer>[]; final gate = RelayRateLimitGate( @@ -1208,6 +1293,59 @@ void main() { }); } +class _ControlledHttpClient extends http.BaseClient { + final _response = Completer(); + bool closed = false; + + @override + Future send(http.BaseRequest request) => + _response.future; + + void complete(http.Response response) { + _response.complete( + http.StreamedResponse( + Stream.value(response.bodyBytes), + response.statusCode, + headers: response.headers, + reasonPhrase: response.reasonPhrase, + request: response.request, + ), + ); + } + + @override + void close() => closed = true; +} + +class _QueuedControlledHttpClient extends http.BaseClient { + final List> _responses = []; + bool closed = false; + + int get requestCount => _responses.length; + + @override + Future send(http.BaseRequest request) { + final response = Completer(); + _responses.add(response); + return response.future; + } + + void complete(int requestIndex, http.Response response) { + _responses[requestIndex].complete( + http.StreamedResponse( + Stream.value(response.bodyBytes), + response.statusCode, + headers: response.headers, + reasonPhrase: response.reasonPhrase, + request: response.request, + ), + ); + } + + @override + void close() => closed = true; +} + class _QueryHarness { final ProviderContainer container; final RelaySessionNotifier session; diff --git a/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart index 79bb03b6b5..cfaab6d4f1 100644 --- a/mobile/test/shared/widgets/avatar_image_test.dart +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -4,17 +4,21 @@ import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; void main() { const svg = '' '🦝'; - Widget subject(String? imageUrl) => MaterialApp( - home: AvatarImage( - imageUrl: imageUrl, - radius: 16, - fallback: const Text('R'), + Widget subject(String? imageUrl, {Color? backgroundColor}) => ProviderScope( + child: MaterialApp( + home: AvatarImage( + imageUrl: imageUrl, + radius: 16, + backgroundColor: backgroundColor, + fallback: const Text('R'), + ), ), ); @@ -55,6 +59,28 @@ void main() { expect(find.text('R'), findsOneWidget); }); + testWidgets('renders animated-avatar posters without an opaque background', ( + tester, + ) async { + const posterUrl = 'https://relay.example/media/poster.png'; + const animationUrl = 'https://relay.example/media/animation.png'; + final animatedUrl = + '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}'; + + await tester.pumpWidget(subject(animatedUrl, backgroundColor: Colors.red)); + + expect( + tester.widget(find.byType(CircleAvatar)).backgroundColor, + Colors.transparent, + ); + expect( + tester + .widget(find.byType(AvatarImageContent)) + .imageUrl, + posterUrl, + ); + }); + testWidgets('reuses parsed raster bytes across parent rebuilds', ( tester, ) async { diff --git a/mobile/test/shared/widgets/modal_presentation_test.dart b/mobile/test/shared/widgets/modal_presentation_test.dart index 068187b545..85767e3f89 100644 --- a/mobile/test/shared/widgets/modal_presentation_test.dart +++ b/mobile/test/shared/widgets/modal_presentation_test.dart @@ -3,6 +3,7 @@ import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/modal_presentation.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -30,6 +31,10 @@ void main() { ), findsOneWidget, ); + final contentClip = tester.widget( + find.byKey(const ValueKey('concentric-sheet-content-clip')), + ); + expect(contentClip.borderRadius, BorderRadius.circular(Radii.dialog)); } finally { debugDefaultTargetPlatformOverride = null; } @@ -75,6 +80,66 @@ void main() { }, ); + testWidgets('native titled sheets leave the concentric surface unobscured', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const surfaceChannel = MethodChannel('buzz/concentric_sheet_surface'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + surfaceChannel, + (call) async => call.method == 'isSupported' ? true : null, + ); + try { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => showBuzzModalBottomSheet( + context: context, + title: 'Members', + builder: (_) => const Text('Sheet body'), + ), + child: const Text('Open sheet'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open sheet')); + await tester.pumpAndSettle(); + + final nativeSurface = tester.widget(find.byType(UiKitView)); + expect( + nativeSurface.creationParams, + containsPair('color', lightColorScheme.surface.toARGB32()), + ); + expect(nativeSurface.creationParams, isNot(contains('headerGradient'))); + expect( + find.byKey(const ValueKey('buzz-sheet-header-gradient')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('buzz-sheet-surface-clip')), + findsNothing, + ); + final contentClip = tester.widget( + find.byKey(const ValueKey('concentric-sheet-content-clip')), + ); + expect(contentClip.borderRadius, BorderRadius.circular(Radii.dialog * 2)); + expect(contentClip.clipBehavior, Clip.antiAlias); + expect(find.text('Members'), findsOneWidget); + } finally { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + surfaceChannel, + null, + ); + debugDefaultTargetPlatformOverride = null; + } + }); + testWidgets( 'non-iOS sheets use Flutter drag handle and shared close control', (tester) async { @@ -88,6 +153,7 @@ void main() { builder: (context) => FilledButton( onPressed: () => showBuzzModalBottomSheet( context: context, + title: 'Sheet title', showDragHandle: true, builder: (_) => const Text('Sheet body'), ), @@ -102,8 +168,51 @@ void main() { await tester.pumpAndSettle(); final closeButton = find.byTooltip('Close sheet'); + final title = find.byKey(const ValueKey('buzz-sheet-title')); expect(closeButton, findsOneWidget); + expect(title, findsOneWidget); + expect( + find.byKey(const ValueKey('buzz-sheet-surface-clip')), + findsOneWidget, + ); expect(tester.getSize(closeButton), const Size.square(44)); + expect(tester.widget(title).style?.fontSize, 16); + expect( + find.byKey(const ValueKey('buzz-sheet-header-gradient')), + findsNothing, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('buzz-sheet-surface')), + ) + .color, + lightColorScheme.surface, + ); + expect( + tester.getTopLeft(find.text('Sheet body')).dy - + tester + .getTopLeft(find.byKey(const ValueKey('buzz-sheet-surface'))) + .dy, + 80, + ); + expect(find.byType(BackdropFilter), findsNothing); + expect( + tester.widget(find.byType(BottomSheet)).backgroundColor, + Colors.transparent, + ); + expect( + tester.getCenter(title).dx, + closeTo(tester.getCenter(find.byType(BottomSheet)).dx, 0.01), + ); + expect( + tester.getCenter(title).dy, + closeTo(tester.getCenter(closeButton).dy, 0.01), + ); + expect( + tester.getCenter(closeButton).dx, + greaterThan(tester.getCenter(title).dx), + ); final closeGutter = find.ancestor( of: closeButton, matching: find.byWidgetPredicate( @@ -125,17 +234,33 @@ void main() { expect(gutterRect.right - closeRect.right, Grid.gutter); expect( tester.widget(find.byType(BottomSheet)).showDragHandle, - isTrue, + isFalse, ); expect( find.byKey(const ValueKey('buzz-sheet-drag-handle')), - findsNothing, + findsOneWidget, + ); + expect( + tester.getTopLeft(closeButton).dy - + tester.getTopLeft(find.byType(BottomSheet)).dy, + Grid.gutter, + ); + final dismissHandle = find.bySemanticsLabel('Dismiss').first; + expect(dismissHandle, findsOneWidget); + final semantics = tester.getSemantics(dismissHandle); + expect(semantics.flagsCollection.isButton, isTrue); + expect( + semantics.getSemanticsData().hasAction(SemanticsAction.tap), + isTrue, + ); + tester.binding.performSemanticsAction( + SemanticsActionEvent( + type: SemanticsAction.tap, + viewId: tester.view.viewId, + nodeId: semantics.id, + ), ); - expect(find.text('Sheet body'), findsOneWidget); - - await tester.tap(closeButton); await tester.pumpAndSettle(); - expect(find.text('Sheet body'), findsNothing); } finally { debugDefaultTargetPlatformOverride = null; @@ -178,7 +303,14 @@ void main() { ); expect(internalHandle, findsOneWidget); expect(tester.getSize(internalHandle), const Size(32, 4)); - expect(find.bySemanticsLabel('Drag handle'), findsOneWidget); + final dismissHandle = find.bySemanticsLabel('Dismiss'); + expect(dismissHandle, findsOneWidget); + final semantics = tester.getSemantics(dismissHandle); + expect(semantics.flagsCollection.isButton, isTrue); + expect( + semantics.getSemanticsData().hasAction(SemanticsAction.tap), + isTrue, + ); expect(find.byTooltip('Close sheet'), findsOneWidget); expect(find.text('Sheet body'), findsOneWidget); } finally { From df9e773a13f17a270fd6531fc74948b8059d58c3 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 13 Aug 2026 21:32:34 -0600 Subject: [PATCH 04/33] Scope desktop presence subscriptions to active demand (#5830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - replace the desktop's global kind-20001 presence subscription with one author-filtered subscription derived from active TanStack presence queries - reconcile changing demand without a delivery gap: promote only after relay EOSE, keep the last confirmed subscription on failure, discard stale opens, and close entirely when demand is empty - preserve REST presence as the initial seed and TTL/crash-recovery backstop - add transport-seam and lifecycle tests for readiness, normalization, churn, retries, close failures, reconnect ownership assumptions, and disposal ## Why The desktop currently receives presence heartbeats from every identity on the relay. A live tap measured roughly 2,700 events/minute (45/sec), about 1 MB/minute and 71.5% of readable traffic, from approximately 1,300 distinct fleet identities. Most are discarded only after WebSocket, Tauri IPC, and JS parsing. This change applies normal Nostr author filtering at relay fan-out, before those costs. It deliberately does not introduce a relay digest protocol or client-side event batching; relevant-author traffic should be small after scoping, and the existing signed-delta/REST-TTL model remains intact. ## Correctness model - active query observers are the demand source; inactive cached queries retain no authors - replacement opens before old closes and is promoted only after EOSE - timeout/CLOSED rejects and closes the candidate while preserving the last good subscription - rapid A→B→C and A→B→A churn cannot unseat current A with stale B - empty demand never sends an unfiltered subscription - RelayClient continues to own reconnect replay; the reconciler does not duplicate subscriptions on reconnect ## Validation Exact pushed head: `8845093aec0330be16efe52d3459ff67f1000ff4` Pre-push hooks passed: - desktop check and file-size ratchet - desktop TypeScript - desktop unit suite: 4,791/4,791 - branch-skew check Focused lifecycle/transport suite: 34/34 passed before commit. Independent Royal Court review found and blocked two prototype flaws (timeout-as-success and starvation-prone trailing debounce); both were fixed and the final worktree was cleared with no remaining correctness or lifecycle blockers. Signed-off-by: Wes Co-authored-by: Carl --- desktop/src/features/presence/hooks.ts | 69 +++-- .../features/presence/lib/presence.test.mjs | 17 ++ desktop/src/features/presence/lib/presence.ts | 13 + .../presenceSubscriptionReconciler.test.mjs | 251 ++++++++++++++++++ .../lib/presenceSubscriptionReconciler.ts | 138 ++++++++++ .../api/presenceRelaySubscription.test.mjs | 62 +++++ .../shared/api/presenceRelaySubscription.ts | 45 ++++ desktop/src/shared/api/relayClientSession.ts | 10 +- desktop/src/shared/constants/kinds.ts | 1 + 9 files changed, 571 insertions(+), 35 deletions(-) create mode 100644 desktop/src/features/presence/lib/presenceSubscriptionReconciler.test.mjs create mode 100644 desktop/src/features/presence/lib/presenceSubscriptionReconciler.ts create mode 100644 desktop/src/shared/api/presenceRelaySubscription.test.mjs create mode 100644 desktop/src/shared/api/presenceRelaySubscription.ts diff --git a/desktop/src/features/presence/hooks.ts b/desktop/src/features/presence/hooks.ts index b1acacafbc..f6718a6a5c 100644 --- a/desktop/src/features/presence/hooks.ts +++ b/desktop/src/features/presence/hooks.ts @@ -9,6 +9,7 @@ import { getPresence } from "@/shared/api/tauri"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; import { + activePresencePubkeys, mergePresenceUpdate, parseLivePresenceEvent, presenceQueryWantsPubkey, @@ -16,6 +17,8 @@ import { PRESENCE_TTL_SECONDS, resolveAutomaticPresenceStatus, } from "@/features/presence/lib/presence"; +import { PresenceSubscriptionReconciler } from "@/features/presence/lib/presenceSubscriptionReconciler"; +import { openPresenceSubscription } from "@/shared/api/presenceRelaySubscription"; import type { PresenceLookup, PresenceStatus } from "@/shared/api/types"; const PRESENCE_STATUS_TICK_INTERVAL_MS = 30_000; @@ -113,18 +116,16 @@ export function usePresenceQuery( } /** - * Subscribe to kind:20001 presence events over WebSocket and update the - * TanStack Query presence cache in-place when updates arrive. Call once - * in AppShell. Uses setQueriesData for targeted per-pubkey updates without - * triggering refetches. Retries with exponential backoff on failure. + * Keep one live presence subscription scoped to pubkeys requested by active + * TanStack queries. Replacement subscriptions open before the old one closes, + * avoiding a live-update gap while query observers change. */ export function usePresenceSubscription() { const queryClient = useQueryClient(); React.useEffect(() => { - let unsub: (() => Promise) | null = null; let isCancelled = false; - let retryTimer: ReturnType | null = null; + let reconcileTimer: ReturnType | null = null; function handlePresenceEvent(event: { pubkey: string; content: string }) { if (isCancelled) return; @@ -141,28 +142,37 @@ export function usePresenceSubscription() { ); } - function subscribeWithRetry(attempt = 0) { - if (isCancelled) return; - void relayClient - .subscribeToPresenceUpdates(handlePresenceEvent) - .then((unsubFn) => { - if (isCancelled) { - void unsubFn(); - return; - } - unsub = unsubFn; - }) - .catch(() => { - if (!isCancelled) { - const delay = Math.min(1000 * 2 ** attempt, 30_000); - retryTimer = setTimeout( - () => subscribeWithRetry(attempt + 1), - delay, - ); - } - }); + const reconciler = new PresenceSubscriptionReconciler({ + open: (authors) => + openPresenceSubscription(authors, handlePresenceEvent, (...args) => + relayClient.subscribeLive(...args), + ), + }); + + function reconcileActiveQueries() { + reconciler.setAuthors( + activePresencePubkeys(queryClient.getQueryCache().getAll()), + ); } - subscribeWithRetry(); + + function scheduleReconcile() { + if (reconcileTimer) return; + reconcileTimer = setTimeout(() => { + reconcileTimer = null; + reconcileActiveQueries(); + }, 100); + } + + const unsubQueryCache = queryClient.getQueryCache().subscribe((event) => { + if ( + event.type === "observerAdded" || + event.type === "observerRemoved" || + event.type === "observerOptionsUpdated" + ) { + scheduleReconcile(); + } + }); + reconcileActiveQueries(); const unsubReconnect = relayClient.subscribeToReconnects(() => { if (!isCancelled) @@ -171,9 +181,10 @@ export function usePresenceSubscription() { return () => { isCancelled = true; + unsubQueryCache(); unsubReconnect(); - if (retryTimer) clearTimeout(retryTimer); - if (unsub) void unsub(); + if (reconcileTimer) clearTimeout(reconcileTimer); + reconciler.dispose(); }; }, [queryClient]); } diff --git a/desktop/src/features/presence/lib/presence.test.mjs b/desktop/src/features/presence/lib/presence.test.mjs index 90a6ac524b..951d280b09 100644 --- a/desktop/src/features/presence/lib/presence.test.mjs +++ b/desktop/src/features/presence/lib/presence.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + activePresencePubkeys, mergePresenceUpdate, parseLivePresenceEvent, presenceQueryWantsPubkey, @@ -15,6 +16,22 @@ const WILL = "8e39cba681211b3782d0e4483e9343719b9b7be66515252da5491f26421896b1"; const OTHER = "44b8e82baaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +test("active presence authors are normalized, deduplicated, and sorted", () => { + const queries = [ + { queryKey: ["presence", WILL.toUpperCase(), OTHER], isActive: () => true }, + { queryKey: ["presence", WILL], isActive: () => true }, + ]; + assert.deepEqual(activePresencePubkeys(queries), [OTHER, WILL].sort()); +}); + +test("inactive and non-presence queries do not retain presence authors", () => { + const queries = [ + { queryKey: ["presence", WILL], isActive: () => false }, + { queryKey: ["profiles", OTHER], isActive: () => true }, + ]; + assert.deepEqual(activePresencePubkeys(queries), []); +}); + test("presence heartbeat is one minute with a three-window TTL", () => { assert.equal(PRESENCE_HEARTBEAT_INTERVAL_MS, 60_000); assert.equal(PRESENCE_TTL_SECONDS, 180); diff --git a/desktop/src/features/presence/lib/presence.ts b/desktop/src/features/presence/lib/presence.ts index 5b1fdc21c5..4f33a91a32 100644 --- a/desktop/src/features/presence/lib/presence.ts +++ b/desktop/src/features/presence/lib/presence.ts @@ -15,6 +15,19 @@ export function parseLivePresenceEvent(event: { return { pubkey: event.pubkey.toLowerCase(), status }; } +export function activePresencePubkeys( + queries: Array<{ queryKey: readonly unknown[]; isActive: () => boolean }>, +): string[] { + const pubkeys = new Set(); + for (const query of queries) { + if (!query.isActive() || query.queryKey[0] !== "presence") continue; + for (const value of query.queryKey.slice(1)) { + if (typeof value === "string" && value) pubkeys.add(value.toLowerCase()); + } + } + return [...pubkeys].sort(); +} + // Presence query keys are ["presence", ...normalizedSortedPubkeys]; a query // "wants" an update only for a pubkey it actually requested. export function presenceQueryWantsPubkey( diff --git a/desktop/src/features/presence/lib/presenceSubscriptionReconciler.test.mjs b/desktop/src/features/presence/lib/presenceSubscriptionReconciler.test.mjs new file mode 100644 index 0000000000..f86000dfbf --- /dev/null +++ b/desktop/src/features/presence/lib/presenceSubscriptionReconciler.test.mjs @@ -0,0 +1,251 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { PresenceSubscriptionReconciler } from "./presenceSubscriptionReconciler.ts"; + +const A = "a".repeat(64); +const B = "b".repeat(64); +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +test("normalizes demand and refuses to open an empty subscription", async () => { + const opened = []; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + opened.push(authors); + return async () => {}; + }, + }); + + reconciler.setAuthors([]); + reconciler.setAuthors([B, A.toUpperCase(), A]); + await flush(); + assert.deepEqual(opened, [[A, B]]); + reconciler.dispose(); +}); + +test("opens replacement before closing the previous subscription", async () => { + const actions = []; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + const key = authors.join(""); + actions.push(`open:${key}`); + return async () => actions.push(`close:${key}`); + }, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.setAuthors([B]); + await flush(); + assert.deepEqual(actions, [`open:${A}`, `open:${B}`, `close:${A}`]); + reconciler.dispose(); +}); + +test("a stale async open is closed and never installed", async () => { + const first = deferred(); + const actions = []; + let opens = 0; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + opens += 1; + const key = authors.join(""); + actions.push(`open:${key}`); + if (opens === 1) await first.promise; + return async () => actions.push(`close:${key}`); + }, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.setAuthors([B]); + first.resolve(); + await flush(); + await flush(); + assert.deepEqual(actions, [`open:${A}`, `close:${A}`, `open:${B}`]); + reconciler.dispose(); +}); + +test("failed replacement preserves the previous subscription and retries", async () => { + const timers = []; + const actions = []; + let failB = true; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + const key = authors.join(""); + actions.push(`open:${key}`); + if (key === B && failB) throw new Error("relay unavailable"); + return async () => actions.push(`close:${key}`); + }, + retryDelay: () => 1, + setTimer: (callback) => { + timers.push(callback); + return timers.length; + }, + clearTimer: () => {}, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.setAuthors([B]); + await flush(); + assert.deepEqual(actions, [`open:${A}`, `open:${B}`]); + assert.equal(timers.length, 1); + + failB = false; + timers.shift()(); + await flush(); + assert.deepEqual(actions, [ + `open:${A}`, + `open:${B}`, + `open:${B}`, + `close:${A}`, + ]); + reconciler.dispose(); +}); + +test("rapid A to B to C keeps A until C is confirmed", async () => { + const openingB = deferred(); + const actions = []; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + const key = authors.join(""); + actions.push(`open:${key}`); + if (key === B) await openingB.promise; + return async () => actions.push(`close:${key}`); + }, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.setAuthors([B]); + await flush(); + reconciler.setAuthors(["c".repeat(64)]); + openingB.resolve(); + await flush(); + await flush(); + assert.deepEqual(actions, [ + `open:${A}`, + `open:${B}`, + `close:${B}`, + `open:${"c".repeat(64)}`, + `close:${A}`, + ]); + reconciler.dispose(); +}); + +test("rapid A to B to A closes stale B but retains current A", async () => { + const openingB = deferred(); + const actions = []; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + const key = authors.join(""); + actions.push(`open:${key}`); + if (key === B) await openingB.promise; + return async () => actions.push(`close:${key}`); + }, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.setAuthors([B]); + await flush(); + reconciler.setAuthors([A]); + openingB.resolve(); + await flush(); + assert.deepEqual(actions, [`open:${A}`, `open:${B}`, `close:${B}`]); + reconciler.dispose(); +}); + +test("prior close failure does not unseat a confirmed replacement", async () => { + const actions = []; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + const key = authors.join(""); + actions.push(`open:${key}`); + return async () => { + actions.push(`close:${key}`); + if (key === A) throw new Error("close failed"); + }; + }, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.setAuthors([B]); + await flush(); + reconciler.setAuthors([B]); + await flush(); + assert.deepEqual(actions, [`open:${A}`, `open:${B}`, `close:${A}`]); + reconciler.dispose(); +}); + +test("dispose closes current and a late replacement without double-closing current", async () => { + const openingB = deferred(); + const closes = { [A]: 0, [B]: 0 }; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + const key = authors.join(""); + if (key === B) await openingB.promise; + return async () => { + closes[key] += 1; + }; + }, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.setAuthors([B]); + await flush(); + reconciler.dispose(); + openingB.resolve(); + await flush(); + assert.deepEqual(closes, { [A]: 1, [B]: 1 }); +}); + +test("dispose during an in-flight open closes the late subscription", async () => { + const opening = deferred(); + let closeCount = 0; + const reconciler = new PresenceSubscriptionReconciler({ + open: async () => { + await opening.promise; + return async () => { + closeCount += 1; + }; + }, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.dispose(); + opening.resolve(); + await flush(); + assert.equal(closeCount, 1); +}); + +test("clearing demand closes current without opening a replacement", async () => { + const actions = []; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + const key = authors.join(""); + actions.push(`open:${key}`); + return async () => actions.push(`close:${key}`); + }, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.setAuthors([]); + await flush(); + assert.deepEqual(actions, [`open:${A}`, `close:${A}`]); + reconciler.dispose(); +}); diff --git a/desktop/src/features/presence/lib/presenceSubscriptionReconciler.ts b/desktop/src/features/presence/lib/presenceSubscriptionReconciler.ts new file mode 100644 index 0000000000..2831785ccf --- /dev/null +++ b/desktop/src/features/presence/lib/presenceSubscriptionReconciler.ts @@ -0,0 +1,138 @@ +export type PresenceSubscriptionClose = () => Promise; + +export interface PresenceSubscriptionReconcilerOptions { + open: (authors: string[]) => Promise; + retryDelay?: (attempt: number) => number; + setTimer?: ( + callback: () => void, + delayMs: number, + ) => ReturnType; + clearTimer?: (timer: ReturnType) => void; +} + +/** + * Reconciles a changing author set onto one live relay subscription. + * + * A replacement opens before the prior subscription closes, so demand changes + * never create a live-update gap. Opens that finish after demand changes are + * closed without becoming current. A failed replacement leaves the last good + * subscription serving its old author set while retrying with bounded backoff. + */ +export class PresenceSubscriptionReconciler { + private readonly open: PresenceSubscriptionReconcilerOptions["open"]; + private readonly retryDelay: (attempt: number) => number; + private readonly setTimer: NonNullable< + PresenceSubscriptionReconcilerOptions["setTimer"] + >; + private readonly clearTimer: NonNullable< + PresenceSubscriptionReconcilerOptions["clearTimer"] + >; + private desiredAuthors: string[] = []; + private desiredKey = ""; + private current: { key: string; close: PresenceSubscriptionClose } | null = + null; + private running = false; + private disposed = false; + private retryAttempt = 0; + private retryTimer: ReturnType | null = null; + + constructor(options: PresenceSubscriptionReconcilerOptions) { + this.open = options.open; + this.retryDelay = + options.retryDelay ?? + ((attempt) => Math.min(1000 * 2 ** attempt, 30_000)); + this.setTimer = options.setTimer ?? setTimeout; + this.clearTimer = options.clearTimer ?? clearTimeout; + } + + setAuthors(authors: string[]) { + if (this.disposed) return; + const normalized = [ + ...new Set(authors.map((author) => author.toLowerCase())), + ] + .filter(Boolean) + .sort(); + const key = normalized.join(","); + if (key === this.desiredKey) return; + + this.desiredAuthors = normalized; + this.desiredKey = key; + this.retryAttempt = 0; + this.cancelRetry(); + void this.reconcile(); + } + + dispose() { + if (this.disposed) return; + this.disposed = true; + this.cancelRetry(); + const current = this.current; + this.current = null; + if (current) void current.close().catch(() => {}); + } + + private currentKey() { + return this.current?.key ?? ""; + } + + private cancelRetry() { + if (this.retryTimer === null) return; + this.clearTimer(this.retryTimer); + this.retryTimer = null; + } + + private scheduleRetry() { + if (this.retryTimer !== null || this.disposed) return; + const delay = this.retryDelay(this.retryAttempt); + this.retryAttempt += 1; + this.retryTimer = this.setTimer(() => { + this.retryTimer = null; + void this.reconcile(); + }, delay); + } + + private async reconcile() { + if (this.running || this.disposed) return; + this.running = true; + try { + while (!this.disposed && this.currentKey() !== this.desiredKey) { + const nextAuthors = this.desiredAuthors; + const nextKey = this.desiredKey; + + if (nextAuthors.length === 0) { + const previous = this.current; + this.current = null; + if (previous) await previous.close().catch(() => {}); + continue; + } + + let nextClose: PresenceSubscriptionClose; + try { + nextClose = await this.open(nextAuthors); + } catch { + if (nextKey === this.desiredKey) this.scheduleRetry(); + return; + } + + if (this.disposed || nextKey !== this.desiredKey) { + await nextClose().catch(() => {}); + continue; + } + + const previous = this.current; + this.current = { key: nextKey, close: nextClose }; + this.retryAttempt = 0; + if (previous) await previous.close().catch(() => {}); + } + } finally { + this.running = false; + if ( + !this.disposed && + this.retryTimer === null && + this.currentKey() !== this.desiredKey + ) { + void this.reconcile(); + } + } + } +} diff --git a/desktop/src/shared/api/presenceRelaySubscription.test.mjs b/desktop/src/shared/api/presenceRelaySubscription.test.mjs new file mode 100644 index 0000000000..c02e904713 --- /dev/null +++ b/desktop/src/shared/api/presenceRelaySubscription.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { openPresenceSubscription } from "./presenceRelaySubscription.ts"; +import { KIND_PRESENCE_UPDATE } from "../constants/kinds.ts"; + +const A = "a".repeat(64); +const B = "b".repeat(64); + +function liveWithReadiness(readiness) { + const calls = []; + let closeCount = 0; + const openLive = async (filter, onEvent, onReady, readinessTimeoutMs) => { + calls.push({ filter, onEvent, readinessTimeoutMs }); + onReady(readiness); + return async () => { + closeCount += 1; + }; + }; + return { calls, closeCount: () => closeCount, openLive }; +} + +test("presence waits for EOSE and sends only normalized authors", async () => { + const { calls, closeCount, openLive } = liveWithReadiness("eose"); + const onEvent = () => {}; + const close = await openPresenceSubscription( + [B, A.toUpperCase(), A], + onEvent, + openLive, + ); + + assert.deepEqual(calls, [ + { + filter: { kinds: [KIND_PRESENCE_UPDATE], authors: [A, B], limit: 0 }, + onEvent, + readinessTimeoutMs: 5_000, + }, + ]); + assert.equal(closeCount(), 0); + await close(); + assert.equal(closeCount(), 1); +}); + +for (const readiness of ["timeout", "closed"]) { + test(`presence ${readiness} closes the candidate and rejects`, async () => { + const { closeCount, openLive } = liveWithReadiness(readiness); + await assert.rejects( + openPresenceSubscription([A], () => {}, openLive), + readiness === "closed" ? /rejected/ : /timed out/i, + ); + assert.equal(closeCount(), 1); + }); +} + +test("empty demand never reaches the relay subscribe primitive", async () => { + const { calls, openLive } = liveWithReadiness("eose"); + await assert.rejects( + openPresenceSubscription([], () => {}, openLive), + /at least one author/, + ); + assert.deepEqual(calls, []); +}); diff --git a/desktop/src/shared/api/presenceRelaySubscription.ts b/desktop/src/shared/api/presenceRelaySubscription.ts new file mode 100644 index 0000000000..623a8e36fa --- /dev/null +++ b/desktop/src/shared/api/presenceRelaySubscription.ts @@ -0,0 +1,45 @@ +import type { + LiveSubscriptionReadiness, + RelaySubscriptionFilter, +} from "@/shared/api/relayClientShared"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_PRESENCE_UPDATE } from "@/shared/constants/kinds"; + +export type OpenLiveSubscription = ( + filter: RelaySubscriptionFilter, + onEvent: (event: RelayEvent) => void, + onReady: (readiness: LiveSubscriptionReadiness) => void, + readinessTimeoutMs: number, +) => Promise<() => Promise>; + +/** Open an author-scoped presence subscription and require relay EOSE. */ +export async function openPresenceSubscription( + pubkeys: string[], + onEvent: (event: RelayEvent) => void, + openLive: OpenLiveSubscription, +) { + const authors = [...new Set(pubkeys.map((pubkey) => pubkey.toLowerCase()))] + .filter(Boolean) + .sort(); + if (authors.length === 0) { + throw new Error("Presence subscriptions require at least one author."); + } + + const readiness: { value: LiveSubscriptionReadiness } = { value: "timeout" }; + const unsubscribe = await openLive( + { kinds: [KIND_PRESENCE_UPDATE], authors, limit: 0 }, + onEvent, + (nextReadiness) => { + readiness.value = nextReadiness; + }, + 5_000, + ); + if (readiness.value === "eose") return unsubscribe; + + await unsubscribe(); + throw new Error( + readiness.value === "closed" + ? "Relay rejected the presence subscription." + : "Timed out confirming the presence subscription.", + ); +} diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 9ffe84b939..29b24f21d1 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -378,10 +378,6 @@ export class RelayClient { ); } - async subscribeToPresenceUpdates(onEvent: (event: RelayEvent) => void) { - return this.subscribe({ kinds: [20001], limit: 0 }, onEvent); - } - async publishUserStatus(text: string, emoji: string): Promise { await this.ensureConnected(); const tags: string[][] = [["d", "general"]]; @@ -414,8 +410,9 @@ export class RelayClient { filter: RelaySubscriptionFilter, onEvent: (event: RelayEvent) => void, onReady?: (readiness: LiveSubscriptionReadiness) => void, + readinessTimeoutMs?: number, ) { - return this.subscribe(filter, onEvent, onReady); + return this.subscribe(filter, onEvent, onReady, readinessTimeoutMs); } async subscribeToChannelMentionEvents( channelId: string, @@ -600,6 +597,7 @@ export class RelayClient { filter: RelaySubscriptionFilter, onEvent: (event: RelayEvent) => void, onReady?: (readiness: LiveSubscriptionReadiness) => void, + readinessTimeoutMs = 250, ) { await this.ensureConnected(); @@ -614,7 +612,7 @@ export class RelayClient { }); const fallbackTimeout = window.setTimeout( () => resolveReady("timeout"), - 250, + readinessTimeoutMs, ); this.subscriptions.set(subId, { diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index f995a63596..df5dfa6521 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -34,6 +34,7 @@ export const KIND_APPROVAL_REQUEST = 46010; export const KIND_MEMBER_ADDED_NOTIFICATION = 44100; export const KIND_MEMBER_REMOVED_NOTIFICATION = 44101; export const KIND_TYPING_INDICATOR = 20002; +export const KIND_PRESENCE_UPDATE = 20001; export const KIND_HUDDLE_REACTION = 24810; export const KIND_HUDDLE_STARTED = 48100; export const KIND_HUDDLE_PARTICIPANT_JOINED = 48101; From ea0960f8d0221de18d7d3504607594035519f33f Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:14:59 -0400 Subject: [PATCH 05/33] Clarify immediate spoken huddle replies (#5863) ## Summary - state that only `buzz messages send` messages are spoken in a huddle - require the first tool call after being addressed to be a brief spoken pickup - explicitly override the normal no-bare-acknowledgment rule and bound follow-up speech - pin those invariants in the prompt test ## Test plan - `cargo test --workspace` from `desktop/src-tauri` - pre-push `desktop-tauri-checks` (clippy and full workspace tests) Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/huddle/agents.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/huddle/agents.rs b/desktop/src-tauri/src/huddle/agents.rs index a64f540f5e..2b5b601de4 100644 --- a/desktop/src-tauri/src/huddle/agents.rs +++ b/desktop/src-tauri/src/huddle/agents.rs @@ -33,16 +33,15 @@ use super::{pipeline::start_auto_enabled_transcription, HuddlePhase}; /// huddle start. Agents load this event into the channel session system prompt. /// /// Keep this deliberately short: the invariant that matters is that a directly -/// addressed user interrupts every other activity and receives an immediate -/// spoken response. +/// addressed user receives an immediate spoken response before any other work. pub fn voice_mode_guidelines(parent_channel_id: &str) -> String { format!( "\ You are in a live voice huddle attached to channel {parent_channel_id}. -Your messages are read aloud in the order sent. -Reply immediately whenever a user addresses you, no matter what else is happening. -Send your first sentence as soon as it is formed, then send each following sentence separately. -Speak plainly and briefly without markdown; post code or long detail to the attached channel instead. +Only messages sent with `buzz messages send` to this huddle channel are spoken aloud, in the order sent; everything else you produce is silent. +When a user addresses you, your FIRST tool call must send a brief spoken reply to this channel, before any file read, search, or other tool call. The usual rule against bare acknowledgments does not apply here; the pickup is the feedback that you heard them. +Then work, sending each useful sentence as its own message the moment it is ready—a few sentences per answer, not a monologue. +Speak plainly without markdown; post code or long detail to the attached channel instead. If you are not addressed, stay silent." ) } @@ -301,10 +300,13 @@ mod tests { use super::{contains_member, voice_mode_guidelines}; #[test] - fn voice_mode_guidelines_are_short_and_pin_immediate_reply() { + fn voice_mode_guidelines_pin_spoken_reply_as_first_tool_call() { let guidelines = voice_mode_guidelines("parent-channel"); assert_eq!(guidelines.lines().count(), 6); - assert!(guidelines.contains("Reply immediately whenever a user addresses you")); + assert!(guidelines.contains("Only messages sent with `buzz messages send`")); + assert!(guidelines.contains("your FIRST tool call must send a brief spoken reply")); + assert!(guidelines.contains("before any file read, search, or other tool call")); + assert!(guidelines.contains("rule against bare acknowledgments does not apply here")); assert!(guidelines.contains("parent-channel")); } From 8b8445f5ef3338c58825194ebc008b98111a0962 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:16:10 -0400 Subject: [PATCH 06/33] fix(desktop): share one timer across same-interval useNow consumers (#5861) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Every `useNow(1000)` consumer owned its own `setInterval`. With dozens of "agent working" surfaces mounted (sidebar channel badges, tray menu, agent session panels, managed-agent rows), each ticked on its own unaligned 1 s timer — a render/composite pass per consumer per second. On a machine running ~23 agent sessions this pinned a sustained **~25% of a core** in `com.apple.WebKit.WebContent` while the app sat idle. This PR makes same-interval `useNow` consumers share one timer: all of them tick in a single `setInterval` callback, so React batches the state updates into one render pass. The last unsubscriber tears the timer down; the visibility gate (pause while hidden, snap fresh on return) is unchanged. Attribution receipts (live dev build, 23 acp sessions): the shimmer was the original suspect from `sample` stacks, but probing `animation: none` left CPU flat (~25%), while clamping `useNow` intervals dropped it immediately. Repeated A/B with this exact change: **~25% → ~3–9%** webview CPU under the same agent load (ambient variance from live agent activity; the delta reproduced across three alternations). ### Related issue None found — follow-up to the presence-firehose investigation (#5830 fixed the subscription side; this is the remaining local render cost). ### Testing - `pnpm test` — 4792/4792 pass, including a new test asserting N same-interval consumers create exactly one timer and the last unmount releases it - `pnpm typecheck`, `biome check` — clean - Live-local per TESTING.md: hot-patched into a running dev desktop with 23 active acp sessions; webview CPU dropped from ~25% sustained to ~3–9% (A/B/A alternation, `ps` sampling over 30 s windows) Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .../shared/lib/useDocumentVisible.test.mjs | 54 +++++++++++++++++++ desktop/src/shared/lib/useNow.ts | 47 ++++++++++++++-- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/desktop/src/shared/lib/useDocumentVisible.test.mjs b/desktop/src/shared/lib/useDocumentVisible.test.mjs index ed7dc05de9..bd3d54cbb3 100644 --- a/desktop/src/shared/lib/useDocumentVisible.test.mjs +++ b/desktop/src/shared/lib/useDocumentVisible.test.mjs @@ -197,6 +197,60 @@ describe("visibility-gated hooks", () => { dom.window.close(); }); + it("useNow consumers with the same interval share one timer", async () => { + mock.timers.enable({ apis: ["Date", "setInterval"], now: 1_000 }); + const dom = new JSDOM( + "
", + ); + Object.defineProperty(dom.window.document, "visibilityState", { + configurable: true, + value: "visible", + }); + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + const setIntervalSpy = mock.method(globalThis, "setInterval"); + const observed = []; + function Clock({ id }) { + const now = useNow(1_000); + React.useEffect(() => { + observed.push([id, now]); + }, [id, now]); + return null; + } + const root = createRoot(document.getElementById("root")); + + await act(async () => + root.render( + React.createElement( + React.Fragment, + null, + React.createElement(Clock, { id: "a" }), + React.createElement(Clock, { id: "b" }), + React.createElement(Clock, { id: "c" }), + ), + ), + ); + assert.equal(setIntervalSpy.mock.callCount(), 1); + + await act(async () => mock.timers.tick(1_000)); + assert.deepEqual(observed.filter(([, now]) => now === 2_000).length, 3); + + // Last unmount releases the shared timer; a fresh mount recreates it. + await act(async () => root.unmount()); + const secondRoot = createRoot(document.getElementById("root")); + await act(async () => + secondRoot.render(React.createElement(Clock, { id: "d" })), + ); + assert.equal(setIntervalSpy.mock.callCount(), 2); + + await act(async () => secondRoot.unmount()); + dom.window.close(); + }); + it("focused polling pauses on blur and resumes after activation yields", async () => { const dom = new JSDOM( "
", diff --git a/desktop/src/shared/lib/useNow.ts b/desktop/src/shared/lib/useNow.ts index 2dda0adad7..be70603a39 100644 --- a/desktop/src/shared/lib/useNow.ts +++ b/desktop/src/shared/lib/useNow.ts @@ -2,10 +2,50 @@ import * as React from "react"; import { useDocumentVisible } from "@/shared/lib/useDocumentVisible"; +type SharedTicker = { + listeners: Set<() => void>; + intervalId: ReturnType; +}; + +const tickers = new Map(); + +/** + * One `setInterval` per distinct interval, shared by every subscriber. All + * same-cadence consumers tick in a single timer callback, so React batches + * their state updates into one render/composite pass instead of N unaligned + * passes per interval. With dozens of "working" badges mounted, per-consumer + * timers pinned a sustained ~25% of a core in the WebKit process. + */ +function subscribeToSharedTick( + intervalMs: number, + listener: () => void, +): () => void { + let ticker = tickers.get(intervalMs); + if (!ticker) { + const created: SharedTicker = { + listeners: new Set(), + intervalId: setInterval(() => { + for (const tick of created.listeners) tick(); + }, intervalMs), + }; + tickers.set(intervalMs, created); + ticker = created; + } + ticker.listeners.add(listener); + + return () => { + ticker.listeners.delete(listener); + if (ticker.listeners.size === 0) { + clearInterval(ticker.intervalId); + tickers.delete(intervalMs); + } + }; +} + /** * Returns `Date.now()`, re-rendering the calling component every `intervalMs`. - * Each consumer owns one `setInterval` cleaned up on unmount — mount the hook - * only where a live clock is actually displayed so idle components never tick. + * Consumers with the same interval share one timer — mount the hook only where + * a live clock is actually displayed so idle components never tick. */ export function useNow(intervalMs: number): number { const [now, setNow] = React.useState(() => Date.now()); @@ -15,8 +55,7 @@ export function useNow(intervalMs: number): number { if (!documentVisible) return; setNow(Date.now()); - const id = setInterval(() => setNow(Date.now()), intervalMs); - return () => clearInterval(id); + return subscribeToSharedTick(intervalMs, () => setNow(Date.now())); }, [documentVisible, intervalMs]); return now; From 43e53fc3491ecbd1def14ede3fb8c9e2d44e84d8 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 14 Aug 2026 16:25:40 +0100 Subject: [PATCH 07/33] Standardize settings section layout (#5855) ## Summary - move Settings section labels outside their framed containers and centralize the spacing - apply the shared hierarchy across Appearance, Notifications, Voice, Agents, Shortcuts, Members, and Profile - give Identity and Sign out complete section treatments while removing redundant in-cell labels ## Testing - desktop pre-push checks, including 4,791 tests - focused Settings layout and sign-out Playwright coverage --------- Signed-off-by: kenny lopez Signed-off-by: Princess Donut Co-authored-by: Princess Donut --- desktop/playwright.config.ts | 1 + .../ui/CommunityMembersSettingsCard.tsx | 18 +-- .../src/features/settings/UpdateChecker.tsx | 9 -- .../settings/ui/AgentsSettingsPanel.tsx | 5 +- .../settings/ui/KeyboardShortcutsCard.tsx | 10 +- .../settings/ui/NotificationSettingsCard.tsx | 14 +- .../settings/ui/ProfileSettingsCard.tsx | 44 +++--- .../settings/ui/SettingsOptionGroup.tsx | 53 +++++-- .../features/settings/ui/SettingsPanels.tsx | 10 +- .../features/settings/ui/SignOutSection.tsx | 22 ++- .../settings/ui/VoiceSettingsCard.tsx | 29 ++-- desktop/tests/e2e/doctor-states.spec.ts | 2 +- desktop/tests/e2e/mobile-pairing-qr.spec.ts | 4 +- desktop/tests/e2e/profile.spec.ts | 4 +- .../tests/e2e/settings-section-layout.spec.ts | 129 ++++++++++++++++++ 15 files changed, 263 insertions(+), 91 deletions(-) create mode 100644 desktop/tests/e2e/settings-section-layout.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index e930f0ef61..8b272436fd 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -130,6 +130,7 @@ export default defineConfig({ "**/profile-nsec-reveal.spec.ts", "**/profile-backup-settings.spec.ts", "**/signout-confirmation.spec.ts", + "**/settings-section-layout.spec.ts", "**/agent-provider-dropdowns.spec.ts", "**/agent-lifecycle-feedback.spec.ts", "**/agent-access-warning.spec.ts", diff --git a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx index a2056b6d37..06b19d6167 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx @@ -12,6 +12,7 @@ import { import { useUsersBatchQuery } from "@/features/profile/hooks"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; import type { RelayMember, @@ -314,17 +315,16 @@ export function CommunityMembersSettingsCard({ description="Manage members and community access." /> -
-
-

+ Members {members.length > 0 ? ( - - {members.length} - + {members.length} ) : null} -

-
+ + } + >
@@ -376,7 +376,7 @@ export function CommunityMembersSettingsCard({ /> )}
-
+
-

Update status

-

Update status

-

Update status

-

Update status

-

Update status

-

Update status

-

Update status

-

Update status

-

Update status

Update failed: {status.message}

diff --git a/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx b/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx index 4a4ecc10bd..7307ff5df0 100644 --- a/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx @@ -1,6 +1,7 @@ import { AgentDefaultsSettingsCard } from "./AgentDefaultsSettingsCard"; import { HarnessesSettingsPanel } from "./HarnessesSettingsPanel"; import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; +import { SettingsOptionGroupList } from "./SettingsOptionGroup"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; export function AgentsSettingsPanel() { @@ -11,11 +12,11 @@ export function AgentsSettingsPanel() { description="Control how agents behave in conversations and run on this machine." /> -
+ -
+ ); } diff --git a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx index 21f71e50f0..c9c12b9421 100644 --- a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx +++ b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx @@ -3,7 +3,11 @@ import { getPlatformKeys, type KeyboardShortcut, } from "@/shared/lib/keyboard-shortcuts"; -import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import { + SettingsOptionGroup, + SettingsOptionGroupList, + SettingsOptionRow, +} from "./SettingsOptionGroup"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; function KeyCombo({ shortcut }: { shortcut: KeyboardShortcut }) { @@ -38,7 +42,7 @@ export function KeyboardShortcutsCard() { description="All available keyboard shortcuts. Shortcuts are read-only." /> -
+ {[...categories.entries()].map(([category, shortcuts]) => ( {shortcuts.map((shortcut) => ( @@ -62,7 +66,7 @@ export function KeyboardShortcutsCard() { ))} ))} -
+ ); } diff --git a/desktop/src/features/settings/ui/NotificationSettingsCard.tsx b/desktop/src/features/settings/ui/NotificationSettingsCard.tsx index 4ffba2d82d..dcab32f044 100644 --- a/desktop/src/features/settings/ui/NotificationSettingsCard.tsx +++ b/desktop/src/features/settings/ui/NotificationSettingsCard.tsx @@ -17,7 +17,11 @@ import { import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Switch } from "@/shared/ui/switch"; -import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import { + SettingsOptionGroup, + SettingsOptionGroupList, + SettingsOptionRow, +} from "./SettingsOptionGroup"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { SoundPicker } from "./SoundPicker"; @@ -76,7 +80,7 @@ export function NotificationSettingsCard({ : "Off"} -
+
@@ -169,7 +173,7 @@ export function NotificationSettingsCard({ {anyAlertsOn ? ( - <> +
{visibleSlots.map((slot) => { const comingSoon = COMING_SOON_SLOTS.has(slot); @@ -249,7 +253,7 @@ export function NotificationSettingsCard({ )}
- +
) : null} ) : null} @@ -281,7 +285,7 @@ export function NotificationSettingsCard({ />
-
+ {permissionBlocked && (

diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index 971a376cbb..64af0f3410 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -23,6 +23,10 @@ import { Input } from "@/shared/ui/input"; import { Spinner } from "@/shared/ui/spinner"; import { Textarea } from "@/shared/ui/textarea"; import { PrivateKeyBackupRow } from "./PrivateKeyBackupRow"; +import { + SettingsOptionGroup, + SettingsOptionGroupList, +} from "./SettingsOptionGroup"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { SignOutSection } from "./SignOutSection"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; @@ -668,15 +672,9 @@ export function ProfileSettingsCard({ data-testid="profile-readonly-content" inert={isAvatarEditorOpen ? true : undefined} > -

-
-
-

- Profile info -

+ + -
- + } + data-testid="profile-metadata-card" + title="Profile info" + >
-
+ -
+
-

- Identity -

+

+ Identity details +

Your keypair and NIP-05 handle are fixed for @@ -782,7 +782,7 @@ export function ProfileSettingsCard({

-
-
+ +
{shouldRenderAvatarEditor ? ( diff --git a/desktop/src/features/settings/ui/SettingsOptionGroup.tsx b/desktop/src/features/settings/ui/SettingsOptionGroup.tsx index 40aa6de5e0..72b292c447 100644 --- a/desktop/src/features/settings/ui/SettingsOptionGroup.tsx +++ b/desktop/src/features/settings/ui/SettingsOptionGroup.tsx @@ -16,23 +16,24 @@ export function SettingsOptionGroup({ surface?: "framed" | "soft"; title?: React.ReactNode; }) { + const hasHeader = Boolean(title || description || headerAction); + return ( -
- {title ? ( -
-
-

{title}

+
+ {hasHeader ? ( +
+
+ {title ? ( +

+ {title} +

+ ) : null} {description ? (

{description} @@ -42,11 +43,33 @@ export function SettingsOptionGroup({ {headerAction ?

{headerAction}
: null}
) : null} - {children} +
+ {children} +
); } +export function SettingsOptionGroupList({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + export function SettingsOptionRow({ className, ...props diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 1dda5bc1c0..b509a70e4e 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -69,7 +69,11 @@ import { ModerationQueueCard } from "./ModerationQueueCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { AgentsSettingsPanel } from "./AgentsSettingsPanel"; import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard"; -import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import { + SettingsOptionGroup, + SettingsOptionGroupList, + SettingsOptionRow, +} from "./SettingsOptionGroup"; import { ProfileSettingsCard } from "./ProfileSettingsCard"; import { UpdateChecker } from "../UpdateChecker"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; @@ -635,7 +639,7 @@ function ThemeSettingsCard() { description="Choose how Buzz looks and feels." /> -
+ -
+ ); } diff --git a/desktop/src/features/settings/ui/SignOutSection.tsx b/desktop/src/features/settings/ui/SignOutSection.tsx index 2b83c011d3..82df014f70 100644 --- a/desktop/src/features/settings/ui/SignOutSection.tsx +++ b/desktop/src/features/settings/ui/SignOutSection.tsx @@ -16,7 +16,7 @@ import { Button } from "@/shared/ui/button"; import { Checkbox } from "@/shared/ui/checkbox"; import { Input } from "@/shared/ui/input"; import { Spinner } from "@/shared/ui/spinner"; -import { SettingsOptionGroup } from "./SettingsOptionGroup"; +import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; /** * The exact phrase the user must type before the destructive sign-out button @@ -122,9 +122,18 @@ export function SignOutSection() { return (
- + +
+

+ Removes your identity key and all local app data from this device. + Before signing out, create and test a password-protected key + backup above — this cannot be undone. +

+
- {provenance ? ( -
- {provenance} -
- ) : null}
); } @@ -351,13 +242,6 @@ function AdvancedRow({ > {field.value ?? "—"} - {provenance ? ( - - ) : null} {isCopyable ? ( -
+
{normalizedEntries.map(([key, field]) => ( {advanced.map((field) => ( - + ))} ) : null} @@ -567,9 +437,7 @@ export function AgentConfigSurfaceRows({ return (
{/* Normalized section */} -
+
{normalizedEntries.length === 0 ? (

No config fields available. @@ -577,12 +445,10 @@ export function AgentConfigSurfaceRows({ ) : ( normalizedEntries.map(([key, field]) => ( )) @@ -613,11 +479,7 @@ export function AgentConfigSurfaceRows({ {advancedOpen ? (

{advanced.map((field) => ( - + ))}
) : null} diff --git a/desktop/src/features/agents/ui/AgentConfigPanelPresentation.test.mjs b/desktop/src/features/agents/ui/AgentConfigPanelPresentation.test.mjs new file mode 100644 index 0000000000..458d762b81 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentConfigPanelPresentation.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const source = await readFile( + new URL("./AgentConfigPanel.tsx", import.meta.url), + "utf8", +); + +test("shared configuration rows show only the effective value", () => { + for (const forbiddenPattern of [ + /Available after agent starts/, + /provenanceSentence/, + /ProvenanceHint/, + /field\.overriddenValue/, + /line-through/, + /isPreSpawn\s*&&\s*["']opacity-/, + ]) { + assert.doesNotMatch(source, forbiddenPattern); + } +}); + +test("unknown normalized values use an em dash", () => { + assert.match(source, /const rawDisplayValue = field\.value \?\? "—";/); +}); + +test("profile model rows keep their bare leading icons", () => { + assert.match(source, /data-slot="agent-config-field-icon"/); + assert.doesNotMatch(source, /rounded-full bg-muted[^\n]* { - const activeDiff = - Number(isManagedAgentActive(right)) - Number(isManagedAgentActive(left)); - if (activeDiff !== 0) return activeDiff; - return left.name.localeCompare(right.name); - })[0]; -} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 8fc7cfaf51..2acf4fe29b 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -901,7 +901,6 @@ export const ChannelPane = React.memo(function ChannelPane({ const panel = ( void; onOpenDm?: (pubkeys: string[]) => Promise | void; onOpenMembers?: () => void; - onOpenProfilePanel: (pubkey: string) => void; + onOpenProfilePanel: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; onOpenThread: (message: TimelineMessage) => void; onResetThreadPanelWidth: () => void; onSelectThreadReplyTarget: (message: TimelineMessage) => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 8150f6df7d..6254afd8c7 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -122,6 +122,7 @@ export function ChannelScreen({ clearMessageRouteTarget, openAgentSessionChannelId, openAgentSessionPubkey, + openProfilePanel, openThreadHeadId, profilePanelPubkey, profilePanelTab, @@ -585,6 +586,7 @@ export function ChannelScreen({ const { handleOpenProfilePanel, handleCloseProfilePanel, handleOpenDm } = useChannelProfilePanel({ closeAgentSession: handleCloseAgentSession, + openProfilePanel, setChannelManagementOpen, setExpandedThreadReplyIds, setOpenThreadHeadId, diff --git a/desktop/src/features/channels/ui/ForumChannelContent.tsx b/desktop/src/features/channels/ui/ForumChannelContent.tsx index 428413c597..3565502616 100644 --- a/desktop/src/features/channels/ui/ForumChannelContent.tsx +++ b/desktop/src/features/channels/ui/ForumChannelContent.tsx @@ -10,6 +10,7 @@ import type { ProfilePanelView, } from "@/features/profile/ui/UserProfilePanelUtils"; import type { Channel } from "@/shared/api/types"; +import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ForumChannelContentProps = { @@ -20,7 +21,10 @@ type ForumChannelContentProps = { onClosePost: () => void; onCloseProfilePanel: () => void; onOpenDm?: (pubkeys: string[]) => Promise | void; - onOpenProfilePanel: (pubkey: string) => void; + onOpenProfilePanel: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; onPanelResizeStart: (event: React.PointerEvent) => void; onProfilePanelTabChange: ( tab: ProfilePanelTab, @@ -97,7 +101,6 @@ export function ForumChannelContent({ > + applyPatch({ + profile: pubkey, + profileTab: options?.tab === "info" ? null : (options?.tab ?? null), + profileView: null, + }), + [applyPatch], + ); + const setProfilePanelView = React.useCallback( (value: ProfilePanelView, options?: PanelSetterOptions) => applyPatch({ profileView: value === "summary" ? null : value }, options), @@ -116,6 +127,7 @@ export function useChannelPanelHistoryState() { clearMessageRouteTarget, openAgentSessionChannelId: values.agentSessionChannel, openAgentSessionPubkey: values.agentSession, + openProfilePanel, openThreadHeadId: values.thread, profilePanelPubkey: values.profile, profilePanelTab: profilePanelTabFromSearch(values.profileTab), diff --git a/desktop/src/features/channels/ui/useChannelProfilePanel.ts b/desktop/src/features/channels/ui/useChannelProfilePanel.ts index 9d6961a93d..61e9211480 100644 --- a/desktop/src/features/channels/ui/useChannelProfilePanel.ts +++ b/desktop/src/features/channels/ui/useChannelProfilePanel.ts @@ -2,9 +2,11 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useOpenDmMutation } from "@/features/channels/hooks"; +import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; type UseChannelProfilePanelOptions = { closeAgentSession: () => void; + openProfilePanel: (pubkey: string, options?: ProfilePanelOpenOptions) => void; setChannelManagementOpen: (open: boolean) => void; setExpandedThreadReplyIds: (value: Set) => void; setOpenThreadHeadId: (value: string | null) => void; @@ -15,6 +17,7 @@ type UseChannelProfilePanelOptions = { export function useChannelProfilePanel({ closeAgentSession, + openProfilePanel, setChannelManagementOpen, setExpandedThreadReplyIds, setOpenThreadHeadId, @@ -26,21 +29,21 @@ export function useChannelProfilePanel({ const openDmMutation = useOpenDmMutation(); const handleOpenProfilePanel = React.useCallback( - (pubkey: string) => { + (pubkey: string, options?: ProfilePanelOpenOptions) => { setOpenThreadHeadId(null); setExpandedThreadReplyIds(new Set()); setThreadScrollTargetId(null); setThreadReplyTargetId(null); closeAgentSession(); setChannelManagementOpen(false); - setProfilePanelPubkey(pubkey); + openProfilePanel(pubkey, options); }, [ closeAgentSession, + openProfilePanel, setChannelManagementOpen, setExpandedThreadReplyIds, setOpenThreadHeadId, - setProfilePanelPubkey, setThreadReplyTargetId, setThreadScrollTargetId, ], diff --git a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts new file mode 100644 index 0000000000..e900e123f5 --- /dev/null +++ b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts @@ -0,0 +1,60 @@ +import * as React from "react"; + +import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent"; +import { useUserProfileQuery } from "@/features/profile/hooks"; +import { ownsAuthorAgent } from "@/features/profile/lib/identity"; +import { useOwnedManagedAgentPersonaId } from "@/features/profile/lib/useOwnedManagedAgentPersonaId"; +import type { ManagedAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export function useCanonicalManagedAgentProfile(input: { + currentPubkey: string | undefined; + managedAgents: readonly ManagedAgent[] | undefined; + personaId: string | undefined; + preserveRequestedInstance?: boolean; + pubkey: string | undefined; +}) { + const { + currentPubkey, + managedAgents, + personaId, + preserveRequestedInstance = false, + pubkey, + } = input; + const directManagedAgent = React.useMemo(() => { + if (!pubkey) return undefined; + const target = normalizePubkey(pubkey); + return managedAgents?.find( + (agent) => normalizePubkey(agent.pubkey) === target, + ); + }, [managedAgents, pubkey]); + const requestedProfileQuery = useUserProfileQuery(pubkey); + const historicalPersonaId = useOwnedManagedAgentPersonaId({ + agentPubkey: pubkey, + enabled: Boolean( + pubkey && + !directManagedAgent && + ownsAuthorAgent(requestedProfileQuery.data, currentPubkey), + ), + ownerPubkey: currentPubkey, + }); + const linkedPersonaId = + personaId ?? directManagedAgent?.personaId ?? historicalPersonaId; + const personaInstances = React.useMemo(() => { + if (!linkedPersonaId) { + return directManagedAgent ? [directManagedAgent] : []; + } + return (managedAgents ?? []).filter( + (agent) => agent.personaId === linkedPersonaId, + ); + }, [directManagedAgent, linkedPersonaId, managedAgents]); + const managedAgent = React.useMemo( + () => + preserveRequestedInstance && directManagedAgent + ? directManagedAgent + : (pickProfileAgent(personaInstances) ?? directManagedAgent), + [directManagedAgent, personaInstances, preserveRequestedInstance], + ); + + return { linkedPersonaId, managedAgent, personaInstances }; +} diff --git a/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.test.mjs b/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.test.mjs new file mode 100644 index 0000000000..4c6ec58f21 --- /dev/null +++ b/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.test.mjs @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; +import { JSDOM } from "jsdom"; + +import { relayClient } from "@/shared/api/relayClient"; +import { + KIND_IA_ARCHIVE_REQUEST, + KIND_MANAGED_AGENT, +} from "@/shared/constants/kinds"; +import { + personaIdFromOwnedManagedAgentArchive, + personaIdFromOwnedManagedAgentEvent, + useOwnedManagedAgentPersonaId, +} from "./useOwnedManagedAgentPersonaId.ts"; + +const OWNER_SECRET = new Uint8Array(32); +OWNER_SECRET[31] = 1; +const OTHER_SECRET = new Uint8Array(32); +OTHER_SECRET[31] = 2; +const OWNER = getPublicKey(OWNER_SECRET); +const AGENT = "a".repeat(64); + +function managedAgentEvent({ + agentPubkey = AGENT, + content = JSON.stringify({ persona_id: "persona-reviewer" }), + secret = OWNER_SECRET, +} = {}) { + return finalizeEvent( + { + created_at: 1, + kind: KIND_MANAGED_AGENT, + tags: [["d", agentPubkey]], + content, + }, + secret, + ); +} + +function managedAgentArchive({ + agentPubkey = AGENT, + personaId = "persona-reviewer", + secret = OWNER_SECRET, +} = {}) { + return finalizeEvent( + { + created_at: 2, + kind: KIND_IA_ARCHIVE_REQUEST, + tags: [["p", agentPubkey]], + content: JSON.stringify({ persona_id: personaId }), + }, + secret, + ); +} + +test("resolves an owner-signed historical agent key to its persona", () => { + assert.equal( + personaIdFromOwnedManagedAgentEvent(managedAgentEvent(), OWNER, AGENT), + "persona-reviewer", + ); +}); + +test("resolves a deleted historical agent key from its owner-signed archive request", () => { + assert.equal( + personaIdFromOwnedManagedAgentArchive(managedAgentArchive(), OWNER, AGENT), + "persona-reviewer", + ); +}); + +test("rejects archive aliases with the wrong owner or target", () => { + assert.equal( + personaIdFromOwnedManagedAgentArchive( + managedAgentArchive({ secret: OTHER_SECRET }), + OWNER, + AGENT, + ), + null, + ); + assert.equal( + personaIdFromOwnedManagedAgentArchive( + managedAgentArchive({ agentPubkey: "b".repeat(64) }), + OWNER, + AGENT, + ), + null, + ); +}); + +test("rejects a managed-agent event from a different owner", () => { + assert.equal( + personaIdFromOwnedManagedAgentEvent( + managedAgentEvent({ secret: OTHER_SECRET }), + OWNER, + AGENT, + ), + null, + ); +}); + +test("rejects a managed-agent event for a different agent key", () => { + assert.equal( + personaIdFromOwnedManagedAgentEvent( + managedAgentEvent({ agentPubkey: "b".repeat(64) }), + OWNER, + AGENT, + ), + null, + ); +}); + +test("rejects empty or malformed persona ids", () => { + assert.equal( + personaIdFromOwnedManagedAgentEvent( + managedAgentEvent({ content: JSON.stringify({ persona_id: "" }) }), + OWNER, + AGENT, + ), + null, + ); + assert.equal( + personaIdFromOwnedManagedAgentEvent( + managedAgentEvent({ content: "not-json" }), + OWNER, + AGENT, + ), + null, + ); +}); + +test("still resolves the live record when the archive lookup fails", async () => { + const dom = new JSDOM("", { + url: "http://localhost", + }); + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const originalFetchFirstEvent = relayClient.fetchFirstEvent; + relayClient.fetchFirstEvent = async (filter) => { + if (filter.kinds?.includes(KIND_IA_ARCHIVE_REQUEST)) { + throw new Error("archive lookup unavailable"); + } + return managedAgentEvent(); + }; + + try { + const { result } = renderHook(() => + useOwnedManagedAgentPersonaId({ + agentPubkey: AGENT, + enabled: true, + ownerPubkey: OWNER, + }), + ); + await act(async () => { + await Promise.resolve(); + }); + assert.equal(result.current, "persona-reviewer"); + } finally { + cleanup(); + relayClient.fetchFirstEvent = originalFetchFirstEvent; + dom.window.close(); + } +}); + +test("does not expose a persona result for stale lookup inputs", async () => { + const dom = new JSDOM("", { + url: "http://localhost", + }); + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const originalFetchFirstEvent = relayClient.fetchFirstEvent; + relayClient.fetchFirstEvent = async (filter) => + filter.kinds?.includes(KIND_MANAGED_AGENT) ? managedAgentEvent() : null; + const observed = []; + + try { + const { result, rerender, unmount } = renderHook( + (props) => { + const personaId = useOwnedManagedAgentPersonaId(props); + observed.push(personaId); + return personaId; + }, + { + initialProps: { + agentPubkey: AGENT, + enabled: true, + ownerPubkey: OWNER, + }, + }, + ); + await act(async () => { + await Promise.resolve(); + }); + assert.equal(result.current, "persona-reviewer"); + + const switchIndex = observed.length; + rerender({ + agentPubkey: "b".repeat(64), + enabled: false, + ownerPubkey: OWNER, + }); + assert.deepEqual(observed.slice(switchIndex), [null]); + assert.equal(result.current, null); + unmount(); + } finally { + cleanup(); + relayClient.fetchFirstEvent = originalFetchFirstEvent; + dom.window.close(); + } +}); diff --git a/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.ts b/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.ts new file mode 100644 index 0000000000..7eef5b21e0 --- /dev/null +++ b/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.ts @@ -0,0 +1,164 @@ +import * as React from "react"; +import { verifyEvent } from "nostr-tools/pure"; + +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_IA_ARCHIVE_REQUEST, + KIND_MANAGED_AGENT, +} from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +type ManagedAgentEventContent = { + persona_id?: unknown; +}; + +function eventHasValidSignature(event: RelayEvent): boolean { + try { + return verifyEvent({ + id: event.id, + pubkey: event.pubkey, + created_at: event.created_at, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.sig, + }); + } catch { + return false; + } +} + +function personaIdFromEventContent(event: RelayEvent): string | null { + try { + const content = JSON.parse(event.content) as ManagedAgentEventContent; + return typeof content.persona_id === "string" && + content.persona_id.trim().length > 0 + ? content.persona_id + : null; + } catch { + return null; + } +} + +export function personaIdFromOwnedManagedAgentEvent( + event: RelayEvent | null, + ownerPubkey: string, + agentPubkey: string, +): string | null { + if (!event || event.kind !== KIND_MANAGED_AGENT) return null; + + const owner = normalizePubkey(ownerPubkey); + const agent = normalizePubkey(agentPubkey); + if (!owner || !agent || normalizePubkey(event.pubkey) !== owner) return null; + if ( + !event.tags.some( + (tag) => tag[0] === "d" && normalizePubkey(tag[1] ?? "") === agent, + ) + ) { + return null; + } + if (!eventHasValidSignature(event)) return null; + + return personaIdFromEventContent(event); +} + +export function personaIdFromOwnedManagedAgentArchive( + event: RelayEvent | null, + ownerPubkey: string, + agentPubkey: string, +): string | null { + if (!event || event.kind !== KIND_IA_ARCHIVE_REQUEST) return null; + + const owner = normalizePubkey(ownerPubkey); + const agent = normalizePubkey(agentPubkey); + if (!owner || !agent || normalizePubkey(event.pubkey) !== owner) return null; + if ( + !event.tags.some( + (tag) => tag[0] === "p" && normalizePubkey(tag[1] ?? "") === agent, + ) + ) { + return null; + } + if (!eventHasValidSignature(event)) return null; + + return personaIdFromEventContent(event); +} + +type PersonaLookupResult = { + key: string; + personaId: string | null; +}; + +/** + * Resolve an owned historical agent key back to its persona. The live + * kind:30177 projection provides the alias before deletion; the owner-signed + * NIP-IA archive request preserves it after the projection is tombstoned. + */ +export function useOwnedManagedAgentPersonaId(input: { + agentPubkey: string | undefined; + enabled: boolean; + ownerPubkey: string | undefined; +}): string | null { + const { agentPubkey, enabled, ownerPubkey } = input; + const [result, setResult] = React.useState(null); + const normalizedOwner = normalizePubkey(ownerPubkey ?? ""); + const normalizedAgent = normalizePubkey(agentPubkey ?? ""); + const lookupKey = + enabled && normalizedOwner && normalizedAgent + ? `${normalizedOwner}:${normalizedAgent}` + : null; + + React.useEffect(() => { + let cancelled = false; + + if (!lookupKey) { + return () => { + cancelled = true; + }; + } + + void Promise.allSettled([ + relayClient.fetchFirstEvent({ + kinds: [KIND_MANAGED_AGENT], + authors: [normalizedOwner], + "#d": [normalizedAgent], + limit: 1, + }), + relayClient.fetchFirstEvent({ + kinds: [KIND_IA_ARCHIVE_REQUEST], + authors: [normalizedOwner], + "#p": [normalizedAgent], + limit: 1, + }), + ]).then(([managedAgentResult, archiveResult]) => { + if (cancelled) return; + const managedAgentEvent = + managedAgentResult.status === "fulfilled" + ? managedAgentResult.value + : null; + const archiveEvent = + archiveResult.status === "fulfilled" ? archiveResult.value : null; + setResult({ + key: lookupKey, + personaId: + personaIdFromOwnedManagedAgentEvent( + managedAgentEvent, + normalizedOwner, + normalizedAgent, + ) ?? + personaIdFromOwnedManagedAgentArchive( + archiveEvent, + normalizedOwner, + normalizedAgent, + ), + }); + }); + + return () => { + cancelled = true; + }; + }, [lookupKey, normalizedAgent, normalizedOwner]); + + return result?.key === lookupKey ? result.personaId : null; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 91040a28e2..3fae4da266 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -49,6 +49,7 @@ import { } from "@/features/profile/hooks"; import { ownsAuthorAgent } from "@/features/profile/lib/identity"; import { resolveProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent"; +import { useCanonicalManagedAgentProfile } from "@/features/profile/lib/useCanonicalManagedAgentProfile"; import { AgentInstructionsFocusedView, ProfileSummaryView, @@ -72,6 +73,7 @@ import { deriveProfileChannels, type ProfilePanelTab, type ProfilePanelView, + profilePanelTargetKey, resolveAgentInstruction, resolvePanelProfile, resolveProfileDisplayName, @@ -86,6 +88,7 @@ import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel"; import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import type { AgentPersona, Channel, @@ -99,7 +102,6 @@ import { useProfileEditAgentRequest } from "@/features/profile/ui/useProfileEdit export type { ProfilePanelTab, ProfilePanelView }; export function UserProfilePanel({ - callerChannelId = null, canResetWidth, currentPubkey, isSinglePanelView = false, @@ -179,28 +181,27 @@ export function UserProfilePanel({ React.useState(null); const [cardMintTarget, setCardMintTarget] = React.useState(null); + const [requestedInstancePubkey, setRequestedInstancePubkey] = React.useState< + string | null + >(null); + const preserveRequestedInstance = Boolean( + pubkey && + requestedInstancePubkey && + normalizePubkey(pubkey) === normalizePubkey(requestedInstancePubkey), + ); const personasQuery = usePersonasQuery(); const managedAgentsQuery = useManagedAgentsQuery({ enabled: true }); - const managedAgent = React.useMemo(() => { - const agents = managedAgentsQuery.data ?? []; - if (pubkey) { - const pubkeyLower = pubkey.toLowerCase(); - return agents.find((agent) => agent.pubkey.toLowerCase() === pubkeyLower); - } - if (persona) { - return agents.find((agent) => agent.personaId === persona.id); - } - return undefined; - }, [managedAgentsQuery.data, persona, pubkey]); - const personaInstances = React.useMemo(() => { - if (!managedAgent?.personaId) return managedAgent ? [managedAgent] : []; - return (managedAgentsQuery.data ?? []).filter( - (agent) => agent.personaId === managedAgent.personaId, - ); - }, [managedAgent, managedAgentsQuery.data]); + const { linkedPersonaId, managedAgent, personaInstances } = + useCanonicalManagedAgentProfile({ + currentPubkey, + managedAgents: managedAgentsQuery.data, + personaId: persona?.id, + preserveRequestedInstance, + pubkey, + }); const resolvedPersonaFromSource = React.useMemo(() => { - const personaId = persona?.id ?? managedAgent?.personaId; + const personaId = linkedPersonaId ?? managedAgent?.personaId; if (personaId) { const refreshedPersona = personasQuery.data?.find( (candidate) => candidate.id === personaId, @@ -218,14 +219,14 @@ export function UserProfilePanel({ return personasQuery.data?.find( (candidate) => candidate.id === managedAgent.personaId, ); - }, [managedAgent?.personaId, persona, personasQuery.data]); + }, [linkedPersonaId, managedAgent?.personaId, persona, personasQuery.data]); const profileIdentityKey = - pubkey ?? managedAgent?.pubkey ?? `persona:${persona?.id ?? "unknown"}`; + managedAgent?.pubkey ?? pubkey ?? `persona:${persona?.id ?? "unknown"}`; const resolvedPersona = useRetainedPersona( resolvedPersonaFromSource, profileIdentityKey, ); - const effectivePubkey = pubkey ?? managedAgent?.pubkey ?? null; + const effectivePubkey = managedAgent?.pubkey ?? pubkey ?? null; const pubkeyLower = effectivePubkey?.toLowerCase() ?? ""; const profileQuery = useUserProfileQuery(effectivePubkey ?? undefined); @@ -372,16 +373,16 @@ export function UserProfilePanel({ } return map; }, [channelsQuery.data]); - - const targetKey = - effectivePubkey ?? `persona:${resolvedPersona?.id ?? "unknown"}`; + const targetKey = profilePanelTargetKey(pubkey, persona?.id); const prevTargetKeyRef = React.useRef(targetKey); React.useEffect(() => { if (prevTargetKeyRef.current === targetKey) return; prevTargetKeyRef.current = targetKey; + if (preserveRequestedInstance) return; + setRequestedInstancePubkey(null); setView("summary", { replace: true }); setTab("info", { replace: true }); - }, [setTab, setView, targetKey]); + }, [preserveRequestedInstance, setTab, setView, targetKey]); const { canHuddle, canMessage, @@ -788,7 +789,6 @@ export function UserProfilePanel({ canInstantiateAgent={canInstantiateAgent} canOpenAgentLogs={canOpenAgentLogs} canViewActivity={canViewActivity} - callerChannelId={callerChannelId} channelCount={profileChannels.length} channelIdToName={channelIdToName} channels={profileChannels} @@ -829,7 +829,11 @@ export function UserProfilePanel({ onExportAgent={ isBot && canManagePersona ? handleExportPersona : undefined } - onOpenInstance={(instancePubkey) => onOpenProfile?.(instancePubkey)} + onOpenInstance={(instancePubkey) => { + setRequestedInstancePubkey(instancePubkey); + onOpenProfile?.(instancePubkey); + setTab("runtime"); + }} onOpenActivity={handleOpenActivity} onOpenChannel={handleOpenChannel} onOpenDiagnostics={() => setView("diagnostics")} diff --git a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx index 06b3d16a96..9ecdc476a1 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx @@ -4,6 +4,7 @@ import { ArrowUpRight, Cpu, Ear, + Fingerprint, Server, Terminal, UserRound, @@ -173,6 +174,7 @@ export function buildPublicFields({ testId="user-profile-copy-pubkey" /> ), + icon: Fingerprint, label: "Public key", testId: "user-profile-public-key", }); @@ -266,6 +268,7 @@ export function buildOwnerFields({ : (ownerProfilePubkey ?? ownerPubkey ?? ownerHandle ?? undefined), displayValue: ownerDisplayName, displayNode: {ownerDisplayName}, + icon: UserRound, label: "Managed by", onClick: ownerClickable && ownerProfilePubkey @@ -501,7 +504,7 @@ function ProfileFieldRow({ const content = ( <> - {variant === "default" && Icon ? ( + {Icon ? ( !AGENT_DETAILS_FIELD_LABELS.has(field.label), ); - const showRuntimePreview = - import.meta.env.DEV && - isOwner === true && - isBot && - managedAgent === undefined; + const runtimeFields = [ + ...runtimeConfigurationFields, + ...runtimeSettingsFields, + ]; const showRuntimeTab = isOwner === true && isBot && @@ -241,29 +232,18 @@ export function ProfileSummaryView({ runtimeSettingsFields.length > 0 || instances.length > 0 || diagnosticsFields.length > 0 || - canOpenAgentLogs || - showRuntimePreview); - const displayedRuntimeFields = showRuntimePreview - ? fillRuntimePreviewFields([ - ...runtimeConfigurationFields, - ...runtimeSettingsFields, - ]) - : [...runtimeConfigurationFields, ...runtimeSettingsFields]; - const displayedDiagnosticsFields = showRuntimePreview - ? fillRuntimePreviewDiagnostics(diagnosticsFields) - : diagnosticsFields; + canOpenAgentLogs); const showDiagnosticsIngress = diagnosticsFields.some((field) => field.label !== "Status") || canOpenAgentLogs; const showActivityIngress = canViewActivity; const showInfoTab = agentInfoFields.length > 0 || - displayedRuntimeFields.length > 0 || + runtimeFields.length > 0 || isArchived || showActivityIngress || showInstructionBlock || managedAgent !== undefined || - showRuntimePreview || !showRuntimeTab; const diagnosticsErrorField = diagnosticsFields.find( @@ -527,7 +507,6 @@ export function ProfileSummaryView({ archiveActions={archiveActions} canArchiveAgent={isBot && archiveActions.canArchive} canDeleteAgent={canDeleteAgent} - callerChannelId={callerChannelId} channelIdToName={channelIdToName} isArchived={isArchived} isDeleteAgentPending={isAgentActionPending} @@ -544,17 +523,14 @@ export function ProfileSummaryView({ ) : null} {activeTab === "runtime" ? (
- {showRuntimePreview ? ( - - ) : null} - ) : showRuntimePreview ? ( - ) : undefined } needsRestart={managedAgent?.needsRestart ?? false} @@ -583,9 +554,6 @@ export function ProfileSummaryView({ onOpenDiagnostics={onOpenDiagnostics} onOpenInstance={onOpenInstance} showDiagnosticsIngress={showDiagnosticsIngress} - showPreviewHarnessLog={ - showRuntimePreview && !showDiagnosticsIngress - } /> {isOwner === true && managedAgent !== undefined ? ( ) : null} - {showRuntimePreview ? ( - - ) : null}
) : null} {activeTab === "channels" ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index 0ab9d8067c..65626b7faa 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -1,6 +1,14 @@ import * as React from "react"; import type { LucideIcon } from "lucide-react"; -import { Archive, ChevronRight, Info, RefreshCw, Wrench } from "lucide-react"; +import { + Archive, + ChevronRight, + Info, + MessageSquare, + RefreshCw, + ScrollText, + Wrench, +} from "lucide-react"; import type { IdentityArchiveActions } from "@/features/identity-archive/hooks"; import type { ManagedAgent, RestartDiffEntry } from "@/shared/api/types"; @@ -62,7 +70,10 @@ export function ProfileIngressRow({ const content = ( <> {Icon ? ( - + ) : null} {label} @@ -191,7 +202,6 @@ export function ProfileInfoTabContent({ archiveActions, canArchiveAgent, canDeleteAgent, - callerChannelId, channelIdToName, isArchived, isDeleteAgentPending, @@ -211,7 +221,6 @@ export function ProfileInfoTabContent({ archiveActions: IdentityArchiveActions; canArchiveAgent: boolean; canDeleteAgent: boolean; - callerChannelId: string | null; channelIdToName: Record; isArchived: boolean; isDeleteAgentPending: boolean; @@ -263,7 +272,6 @@ export function ProfileInfoTabContent({ ; feedScope: ProfileActivityFeedScope; onOpenActivity: (channelId?: string | null) => void; @@ -398,7 +405,7 @@ function ProfileLiveActivityEmbed({ const activeChannelId = resolveActivityChannelId( slides, selectedChannelId, - callerChannelId ?? feedScope.preferredChannelId, + feedScope.preferredChannelId, ); const selectedIndex = activeChannelId ? slides.indexOf(activeChannelId) : 0; @@ -499,7 +506,7 @@ function ProfileLiveActivityEmbed({ void; onToggleStartOnLaunch?: () => void; showDiagnosticsIngress: boolean; - showPreviewHarnessLog?: boolean; }) { const startOnLaunchFieldIndex = configurationFields.findIndex( (field) => field.label === "Start on launch", ); const startOnLaunchField = configurationFields[startOnLaunchFieldIndex]; - const configurationFieldsBeforeStartOnLaunch = - startOnLaunchFieldIndex >= 0 - ? configurationFields.slice(0, startOnLaunchFieldIndex) - : configurationFields; - const configurationFieldsAfterStartOnLaunch = - startOnLaunchFieldIndex >= 0 - ? configurationFields.slice(startOnLaunchFieldIndex + 1) - : []; - const [previewStartOnLaunchEnabled, setPreviewStartOnLaunchEnabled] = - React.useState(startOnLaunchField?.displayValue === "Yes"); - const isRuntimePreview = - startOnLaunchField !== undefined && startOnLaunchEnabled === undefined; + const StartOnLaunchIcon = startOnLaunchField?.icon; + const remainingConfigurationFields = configurationFields.filter( + (_, index) => index !== startOnLaunchFieldIndex, + ); const resolvedStartOnLaunchEnabled = - startOnLaunchEnabled ?? previewStartOnLaunchEnabled; - const canToggleStartOnLaunch = - isRuntimePreview || onToggleStartOnLaunch !== undefined; + startOnLaunchEnabled ?? startOnLaunchField?.displayValue === "Yes"; + const canToggleStartOnLaunch = onToggleStartOnLaunch !== undefined; const handleStartOnLaunchToggle = React.useCallback(() => { if (startOnLaunchPending) return; - if (isRuntimePreview) { - setPreviewStartOnLaunchEnabled((enabled) => !enabled); - return; - } onToggleStartOnLaunch?.(); - }, [isRuntimePreview, onToggleStartOnLaunch, startOnLaunchPending]); + }, [onToggleStartOnLaunch, startOnLaunchPending]); const statusDiagnosticsFields = diagnosticsFields.filter( (field) => field.label === "Status", ); const hasActivityRows = statusDiagnosticsFields.length > 0 || - showDiagnosticsIngress || - showPreviewHarnessLog; - const hasConfigurationRows = configurationFields.length > 0; + startOnLaunchField !== undefined || + showDiagnosticsIngress; + const hasConfigurationRows = remainingConfigurationFields.length > 0; const hasInstances = instances.length > 0; if ( @@ -872,32 +864,6 @@ export function ProfileRuntimeTabContent({ variant="runtime" /> ) : null} - {showDiagnosticsIngress ? ( - - ) : showPreviewHarnessLog ? ( - - ) : null} - - ) : null} - {hasConfigurationRows ? ( - - {startOnLaunchField ? (
+ {StartOnLaunchIcon ? ( + + ) : null} {startOnLaunchField.label} @@ -932,8 +904,25 @@ export function ProfileRuntimeTabContent({ />
) : null} + {showDiagnosticsIngress ? ( + + ) : null} +
+ ) : null} + {hasConfigurationRows ? ( + diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index 89837f6017..c3ff723375 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -6,6 +6,7 @@ import { parseProfilePanelView, personaManagedAgentUpdate, profilePanelTabFromSearch, + profilePanelTargetKey, profilePanelViewFromSearch, } from "./UserProfilePanelUtils.ts"; @@ -189,3 +190,19 @@ test("profilePanelTabFromSearch falls back to info for invalid values", () => { assert.equal(profilePanelTabFromSearch("missing"), "info"); assert.equal(profilePanelTabFromSearch(null), "info"); }); + +test("profile target identity stays stable while a requested pubkey is canonicalized", () => { + const historicalPubkey = "a".repeat(64); + assert.equal( + profilePanelTargetKey(historicalPubkey, undefined), + historicalPubkey, + ); + assert.equal( + profilePanelTargetKey(historicalPubkey, "resolved-persona"), + historicalPubkey, + ); + assert.equal( + profilePanelTargetKey(undefined, "requested-persona"), + "persona:requested-persona", + ); +}); diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index df09726fec..16999816c3 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -88,8 +88,14 @@ export function profilePanelTabFromSearch(value: unknown): ProfilePanelTab { return parseProfilePanelTab(value) ?? "info"; } +export function profilePanelTargetKey( + pubkey: string | undefined, + personaId: string | undefined, +): string { + return pubkey ?? `persona:${personaId ?? "unknown"}`; +} + export type UserProfilePanelProps = { - callerChannelId?: string | null; canResetWidth?: boolean; currentPubkey?: string; isSinglePanelView?: boolean; diff --git a/desktop/src/features/profile/ui/UserProfileRuntimeContent.test.mjs b/desktop/src/features/profile/ui/UserProfileRuntimeContent.test.mjs new file mode 100644 index 0000000000..5937622f29 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfileRuntimeContent.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const panelSectionsSource = await readFile( + new URL("./UserProfilePanelSections.tsx", import.meta.url), + "utf8", +); +const panelTabsSource = await readFile( + new URL("./UserProfilePanelTabs.tsx", import.meta.url), + "utf8", +); + +test("profile runtime surfaces never synthesize preview agent data", () => { + for (const forbiddenPattern of [ + /UserProfileRuntimePreview/, + /fillRuntimePreview/, + /showRuntimePreview/, + /UserProfileConfigPreview/, + /import\.meta\.env\.DEV/, + ]) { + assert.doesNotMatch(panelSectionsSource, forbiddenPattern); + } +}); + +test("runtime rows do not add interactive preview-only controls", () => { + for (const forbiddenPattern of [ + /previewStartOnLaunchEnabled/, + /isRuntimePreview/, + /showPreviewHarnessLog/, + /diagnostics-ingress-preview/, + ]) { + assert.doesNotMatch(panelTabsSource, forbiddenPattern); + } +}); diff --git a/desktop/src/features/profile/ui/UserProfileRuntimePreview.tsx b/desktop/src/features/profile/ui/UserProfileRuntimePreview.tsx deleted file mode 100644 index 2c7f784005..0000000000 --- a/desktop/src/features/profile/ui/UserProfileRuntimePreview.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { - AgentConfigSurfaceRows, - type AgentConfigPanelSection, -} from "@/features/agents/ui/AgentConfigPanel"; -import type { ProfileField } from "@/features/profile/ui/UserProfilePanelFields"; -import { Badge } from "@/shared/ui/badge"; -import type { NormalizedField, RuntimeConfigSurface } from "@/shared/api/types"; - -function previewField(value: string): NormalizedField { - return { - isRequired: false, - origin: "harnessDefault", - overriddenOrigin: null, - overriddenValue: null, - value, - writeVia: { type: "readOnly" }, - }; -} - -const PROFILE_RUNTIME_PREVIEW: RuntimeConfigSurface = { - advanced: [ - { - key: "workingDirectory", - label: "Working directory", - origin: "buzzExplicit", - schemaType: { type: "string" }, - value: "~/Development/buzz", - writeVia: { type: "readOnly" }, - }, - ], - extensions: [{ enabled: true, kind: "stdio", name: "Buzz developer tools" }], - isPreSpawn: false, - normalized: { - contextLimit: previewField("200,000"), - maxOutputTokens: previewField("8,192"), - mode: previewField("Auto"), - model: previewField("claude-sonnet-4-20250514"), - provider: previewField("anthropic"), - systemPrompt: null, - thinkingEffort: previewField("High"), - }, - runtimeId: "goose", - runtimeLabel: "Goose", - sources: { - acpConfigOptions: "available", - acpNative: "available", - configFile: "available", - configFilePath: "~/.config/goose/config.yaml", - envVars: "notApplicable", - mcpConfigFilePath: null, - }, -}; - -const PROFILE_RUNTIME_PREVIEW_FIELDS: ProfileField[] = [ - { - copyValue: "goose", - displayValue: "Goose", - label: "Runtime", - testId: "user-profile-runtime", - }, - { - copyValue: "goose acp", - displayValue: "goose acp", - label: "ACP command", - testId: "user-profile-acp", - }, - { - copyValue: "goose mcp", - displayValue: "goose mcp", - label: "MCP command", - testId: "user-profile-mcp", - }, - { - displayValue: "Yes", - label: "Start on launch", - testId: "user-profile-start-on-launch", - }, - { - displayValue: "Only the owner", - label: "Who can send instructions", - testId: "user-profile-respond-to", - }, -]; - -const PROFILE_RUNTIME_PREVIEW_DIAGNOSTICS: ProfileField[] = [ - { - displayNode: ( - - Running - - ), - displayValue: "Running", - label: "Status", - testId: "user-profile-agent-status", - }, -]; - -function appendMissingPreviewFields( - fields: ProfileField[], - previewFields: ProfileField[], -) { - const existingLabels = new Set(fields.map((field) => field.label)); - return [ - ...fields, - ...previewFields.filter((field) => !existingLabels.has(field.label)), - ]; -} - -export function fillRuntimePreviewFields(fields: ProfileField[]) { - return appendMissingPreviewFields(fields, PROFILE_RUNTIME_PREVIEW_FIELDS); -} - -export function fillRuntimePreviewDiagnostics(fields: ProfileField[]) { - return appendMissingPreviewFields( - fields, - PROFILE_RUNTIME_PREVIEW_DIAGNOSTICS, - ); -} - -export function UserProfileRuntimePreviewNotice() { - return ( -
-

- Preview runtime data -

-

- Staging doesn’t include every production runtime detail. Missing values - below use examples. -

-
- ); -} - -export function UserProfileConfigPreview({ - onEdit, - sections, -}: { - onEdit?: () => void; - sections: readonly AgentConfigPanelSection[]; -}) { - return ( -
- -
- ); -} diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index df5dfa6521..b98ba37d75 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -10,6 +10,7 @@ export const KIND_NIP29_DELETE_EVENT = 9005; // Tag shapes are pinned by buzz-sdk builders + relay moderation_commands.rs. export const KIND_REPORT = 1984; export const KIND_PRODUCT_FEEDBACK = 42000; +export const KIND_IA_ARCHIVE_REQUEST = 9035; export const KIND_MODERATION_BAN = 9040; export const KIND_MODERATION_UNBAN = 9041; export const KIND_MODERATION_TIMEOUT = 9042; diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 4c51bcc590..7a3925eb69 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -2690,14 +2690,12 @@ test("duplicate instances move from the agents gallery into the agent profile", await page.getByTestId(`user-profile-instance-${additionalPubkey}`).click(); await expect(page.getByTestId("user-profile-panel")).toBeVisible(); - await expect(page.getByTestId("user-profile-delete-agent-row")).toBeVisible(); + await expect(page.getByTestId("user-profile-agent-status")).toContainText( + "Stopped", + ); await expect( page.getByTestId("user-profile-settings-menu-trigger"), ).toHaveCount(0); - await expect( - page.getByTestId("user-profile-duplicate-agent-row"), - ).toBeVisible(); - await expect(page.getByTestId("user-profile-export-agent-row")).toBeVisible(); await expect( page.getByTestId(`user-profile-agent-delete-${additionalPubkey}`), ).toHaveCount(0); diff --git a/desktop/tests/e2e/config-bridge-screenshots.spec.ts b/desktop/tests/e2e/config-bridge-screenshots.spec.ts index 453ffddee9..3e800e72b0 100644 --- a/desktop/tests/e2e/config-bridge-screenshots.spec.ts +++ b/desktop/tests/e2e/config-bridge-screenshots.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from "@playwright/test"; +import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; const SHOTS = "test-results/config-bridge"; @@ -104,8 +105,13 @@ async function openAgentProfileFromChannel( agentName: string, { anchorText = "Model", + scrollToBottom = true, tab = "Info", - }: { anchorText?: string; tab?: "Info" | "Runtime" } = {}, + }: { + anchorText?: string; + scrollToBottom?: boolean; + tab?: "Info" | "Runtime"; + } = {}, ) { await page.goto("/", { waitUntil: "domcontentloaded" }); await waitForInvokeBridge(page); @@ -132,17 +138,19 @@ async function openAgentProfileFromChannel( // Scroll the panel's internal scroll container to the bottom so the // config section content is fully visible. await configAnchor.scrollIntoViewIfNeeded(); - await panel.evaluate((el) => { - // The scrollable container is the profileBody div with overflow-y-auto. - // Find it by checking which child actually scrolls. - const scrollable = - el.querySelector("[data-radix-scroll-area-viewport]") ?? - Array.from(el.querySelectorAll("*")).find( - (child) => child.scrollHeight > child.clientHeight + 10, - ) ?? - el; - scrollable.scrollTop = scrollable.scrollHeight; - }); + if (scrollToBottom) { + await panel.evaluate((el) => { + // The scrollable container is the profileBody div with overflow-y-auto. + // Find it by checking which child actually scrolls. + const scrollable = + el.querySelector("[data-radix-scroll-area-viewport]") ?? + Array.from(el.querySelectorAll("*")).find( + (child) => child.scrollHeight > child.clientHeight + 10, + ) ?? + el; + scrollable.scrollTop = scrollable.scrollHeight; + }); + } await panel.page().waitForTimeout(200); return panel; @@ -150,9 +158,7 @@ async function openAgentProfileFromChannel( // Settle any in-flight animations before capture. async function settleAnimations(panel: import("@playwright/test").Locator) { - await panel.evaluate((el) => - Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)), - ); + await waitForAnimations(panel.page()); } test.describe("config bridge screenshots", () => { @@ -177,11 +183,16 @@ test.describe("config bridge screenshots", () => { await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); const panel = await openAgentProfileFromChannel(page, "Goose Agent", { + scrollToBottom: false, tab: "Runtime", }); - // The folded config panel: provenance sentences inline under each value. - await expect(panel.getByText("Set in Buzz").first()).toBeVisible(); + // The shared config panel shows only the effective value. + await expect(panel.getByText("gpt-4o", { exact: true })).toBeVisible(); + await expect(panel.getByText("gpt-4o-mini", { exact: true })).toHaveCount( + 0, + ); + await expect(panel.getByText("Set in Buzz")).toHaveCount(0); await settleAnimations(panel); await panel.screenshot({ path: `${SHOTS}/01-folded-config-panel.png` }); @@ -196,12 +207,14 @@ test.describe("config bridge screenshots", () => { { tab: "Runtime" }, ); - // A runtimeOverride model shows the live model, the persona baseline as a - // NON-struck secondary value, and the "Live override" sentence. + // A runtime override shows only the effective live model. await expect( - panel.getByText("Live override (this session only)"), + panel.getByText("claude-opus-4-20250514", { exact: true }), ).toBeVisible(); - await expect(panel.getByText("gpt-4o", { exact: true })).toBeVisible(); + await expect(panel.getByText("gpt-4o", { exact: true })).toHaveCount(0); + await expect( + panel.getByText("Live override (this session only)"), + ).toHaveCount(0); await settleAnimations(panel); await panel.screenshot({ @@ -209,7 +222,7 @@ test.describe("config bridge screenshots", () => { }); }); - test("03 — provenance sentences", async ({ page }) => { + test("03 — effective values without provenance", async ({ page }) => { await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); const panel = await openAgentProfileFromChannel( @@ -220,19 +233,17 @@ test.describe("config bridge screenshots", () => { }, ); - // Multiple distinct provenance origins visible at once. - await expect(panel.getByText("Set in Buzz").first()).toBeVisible(); - await expect(panel.getByText("Inherited from template")).toHaveCount(0); - await expect( - panel.getByText("From environment variable (GOOSE_MODE)"), - ).toBeVisible(); - await expect( - panel.getByText("From config file (~/.config/goose/config.yaml)").first(), - ).toBeVisible(); + // Values from different sources use the same simple two-line hierarchy. + await expect(panel.getByText("gpt-4o", { exact: true })).toBeVisible(); + await expect(panel.getByText("openai", { exact: true })).toBeVisible(); + await expect(panel.getByText("auto", { exact: true })).toBeVisible(); + await expect(panel.getByText(/From config file/)).toHaveCount(0); + await expect(panel.getByText(/Inherited from/)).toHaveCount(0); + await expect(panel.getByText(/From environment variable/)).toHaveCount(0); await settleAnimations(panel); await panel.screenshot({ - path: `${SHOTS}/03-provenance-sentences.png`, + path: `${SHOTS}/03-effective-values.png`, }); }); @@ -243,10 +254,11 @@ test.describe("config bridge screenshots", () => { tab: "Runtime", }); - // ACP-only fields show "Available after agent starts" before spawn. - await expect( - panel.getByText("Available after agent starts").first(), - ).toBeVisible(); + // Unknown pre-start values stay empty rather than adding an explanatory row. + await expect(panel.getByText("Available after agent starts")).toHaveCount( + 0, + ); + await expect(panel.getByText("—", { exact: true })).toHaveCount(2); await settleAnimations(panel); await panel.screenshot({ path: `${SHOTS}/04-pre-spawn-state.png` }); @@ -352,10 +364,7 @@ test.describe("config bridge screenshots", () => { }); await panel.page().waitForTimeout(200); - // Settle any in-flight animations before capture. - await panel.evaluate((el) => - Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)), - ); + await settleAnimations(panel); await panel.screenshot({ path: `${SHOTS}/06-profile-side-panel-config.png`, diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 48c5fb9bcb..9f17f9e500 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -5,6 +5,7 @@ import { installMockBridge, TEST_IDENTITIES, } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; import { expectEmojiMartStylesInstalled } from "../helpers/css"; import { openProfileMenu, openSettings } from "../helpers/settings"; @@ -73,6 +74,102 @@ async function expectHashSearchParam( await expect.poll(() => getHashSearchParam(page, name)).toBe(value); } +async function readVisibleProfileSurface(page: Page) { + const panel = page.getByTestId("user-profile-panel"); + await expect(panel).toBeVisible(); + + return panel.evaluate((element) => { + const isVisible = (candidate: Element) => { + if (!(candidate instanceof HTMLElement)) return false; + const style = getComputedStyle(candidate); + const rect = candidate.getBoundingClientRect(); + return ( + style.display !== "none" && + style.visibility !== "hidden" && + rect.width > 0 && + rect.height > 0 + ); + }; + const visibleTestIds = Array.from( + element.querySelectorAll("[data-testid]"), + ) + .filter(isVisible) + .map((candidate) => candidate.dataset.testid) + .filter((value): value is string => Boolean(value)) + .filter( + (value) => + (value.startsWith("user-profile-") || + value.startsWith("agent-config-")) && + !value.endsWith("resize-handle"), + ) + .sort(); + const visibleControls = Array.from( + element.querySelectorAll( + 'button, [role="button"], [role="tab"], [role="switch"]', + ), + ) + .filter(isVisible) + .map((candidate) => ({ + label: + candidate.getAttribute("aria-label") ?? + candidate.textContent?.replace(/\s+/g, " ").trim() ?? + "", + testId: candidate.dataset.testid ?? null, + })) + .filter(({ label, testId }) => label.length > 0 || testId !== null) + .filter(({ testId }) => !testId?.endsWith("resize-handle")) + .sort((left, right) => + `${left.testId}:${left.label}`.localeCompare( + `${right.testId}:${right.label}`, + ), + ); + + const activityChannelLabel = element + .querySelector( + '[data-testid="user-profile-activity-channel-label"]', + ) + ?.textContent?.replace(/\s+/g, " ") + .trim(); + + return { + activityChannelLabel: activityChannelLabel ?? null, + visibleControls, + visibleTestIds, + }; + }); +} + +async function readOwnedAgentProfileContract(page: Page) { + const tabs = ["info", "runtime", "channels", "memories"] as const; + const contract: Partial< + Record< + (typeof tabs)[number], + Awaited> + > + > = {}; + + for (const tab of tabs) { + const trigger = page.getByTestId(`user-profile-tab-${tab}`); + await expect(trigger).toBeVisible(); + if ((await trigger.getAttribute("data-state")) !== "active") { + await trigger.click(); + } + await expect(trigger).toHaveAttribute("data-state", "active"); + if (tab === "memories") { + await expect(page.getByTestId("agent-memory-section")).toBeVisible(); + } + await waitForAnimations(page); + contract[tab] = await readVisibleProfileSurface(page); + } + + await page.getByTestId("user-profile-tab-info").click(); + await expect(page.getByTestId("user-profile-tab-info")).toHaveAttribute( + "data-state", + "active", + ); + return contract; +} + async function addGenericAgent( page: Page, channelName: string, @@ -242,6 +339,83 @@ test("profile panel shows communication actions as quick action tiles", async ({ await expect(page.getByTestId("message-wave-attachment")).toBeVisible(); }); +test("owned agent profile stays in parity between Agents and its DM", async ({ + page, +}) => { + await installMockBridge(page, { + agentMemory: createMockAgentMemoryListing(), + oaOwnerIsMe: true, + }); + await page.goto("/"); + const agentName = "Parity Bot"; + const agentPubkey = await addGenericAgent( + page, + "general", + agentName, + "Keep every profile entry point in sync.", + ); + + await page.getByTestId("open-agents-view").click(); + await page + .getByRole("button", { name: `${agentName} agent profile` }) + .click(); + await page.getByTestId("user-profile-message").click(); + await expect(page.getByTestId("chat-header-dm-avatar")).toBeVisible(); + const dmChannelId = await page.evaluate(() => { + const match = window.location.hash.match(/\/channels\/([^?]+)/); + if (!match?.[1]) { + throw new Error("Could not resolve the agent DM channel id."); + } + return decodeURIComponent(match[1]); + }); + await page.evaluate( + ({ dmChannelId, pubkey }) => { + const seed = ( + window as Window & { + __BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: { + agentPubkey: string; + channelId: string; + turnId: string; + }) => void; + } + ).__BUZZ_E2E_SEED_ACTIVE_TURNS__; + if (!seed) { + throw new Error("Active-turn test bridge is unavailable."); + } + seed({ + agentPubkey: pubkey, + channelId: "00000000-0000-0000-0000-000000000001", + turnId: "profile-parity-other-channel", + }); + seed({ + agentPubkey: pubkey, + channelId: dmChannelId, + turnId: "profile-parity-dm-channel", + }); + }, + { dmChannelId, pubkey: agentPubkey }, + ); + + await page.getByTestId("open-agents-view").click(); + await page + .getByRole("button", { name: `${agentName} agent profile` }) + .click(); + const agentsSurface = await readOwnedAgentProfileContract(page); + + await page.getByTestId("user-profile-message").click(); + await expect(page.getByTestId("chat-header-dm-avatar")).toBeVisible(); + await page + .getByTestId("chat-header") + .getByRole("button", { name: `Open profile for ${agentName}` }) + .click(); + await expect(page.getByTestId("user-profile-public-key")).toContainText( + agentPubkey.slice(0, 8), + ); + const dmSurface = await readOwnedAgentProfileContract(page); + + expect(dmSurface).toEqual(agentsSurface); +}); + test("keeps the saved profile description after a community round trip", async ({ page, }) => { @@ -1224,12 +1398,12 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a page .getByTestId("user-profile-public-key") .locator('[data-slot="profile-field-icon"]'), - ).toHaveCount(0); + ).toHaveCount(1); await expect( page .getByTestId("user-profile-managed-by") .locator('[data-slot="profile-field-icon"]'), - ).toHaveCount(0); + ).toHaveCount(1); const managedByRow = page.getByTestId("user-profile-managed-by"); const managedByActionIndicator = page.getByTestId( "user-profile-managed-by-action-indicator", @@ -1249,6 +1423,18 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a const instructionRow = page.getByTestId("user-profile-agent-instruction-row"); await expect(instructionRow).toContainText("Agent instructions"); await expect(instructionRow).not.toContainText("View"); + await expect( + instructionRow.locator('[data-slot="profile-ingress-icon"]'), + ).toHaveCount(1); + for (const rowTestId of [ + "user-profile-agent-instruction-row", + "user-profile-public-key", + "user-profile-managed-by", + ]) { + await expect( + page.getByTestId(rowTestId).locator(":scope > span.rounded-full"), + ).toHaveCount(0); + } const publicKeyRow = page.getByTestId("user-profile-public-key"); const publicKeyCopy = page.getByTestId("user-profile-public-key-copy-status"); await expect(publicKeyCopy).toHaveCSS("opacity", "0"); @@ -1414,6 +1600,11 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a name: "Activity", }), ).toBeVisible(); + await expect( + page + .getByTestId("user-profile-agent-status") + .locator('[data-slot="profile-field-icon"]'), + ).toHaveCount(1); await expect( page.getByTestId("user-profile-model-settings-section"), ).toBeVisible(); @@ -1428,6 +1619,12 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a page.getByTestId("user-profile-agent-instruction-row"), ).toHaveCount(0); const modelEditRow = page.getByRole("button", { name: "Edit Model" }); + await expect( + modelEditRow.locator('[data-slot="agent-config-field-icon"]'), + ).toHaveCount(1); + await expect(modelEditRow.locator(":scope > span.rounded-full")).toHaveCount( + 0, + ); const modelEditIndicator = page.getByTestId( "agent-config-model-edit-indicator", ); @@ -1441,14 +1638,44 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a .getByRole("button", { name: "Cancel" }) .click(); const acpRow = page.getByTestId("user-profile-acp"); + await expect(acpRow.locator('[data-slot="profile-field-icon"]')).toHaveCount( + 1, + ); const acpCopy = page.getByTestId("user-profile-acp-copy-status"); await expect(acpCopy).toHaveCSS("opacity", "0"); await acpRow.hover(); await expect(acpCopy).toHaveCSS("opacity", "1"); const startOnLaunchRow = page.getByTestId("user-profile-start-on-launch"); + await expect( + startOnLaunchRow.locator('[data-slot="profile-field-icon"]'), + ).toHaveCount(1); const startOnLaunchToggle = page.getByTestId( "user-profile-start-on-launch-toggle", ); + const activitySection = page.getByTestId( + "user-profile-runtime-activity-section", + ); + await expect( + activitySection.getByTestId("user-profile-agent-status"), + ).toBeVisible(); + await expect( + activitySection.getByTestId("user-profile-start-on-launch"), + ).toBeVisible(); + const activityRowOrder = await activitySection + .locator("[data-testid^='user-profile-']") + .evaluateAll((rows) => + rows + .map((row) => row.getAttribute("data-testid")) + .filter((testId): testId is string => testId !== null), + ); + expect(activityRowOrder.indexOf("user-profile-start-on-launch")).toBe( + activityRowOrder.indexOf("user-profile-agent-status") + 1, + ); + await expect( + page + .getByTestId("user-profile-agent-configuration-section") + .getByTestId("user-profile-start-on-launch"), + ).toHaveCount(0); await expect(startOnLaunchRow).not.toContainText("Yes"); await expect(startOnLaunchRow).toBeChecked(); await expect(startOnLaunchToggle).toHaveAttribute("data-state", "checked"); @@ -1513,6 +1740,9 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a const diagnosticsIngress = page.getByTestId( "user-profile-diagnostics-ingress", ); + await expect( + diagnosticsIngress.locator('[data-slot="profile-ingress-icon"]'), + ).toHaveCount(1); await expect(diagnosticsIngress).not.toContainText("View"); await expect( diagnosticsIngress.locator("svg.lucide-chevron-right"), @@ -1598,6 +1828,76 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a await expect(page.getByTestId("agent-memory-list")).toContainText("orphan"); }); +test("an older agent message opens the same persona instance as the Agents library", async ({ + page, +}) => { + const personaId = "profile-parity-agent"; + const historicalPubkey = TEST_IDENTITIES.charlie.pubkey; + const currentPubkey = "d".repeat(64); + await installMockBridge(page, { + agentMemory: createMockAgentMemoryListing(), + managedAgents: [ + { + channelNames: ["agents"], + name: "Earlier Parity Agent", + personaId, + pubkey: historicalPubkey, + status: "stopped", + }, + { + channelNames: ["agents"], + name: "Current Parity Agent", + personaId, + pubkey: currentPubkey, + status: "running", + }, + ], + oaOwnerIsMe: true, + personas: [ + { + displayName: "Parity Agent", + id: personaId, + isActive: true, + systemPrompt: "Keep every profile entry point in sync.", + }, + ], + }); + await page.goto("/"); + + await page.getByTestId("open-agents-view").click(); + await page.getByTestId(`persona-agent-row-${personaId}`).click(); + await expect( + page.getByTestId("user-profile-agent-primary-action"), + ).toHaveAttribute("aria-label", "Stop"); + const agentsLibraryContract = await readOwnedAgentProfileContract(page); + + await page.getByTestId("user-profile-tab-runtime").click(); + await page.getByTestId("user-profile-instances").click(); + await page.getByTestId(`user-profile-instance-${historicalPubkey}`).click(); + await expectHashSearchParam(page, "profile", historicalPubkey); + await expectHashSearchParam(page, "profileTab", "runtime"); + await expect( + page.getByTestId("user-profile-agent-primary-action"), + ).toHaveAttribute("aria-label", "Start agent"); + await expect( + page.getByTestId(`user-profile-instance-${historicalPubkey}`), + ).toContainText("Current"); + + await page.getByTestId("auxiliary-panel-close").click(); + await page.getByTestId("channel-agents").click(); + const historicalMessage = page + .getByTestId("message-row") + .filter({ hasText: "Indexing the channel catalog now." }); + await expect(historicalMessage).toBeVisible(); + await historicalMessage.locator("button").first().click(); + await expect( + page.getByTestId("user-profile-agent-primary-action"), + ).toHaveAttribute("aria-label", "Stop"); + const messageContract = await readOwnedAgentProfileContract(page); + + expect(messageContract).toEqual(agentsLibraryContract); +}); + test("restored Inbox deep link hides the back arrow", async ({ page }) => { // Charlie is a `bot` member of #agents and authors a seeded message there; // seeding a managed agent with the same pubkey makes that message's avatar @@ -1723,6 +2023,13 @@ test("declared owner sees runtime tab without a relay-agent record", async ({ ); await expect(panel.getByTestId("user-profile-runtime")).toHaveCount(0); await expect(panel.getByTestId("user-profile-respond-to")).toHaveCount(0); + await expect(panel.getByTestId("user-profile-runtime-preview")).toHaveCount( + 0, + ); + await expect( + panel.getByTestId("user-profile-runtime-preview-notice"), + ).toHaveCount(0); + await expect(panel.getByText("Harness log", { exact: true })).toHaveCount(0); // No relay/managed runtime record means no write or management affordance — // only the truthful NIP-OA profile signal is rendered in Runtime. @@ -1732,6 +2039,37 @@ test("declared owner sees runtime tab without a relay-agent record", async ({ ).toHaveCount(0); }); +test("non-owner agent profile shows only reported public agent data", async ({ + page, +}) => { + await page.goto("/"); + + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + + const messageRow = page + .getByTestId("message-row") + .filter({ hasText: "Indexing the channel catalog now." }); + await expect(messageRow).toBeVisible(); + await messageRow.locator("button").first().click(); + + const panel = page.getByTestId("user-profile-panel"); + await expect(panel).toBeVisible(); + await expect(panel.getByTestId("user-profile-agent-type")).toContainText( + "codex", + ); + await expect(panel.getByTestId("user-profile-capabilities")).toContainText( + "code, reviews", + ); + await expect(panel.getByRole("tab", { name: "Runtime" })).toHaveCount(0); + await expect(panel.getByTestId("user-profile-runtime-preview")).toHaveCount( + 0, + ); + await expect( + panel.getByTestId("user-profile-runtime-preview-notice"), + ).toHaveCount(0); +}); + test("owned agent absent from relay/managed lists still renders agent framing", async ({ page, }) => { From 17977814d38a841ed475b318a5dfd4bc8405d049 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 14 Aug 2026 11:38:18 -0400 Subject: [PATCH 09/33] fix(desktop): amortize observer journal eviction with a low-water mark (#5808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #5718. ## What happens `appendAgentEvents` evicts the per-agent live observer journal back to *exactly* `MAX_OBSERVER_EVENTS`: ```ts const trimmed = sorted.length > MAX_OBSERVER_EVENTS; const final = trimmed ? sorted.slice(sorted.length - MAX_OBSERVER_EVENTS) : sorted; ``` Once an agent's journal reaches 3000, `current.length` is 3000 forever, every later append makes `sorted.length >= 3001`, and `trimmed` is `true` on every call. That permanently disables the incremental-fold gate: ```ts if (allAtEnd && !trimmed) { /* incremental fold */ } else { transcriptByAgent.set(key, buildTranscriptState(final)); } ``` So every steady-state append then replays the whole retained window through `buildTranscriptState`, which is itself O(streamed-text) because streaming chunks fold as uncapped string concat. Nothing shrinks `eventsByAgent` except a store reset, so the state is permanent for the life of the renderer process, per agent. At ~90 frames/min an agent crosses the cap in ~33 minutes; from then on live CPU escalates (issue receipts: 188x on a headless ingest, renderer CPU climbing to 119% of a core after five minutes idle). This is not an off-by-one — a cap of 3000 does want `>`. The defect is that trimming *to* the cap re-arms eviction on the very next append, and eviction is what forces the replay. ## Fix Evict to a low-water mark below the cap: ```ts const OBSERVER_EVENTS_LOW_WATER = Math.floor(MAX_OBSERVER_EVENTS * 0.9); ``` The journal still never exceeds `MAX_OBSERVER_EVENTS`; it now has to be refilled by ~300 ordinary appends before the next eviction, so one replay is amortized across the appends that refill it. Retention semantics (newest-N at trim time) and the derived transcript are unchanged. The mark is a **fraction of the cap** rather than a fixed count so the math stays correct if the cap is ever made per-agent — a fixed headroom could exceed a smaller cap and drive the slice length negative. ### Eviction floor Low-water eviction leaves headroom below the cap, and the dedup set is built only from the *retained* array — so once eviction discards the oldest frames, the journal no longer remembers them. A relay reconnect replaying a pre-eviction frame (normal relay behavior, and the reason the dedup set exists) would be re-admitted into the headroom, and a later refill to the cap would then trim away up to 300 legitimate retained events with **no new activity** — a bounded display-window loss plus rebuild churn that partially defeats the amortization. To close that, each agent carries an **eviction floor**: the ordering key of the newest event eviction has ever discarded (`evictionFloorByAgent`, recorded at trim time as the entry just below the retained window). `appendAgentEvents` rejects any arrival at or before the floor (`isObserverEventAfter`, so an equal key is rejected — the floor event itself was evicted); a stale-only batch returns `false` with no rebuild and no notify. Out-of-order frames *newer* than the floor are still admitted via the rebuild fallback, so the fold-gate semantics are unchanged. The floor is cleared in `resetAgentObserverStore` alongside the other per-agent maps. ## Evidence `observerTranscriptRetention.test.mjs` asserts the retention window's **shape** — the observable signal for which ingest path runs, since transcript *content* is identical on both paths by design — plus boundary cases and the invariant that the derived transcript still equals a full replay of the retained window. Against the pre-fix trim-to-cap shape, three tests fail on the mechanism itself (`test_append_crossing_cap_trims_to_exactly_low_water`, `test_headroom_refills_before_next_eviction`, `test_single_batch_larger_than_cap_trims_to_low_water` — each expects headroom the old shape never leaves), and the cost shows up directly in runtime: | | `observerTranscriptRetention.test.mjs` (single-event appends past the cap) | |---|---| | trim-to-cap (pre-fix) | **429,105 ms** | | this branch | **16,221 ms** | ~26x on this workload, consistent with the 188x the issue measured on a heavier one (their events accumulate streaming text; these do not, so this understates it). Three further tests pin the **eviction floor** against reconnect replay: a replay of already-evicted frames leaves the retained window byte-identical and notifies no listener; a pre-floor frame arriving after a refill to the cap drops no retained events; and an out-of-order frame *newer* than the floor is still admitted. Deleting the floor check turns exactly the first two red while the out-of-order case stays green — confirming the tests pin the floor's rejection without over-constraining legitimate out-of-order delivery. ## Merge-order note This PR collides with #5596 (bounded renderer accumulators) on `observerRelayStore.ts` by design — #5596 refactors this exact eviction into `mergeObserverEventBatch` in a new `observerEventOrdering.ts` and adds a second, unpinned-agent tier (`truncateUnpinnedAgentWindow`, `UNPINNED_AGENT_EVENT_TAIL`). This PR merges first; #5596 rebases over it, porting the low-water cap-math **and the per-agent eviction floor** into `mergeObserverEventBatch`, and applying the same headroom to the unpinned-tier truncate (which must also record a floor when it trims). The fraction-of-cap form makes the low-water port mechanical — it feeds either the 3000 pinned cap or the 100 unpinned tail without a fixed-count underflow. ## Credits Supersedes #5767 (Chessing234's low-water-mark approach and the runtime measurements). Closes #5718. Issue receipts from the reporter, GeneralJah215 (188x headless, 119%/core after 5min idle). --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- .../src/features/agents/observerRelayStore.ts | 51 ++- .../observerTranscriptRetention.test.mjs | 305 ++++++++++++++++++ 2 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/agents/observerTranscriptRetention.test.mjs diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 4a8313a1e1..a5495d33e0 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -27,6 +27,15 @@ import { } from "./ui/agentSessionTranscript"; const MAX_OBSERVER_EVENTS = 3000; +// Length the per-agent journal is evicted down to when it overflows +// MAX_OBSERVER_EVENTS. Eviction rebuilds the transcript from the retained +// window (see appendAgentEvents), so trimming back to exactly the cap re-arms +// eviction on the very next append — every steady-state append then replays the +// whole history. Leaving 10% headroom amortizes one rebuild across the ~300 +// appends that refill it, while keeping the window within the cap. Expressed as +// a fraction (not a fixed count) so the same math stays correct if the cap is +// ever made per-agent, where a fixed headroom could exceed a smaller cap. +const OBSERVER_EVENTS_LOW_WATER = Math.floor(MAX_OBSERVER_EVENTS * 0.9); const MAX_PENDING_UNKNOWN_AGENT_FRAMES = 100; export type ObserverSnapshot = { @@ -49,6 +58,20 @@ const eventsByAgent = new Map(); const transcriptByAgent = new Map(); const snapshotByAgent = new Map(); +// Per-agent eviction floor: the ordering key of the newest event that eviction +// has ever discarded for this agent. Once the journal is trimmed to the +// low-water mark, the dedup set (built only from the retained array) no longer +// remembers the discarded frames, so a delayed/replayed relay frame at or below +// that boundary would be re-admitted into the headroom — and a later refill to +// the cap would then trim away 300 legitimate retained events with no new +// activity. The floor rejects any arrival at or before it (equal included: the +// floor event itself was evicted), so already-evicted history can never +// re-enter. Cleared with the observer store; only advances forward. +const evictionFloorByAgent = new Map< + string, + { timestamp: string; seq: number } +>(); + // Channel-scoped archive event journal — holds paged history loaded from the local // SQLite archive without the MAX_OBSERVER_EVENTS live-relay cap. Keyed by // `${normalizedAgentPubkey}:${channelId}`. The live relay path writes to @@ -201,13 +224,25 @@ function appendAgentEvents( const key = normalizePubkey(agentPubkey); const current = eventsByAgent.get(key) ?? []; + + // Reject any arrival at or before the eviction floor: those frames were + // already discarded, so re-admitting them (they fit within the headroom + // below the cap) would let a later refill trim away legitimate retained + // events. Admit only frames strictly after the floor — the floor event + // itself was evicted, so an equal ordering key is rejected too. + const floor = evictionFloorByAgent.get(key); + const admissible = floor + ? events.filter((event) => isObserverEventAfter(event, floor)) + : events; + if (admissible.length === 0) return false; + const seen = new Set( current.map( (event) => `${event.timestamp.length}:${event.timestamp}:${event.seq}`, ), ); const added: ObserverEvent[] = []; - for (const event of events) { + for (const event of admissible) { const eventKey = `${event.timestamp.length}:${event.timestamp}:${event.seq}`; if (seen.has(eventKey)) continue; seen.add(eventKey); @@ -219,10 +254,21 @@ function appendAgentEvents( const sorted = [...current, ...sortedAdded].sort(compareObserverEvents); const trimmed = sorted.length > MAX_OBSERVER_EVENTS; const final = trimmed - ? sorted.slice(sorted.length - MAX_OBSERVER_EVENTS) + ? sorted.slice(sorted.length - OBSERVER_EVENTS_LOW_WATER) : sorted; eventsByAgent.set(key, final); + // Record the newest event this trim discarded as the agent's eviction floor. + // It is the entry just below the retained window; the floor only advances, + // since the retained window is always the newest tail. + if (trimmed) { + const boundary = sorted[sorted.length - OBSERVER_EVENTS_LOW_WATER - 1]; + evictionFloorByAgent.set(key, { + timestamp: boundary.timestamp, + seq: boundary.seq, + }); + } + // The common live path appends a sorted batch after the retained window. Fold // that batch through the transcript state once without rebuilding history. // Out-of-order arrivals and cap eviction rebuild from the final window so @@ -807,6 +853,7 @@ export function resetAgentObserverStore() { eventProcessingQueue = Promise.resolve(); eventsByAgent.clear(); transcriptByAgent.clear(); + evictionFloorByAgent.clear(); snapshotByAgent.clear(); archiveEventsByChannel.clear(); knownAgentPubkeys.clear(); diff --git a/desktop/src/features/agents/observerTranscriptRetention.test.mjs b/desktop/src/features/agents/observerTranscriptRetention.test.mjs new file mode 100644 index 0000000000..861940c119 --- /dev/null +++ b/desktop/src/features/agents/observerTranscriptRetention.test.mjs @@ -0,0 +1,305 @@ +/** + * Retention behavior of the per-agent live observer journal. + * + * `appendAgentEvents` derives the transcript incrementally when a batch lands + * after the retained window, and falls back to a full `buildTranscriptState` + * replay when the window is evicted. Evicting back to *exactly* the cap made + * that fallback permanent: an agent parked at the cap evicts one event on every + * append, so `trimmed` is true forever and every steady-state append replays the + * whole history through `buildTranscriptState` (issue #5718: 188x headless, + * live CPU escalating to 119%/core after 5min idle). + * + * The fix evicts to a low-water mark below the cap, so the window must refill + * through ordinary appends before the next eviction — one replay amortized + * across the refill. Transcript content is identical on the fold and rebuild + * paths (that is the point of the fallback), so these tests assert the + * observable that distinguishes them: the retained window's SHAPE after + * eviction. They also pin the invariant that the derived transcript still equals + * a full replay of the retained window regardless of which path ran. + */ + +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +import { + getAgentObserverSnapshot, + getAgentTranscript, + resetAgentObserverStore, + subscribeAgentObserverStore, + syncAgentObserverEvents, +} from "@/features/agents/observerRelayStore.ts"; +import { buildTranscript } from "@/features/agents/ui/agentSessionTranscript.ts"; + +// Mirrors the private constants in observerRelayStore.ts. LOW_WATER is +// Math.floor(MAX * 0.9); the tests assert exact shapes against these values so +// a regression in the eviction math (e.g. reverting to trim-to-cap) fails here. +const MAX_OBSERVER_EVENTS = 3000; +const OBSERVER_EVENTS_LOW_WATER = Math.floor(MAX_OBSERVER_EVENTS * 0.9); + +const AGENT_PUBKEY = "a".repeat(64); + +/** One live observer event; monotonic timestamp keyed to seq so the store's + * timestamp-then-seq sort matches insertion order. */ +function makeEvent(seq) { + return { + seq, + timestamp: new Date(1_760_000_000_000 + seq * 1000).toISOString(), + kind: "turn_started", + agentIndex: 0, + channelId: "chan-1", + sessionId: "sess-1", + turnId: `turn-${seq}`, + payload: {}, + }; +} + +function windowLength() { + return getAgentObserverSnapshot(AGENT_PUBKEY).events.length; +} + +/** Append events seq 1..count one at a time, mirroring the live relay path + * where each frame appends and notifies individually. */ +function fillSequential(count) { + for (let seq = 1; seq <= count; seq += 1) { + syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(seq)]); + } +} + +describe("live observer journal retention — amortized eviction", () => { + beforeEach(() => { + resetAgentObserverStore(); + }); + + it("test_window_never_exceeds_cap", () => { + for (let seq = 1; seq <= MAX_OBSERVER_EVENTS + 750; seq += 1) { + syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(seq)]); + assert.ok( + windowLength() <= MAX_OBSERVER_EVENTS, + `window grew to ${windowLength()} at seq ${seq}`, + ); + } + }); + + it("test_append_at_cap_does_not_trim_prematurely", () => { + // Filling to exactly the cap must NOT evict — `trimmed` is `length > cap`, + // and length === cap is not over. A premature trim here would mean the + // fraction math or the comparison regressed. + fillSequential(MAX_OBSERVER_EVENTS); + assert.equal( + windowLength(), + MAX_OBSERVER_EVENTS, + "reaching exactly the cap retains the full window, no eviction", + ); + }); + + it("test_append_crossing_cap_trims_to_exactly_low_water", () => { + // The append that pushes past the cap must leave the window at exactly the + // low-water mark — not back at the cap (which would re-arm eviction on the + // very next append and keep the transcript rebuilding forever). + fillSequential(MAX_OBSERVER_EVENTS); + syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(MAX_OBSERVER_EVENTS + 1)]); + + assert.equal( + windowLength(), + OBSERVER_EVENTS_LOW_WATER, + "crossing the cap trims to exactly the low-water mark, leaving headroom", + ); + assert.ok( + windowLength() < MAX_OBSERVER_EVENTS, + "headroom exists below the cap after eviction", + ); + }); + + it("test_headroom_refills_before_next_eviction", () => { + // After the first eviction leaves headroom, subsequent appends must GROW + // the window (no eviction) until it refills to the cap — proving eviction + // is amortized across the refill, not per-append. + fillSequential(MAX_OBSERVER_EVENTS + 1); + assert.equal(windowLength(), OBSERVER_EVENTS_LOW_WATER); + + const headroom = MAX_OBSERVER_EVENTS - OBSERVER_EVENTS_LOW_WATER; + for (let i = 1; i <= headroom; i += 1) { + syncAgentObserverEvents(AGENT_PUBKEY, [ + makeEvent(MAX_OBSERVER_EVENTS + 1 + i), + ]); + assert.equal( + windowLength(), + OBSERVER_EVENTS_LOW_WATER + i, + `append ${i} into the headroom must grow the window, not evict`, + ); + } + // The window is now back at the cap; the next append evicts again. + syncAgentObserverEvents(AGENT_PUBKEY, [ + makeEvent(MAX_OBSERVER_EVENTS + 2 + headroom), + ]); + assert.equal( + windowLength(), + OBSERVER_EVENTS_LOW_WATER, + "the window only evicts again after the headroom is refilled", + ); + }); + + it("test_eviction_keeps_newest_events_drops_oldest", () => { + const total = MAX_OBSERVER_EVENTS + 400; + fillSequential(total); + const events = getAgentObserverSnapshot(AGENT_PUBKEY).events; + assert.equal(events.at(-1).seq, total, "newest event is retained"); + assert.equal( + events.at(0).seq, + total - events.length + 1, + "retention is the newest-N contiguous tail", + ); + }); + + it("test_derived_transcript_equals_full_replay_after_eviction", () => { + // The rebuild fallback and the incremental fold must agree: after crossing + // the cap (rebuild path) the stored transcript equals a fresh replay of the + // retained window. + fillSequential(MAX_OBSERVER_EVENTS + 600); + const retained = getAgentObserverSnapshot(AGENT_PUBKEY).events; + assert.deepEqual( + getAgentTranscript(AGENT_PUBKEY), + buildTranscript(retained), + "the derived transcript matches a full replay of the retained window", + ); + }); + + it("test_single_batch_larger_than_cap_trims_to_low_water", () => { + const batch = []; + for (let seq = 1; seq <= MAX_OBSERVER_EVENTS + 900; seq += 1) { + batch.push(makeEvent(seq)); + } + syncAgentObserverEvents(AGENT_PUBKEY, batch); + assert.equal( + windowLength(), + OBSERVER_EVENTS_LOW_WATER, + "a single over-cap batch also trims to the low-water mark", + ); + assert.equal( + getAgentObserverSnapshot(AGENT_PUBKEY).events.at(-1).seq, + MAX_OBSERVER_EVENTS + 900, + "the newest event of an over-cap batch is retained", + ); + }); +}); + +describe("live observer journal retention — eviction floor (reconnect replay)", () => { + // The dedup set is built only from the retained array, so once eviction + // discards the oldest frames the journal no longer remembers them. Relay + // reconnect replays old frames as a normal behavior; without a floor a + // replayed pre-eviction frame is re-admitted into the headroom and a later + // refill to the cap trims away legitimate retained events with no new + // activity. These pin the floor that rejects already-evicted history. + beforeEach(() => { + resetAgentObserverStore(); + }); + + it("test_replayed_pre_floor_frames_do_not_change_retained_window", () => { + // Overflow to the low-water mark, then replay the discarded oldest frames. + // They are at or below the eviction floor, so none is re-admitted: the + // retained window is byte-identical and no listener is notified. + fillSequential(MAX_OBSERVER_EVENTS + 1); + const before = getAgentObserverSnapshot(AGENT_PUBKEY).events; + assert.equal(before.length, OBSERVER_EVENTS_LOW_WATER); + const beforeFirstSeq = before.at(0).seq; + + let notifications = 0; + const unsubscribe = subscribeAgentObserverStore(() => { + notifications += 1; + }); + try { + // seq 1..(beforeFirstSeq - 1) were discarded; replay a spread of them + // plus the boundary event itself (beforeFirstSeq - 1 is the floor). + for (let seq = 1; seq < beforeFirstSeq; seq += 1) { + syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(seq)]); + } + } finally { + unsubscribe(); + } + + const after = getAgentObserverSnapshot(AGENT_PUBKEY).events; + assert.deepEqual( + after, + before, + "replaying already-evicted frames must not change the retained window", + ); + assert.equal( + notifications, + 0, + "a stale-only replay does no work and notifies no listener", + ); + }); + + it("test_pre_floor_frame_after_refill_drops_no_retained_events", () => { + // Overflow to low-water, refill to the cap through ordinary appends, then + // inject a single pre-floor (already-evicted) frame. Pre-fix this pushed + // length to cap+1 and trimmed away 300 legitimate retained events; the + // floor now rejects it, so the retained window is untouched. + fillSequential(MAX_OBSERVER_EVENTS + 1); + const floorBoundarySeq = + getAgentObserverSnapshot(AGENT_PUBKEY).events.at(0).seq; + const headroom = MAX_OBSERVER_EVENTS - OBSERVER_EVENTS_LOW_WATER; + for (let i = 1; i <= headroom; i += 1) { + syncAgentObserverEvents(AGENT_PUBKEY, [ + makeEvent(MAX_OBSERVER_EVENTS + 1 + i), + ]); + } + const atCap = getAgentObserverSnapshot(AGENT_PUBKEY).events; + assert.equal(atCap.length, MAX_OBSERVER_EVENTS, "refilled back to the cap"); + + // A pre-floor frame (seq strictly below the boundary that was evicted). + syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(floorBoundarySeq - 1)]); + + const after = getAgentObserverSnapshot(AGENT_PUBKEY).events; + assert.equal( + after.length, + MAX_OBSERVER_EVENTS, + "a rejected pre-floor frame must not trigger a trim below the cap", + ); + assert.deepEqual( + after, + atCap, + "no legitimate retained event is dropped by a pre-floor arrival", + ); + }); + + it("test_out_of_order_frame_newer_than_floor_is_still_admitted", () => { + // The floor must reject only already-evicted history, never a legitimate + // out-of-order frame that sorts after the floor. Such a frame lands in the + // retained window (via the rebuild fallback) and advances the length. + fillSequential(MAX_OBSERVER_EVENTS + 1); + const retained = getAgentObserverSnapshot(AGENT_PUBKEY).events; + const oldestRetainedSeq = retained.at(0).seq; + const lengthBefore = retained.length; + + // The eviction floor is the boundary event just below the retained window + // (seq oldestRetainedSeq - 1). Construct a never-seen frame whose timestamp + // sits strictly between the floor and the oldest retained event — out of + // order versus the tail, but newer than the floor, so it must be admitted. + const floorSeq = oldestRetainedSeq - 1; + const oooEvent = { + seq: 1_000_000_000, + timestamp: new Date( + 1_760_000_000_000 + floorSeq * 1000 + 500, + ).toISOString(), + kind: "turn_started", + agentIndex: 0, + channelId: "chan-1", + sessionId: "sess-1", + turnId: "turn-ooo", + payload: {}, + }; + syncAgentObserverEvents(AGENT_PUBKEY, [oooEvent]); + + const after = getAgentObserverSnapshot(AGENT_PUBKEY).events; + assert.equal( + after.length, + lengthBefore + 1, + "an out-of-order frame newer than the floor is admitted, not rejected", + ); + assert.ok( + after.some((event) => event.seq === oooEvent.seq), + "the admitted frame is present in the retained window", + ); + }); +}); From caa64b5e8f584a740e331887a5dd1cda32bcb958 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Fri, 14 Aug 2026 11:39:53 -0400 Subject: [PATCH 10/33] feat(desktop): one relative date ladder across chat and the Inbox (#3769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 6 of #2216. Independent of #3642 — cut from `main`, no shared files in conflict. ## Why Five surfaces formatted the same thing five ways, and none of them matched the writing standard's Today / Yesterday / weekday / date progression. | Surface | Before | |---|---| | Chat day divider | `Monday, March 31st` — ordinal suffix, which the standard says to avoid | | Inbox section header | `Yesterday`, but never `Today`; always printed the year | | Inbox list row | A third implementation | | Inbox thread pane header | `Jul 8, 2026, 2:34 PM` — always absolute, always with the year, never relative at any distance | | Channel message header | `9:05 AM` — a bare clock, so a message from last week has nothing to anchor it once its day divider scrolls away | There were three separate date implementations doing this, which is the symptom worth naming: **two different jobs were being solved ad hoc at each call site.** A header that labels a *group* of items needs a different label than an individual item's own timestamp. ## What `shared/lib/datetime.ts` owns both ladders: ``` formatDayGroupLabel formatItemTimestamp (day divider, section header) (list row, message header) Today → Today withTime:false withTime:true Yesterday → Yesterday 2:34 PM 2:34 PM 2–6 days → Monday Yesterday Yesterday at 2:34 PM this year → June 20 Monday Monday at 2:34 PM older → June 20, 2025 Jun 20 Jun 20 at 2:34 PM Jun 20, 2025 Jun 20, 2025 at 2:34 PM ``` ## Two deliberate deviations from the standard Both are documented at the definition, not just here. **The oldest band keeps the day.** The standard collapses anything over ten months to month-and-year (`Aug 2022`). A group label has to *identify* its day — collapsing would give every day in a month the same divider, so scrolling old history would show a run of identical headers with no way to tell one day from the next. Only the year is conditional. There's a test asserting three consecutive 2022 dates produce three distinct labels. **Roomy surfaces keep the time of day at every band.** `Yesterday at 9:05 AM`, not `Yesterday`. This is a chat and collaboration workspace rather than a transactional product — where you read conversation, the time is content, not chrome. Narrow list rows still drop it (`withTime: false`) and rely on the existing hover tooltip, which stays the absolute value. `withTime` is a surface decision, not a preference. Today needs no date word in either mode: a bare clock already reads as today, and "Today at 2:34 PM" is longer without saying more. ## Derived rather than captured `MessageTimestamp` now takes only `createdAt` and derives both of its labels, instead of receiving a pre-formatted `time` string. A relative label captured when the message list was formatted would be frozen at that wording; deriving it means each render recomputes. This does not make it live — `MessageRow` is memoized, so a row already on screen when the clock passes midnight keeps saying "Today" until something re-renders it. The day divider above it has always had the same property, and both correct themselves on the next message, scroll, or navigation. Called out in the component doc so the next person doesn't read "derived" as "reactive". The memo comparator moved from `message.time` to `message.createdAt`. Behavior-identical — `time` was a pure function of `createdAt` — but it now names the prop the row actually reads. The 36px continuation hover gutter stays clock-only. A relative label doesn't fit in `w-9`. ## Middot between metadata segments `managed by you 9:53 AM` ran two unrelated facts together as if they were one phrase. Now `managed by you · 9:53 AM`. - `aria-hidden` — punctuation for the eye only. The header already reads as separate nodes to a screen reader, and `MessageAgentOwner` supplies its own "Agent managed by" label. - Grouped with the segment it precedes, so it can't wrap to the start of a line on its own — as loose siblings in a `flex-wrap` row, an orphaned divider is exactly what happens. - No margin; spacing comes from the container gap. - **No separator after the author name.** "Alice 9:53 AM" already reads as a name followed by a time. Dividers go between metadata segments only. Middot is already the app's separator for this — `MessageThreadSummaryRow`, the mention list, project rows, 46 files in total. Applied to the channel message header, channel system rows, and the Inbox thread pane. Left-side Inbox activity rows deliberately unchanged. ## Verified Screenshots taken through `just desktop-screenshot`: - `#agents` — `nadia 🤖 managed by you · 10:20 AM`, and the `Today` divider with clock-only rows - Inbox thread pane — `alice 🤖 owner unavailable · 12:00 PM` **Gap worth naming:** every mock channel message is same-day, so the past-day labels (`Yesterday at 9:05 AM`, `Jun 20 at 2:34 PM`) are covered by unit tests rather than by a rendered screenshot. Happy to add a spec that seeds an older `created_at` if a reviewer wants to see them. ## Validation - `pnpm check`, `pnpm typecheck` — clean - Unit: **3800/3800**, including 17 new tests in `shared/lib/datetime.test.mjs` and 4 in `messageTimestampContract.test.mjs` The datetime tests pin the things that are easy to regress: Today/Yesterday as *calendar* boundaries rather than 24-hour windows (a message 15 hours old across midnight is "Yesterday"; one 22 hours old on the same day is "Today"), the weekday band bounded at both ends so a future timestamp from clock skew never gets labelled with a past weekday, no ordinals across all the tricky days (1/2/3/11/12/13/21/22/23/31), the year omitted within the current year, and compact labels staying ≤12 chars for a narrow row. - Smoke E2E: **783 passed, 2 failed, 1 skipped** Both failures are pre-existing and unrelated, confirmed by re-running each against a clean tree: 1. `video-attachment.spec.ts:223` — fails deterministically on clean `main` 2. `community-rail.spec.ts:797` (keyboard drag-and-drop reorder) — flaky on clean `main`: 2/5 failures there vs 3/5 with this branch, i.e. noise ## Mobile Mobile had the same divergence, so it moves with desktop rather than drifting until the next pass. `mobile/lib/features/channels/date_formatters.dart`: | Before | After | |---|---| | `formatDayHeading` → Today / Yesterday / `Tuesday, March 31, 2026` | Today / Yesterday / `Tuesday` / `March 31` / `March 31, 2025` | | `formatThreadSummaryLastReplyTime` → `on May 19th` | `on May 19` | Same two departures from the standard as desktop, documented at the definition and cross-referenced to `datetime.ts` so the next person editing one finds the other. Day comparison also moved to a rounded start-of-day difference, so a DST transition counts as one calendar day rather than zero — Dart's `Duration.inDays` truncates. **Message timestamps stay clock-only on mobile.** Desktop message headers now read `Yesterday at 9:05 AM`; mobile keeps `9:05 AM` at every band. That's the compact side of the same surface split the desktop change makes — a mobile timestamp sits inside a chat bubble on a narrow screen with the day divider a short scroll away, where a date word costs width it doesn't earn. Recorded as a decision at `formatMessageTime` so it doesn't read as an oversight. Mobile needs no middot work: message headers have no "managed by" segment, and the mention suggestion list already uses `\u00b7`. Validation: `dart format` clean, `flutter analyze` no issues, `flutter test` **911 passed, 1 skipped** — 8 new day-heading tests covering the weekday band, the year boundary, ordinals across 1/2/3/11/12/13/21/22/23/31, distinct labels for consecutive days in the oldest band, and calendar-day rather than 24-hour bands. ## Out of scope - **Search results.** `SearchResultItem.tsx` and `TopbarSearch.tsx` hand-roll a `5m ago` elapsed format. That's a third *kind* of label — elapsed rather than relative-calendar — and deciding whether search should switch is a separate call. - **`formatThreadSummaryLastReplyTime`** keeps its own "3 hours ago" elapsed scale on both platforms; only its old-reply fallback lost the ordinal (`on May 19th` → `on May 19`). - **Mobile search.** `relativeTime` returns `7/31/2026` past a week, matching the desktop search format that's also out of scope above. Both should change together or not at all. --------- Signed-off-by: Clay Delk Co-authored-by: Claude Opus 5 (1M context) --- desktop/src/features/home/lib/inbox.ts | 70 ++----- .../src/features/home/ui/InboxMessageRow.tsx | 43 +++- .../messages/lib/dateFormatters.test.mjs | 93 ++------- .../features/messages/lib/dateFormatters.ts | 91 ++------- .../messages/ui/MessageAgentOwner.tsx | 34 ++-- .../features/messages/ui/MessageHeader.tsx | 56 +++++- .../src/features/messages/ui/MessageRow.tsx | 37 ++-- .../features/messages/ui/MessageTimestamp.tsx | 30 ++- .../features/messages/ui/SystemMessageRow.tsx | 29 ++- .../messages/ui/TimelineMessageList.tsx | 14 +- .../ui/messageTimestampContract.test.mjs | 41 ++++ desktop/src/shared/lib/datetime.test.mjs | 185 ++++++++++++++++++ desktop/src/shared/lib/datetime.ts | 175 +++++++++++++++++ .../features/channels/date_formatters.dart | 87 +++++--- .../channels/date_formatters_test.dart | 97 +++++++-- 15 files changed, 770 insertions(+), 312 deletions(-) create mode 100644 desktop/src/features/messages/ui/messageTimestampContract.test.mjs create mode 100644 desktop/src/shared/lib/datetime.test.mjs create mode 100644 desktop/src/shared/lib/datetime.ts diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index e34fa0c200..6f32a2f775 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -18,6 +18,10 @@ import type { HomeFeedResponse, RelayEvent, } from "@/shared/api/types"; +import { + formatDayGroupLabel, + formatItemTimestamp, +} from "@/shared/lib/datetime"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; export type InboxFilter = @@ -86,6 +90,7 @@ export type InboxReply = { */ signerPubkey?: string; tags?: string[][]; + /** Clock time only, for the hover gutter on continuation rows. */ timeLabel?: string; }; @@ -103,11 +108,6 @@ export type InboxGroup = { type InboxChannel = Pick; -const listTimeFormatter = new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", -}); - const fullTimeFormatter = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", @@ -116,31 +116,6 @@ const fullTimeFormatter = new Intl.DateTimeFormat("en-US", { minute: "2-digit", }); -const shortDateFormatter = new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", -}); - -const shortDateWithYearFormatter = new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - year: "numeric", -}); - -const weekdayFormatter = new Intl.DateTimeFormat("en-US", { - weekday: "long", -}); - -function startOfDay(value: Date) { - return new Date(value.getFullYear(), value.getMonth(), value.getDate()); -} - -function diffInDays(from: Date, to: Date) { - return Math.round( - (startOfDay(from).getTime() - startOfDay(to).getTime()) / 86_400_000, - ); -} - function tagValue(item: FeedItem, name: string) { return item.tags.find((tag) => tag[0] === name)?.[1]?.trim() || null; } @@ -445,23 +420,7 @@ export function findInboxItemByEventId( } function formatInboxTimestamp(unixSeconds: number) { - const date = new Date(unixSeconds * 1_000); - const now = new Date(); - const dayDiff = diffInDays(now, date); - - if (dayDiff === 0) { - return listTimeFormatter.format(date); - } - - if (dayDiff === 1) { - return "Yesterday"; - } - - if (now.getFullYear() === date.getFullYear()) { - return shortDateFormatter.format(date); - } - - return shortDateWithYearFormatter.format(date); + return formatItemTimestamp(unixSeconds); } export function formatInboxFullTimestamp(unixSeconds: number) { @@ -480,21 +439,14 @@ export function relayEventFromFeedItem(item: FeedItem): RelayEvent { }; } -export function groupInboxItems(items: InboxItem[]): InboxGroup[] { +export function groupInboxItems( + items: InboxItem[], + nowSeconds = Date.now() / 1_000, +): InboxGroup[] { const groups = new Map(); - const now = new Date(); for (const item of items) { - const date = new Date(item.latestActivityAt * 1_000); - const dayDiff = diffInDays(now, date); - const label = - dayDiff === 0 - ? "Today" - : dayDiff === 1 - ? "Yesterday" - : dayDiff < 7 - ? weekdayFormatter.format(date) - : shortDateWithYearFormatter.format(date); + const label = formatDayGroupLabel(item.latestActivityAt, nowSeconds); const current = groups.get(label) ?? []; current.push(item); diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 0c49b2423b..c19a0acee7 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -4,10 +4,12 @@ import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import type { InboxContextMessage } from "@/features/home/lib/inbox"; import { toTimelineMessage } from "@/features/home/lib/inboxViewHelpers"; import { formatTimeWithoutDayPeriod } from "@/features/messages/lib/dateFormatters"; +import { formatItemTimestamp } from "@/shared/lib/datetime"; import type { TimelineMessage } from "@/features/messages/types"; import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey"; import { MessageActionBar } from "@/features/messages/ui/MessageActionBar"; import { MessageAgentOwner } from "@/features/messages/ui/MessageAgentOwner"; +import { MessageMetaSeparator } from "@/features/messages/ui/MessageHeader"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { UnreadDivider } from "@/features/messages/ui/UnreadDivider"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; @@ -94,6 +96,21 @@ export function InboxMessageRow({ const hoverTimestampLabel = formatTimeWithoutDayPeriod( message.timeLabel ?? message.fullTimestampLabel, ); + // Derived here rather than plumbed in with the message: the thread pane has no + // day divider to supply the date, and deriving on render means a row does not + // keep saying "Today" after midnight. `fullTimestampLabel` stays the absolute + // value behind the hover title. + const timestampLabel = formatItemTimestamp(message.createdAt, { + withTime: true, + }); + const timestampNode = ( +

+ {timestampLabel} +

+ ); return (
@@ -192,14 +209,24 @@ export function InboxMessageRow({ {message.isAgent ? ( - - ) : null} -

- {message.fullTimestampLabel} -

+ <> + + {/* + Grouped with the timestamp so the divider never wraps to the + start of a line on its own. Gap matches the container's, so + spacing reads the same either side of the divider. + */} + + + {timestampNode} + + + ) : ( + timestampNode + )}
)} diff --git a/desktop/src/features/messages/lib/dateFormatters.test.mjs b/desktop/src/features/messages/lib/dateFormatters.test.mjs index f579cbfcf6..138851b163 100644 --- a/desktop/src/features/messages/lib/dateFormatters.test.mjs +++ b/desktop/src/features/messages/lib/dateFormatters.test.mjs @@ -2,8 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - formatDayHeading, - formatShortMonthDayOrdinal, + formatShortMonthDay, formatThreadSummaryLastReplyTime, formatTimeWithoutDayPeriod, startOfLocalDaySeconds, @@ -13,66 +12,16 @@ function localUnixSeconds(year, monthIndex, day) { return new Date(year, monthIndex, day, 12).getTime() / 1_000; } -function weekday(date) { - return new Intl.DateTimeFormat("en-US", { weekday: "long" }).format(date); -} - -function month(date) { - return new Intl.DateTimeFormat("en-US", { month: "long" }).format(date); -} - -test("formatShortMonthDayOrdinal formats month before ordinal day", () => { - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 19)), - "May 19th", - ); +test("formatShortMonthDay abbreviates the month and omits the ordinal", () => { + assert.equal(formatShortMonthDay(localUnixSeconds(2026, 4, 19)), "May 19"); + assert.equal(formatShortMonthDay(localUnixSeconds(2026, 4, 1)), "May 1"); }); -test("formatShortMonthDayOrdinal handles ordinal suffixes", () => { - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 1)), - "May 1st", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 2)), - "May 2nd", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 3)), - "May 3rd", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 4)), - "May 4th", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 11)), - "May 11th", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 12)), - "May 12th", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 13)), - "May 13th", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 21)), - "May 21st", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 22)), - "May 22nd", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 23)), - "May 23rd", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 31)), - "May 31st", - ); +test("no day carries an ordinal suffix", () => { + for (const day of [1, 2, 3, 4, 11, 12, 13, 21, 22, 23, 31]) { + const label = formatShortMonthDay(localUnixSeconds(2026, 4, day)); + assert.doesNotMatch(label, /\d(?:st|nd|rd|th)\b/, `ordinal in "${label}"`); + } }); test("formatTimeWithoutDayPeriod removes AM/PM suffixes", () => { @@ -108,31 +57,11 @@ test("formatThreadSummaryLastReplyTime expands relative units", () => { ); }); -test("formatThreadSummaryLastReplyTime uses ordinal dates for older replies", () => { +test("formatThreadSummaryLastReplyTime dates older replies without an ordinal", () => { const now = localUnixSeconds(2026, 5, 15); const replyAt = localUnixSeconds(2026, 4, 19); - assert.equal(formatThreadSummaryLastReplyTime(replyAt, now), "on May 19th"); -}); - -test("formatDayHeading omits the year for current-year dates", () => { - const now = new Date(); - const date = new Date(now.getFullYear(), (now.getMonth() + 6) % 12, 19, 12); - - assert.equal( - formatDayHeading(date.getTime() / 1_000), - `${weekday(date)}, ${month(date)} 19th`, - ); -}); - -test("formatDayHeading includes the year for other years", () => { - const year = new Date().getFullYear() - 1; - const date = new Date(year, 4, 19, 12); - - assert.equal( - formatDayHeading(date.getTime() / 1_000), - `${weekday(date)}, May 19th, ${year}`, - ); + assert.equal(formatThreadSummaryLastReplyTime(replyAt, now), "on May 19"); }); test("startOfLocalDaySeconds collapses a day's timestamps to one value", () => { diff --git a/desktop/src/features/messages/lib/dateFormatters.ts b/desktop/src/features/messages/lib/dateFormatters.ts index 04c85d8150..f752bdfd20 100644 --- a/desktop/src/features/messages/lib/dateFormatters.ts +++ b/desktop/src/features/messages/lib/dateFormatters.ts @@ -4,9 +4,17 @@ * - `formatTime` — short clock time ("2:34 PM"), used in message rows. * - `formatFullDateTime` — verbose string for tooltips * ("Wednesday, April 2, 2026 at 2:34 PM"). - * - `formatDayHeading` — label for day dividers / sticky headers. - * Returns "Today", "Yesterday", or a date like "Monday, March 31st". * - `isSameDay` — compare two unix-second timestamps. + * + * Relative labels ("Today", "Yesterday", "June 20", "Yesterday at 9:05 AM") are + * not here: chat and the Inbox share them from `shared/lib/datetime.ts`. What + * stays in this file is the absolute end of the range — a bare clock time, the + * verbose tooltip string, and same-day comparison. + * + * `formatTime` is for places with only enough room for a clock: the hover gutter + * that replaces the avatar on continuation rows. A message header uses the + * relative ladder instead, because the day divider that supplies its date + * scrolls away while the messages under it stay on screen. */ const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -25,16 +33,9 @@ const FULL_DATE_TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { minute: "2-digit", }); -const WEEKDAY_FORMATTER = new Intl.DateTimeFormat("en-US", { - weekday: "long", -}); - -const LONG_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", { - month: "long", -}); - -const SHORT_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", { +const SHORT_MONTH_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { month: "short", + day: "numeric", }); /** Short clock time, e.g. "2:34 PM". */ @@ -52,34 +53,6 @@ export function formatFullDateTime(unixSeconds: number): string { return FULL_DATE_TIME_FORMATTER.format(new Date(unixSeconds * 1_000)); } -/** - * Human-friendly day label for dividers and sticky headers. - * Returns "Today", "Yesterday", a current-year date like "Monday, March 31st", - * or a prior-year date like "Monday, March 31st, 2025". - */ -export function formatDayHeading(unixSeconds: number): string { - const date = new Date(unixSeconds * 1_000); - const now = new Date(); - - if (isSameDayDate(date, now)) { - return "Today"; - } - - const yesterday = new Date(now); - yesterday.setDate(yesterday.getDate() - 1); - if (isSameDayDate(date, yesterday)) { - return "Yesterday"; - } - - const dateLabel = `${WEEKDAY_FORMATTER.format(date)}, ${formatMonthDayOrdinal( - date, - LONG_MONTH_FORMATTER, - )}`; - return date.getFullYear() === now.getFullYear() - ? dateLabel - : `${dateLabel}, ${date.getFullYear()}`; -} - /** True when two unix-second timestamps fall on the same calendar day (local time). */ export function isSameDay(a: number, b: number): boolean { return isSameDayDate(new Date(a * 1_000), new Date(b * 1_000)); @@ -97,17 +70,14 @@ export function startOfLocalDaySeconds(unixSeconds: number): number { return Math.floor(date.getTime() / 1_000); } -/** Short month + ordinal day, e.g. "May 19th". */ -export function formatShortMonthDayOrdinal(unixSeconds: number): string { - return formatMonthDayOrdinal( - new Date(unixSeconds * 1_000), - SHORT_MONTH_FORMATTER, - ); +/** Short month + day, e.g. "May 19". No ordinal suffix, per the writing standard. */ +export function formatShortMonthDay(unixSeconds: number): string { + return SHORT_MONTH_DAY_FORMATTER.format(new Date(unixSeconds * 1_000)); } /** * Relative thread-summary timestamp with expanded units, e.g. "3 hours ago", - * falling back to "on May 19th" for older replies. + * falling back to "on May 19" for older replies. */ export function formatThreadSummaryLastReplyTime( unixSeconds: number, @@ -120,7 +90,7 @@ export function formatThreadSummaryLastReplyTime( if (diff < 86_400) return formatAgo(Math.floor(diff / 3_600), "hour"); if (diff < 604_800) return formatAgo(Math.floor(diff / 86_400), "day"); - return `on ${formatShortMonthDayOrdinal(unixSeconds)}`; + return `on ${formatShortMonthDay(unixSeconds)}`; } function isSameDayDate(a: Date, b: Date): boolean { @@ -131,33 +101,6 @@ function isSameDayDate(a: Date, b: Date): boolean { ); } -function formatMonthDayOrdinal( - date: Date, - monthFormatter: Intl.DateTimeFormat, -): string { - return `${monthFormatter.format(date)} ${date.getDate()}${ordinalSuffix( - date.getDate(), - )}`; -} - function formatAgo(value: number, unit: string): string { return `${value} ${unit}${value === 1 ? "" : "s"} ago`; } - -function ordinalSuffix(day: number): string { - const lastTwoDigits = day % 100; - if (lastTwoDigits >= 11 && lastTwoDigits <= 13) { - return "th"; - } - - switch (day % 10) { - case 1: - return "st"; - case 2: - return "nd"; - case 3: - return "rd"; - default: - return "th"; - } -} diff --git a/desktop/src/features/messages/ui/MessageAgentOwner.tsx b/desktop/src/features/messages/ui/MessageAgentOwner.tsx index e5b92cd734..e394d567b4 100644 --- a/desktop/src/features/messages/ui/MessageAgentOwner.tsx +++ b/desktop/src/features/messages/ui/MessageAgentOwner.tsx @@ -17,14 +17,28 @@ export function MessageAgentOwner({ {ownerLabel ? "Agent managed by" : "Agent; owner unavailable"} + {/* + * Icon and label sit directly in this baseline row rather than in a nested + * flex wrapper, so the label's own baseline is what aligns with the author + * name beside it. Both branches share the icon for the same reason: two + * wrappers meant two alignment rules and the "owner unavailable" variant + * had drifted a pixel off the other one. + * + * `self-center` keeps the icon out of baseline alignment, so the label — + * not the icon's box — sets this chip's baseline. Centred on the line box + * the glyph's ink still rides ~1.6px above the text's cap band, reading as + * a couple of pixels too high; 0.125em drops its optical centre onto that + * band. In em so it holds under Cmd +/- zoom, and as a transform so it + * shifts nothing else in the row. + */} +
); @@ -627,11 +630,19 @@ export const MessageRow = React.memo( const inlineMetadataNode = (
- + {statusMetadataNode}
); + const personaNode = + message.personaDisplayName && + message.personaDisplayName !== message.author ? ( + + {message.personaDisplayName} + + ) : null; + const continuationMetadataNode = isDisplayedAsContinuation && statusMetadataNode ? (
@@ -657,14 +668,14 @@ export const MessageRow = React.memo( ) : ( authorNode )} - {agentOwnerNode} - {inlineMetadataNode} - {message.personaDisplayName && - message.personaDisplayName !== message.author ? ( - - {message.personaDisplayName} - - ) : null} + {/* Author is not a segment: "Alice 9:53 AM" needs no divider. */} + ); const bodyContainerClass = isDisplayedAsContinuation @@ -932,7 +943,9 @@ export const MessageRow = React.memo( prev.message.ownerLabel === next.message.ownerLabel && prev.message.avatarUrl === next.message.avatarUrl && prev.message.accent === next.message.accent && - prev.message.time === next.message.time && + // The header timestamp and hover gutter both derive from createdAt (the + // old `time` prop was the same value pre-formatted; this row reads neither). + prev.message.createdAt === next.message.createdAt && prev.message.depth === next.message.depth && prev.message.kind === next.message.kind && prev.message.pending === next.message.pending && diff --git a/desktop/src/features/messages/ui/MessageTimestamp.tsx b/desktop/src/features/messages/ui/MessageTimestamp.tsx index ff5394b76b..100f6c6be3 100644 --- a/desktop/src/features/messages/ui/MessageTimestamp.tsx +++ b/desktop/src/features/messages/ui/MessageTimestamp.tsx @@ -1,8 +1,10 @@ import { formatFullDateTime, + formatTime, formatTimeWithoutDayPeriod, } from "@/features/messages/lib/dateFormatters"; import { cn } from "@/shared/lib/cn"; +import { formatItemTimestamp } from "@/shared/lib/datetime"; import { Tooltip, TooltipContent, @@ -12,18 +14,40 @@ import { const TIMESTAMP_TOOLTIP_DELAY_MS = 500; +/** + * The timestamp beside a message author, and the clock that fades in over the + * avatar gutter on continuation rows. + * + * Both labels are derived from `createdAt` here rather than taken as a + * pre-formatted string, so the wording is recomputed on each render instead of + * being frozen at the time the message list was formatted. Note this does not + * make it live: `MessageRow` is memoized, so a row already on screen when the + * clock passes midnight keeps saying "Today" until something re-renders it. The + * day divider above it has the same property, and both correct themselves on the + * next message, scroll, or navigation. + * + * The two modes carry different information on purpose: + * + * - Header (default) — the full relative label ("Yesterday at 9:05 AM"). The day + * divider above the group says which day it is, but a divider scrolls out of + * view while its messages stay on screen, so a bare clock time on a row from + * last week has nothing to anchor it. + * - `hideDayPeriod` — clock only, minus the AM/PM marker. This renders in a + * 36px-wide gutter where the avatar would be, so it has room for "9:05" and + * nothing more. + */ export function MessageTimestamp({ className, createdAt, hideDayPeriod = false, - time, }: { className?: string; createdAt: number; hideDayPeriod?: boolean; - time: string; }) { - const displayTime = hideDayPeriod ? formatTimeWithoutDayPeriod(time) : time; + const displayTime = hideDayPeriod + ? formatTimeWithoutDayPeriod(formatTime(createdAt)) + : formatItemTimestamp(createdAt, { withTime: true }); return ( {displayedIdentityIsAgent ? ( - - ) : null} - + <> + + {/* Grouped with the timestamp so the two wrap together. */} + + + + + + ) : ( + + )}

{description.action} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index bf2da03f40..d7ef78ea04 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { VList } from "virtua"; import type { VListHandle } from "virtua"; -import { formatDayHeading } from "@/features/messages/lib/dateFormatters"; +import { formatDayGroupLabel } from "@/shared/lib/datetime"; import { buildTimelineDayGroups, buildTimelineItems, @@ -346,13 +346,13 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ data-day-label={ group.headingTimestamp === null ? undefined - : formatDayHeading(group.headingTimestamp) + : formatDayGroupLabel(group.headingTimestamp) } data-testid="message-timeline-day-group" key={group.key} > {hideDayDividers || group.headingTimestamp === null ? null : ( - + )} {group.items.map((item) => ( @@ -507,7 +507,7 @@ function VirtualizedTimelineRows({ const renderedDividerPillTop = ( divider: (typeof dayDividerItems)[number], ) => { - const label = formatDayHeading(divider.item.headingTimestamp); + const label = formatDayGroupLabel(divider.item.headingTimestamp); const source = [ ...scroller.querySelectorAll( '[data-testid="message-timeline-day-divider"]', @@ -564,11 +564,11 @@ function VirtualizedTimelineRows({ pinnedLabel.style.transform = `translateY(${nextTranslateY}px)`; } const nextLabel = activeDivider - ? formatDayHeading(activeDivider.item.headingTimestamp) + ? formatDayGroupLabel(activeDivider.item.headingTimestamp) : null; const incomingLabel = nextDivider && nextTranslateY < 0 - ? formatDayHeading(nextDivider.item.headingTimestamp) + ? formatDayGroupLabel(nextDivider.item.headingTimestamp) : null; const activeSourcePill = sourcePills.find( (pill) => pill.parentElement?.dataset.dayLabel === nextLabel, @@ -762,7 +762,7 @@ function VirtualizedTimelineRows({ return

{item.content}
; } if (item.kind === "day-divider") { - const dayLabel = formatDayHeading(item.headingTimestamp); + const dayLabel = formatDayGroupLabel(item.headingTimestamp); return (
{ + // Regression guard: this used to render a bare clock time, so a message from + // last week read "9:05 AM" once its day divider scrolled out of view. + assert.match( + source, + /: formatItemTimestamp\(createdAt, \{ withTime: true \}\)/, + ); +}); + +test("the continuation gutter stays clock-only", () => { + // 36px of width (w-9). A relative label would not fit. + assert.match( + source, + /hideDayPeriod \? formatTimeWithoutDayPeriod\(formatTime\(createdAt\)\)/, + ); +}); + +test("both labels derive from createdAt, not a pre-formatted prop", () => { + // A captured string would keep saying "Today" after midnight. + assert.doesNotMatch(source, /time: string/); + assert.doesNotMatch(source, /\btime\b(?!\w)[^;]*?=\s*\{/); +}); + +test("the tooltip still carries the unabbreviated timestamp", () => { + assert.match(source, /formatFullDateTime\(createdAt\)/); +}); diff --git a/desktop/src/shared/lib/datetime.test.mjs b/desktop/src/shared/lib/datetime.test.mjs new file mode 100644 index 0000000000..f7d0684be1 --- /dev/null +++ b/desktop/src/shared/lib/datetime.test.mjs @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { formatDayGroupLabel, formatItemTimestamp } from "./datetime.ts"; + +/** Local-time unix seconds, so the tests read in the same zone the code uses. */ +function at(year, monthIndex, day, hour = 12, minute = 0) { + return new Date(year, monthIndex, day, hour, minute).getTime() / 1_000; +} + +const NOW = at(2026, 6, 30, 14, 30); // Thu Jul 30 2026, 2:30 PM local + +test("the same calendar day reads Today", () => { + assert.equal(formatDayGroupLabel(at(2026, 6, 30, 9, 5), NOW), "Today"); + // Just after midnight and just before it still count as the same day. + assert.equal(formatDayGroupLabel(at(2026, 6, 30, 0, 1), NOW), "Today"); + assert.equal(formatDayGroupLabel(at(2026, 6, 30, 23, 59), NOW), "Today"); +}); + +test("the previous calendar day reads Yesterday", () => { + assert.equal(formatDayGroupLabel(at(2026, 6, 29, 23, 59), NOW), "Yesterday"); + assert.equal(formatDayGroupLabel(at(2026, 6, 29, 0, 0), NOW), "Yesterday"); +}); + +test("Yesterday is a calendar boundary, not a 24-hour window", () => { + // 15 hours earlier, but the day rolled over: yesterday, not Today. + const lateLastNight = at(2026, 6, 29, 23, 30); + assert.equal(formatDayGroupLabel(lateLastNight, NOW), "Yesterday"); + // 22 hours earlier and still the same calendar day: Today. + const earlyToday = at(2026, 6, 30, 0, 30); + assert.equal(formatDayGroupLabel(earlyToday, NOW), "Today"); +}); + +test("two to six days back reads as the weekday alone", () => { + assert.equal(formatDayGroupLabel(at(2026, 6, 28), NOW), "Tuesday"); + assert.equal(formatDayGroupLabel(at(2026, 6, 27), NOW), "Monday"); + assert.equal(formatDayGroupLabel(at(2026, 6, 24), NOW), "Friday"); +}); + +test("seven days back leaves the weekday band, so it can't repeat a name", () => { + // Same weekday name as today — a weekday label here would be ambiguous. + assert.equal(formatDayGroupLabel(at(2026, 6, 23), NOW), "Thursday, July 23"); +}); + +test("older dates in the current year omit the year", () => { + assert.equal(formatDayGroupLabel(at(2026, 5, 20), NOW), "Saturday, June 20"); + assert.equal(formatDayGroupLabel(at(2026, 0, 3), NOW), "Saturday, January 3"); +}); + +test("dates in earlier years include the year", () => { + assert.equal(formatDayGroupLabel(at(2025, 5, 20), NOW), "June 20, 2025"); + assert.equal(formatDayGroupLabel(at(2022, 7, 22), NOW), "August 22, 2022"); +}); + +test("old dates keep the day, so consecutive dividers stay distinguishable", () => { + // The standard would collapse these to "Aug 2022"; a group label must + // identify its own day. + const labels = [ + formatDayGroupLabel(at(2022, 7, 21), NOW), + formatDayGroupLabel(at(2022, 7, 22), NOW), + formatDayGroupLabel(at(2022, 7, 23), NOW), + ]; + assert.equal(new Set(labels).size, 3, `labels repeated: ${labels}`); +}); + +test("no label carries an ordinal suffix", () => { + const probes = [ + at(2026, 6, 30), + at(2026, 6, 29), + at(2026, 6, 27), + at(2026, 5, 1), + at(2026, 5, 2), + at(2026, 5, 3), + at(2026, 5, 11), + at(2026, 5, 12), + at(2026, 5, 13), + at(2026, 5, 21), + at(2026, 5, 22), + at(2026, 5, 23), + at(2025, 5, 20), + ]; + for (const probe of probes) { + assert.doesNotMatch( + formatDayGroupLabel(probe, NOW), + /\d(?:st|nd|rd|th)\b/, + `ordinal suffix in ${formatDayGroupLabel(probe, NOW)}`, + ); + } +}); + +test("a future timestamp is not labelled with a past weekday", () => { + // Clock skew, or a relay running ahead. "Tuesday" would read as last Tuesday. + const nextWeek = at(2026, 7, 4); + assert.equal(formatDayGroupLabel(nextWeek, NOW), "Tuesday, August 4"); + // Still same-day, so Today remains correct for small skew. + assert.equal(formatDayGroupLabel(at(2026, 6, 30, 23, 0), NOW), "Today"); +}); + +test("the label follows the current clock, not a captured one", () => { + const event = at(2026, 6, 30, 9, 0); + assert.equal(formatDayGroupLabel(event, NOW), "Today"); + // Same event, read a day later. + assert.equal(formatDayGroupLabel(event, at(2026, 6, 31, 9, 0)), "Yesterday"); +}); + +// ── formatItemTimestamp ───────────────────────────────────────────────────── + +test("today is a bare clock time in both modes", () => { + const today = at(2026, 6, 30, 9, 5); + assert.equal(formatItemTimestamp(today, { nowSeconds: NOW }), "9:05 AM"); + assert.equal( + formatItemTimestamp(today, { withTime: true, nowSeconds: NOW }), + "9:05 AM", + ); +}); + +test("compact mode drops the time outside today", () => { + const opts = { nowSeconds: NOW }; + assert.equal(formatItemTimestamp(at(2026, 6, 29, 9, 5), opts), "Yesterday"); + assert.equal(formatItemTimestamp(at(2026, 6, 27, 9, 5), opts), "Monday"); + assert.equal(formatItemTimestamp(at(2026, 5, 20, 9, 5), opts), "Sat, Jun 20"); + assert.equal( + formatItemTimestamp(at(2025, 5, 20, 9, 5), opts), + "Jun 20, 2025", + ); +}); + +test("roomy mode keeps the time at every band, joined with 'at'", () => { + const opts = { withTime: true, nowSeconds: NOW }; + assert.equal( + formatItemTimestamp(at(2026, 6, 29, 9, 5), opts), + "Yesterday at 9:05 AM", + ); + assert.equal( + formatItemTimestamp(at(2026, 6, 27, 14, 34), opts), + "Monday at 2:34 PM", + ); + assert.equal( + formatItemTimestamp(at(2026, 5, 20, 14, 34), opts), + "Sat, Jun 20 at 2:34 PM", + ); + assert.equal( + formatItemTimestamp(at(2025, 5, 20, 14, 34), opts), + "Jun 20, 2025 at 2:34 PM", + ); +}); + +test("the year is omitted within the current year in both modes", () => { + for (const withTime of [false, true]) { + const label = formatItemTimestamp(at(2026, 0, 3, 9, 5), { + withTime, + nowSeconds: NOW, + }); + assert.doesNotMatch(label, /2026/, `current year leaked into "${label}"`); + } +}); + +test("no item label carries an ordinal suffix", () => { + for (const withTime of [false, true]) { + for (const day of [1, 2, 3, 11, 12, 13, 21, 22, 23, 31]) { + const label = formatItemTimestamp(at(2026, 0, day, 9, 5), { + withTime, + nowSeconds: NOW, + }); + assert.doesNotMatch( + label, + /\d(?:st|nd|rd|th)\b/, + `ordinal in "${label}"`, + ); + } + } +}); + +test("compact labels stay short enough for a narrow list row", () => { + for (const probe of [ + at(2026, 6, 30, 14, 34), + at(2026, 6, 29), + at(2026, 6, 27), + at(2026, 5, 20), + at(2025, 5, 20), + ]) { + const label = formatItemTimestamp(probe, { nowSeconds: NOW }); + assert.ok(label.length <= 12, `"${label}" is ${label.length} chars`); + } +}); diff --git a/desktop/src/shared/lib/datetime.ts b/desktop/src/shared/lib/datetime.ts new file mode 100644 index 0000000000..cd5a11f062 --- /dev/null +++ b/desktop/src/shared/lib/datetime.ts @@ -0,0 +1,175 @@ +/** + * Relative date labels shared by the chat timeline and the Inbox. + * + * Both surfaces group items by calendar day and label the group. They used to + * do it independently — chat via `messages/lib/dateFormatters.formatDayHeading`, + * the Inbox inline inside `groupInboxItems` — and the two drifted apart: chat + * appended an ordinal ("Monday, March 31st") while the Inbox always printed the + * year ("Jul 8, 2026", even for a date three weeks ago). + * + * The ladder follows the Block writing standard for relative dates, with one + * deliberate deviation noted on `formatDayGroupLabel`. + */ + +const WEEKDAY_FORMATTER = new Intl.DateTimeFormat("en-US", { + weekday: "long", +}); + +const WEEKDAY_MONTH_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { + weekday: "long", + month: "long", + day: "numeric", +}); + +const MONTH_DAY_YEAR_FORMATTER = new Intl.DateTimeFormat("en-US", { + month: "long", + day: "numeric", + year: "numeric", +}); + +const SHORT_WEEKDAY_SHORT_MONTH_DAY_FORMATTER = new Intl.DateTimeFormat( + "en-US", + { + weekday: "short", + month: "short", + day: "numeric", + }, +); + +const SHORT_MONTH_DAY_YEAR_FORMATTER = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", +}); + +const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", +}); + +/** Days in a week, past which the weekday name stops being unambiguous. */ +const WEEKDAY_BAND_DAYS = 7; + +/** + * Label for a group of items that share a calendar day — a chat day divider or + * an Inbox section header. + * + * ``` + * Today → "Today" + * Yesterday → "Yesterday" + * 2–6 days ago → "Monday" + * older, this year → "Saturday, June 20" + * earlier years → "June 20, 2025" + * ``` + * + * The weekday rides along within the current year — feedback was that it still + * orients ("was that a weekend?") well past the six-day band. Beyond a year it + * stops earning its width: nobody maps "Friday" to anything a year later, and + * the year itself needs the room. + * + * Deliberate deviation from the standard: the standard collapses anything over + * ten months old to month and year ("Aug 2022"). A group label has to *identify* + * its day — collapsing would give every day in a month the same header, so + * scrolling old history would show a run of identical dividers with no way to + * tell one day from the next. The day is kept and only the year is conditional. + * + * No ordinal suffix ("June 20", never "June 20th"), per the standard. + * + * `nowSeconds` is injectable so the relative bands are testable; it must stay a + * parameter rather than a captured constant, because a label rendered before + * midnight has to say "Yesterday" once the day rolls over. + */ +export function formatDayGroupLabel( + unixSeconds: number, + nowSeconds = Date.now() / 1_000, +): string { + const date = new Date(unixSeconds * 1_000); + const now = new Date(nowSeconds * 1_000); + const dayDiff = calendarDaysBetween(now, date); + + if (dayDiff === 0) return "Today"; + if (dayDiff === 1) return "Yesterday"; + // Bounded below as well as above: a timestamp in the future (clock skew, or a + // relay ahead of this machine) must not be labelled with a weekday that reads + // as the recent past. + if (dayDiff > 1 && dayDiff < WEEKDAY_BAND_DAYS) { + return WEEKDAY_FORMATTER.format(date); + } + + return date.getFullYear() === now.getFullYear() + ? WEEKDAY_MONTH_DAY_FORMATTER.format(date) + : MONTH_DAY_YEAR_FORMATTER.format(date); +} + +/** + * Label for a single item's timestamp — an Inbox list row, or a message in the + * Inbox thread pane. + * + * ``` + * withTime: false (narrow rows) withTime: true (roomy rows) + * Today → "2:34 PM" Today → "2:34 PM" + * Yest. → "Yesterday" Yest. → "Yesterday at 2:34 PM" + * 2–6d → "Monday" 2–6d → "Monday at 2:34 PM" + * year → "Sat, Jun 20" year → "Sat, Jun 20 at 2:34 PM" + * older → "Jun 20, 2025" older → "Jun 20, 2025 at 2:34 PM" + * ``` + * + * The weekday stays through the current year (abbreviated, matching the month) + * and drops once the year appears — see `formatDayGroupLabel` for why. + * + * Today needs no date word in either mode: a bare clock time already reads as + * today, and "Today at 2:34 PM" is longer without saying more. + * + * `withTime` is a surface decision, not a preference. Somewhere you read + * conversation, the time is part of the content, so pass `true`. In a narrow + * list row it costs more width than it earns and the full timestamp is a hover + * away, so pass `false`. + * + * Months are abbreviated here but spelled out in `formatDayGroupLabel` — a + * day divider is a roomy header of its own, an item label shares a row with a + * name, a channel, and a preview. + */ +export function formatItemTimestamp( + unixSeconds: number, + { + withTime = false, + nowSeconds = Date.now() / 1_000, + }: { withTime?: boolean; nowSeconds?: number } = {}, +): string { + const date = new Date(unixSeconds * 1_000); + const now = new Date(nowSeconds * 1_000); + const dayDiff = calendarDaysBetween(now, date); + const time = TIME_FORMATTER.format(date); + + if (dayDiff === 0) return time; + + let dayLabel: string; + if (dayDiff === 1) { + dayLabel = "Yesterday"; + } else if (dayDiff > 1 && dayDiff < WEEKDAY_BAND_DAYS) { + dayLabel = WEEKDAY_FORMATTER.format(date); + } else { + dayLabel = + date.getFullYear() === now.getFullYear() + ? SHORT_WEEKDAY_SHORT_MONTH_DAY_FORMATTER.format(date) + : SHORT_MONTH_DAY_YEAR_FORMATTER.format(date); + } + + return withTime ? `${dayLabel} at ${time}` : dayLabel; +} + +/** Local midnight of the calendar day containing `date`. */ +function startOfLocalDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +/** + * Whole calendar days from `date` to `now`, in local time. Rounded rather than + * floored so a DST transition — a 23- or 25-hour day — still counts as one day. + */ +function calendarDaysBetween(now: Date, date: Date): number { + return Math.round( + (startOfLocalDay(now).getTime() - startOfLocalDay(date).getTime()) / + 86_400_000, + ); +} diff --git a/mobile/lib/features/channels/date_formatters.dart b/mobile/lib/features/channels/date_formatters.dart index 2378c305fb..e67154a753 100644 --- a/mobile/lib/features/channels/date_formatters.dart +++ b/mobile/lib/features/channels/date_formatters.dart @@ -4,11 +4,36 @@ import 'package:intl/intl.dart'; // Re-export shortPubkey so existing callers continue to compile. export '../../shared/utils/string_utils.dart' show shortPubkey; -final _fullDateFormat = DateFormat('EEEE, MMMM d, y'); -final _shortMonthFormat = DateFormat('MMM'); +final _weekdayFormat = DateFormat('EEEE'); +final _weekdayMonthDayFormat = DateFormat('EEEE, MMMM d'); +final _monthDayYearFormat = DateFormat('MMMM d, y'); +final _shortMonthDayFormat = DateFormat('MMM d'); final _messageTimeFormat = DateFormat('h:mm a', 'en_US'); -/// Returns "Today", "Yesterday", or a full date like "Monday, March 31, 2026". +/// Days in a week, past which the weekday name stops being unambiguous. +const _weekdayBandDays = 7; + +/// Label for a day divider: "Today", "Yesterday", "Monday", +/// "Tuesday, March 31", or "March 31, 2025". +/// +/// ``` +/// Today → "Today" +/// Yesterday → "Yesterday" +/// 2–6 days ago → "Monday" +/// older, this year → "Tuesday, March 31" +/// earlier years → "March 31, 2025" +/// ``` +/// +/// The weekday rides along within the current year — it still orients ("was +/// that a weekend?") well past the six-day band. Beyond a year it stops +/// earning its width, and the year takes its place. +/// +/// Mirrors `formatDayGroupLabel` in `desktop/src/shared/lib/datetime.ts`, +/// including its two departures from the Block writing standard: the day is +/// kept in the oldest band rather than collapsing to month-and-year, because a +/// divider has to *identify* its day — collapsing would give every day in a +/// month the same header. And there is no ordinal suffix ("March 31", never +/// "March 31st"), which the standard does ask for. /// /// [now] is exposed for testing; production callers should omit it. String formatDayHeading(int unixSeconds, {@visibleForTesting DateTime? now}) { @@ -17,21 +42,28 @@ String formatDayHeading(int unixSeconds, {@visibleForTesting DateTime? now}) { isUtc: true, ).toLocal(); now ??= DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final messageDay = DateTime(date.year, date.month, date.day); + final dayDiff = _calendarDaysBetween(now, date); - if (today.year == messageDay.year && - today.month == messageDay.month && - today.day == messageDay.day) { - return 'Today'; - } - final yesterday = DateTime(now.year, now.month, now.day - 1); - if (yesterday.year == messageDay.year && - yesterday.month == messageDay.month && - yesterday.day == messageDay.day) { - return 'Yesterday'; + if (dayDiff == 0) return 'Today'; + if (dayDiff == 1) return 'Yesterday'; + // Bounded below as well as above: a timestamp in the future (clock skew, or a + // relay ahead of this device) must not be labelled with a weekday that reads + // as the recent past. + if (dayDiff > 1 && dayDiff < _weekdayBandDays) { + return _weekdayFormat.format(date); } - return _fullDateFormat.format(date); + + return date.year == now.year + ? _weekdayMonthDayFormat.format(date) + : _monthDayYearFormat.format(date); +} + +/// Whole calendar days from [date] to [now], in local time. Rounded rather than +/// truncated so a DST transition — a 23- or 25-hour day — still counts as one. +int _calendarDaysBetween(DateTime now, DateTime date) { + final startOfNow = DateTime(now.year, now.month, now.day); + final startOfDate = DateTime(date.year, date.month, date.day); + return (startOfNow.difference(startOfDate).inHours / 24).round(); } /// Whether two unix-second timestamps fall on the same calendar day (local time). @@ -65,7 +97,7 @@ String relativeTime(int unixSeconds) { } /// Returns desktop-parity thread activity copy such as "just now", -/// "3 hours ago", or "on May 19th". +/// "3 hours ago", or "on May 19". String formatThreadSummaryLastReplyTime( int unixSeconds, { @visibleForTesting int? nowSeconds, @@ -83,25 +115,20 @@ String formatThreadSummaryLastReplyTime( unixSeconds * 1000, isUtc: true, ).toLocal(); - return 'on ${_shortMonthFormat.format(date)} ' - '${date.day}${_ordinalSuffix(date.day)}'; + // No ordinal suffix, per the writing standard. + return 'on ${_shortMonthDayFormat.format(date)}'; } String _formatAgo(int value, String unit) => '$value $unit${value == 1 ? '' : 's'} ago'; -String _ordinalSuffix(int day) { - final lastTwoDigits = day % 100; - if (lastTwoDigits >= 11 && lastTwoDigits <= 13) return 'th'; - return switch (day % 10) { - 1 => 'st', - 2 => 'nd', - 3 => 'rd', - _ => 'th', - }; -} - /// Desktop-parity message clock time, e.g. "2:34 PM". +/// +/// Deliberately clock-only at every band, unlike desktop's message header, +/// which reads "Yesterday at 2:34 PM". Mobile timestamps sit inside a chat +/// bubble on a narrow screen with the day divider a short scroll away, so this +/// is the compact side of that split — not an oversight. Change it only +/// alongside a layout that has room for a date. String formatMessageTime(int unixSeconds) { final date = DateTime.fromMillisecondsSinceEpoch( unixSeconds * 1000, diff --git a/mobile/test/features/channels/date_formatters_test.dart b/mobile/test/features/channels/date_formatters_test.dart index 42363699f7..f8e5b11441 100644 --- a/mobile/test/features/channels/date_formatters_test.dart +++ b/mobile/test/features/channels/date_formatters_test.dart @@ -19,9 +19,59 @@ void main() { expect(formatDayHeading(yesterday, now: now), 'Yesterday'); }); - test('older date returns full formatted date', () { - final older = _ts(DateTime(2026, 3, 31, 12, 0)); - expect(formatDayHeading(older, now: now), 'Tuesday, March 31, 2026'); + test('two to six days back names the weekday alone', () { + expect( + formatDayHeading(_ts(DateTime(2026, 4, 21, 9)), now: now), + 'Tuesday', + ); + expect( + formatDayHeading(_ts(DateTime(2026, 4, 18, 9)), now: now), + 'Saturday', + ); + }); + + test( + 'a week back switches to weekday + date, without the current year', + () { + // Seven days is the first day a bare weekday name stops being + // unambiguous, so the date joins it. + expect( + formatDayHeading(_ts(DateTime(2026, 4, 16, 9)), now: now), + 'Thursday, April 16', + ); + expect( + formatDayHeading(_ts(DateTime(2026, 3, 31, 12)), now: now), + 'Tuesday, March 31', + ); + }, + ); + + test('an earlier year keeps the year', () { + expect( + formatDayHeading(_ts(DateTime(2025, 3, 31, 12)), now: now), + 'March 31, 2025', + ); + }); + + test('no divider carries an ordinal suffix', () { + // 1/2/3 and the 11/12/13 exceptions are where ordinals used to appear. + for (final day in [1, 2, 3, 11, 12, 13, 21, 22, 23, 31]) { + expect( + formatDayHeading(_ts(DateTime(2025, 1, day, 12)), now: now), + isNot(matches(RegExp(r'\d(st|nd|rd|th)'))), + ); + } + }); + + test('the oldest band still tells consecutive days apart', () { + // The reason the day is kept rather than collapsed to "Jan 2022". + final labels = [3, 4, 5] + .map( + (day) => + formatDayHeading(_ts(DateTime(2022, 1, day, 12)), now: now), + ) + .toSet(); + expect(labels, hasLength(3)); }); test('midnight boundary: 11:59 PM today vs 12:01 AM tomorrow', () { @@ -29,11 +79,9 @@ void main() { final earlyTomorrow = _ts(DateTime(2026, 4, 24, 0, 1)); expect(formatDayHeading(lateTonight, now: now), 'Today'); - // Tomorrow relative to our fixed "now" is not today or yesterday. - expect( - formatDayHeading(earlyTomorrow, now: now), - 'Friday, April 24, 2026', - ); + // A future timestamp must not read as the recent past. The attached + // date keeps the weekday from being mistaken for last Friday. + expect(formatDayHeading(earlyTomorrow, now: now), 'Friday, April 24'); }); test('cross-month boundary: April 1 → March 31 is yesterday', () { @@ -41,6 +89,26 @@ void main() { final march31 = _ts(DateTime(2026, 3, 31, 20, 0)); expect(formatDayHeading(march31, now: april1), 'Yesterday'); }); + + test('bands are calendar days, not 24-hour windows', () { + // 15 hours old but across midnight, so "Yesterday"; 22 hours old on the + // same calendar day, so "Today". + final elevenPmYesterday = DateTime(2026, 4, 23, 2, 0); + expect( + formatDayHeading( + _ts(DateTime(2026, 4, 22, 23, 0)), + now: elevenPmYesterday, + ), + 'Yesterday', + ); + expect( + formatDayHeading( + _ts(DateTime(2026, 4, 23, 1, 0)), + now: DateTime(2026, 4, 23, 23, 0), + ), + 'Today', + ); + }); }); group('isSameDay', () { @@ -79,23 +147,24 @@ void main() { ); }); - test('uses a short month and ordinal for older replies', () { + test('dates older replies with a short month and no ordinal', () { final may19 = _ts(DateTime(2026, 5, 19, 12)); - final may27 = _ts(DateTime(2026, 5, 27, 12)); + final may1 = _ts(DateTime(2026, 5, 1, 12)); expect( formatThreadSummaryLastReplyTime( may19, nowSeconds: _ts(DateTime(2026, 5, 27, 12)), ), - 'on May 19th', + 'on May 19', ); + // The 1st is where an ordinal is most tempting. expect( formatThreadSummaryLastReplyTime( - may27, - nowSeconds: _ts(DateTime(2026, 6, 4, 12)), + may1, + nowSeconds: _ts(DateTime(2026, 5, 27, 12)), ), - 'on May 27th', + 'on May 1', ); }); }); From 1d51081b8abf4d3f9ec7fc676207f967a843e860 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 14 Aug 2026 10:03:37 -0600 Subject: [PATCH 11/33] Teach agents to inherit Buzz product intent (#5875) ## Summary - make `VISION.md`, relevant `VISION_*.md`, and applicable testing guides explicit planning and review inputs for non-trivial Buzz changes - teach managed agents to load repository-root and path-local `AGENTS.md` files after selecting a checkout - distinguish CI evidence from exercising the live workflow for user-visible and integration behavior - turn repeatable mistakes into same-session durable lessons, keeping only load-bearing rules in core memory and promoting shared lessons to team guidance - pin the new managed-agent prompt invariants in tests - preserve the exact display name shown in Buzz when mentioning or addressing someone; never infer or look up a surname merely to sound more complete ### Related issue None found after searching `block/buzz` issues and PRs for agent instruction, vision, and product-intent routing. ### Testing At commit `07ef705b42f58d3be6981165c6959d541ada0ba7`: - `cargo fmt --all -- --check` - `cargo test -p buzz-acp agent_draft_prompt_tests` (4 passed) - mandatory pre-push hooks passed on the exact pushed head: `branch-skew`, `desktop-check`, `desktop-typecheck`, `mobile-test`, `desktop-test`, `rust-tests`, and `desktop-tauri-checks` - `git diff --check origin/main...HEAD` --------- Signed-off-by: Wes Co-authored-by: Carl --- AGENTS.md | 19 +++++++++++++++++++ crates/buzz-acp/src/base_prompt.md | 9 ++++++--- crates/buzz-acp/src/lib.rs | 16 ++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2d3939bbb3..4ad827a0b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,25 @@ code style, PR process, architecture), see [CONTRIBUTING.md](CONTRIBUTING.md). --- +## Product Contract + +Before planning or reviewing a non-trivial change: + +1. Read [VISION.md](VISION.md). +2. Read the `VISION_*.md` documents relevant to the affected product surface. +3. Read the applicable guidance in [TESTING.md](TESTING.md) and any + package-local `TESTING.md`. +4. Check that the proposed design advances, or at least does not contradict, + that product intent. Call out any intentional tension explicitly. + +Implementation describes the product today; the vision documents describe the +product it is becoming. A locally correct change can still be wrong if it works +against that direction. Scale validation to the change's risk and exercise the +real workflow for user-visible or integration behavior when practical; green CI +and runtime evidence answer different questions. + +--- + ## Ecosystem Buzz spans five repos. This one (`block/buzz`) is the OSS source for the relay, desktop, mobile, and CLI. The others handle internal builds and deployment: diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 1d85221f11..1695b4863f 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -47,7 +47,7 @@ For explicit changes to an existing personal agent, use `buzz agents draft-updat ### Mentions -- Use the person's **exact full display name** after `@` (e.g., `@Will Pfleger`, not `@Will`). Partial names fail silently. +- For a notifying `@mention`, use the person's **exact display name as shown in Buzz** (e.g., `@Will Pfleger`, not `@Will`, when the displayed name is `Will Pfleger`). Do not expand a short display name, infer a surname, or spend tool calls looking for a “fuller” name merely to address someone. Partial names fail silently. - Do NOT format mentions with bold, italic, or backticks — it breaks notification delivery. - When you know intended recipient pubkeys, send readable `@Name` text and pass the identities separately in the same command: `buzz messages send ... --content "@Name ..." --mention `. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed. - Without `--mention`, the CLI resolves `@Name` against current channel members. It stops before sending on an unresolved/ambiguous name or a mentioned pubkey that is not a member. For a non-member, add them explicitly with `buzz channels add-member` only when authorized, then retry. Sending never changes membership automatically. @@ -81,7 +81,7 @@ All replies and delegations — including task assignments to other agents — g - For work that requires follow-up tools, create an open todo **before** sending the pickup acknowledgment. Keep it open until the deliverable is verified and you have sent a completion or blocker message; never end a turn with open todo state unless you have posted that completion or blocker message. - Use GitHub-flavored Markdown. Fenced code blocks with language tags for syntax highlighting. - No push notifications — poll with `buzz messages get --channel --since `. -- Address people by the name in their own message header. +- Address people using the name shown in their own message header. Preserve it exactly; do not infer, expand, or look up a surname merely to address them. - Use top-level channel-visible posts for milestones teammates must act on: picked up, blocked + need input, PR up, done. - Praise in public; correct in the work, not the person. @@ -115,6 +115,7 @@ These paths are relative to your working directory — keep exploration there. N Your `core` memory is auto-injected into your context every turn — it holds identity, durable rules, and goals across sessions. - **Keep `core` small.** A line earns a permanent slot only if it matters across most sessions or prevents a sharp repeat mistake. Treat the 65,535-byte hard limit as a wall to stay far from, not a budget to fill — aim to keep `core` under ~10 KB (roughly your healthy baseline). +- **Turn mistakes into durable lessons.** When a mistake exposes a repeatable mechanism, record the invariant in the same session. Keep only the load-bearing rule in `core`; put detailed evidence and procedures in cold memory. If the lesson improves a shared workflow, update the team's shared guidance so others do not have to re-earn it. - **Durable detail goes to a cold `mem/` slug, not `core`.** Long-lived findings that don't need to be in front of you every turn belong in a `mem/` slug you read on demand — not appended to `core`. - **Evict completed work.** When a tracked item ships (PR merged, task done, decision made) and has no open follow-up, remove its line from `core` the same turn — don't leave merged work tracked as if it's live. The detail already lives in its cold `mem/` slug if you need it later. - **Treat `core` as load-bearing.** Follow it unless newer explicit user instructions override it. @@ -130,13 +131,15 @@ These are guidelines, not a fixed procedure — apply judgment to the task in fr - **Plan briefly, then build.** Be opinionated about the safest concrete approach. Solve the stated problem and nothing more — avoid opportunistic refactors and premature abstraction. - **Match what's there.** Follow the surrounding code's conventions and module boundaries. Read neighboring code first. - **Attribute results to the exact state that produced them.** Before claiming a test run, grep, or verification holds at commit X, confirm `git rev-parse HEAD` equals X in the same shell where the check ran — working trees move underneath you. Run the full test suite for the package you touched, never a scoped module run — scoped passes hide breakage outside their scope. Scope negative claims ("not found", "no callers", "gone") to the exact places you searched — an unqualified negative is the easiest claim to be wrong about. -- **Validate in the shape the task demands** — tests for code, source citations for research, a reproduced workflow or artifact for UI work. If the same failure hits twice, change angle rather than retrying. +- **Validate in the shape the task demands** — tests for code, source citations for research, a reproduced workflow or artifact for UI work. CI and live workflow evidence answer different questions: for user-visible or integration behavior, exercise the real workflow when practical and scale the depth to the risk. If the same failure hits twice, change angle rather than retrying. - **Get a second opinion on risky changes.** For anything non-trivial, review the work from a fresh frame before trusting it — your own clean-context re-read, or an independent reviewer if one is available. Don't tell the reviewer what you expect them to find. - **Self-review before calling it done.** Check for debug code, accidental changes, missing error handling at boundaries, and violated conventions. - **Scale effort to risk.** A typo or config tweak just gets done. A multi-file change touching persistence, auth, or anything user-visible earns the full discipline above. ## Working in the Repo +- After selecting a repository or worktree, read its root `AGENTS.md` and any path-local `AGENTS.md` files that apply before planning or editing. The workspace-level file is team context; it does not replace repository-owned instructions. +- Treat repository-owned product, architecture, and vision documents as design constraints, not optional background. Read the relevant documents before making non-trivial plans, and surface any intentional conflict with them. - Make file changes in a worktree, not on the default branch. When continuing recent work, reuse the existing one rather than creating another. - Before committing, read the repo-local git `user.name` / `user.email`; if email is empty, stop and ask. Include the trailers the repo requires. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 27b9000b7b..b3986fdb9b 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -4274,9 +4274,25 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("buzz messages send ... --content -")); } + #[test] + fn shared_base_prompt_teaches_repo_context_and_learning_loop() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("read its root `AGENTS.md`")); + assert!(prompt.contains("path-local `AGENTS.md`")); + assert!( + prompt.contains("product, architecture, and vision documents as design constraints") + ); + assert!(prompt.contains("CI and live workflow evidence answer different questions")); + assert!(prompt.contains("record the invariant in the same session")); + assert!(prompt.contains("update the team's shared guidance")); + } + #[test] fn shared_base_prompt_teaches_single_command_mentions_and_preflight() { let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("use the person's **exact display name as shown in Buzz**")); + assert!(prompt.contains("Do not expand a short display name, infer a surname")); + assert!(prompt.contains("Preserve it exactly; do not infer, expand, or look up a surname")); assert!(prompt.contains("--mention ")); assert!(prompt.contains("every presentation-only name that should notify")); assert!( From 17d2147ecadaef5891da598cf8f5257f7787992b Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 14 Aug 2026 17:16:28 +0100 Subject: [PATCH 12/33] Fix video comment effect wrapping (#5748) ## What changed - render video-review timecode chips inside the first Markdown paragraph so comment text wraps naturally around them - reuse the canonical video-review chip treatment across the timeline, Inbox previews, and Inbox detail - preserve video-review context in Inbox so timestamp chips remain interactive ## Why Video comments now support Markdown-like effects, but non-player surfaces rendered the timestamp beside a separate text layout. That kept the chip and comment from sharing the same inline flow and made Inbox behavior inconsistent with the player. ## Validation - `pnpm --dir desktop check` - 100 focused Markdown, timecode, video-review, and Inbox unit tests - `pnpm --dir desktop build:e2e` - focused `video-attachment.spec.ts` Playwright scenario - pre-push desktop typecheck and 4,761-test desktop suite - native Builderlab staging with the configured profile Focused timeline and Inbox snapshots will be attached in a PR comment. --------- Signed-off-by: kenny lopez Signed-off-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> --- desktop/package.json | 1 + .../src/features/home/lib/inboxViewHelpers.ts | 3 + .../src/features/home/ui/InboxDetailPane.tsx | 152 ++++++++++++---- .../src/features/home/ui/InboxListPane.tsx | 36 +++- .../src/features/home/ui/InboxMessageRow.tsx | 17 +- .../messages/lib/videoReviewContext.test.mjs | 91 +++++++++ .../messages/lib/videoReviewContext.ts | 106 +++++++++-- .../src/features/messages/ui/MessageRow.tsx | 34 +--- desktop/src/shared/lib/computeConfigNudge.ts | 12 ++ .../shared/lib/rehypeLeadingInlineContent.ts | 115 ++++++++++++ .../shared/ui/VideoReviewCommentMarkdown.tsx | 77 ++++++++ .../shared/ui/VideoReviewTimecodeButton.tsx | 50 ++++- desktop/src/shared/ui/markdown.test.mjs | 31 +++- desktop/src/shared/ui/markdown.tsx | 55 +++--- .../src/shared/ui/markdown/nodeCache.test.mjs | 98 ++++++++++ desktop/src/shared/ui/markdown/nodeCache.ts | 7 + desktop/src/shared/ui/markdown/types.ts | 6 + desktop/tests/e2e/inbox-edit.spec.ts | 12 +- desktop/tests/e2e/video-attachment.spec.ts | 172 +++++++++++++++++- pnpm-lock.yaml | 3 + 20 files changed, 951 insertions(+), 127 deletions(-) create mode 100644 desktop/src/shared/lib/rehypeLeadingInlineContent.ts create mode 100644 desktop/src/shared/ui/VideoReviewCommentMarkdown.tsx diff --git a/desktop/package.json b/desktop/package.json index b6581d4805..7abcb8f205 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -67,6 +67,7 @@ "emoji-mart": "^5.6.0", "jdenticon": "^3.3.0", "lucide-react": "^1.0.0", + "mdast-util-from-markdown": "^2.0.3", "motion": "^12.38.0", "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", diff --git a/desktop/src/features/home/lib/inboxViewHelpers.ts b/desktop/src/features/home/lib/inboxViewHelpers.ts index 42ed8e1a5a..d1bffd1899 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.ts +++ b/desktop/src/features/home/lib/inboxViewHelpers.ts @@ -228,6 +228,7 @@ export function toInboxContextMessage( export function toTimelineMessage( message: InboxContextMessage, ): TimelineMessage { + const threadReference = getThreadReference(message.tags ?? []); return { id: message.id, author: message.authorLabel, @@ -239,8 +240,10 @@ export function toTimelineMessage( createdAt: message.createdAt, depth: message.depth, kind: message.kind, + parentId: message.parentId ?? threadReference.parentId, pubkey: message.authorPubkey, reactions: message.reactions ?? [], + rootId: message.rootId ?? threadReference.rootId, signerPubkey: message.signerPubkey, tags: message.tags, time: message.timeLabel ?? message.fullTimestampLabel, diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 8ad0237675..9bb593087d 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -19,7 +19,10 @@ import { ProjectInboxDetail } from "@/features/home/ui/ProjectInboxDetail"; import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; import { useCommunities } from "@/features/communities/useCommunities"; import { formatInboxTypeLabel } from "@/features/home/lib/inbox"; -import { hasInboxThreadContext } from "@/features/home/lib/inboxViewHelpers"; +import { + hasInboxThreadContext, + toTimelineMessage, +} from "@/features/home/lib/inboxViewHelpers"; import { type InboxDisplayMessage, InboxMessageRow, @@ -35,6 +38,10 @@ import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionP import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; import { buildEditMentionState } from "@/features/messages/lib/draftMentionRefs"; import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; +import { + buildVideoReviewPresentationByMessageId, + hasRenderedVideoAttachment, +} from "@/features/messages/lib/videoReviewContext"; import { getThreadReference } from "@/features/messages/lib/threading"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; @@ -46,6 +53,7 @@ import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { VideoReviewNavigationProvider } from "@/shared/ui/VideoReviewNavigation"; import { DropdownMenu, DropdownMenuContent, @@ -64,6 +72,9 @@ const MembersSidebar = React.lazy(async () => { return { default: module.MembersSidebar }; }); +const EMPTY_CONTEXT_MESSAGES: InboxContextMessage[] = []; +const EMPTY_REPLIES: InboxReply[] = []; + type InboxDetailPaneProps = { agentPubkeys?: ReadonlySet; canDelete: boolean; @@ -143,7 +154,11 @@ export function InboxDetailPane(props: InboxDetailPaneProps) { ); } - return ; + return ( + + + + ); } function InboxMessageDetailPane({ @@ -160,9 +175,9 @@ function InboxMessageDetailPane({ hasThreadContextLoadError = false, isThreadContextLoading = false, item, - messages = [], + messages = EMPTY_CONTEXT_MESSAGES, profiles, - replies = [], + replies = EMPTY_REPLIES, channel, contextChannelName = null, currentPubkey, @@ -199,7 +214,6 @@ function InboxMessageDetailPane({ // Build the plain, non-virtualized timeline the shared hook anchors against. // Live arrivals rerun its layout compensation without changing the target. - const selectedMessage = messages.find((message) => message.isSelected); // A latest reply can represent an Inbox conversation. Resolve the actual // root from loaded context or the complete feed group; never treat an // unresolved root/profile lookup as an authoritative empty audience. @@ -234,34 +248,100 @@ function InboxMessageDetailPane({ ) : [] : undefined; - const pendingReplyMessages: InboxDisplayMessage[] = replies.map((reply) => ({ - ...reply, - depth: reply.depth ?? (selectedMessage?.depth ?? 0) + 1, - isSelected: false, - mentionNames: [], - })); - const displayMessages: InboxDisplayMessage[] = - messages.length > 0 - ? [...messages, ...pendingReplyMessages] - : item - ? [ - { - authorLabel: item.senderLabel, - authorPubkey: item.item.pubkey, - avatarUrl: item.avatarUrl, - content: item.preview, - createdAt: item.item.createdAt, - depth: 0, - fullTimestampLabel: item.fullTimestampLabel, - id: item.id, - isSelected: true, - mentionNames: item.mentionNames, - mentionPubkeysByName: item.mentionPubkeysByName, - timeLabel: formatTime(item.item.createdAt), - }, - ...pendingReplyMessages, - ] - : pendingReplyMessages; + const displayMessages = React.useMemo(() => { + const selectedMessage = messages.find((message) => message.isSelected); + const pendingReplyMessages: InboxDisplayMessage[] = replies.map( + (reply) => ({ + ...reply, + depth: reply.depth ?? (selectedMessage?.depth ?? 0) + 1, + isSelected: false, + mentionNames: [], + }), + ); + + if (messages.length > 0) { + return [...messages, ...pendingReplyMessages]; + } + if (!item) return pendingReplyMessages; + + const threadReference = getThreadReference(item.item.tags); + return [ + { + authorLabel: item.senderLabel, + authorPubkey: item.item.pubkey, + avatarUrl: item.avatarUrl, + content: item.preview, + createdAt: item.item.createdAt, + depth: 0, + fullTimestampLabel: item.fullTimestampLabel, + id: item.id, + isSelected: true, + mentionNames: item.mentionNames, + mentionPubkeysByName: item.mentionPubkeysByName, + kind: item.item.kind, + parentId: threadReference.parentId, + rootId: threadReference.rootId, + tags: item.item.tags, + timeLabel: formatTime(item.item.createdAt), + }, + ...pendingReplyMessages, + ]; + }, [item, messages, replies]); + const videoReviewMessages = React.useMemo( + () => displayMessages.map(toTimelineMessage), + [displayMessages], + ); + const videoReviewChannelType = + item?.item.channelType === "dm" || + item?.item.channelType === "stream" || + item?.item.channelType === "forum" + ? item.item.channelType + : null; + const handleSendVideoReviewComment = React.useCallback( + ( + message: TimelineMessage, + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + parentEventId?: string, + ) => + onSendReply({ + content, + mediaTags, + mentionPubkeys, + parentEventId: parentEventId ?? message.id, + }), + [onSendReply], + ); + const videoReviewPresentation = React.useMemo( + () => + buildVideoReviewPresentationByMessageId( + { + channelId: item?.item.channelId, + channelName: contextChannelName ?? item?.channelLabel ?? undefined, + channelType: videoReviewChannelType, + isSendingVideoReviewComment: isSendingReply, + messages: videoReviewMessages, + onSendVideoReviewComment: canReply + ? handleSendVideoReviewComment + : undefined, + onToggleReaction, + profiles, + }, + hasRenderedVideoAttachment, + ), + [ + canReply, + contextChannelName, + handleSendVideoReviewComment, + isSendingReply, + item, + onToggleReaction, + profiles, + videoReviewChannelType, + videoReviewMessages, + ], + ); const { onScroll } = useAnchoredScroll({ channelId: conversationId, contentRef, @@ -674,6 +754,12 @@ function InboxMessageDetailPane({ onSelectReplyTarget={handleSelectReplyTarget} onToggleReaction={onToggleReaction} showUnreadBoundary={hasUnreadBoundary} + videoReviewCommentRootId={videoReviewPresentation.commentRootIdsByMessageId.get( + message.id, + )} + videoReviewContext={videoReviewPresentation.contextsByMessageId.get( + message.id, + )} /> ); })} diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 17b06bf284..b83a19a167 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -8,6 +8,8 @@ import { type InboxTypeLabel, } from "@/features/home/lib/inbox"; import { buildInboxListRows } from "@/features/home/lib/inboxListRows"; +import { hasRenderedVideoAttachment } from "@/features/messages/lib/videoReviewContext"; +import { getThreadReference } from "@/features/messages/lib/threading"; import { InboxFilterMenu } from "@/features/home/ui/InboxFilterMenu"; import { DraftsPanel, @@ -30,7 +32,7 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/shared/ui/context-menu"; -import { Markdown } from "@/shared/ui/markdown"; +import { VideoReviewCommentMarkdown } from "@/shared/ui/VideoReviewCommentMarkdown"; import { MENTION_CHIP_BASE_CLASSES, MESSAGE_MARKDOWN_CLASS, @@ -121,6 +123,34 @@ function formatReminderStatus(notBefore: number | undefined) { return `Reminder in ${Math.floor(secondsUntil / 86_400)}d`; } +function getInboxVideoReviewCommentRootId(item: InboxItem) { + const feedItems = [item.item, ...item.groupItems]; + const feedItemById = new Map( + feedItems.map((feedItem) => [feedItem.id, feedItem]), + ); + const videoMessageIds = new Set( + feedItems + .filter((feedItem) => + hasRenderedVideoAttachment({ + body: feedItem.content, + tags: feedItem.tags, + }), + ) + .map((feedItem) => feedItem.id), + ); + const visited = new Set(); + let ancestorId = getThreadReference(item.item.tags).parentId; + + while (ancestorId && !visited.has(ancestorId)) { + if (videoMessageIds.has(ancestorId)) return ancestorId; + visited.add(ancestorId); + const ancestor = feedItemById.get(ancestorId); + ancestorId = ancestor ? getThreadReference(ancestor.tags).parentId : null; + } + + return undefined; +} + function PersonalItemRow({ id, location, @@ -274,6 +304,7 @@ export function InboxListPane({ ); const hasChannelTarget = Boolean(item.item.channelId); const typeLabel = getInboxTypeLabel(item); + const videoReviewCommentRootId = getInboxVideoReviewCommentRootId(item); const isSenderAgent = agentPubkeys?.has(normalizePubkey(item.item.pubkey)) === true; const profileRole = isSenderAgent ? "bot" : undefined; @@ -408,11 +439,12 @@ export function InboxListPane({ : "font-semibold text-foreground", )} > -
diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index c19a0acee7..0d04fd076a 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -17,9 +17,11 @@ import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import { Markdown } from "@/shared/ui/markdown"; import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; +import { VideoReviewCommentMarkdown } from "@/shared/ui/VideoReviewCommentMarkdown"; +import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; export type InboxDisplayMessage = InboxContextMessage & { depth: number; @@ -43,6 +45,8 @@ type InboxMessageRowProps = { remove: boolean, ) => Promise; showUnreadBoundary?: boolean; + videoReviewCommentRootId?: string; + videoReviewContext?: VideoReviewContext; }; export function InboxMessageRow({ @@ -58,11 +62,17 @@ export function InboxMessageRow({ onSelectReplyTarget, onToggleReaction, showUnreadBoundary = false, + videoReviewCommentRootId, + videoReviewContext, }: InboxMessageRowProps) { const timelineMessage = React.useMemo( () => toTimelineMessage(message), [message], ); + const imetaByUrl = React.useMemo( + () => (message.tags ? parseImetaTags(message.tags) : undefined), + [message.tags], + ); const { customEmoji, emojiOnly } = useMessageEmoji( message.content, message.tags, @@ -231,7 +241,7 @@ export function InboxMessageRow({ )}
- { ); assert.equal(hasVideoAttachment(message({ body: "plain text" })), false); + assert.equal( + hasVideoAttachment( + message({ + body: "orphan metadata only", + tags: [["imeta", "url https://cdn.example.com/cut.mp4", "m video/mp4"]], + }), + ), + true, + ); + assert.equal( + hasRenderedVideoAttachment( + message({ + body: "orphan metadata only", + tags: [["imeta", "url https://cdn.example.com/cut.mp4", "m video/mp4"]], + }), + ), + false, + ); +}); +test("hasVideoAttachment uses the Markdown renderer's video classification", () => { + assert.equal( + hasVideoAttachment( + message({ body: "![Demo](https://cdn.example.com/cut.mp4)" }), + ), + true, + ); + assert.equal( + hasVideoAttachment( + message({ body: "![Poster](https://cdn.example.com/cut.jpg)" }), + ), + false, + ); + assert.equal( + hasVideoAttachment( + message({ + body: "![Demo](https://relay/media/cut.mp4)", + tags: [["imeta", "url https://relay/media/cut.mp4", "m image/png"]], + }), + ), + false, + ); + assert.equal( + hasVideoAttachment( + message({ + body: "![Demo][clip]\n\n[clip]: https://cdn.example.com/cut.mp4", + }), + ), + true, + ); + assert.equal( + hasVideoAttachment( + message({ + body: "```md\n![Demo](https://cdn.example.com/cut.mp4)\n```", + }), + ), + false, + ); }); test("buildVideoReviewCommentsByRootId includes nested descendants", () => { @@ -211,6 +269,39 @@ test("buildVideoReviewCommentRootIdsByMessageId targets the nearest video ancest ); }); +test("buildVideoReviewCommentRootIdsByMessageId can require rendered video roots", () => { + const orphanVideo = message({ + id: "orphan-video", + body: "metadata only", + tags: [["imeta", "url https://relay/media/a.mp4", "m video/mp4"]], + }); + const comment = message({ + id: "comment", + body: "[00:01] review this", + parentId: orphanVideo.id, + rootId: orphanVideo.id, + }); + + assert.deepEqual( + [ + ...buildVideoReviewCommentRootIdsByMessageId([ + orphanVideo, + comment, + ]).entries(), + ], + [[comment.id, orphanVideo.id]], + ); + assert.deepEqual( + [ + ...buildVideoReviewCommentRootIdsByMessageId( + [orphanVideo, comment], + hasRenderedVideoAttachment, + ).entries(), + ], + [], + ); +}); + test("buildVideoReviewContextForMessage posts against the source video", async () => { const video = message({ id: "video", diff --git a/desktop/src/features/messages/lib/videoReviewContext.ts b/desktop/src/features/messages/lib/videoReviewContext.ts index 78401214a2..a63843c1ce 100644 --- a/desktop/src/features/messages/lib/videoReviewContext.ts +++ b/desktop/src/features/messages/lib/videoReviewContext.ts @@ -1,6 +1,10 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; +import { isVideoMedia } from "@/shared/ui/markdown/mediaEntry"; +import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; type SendVideoReviewComment = ( @@ -17,18 +21,79 @@ type ToggleMessageReaction = ( remove: boolean, ) => Promise; -export function hasVideoAttachment(message: TimelineMessage): boolean { - if (message.body.includes("![video](")) return true; +type VideoRootPredicate = ( + message: Pick, +) => boolean; + +type MarkdownAstNode = { + children?: MarkdownAstNode[]; + identifier?: string; + type: string; + url?: string; +}; + +function markdownImageUrls(body: string): string[] { + if (!body.includes("![")) return []; + + const definitions = new Map(); + const directUrls: string[] = []; + const referenceIds: string[] = []; + + const visit = (node: MarkdownAstNode) => { + if (node.type === "definition" && node.identifier && node.url) { + if (!definitions.has(node.identifier)) { + definitions.set(node.identifier, node.url); + } + } else if (node.type === "image" && node.url) { + directUrls.push(node.url); + } else if (node.type === "imageReference" && node.identifier) { + referenceIds.push(node.identifier); + } + + node.children?.forEach(visit); + }; - return ( - message.tags?.some( - (tag) => - tag[0] === "imeta" && - tag.some((part) => part.toLowerCase().startsWith("m video/")), - ) ?? false + visit(fromMarkdown(body) as MarkdownAstNode); + return [ + ...directUrls, + ...referenceIds.flatMap((identifier) => { + const url = definitions.get(identifier); + return url ? [url] : []; + }), + ]; +} + +/** + * Returns whether a message contains a video URL in a Markdown image that + * the renderer will actually mount. Orphan imeta entries are intentionally + * excluded because they do not produce a video player. + */ +export function hasRenderedVideoAttachment( + message: Pick, +): boolean { + const imetaByUrl = parseImetaTags(message.tags ?? []); + return markdownImageUrls(message.body).some((src) => + isVideoMedia(src, imetaByUrl.get(src)?.m), ); } +export function hasVideoAttachment( + message: Pick, +): boolean { + const imetaByUrl = parseImetaTags(message.tags ?? []); + if ( + [...imetaByUrl.values()].some((entry) => isVideoMedia(entry.url, entry.m)) + ) { + return true; + } + + for (const src of markdownImageUrls(message.body)) { + if (isVideoMedia(src, imetaByUrl.get(src)?.m)) return true; + } + + return false; +} + export function buildVideoReviewCommentsByRootId( messages: TimelineMessage[], ): Map { @@ -95,10 +160,11 @@ export function buildVideoReviewCommentsForRoot( export function buildVideoReviewCommentRootIdsByMessageId( messages: TimelineMessage[], + videoRootPredicate: VideoRootPredicate = hasVideoAttachment, ): ReadonlyMap { const messageById = new Map(messages.map((message) => [message.id, message])); const videoMessageIds = new Set( - messages.filter(hasVideoAttachment).map((message) => message.id), + messages.filter(videoRootPredicate).map((message) => message.id), ); const rootIdsByMessageId = new Map(); @@ -130,6 +196,7 @@ export function buildVideoReviewContextForMessage({ onSendVideoReviewComment, onToggleReaction, profiles, + videoRootPredicate = hasVideoAttachment, }: { channelId?: string | null; channelName?: string; @@ -140,8 +207,9 @@ export function buildVideoReviewContextForMessage({ onSendVideoReviewComment?: SendVideoReviewComment; onToggleReaction?: ToggleMessageReaction; profiles?: UserProfileLookup; + videoRootPredicate?: VideoRootPredicate; }): VideoReviewContext | undefined { - if (!hasVideoAttachment(message)) { + if (!videoRootPredicate(message)) { return undefined; } @@ -185,6 +253,7 @@ export function buildVideoReviewContextsByMessageId({ onSendVideoReviewComment, onToggleReaction, profiles, + videoRootPredicate = hasVideoAttachment, }: { channelId?: string | null; channelName?: string; @@ -194,9 +263,10 @@ export function buildVideoReviewContextsByMessageId({ onSendVideoReviewComment?: SendVideoReviewComment; onToggleReaction?: ToggleMessageReaction; profiles?: UserProfileLookup; + videoRootPredicate?: VideoRootPredicate; }): ReadonlyMap { const contexts = new Map(); - if (!messages.some(hasVideoAttachment)) { + if (!messages.some(videoRootPredicate)) { return contexts; } @@ -212,6 +282,7 @@ export function buildVideoReviewContextsByMessageId({ onSendVideoReviewComment, onToggleReaction, profiles, + videoRootPredicate, }); if (context) { contexts.set(message.id, context); @@ -221,17 +292,28 @@ export function buildVideoReviewContextsByMessageId({ return contexts; } +/** + * Builds the paired video-review maps used by timeline presentation: contexts + * are keyed by video message, while comment roots map each descendant back to + * its nearest video ancestor. + */ export function buildVideoReviewPresentationByMessageId( args: Parameters[0], + videoRootPredicate: VideoRootPredicate = hasVideoAttachment, ) { return { commentRootIdsByMessageId: buildVideoReviewCommentRootIdsByMessageId( args.messages, + videoRootPredicate, ), - contextsByMessageId: buildVideoReviewContextsByMessageId(args), + contextsByMessageId: buildVideoReviewContextsByMessageId({ + ...args, + videoRootPredicate, + }), }; } +/** The synchronized context and comment-root maps for a rendered timeline. */ export type VideoReviewPresentation = ReturnType< typeof buildVideoReviewPresentationByMessageId >; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index ab4f29b806..de496836c1 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -42,11 +42,8 @@ import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; import { parseWaveMessageContent } from "@/features/messages/lib/waveMessage"; import { resolveSnapshotSharedBy } from "@/features/messages/lib/snapshotSharedBy"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; -import { Markdown } from "@/shared/ui/markdown"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; -import { useOpenVideoReviewAt } from "@/shared/ui/VideoReviewNavigation"; -import { parseVideoReviewTimecode } from "@/shared/ui/videoReviewTimecode"; -import { VideoReviewTimecodeButton } from "@/shared/ui/VideoReviewTimecodeButton"; +import { VideoReviewCommentMarkdown } from "@/shared/ui/VideoReviewCommentMarkdown"; import { MessageActionBar } from "./MessageActionBar"; import { editMessage } from "@/shared/api/tauri"; import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages"; @@ -301,7 +298,6 @@ export const MessageRow = React.memo( const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5"; const { nonDmChannelNames: channelNames } = useChannelNavigation(); - const openVideoReviewAt = useOpenVideoReviewAt(); const indentRem = getThreadReplyIndentRem(message.depth); const descendantGuideOffsetRem = connectDescendants @@ -411,12 +407,8 @@ export const MessageRow = React.memo( ); } - const reviewRootEventId = videoReviewCommentRootId; - const reviewTimecode = reviewRootEventId - ? parseVideoReviewTimecode(message.body) - : null; - const markdown = ( - ); - if (!reviewRootEventId || !reviewTimecode || !openVideoReviewAt) { - return markdown; - } - - return ( -
- { - event.stopPropagation(); - openVideoReviewAt(reviewRootEventId, reviewTimecode.seconds); - }} - /> -
{markdown}
-
- ); } } }; diff --git a/desktop/src/shared/lib/computeConfigNudge.ts b/desktop/src/shared/lib/computeConfigNudge.ts index 25040713d4..08e9d4447b 100644 --- a/desktop/src/shared/lib/computeConfigNudge.ts +++ b/desktop/src/shared/lib/computeConfigNudge.ts @@ -45,3 +45,15 @@ export function selectProseOrNudge( ): ReactNode { return configNudge === null ? markdownNode : null; } + +/** + * Keeps inline content visible beside the nudge card when the prose node is + * suppressed. This preserves controls, such as a video-review timecode, that + * were extracted from the original message before the sentinel was removed. + */ +export function selectNudgeLeadingContent( + configNudge: ConfigNudgePayload | null, + leadingInlineContent: ReactNode | undefined, +): ReactNode { + return configNudge !== null ? leadingInlineContent : null; +} diff --git a/desktop/src/shared/lib/rehypeLeadingInlineContent.ts b/desktop/src/shared/lib/rehypeLeadingInlineContent.ts new file mode 100644 index 0000000000..a2a0c4c9f0 --- /dev/null +++ b/desktop/src/shared/lib/rehypeLeadingInlineContent.ts @@ -0,0 +1,115 @@ +// Minimal HAST types — matches the pattern in rehypeImageGallery.ts. +interface HastText { + type: "text"; + value: string; +} + +interface HastElement { + type: "element"; + tagName: string; + properties: Record; + children: HastNode[]; +} + +type HastNode = HastElement | HastText | { type: string }; + +interface HastRoot { + type: "root"; + children: HastNode[]; +} + +const INLINE_TARGETS = new Set([ + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "li", + "p", + "td", + "th", +]); + +function isElement(node: HastNode): node is HastElement { + return node.type === "element"; +} + +function isText(node: HastNode): node is HastText { + return node.type === "text"; +} + +function isMediaOnlyParagraph(node: HastElement): boolean { + if (node.tagName !== "p") return false; + + const meaningful = node.children.filter( + (child) => + !(isText(child) && child.value.trim() === "") && + !(isElement(child) && child.tagName === "br"), + ); + return ( + meaningful.length > 0 && + meaningful.every((child) => isElement(child) && child.tagName === "img") + ); +} + +function leadingMarker(): HastElement { + return { + type: "element", + tagName: "span", + properties: { "data-leading-inline-content": "" }, + children: [], + }; +} + +function isMeaningfulNode(node: HastNode): boolean { + return !(isText(node) && node.value.trim() === ""); +} + +function prependToFirstInlineTarget(node: HastNode): boolean { + if (!isElement(node)) return false; + + if (INLINE_TARGETS.has(node.tagName) && !isMediaOnlyParagraph(node)) { + // Nested paragraphs provide the natural prose flow for quotes and loose + // list items. Tight list items contain text directly, so the
  • itself + // is the correct fallback target. + if (node.tagName === "li") { + const directParagraph = node.children.find( + (child) => isElement(child) && child.tagName === "p", + ); + if (directParagraph && prependToFirstInlineTarget(directParagraph)) { + return true; + } + } + node.children.unshift(leadingMarker()); + return true; + } + + // Only inspect the first rendered block. If it cannot accept inline content + // (for example, code or media), the caller inserts the fallback before its + // containing block instead of moving the marker into later prose. + const firstChild = node.children.find(isMeaningfulNode); + return firstChild ? prependToFirstInlineTarget(firstChild) : false; +} + +/** + * Inserts a render-time marker into the first prose-capable Markdown block. + * Blocks without inline flow, such as code and media, receive a preceding + * marker paragraph so callers never lose their leading control. + */ +export default function rehypeLeadingInlineContent() { + return (tree: HastRoot) => { + for (const child of tree.children) { + if (isText(child) && child.value.trim() === "") continue; + if (prependToFirstInlineTarget(child)) return; + break; + } + + tree.children.unshift({ + type: "element", + tagName: "p", + properties: {}, + children: [leadingMarker()], + }); + }; +} diff --git a/desktop/src/shared/ui/VideoReviewCommentMarkdown.tsx b/desktop/src/shared/ui/VideoReviewCommentMarkdown.tsx new file mode 100644 index 0000000000..d1a0815123 --- /dev/null +++ b/desktop/src/shared/ui/VideoReviewCommentMarkdown.tsx @@ -0,0 +1,77 @@ +import * as React from "react"; + +import { Markdown } from "@/shared/ui/markdown"; +import type { MarkdownProps } from "@/shared/ui/markdown/types"; +import { useOpenVideoReviewAt } from "@/shared/ui/VideoReviewNavigation"; +import { parseVideoReviewTimecode } from "@/shared/ui/videoReviewTimecode"; +import { + VideoReviewTimecodeButton, + VideoReviewTimecodeChip, +} from "@/shared/ui/VideoReviewTimecodeButton"; + +type VideoReviewCommentMarkdownProps = Omit< + MarkdownProps, + "leadingInlineContent" +> & { + videoReviewCommentRootId?: string; +}; + +/** Renders a video-review timecode inside the comment's first Markdown line. */ +export function VideoReviewCommentMarkdown({ + content, + interactive = true, + videoReviewCommentRootId, + ...markdownProps +}: VideoReviewCommentMarkdownProps) { + const openVideoReviewAt = useOpenVideoReviewAt(); + const reviewTimecode = React.useMemo( + () => (videoReviewCommentRootId ? parseVideoReviewTimecode(content) : null), + [content, videoReviewCommentRootId], + ); + const handleTimecodeClick = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + if (reviewTimecode && videoReviewCommentRootId) { + openVideoReviewAt?.(videoReviewCommentRootId, reviewTimecode.seconds); + } + }, + [openVideoReviewAt, reviewTimecode, videoReviewCommentRootId], + ); + const leadingInlineContent = React.useMemo(() => { + if (!reviewTimecode) return undefined; + + const timecode = + interactive && openVideoReviewAt ? ( + + ) : ( + + ); + return <>{timecode} ; + }, [handleTimecodeClick, interactive, openVideoReviewAt, reviewTimecode]); + + if (!reviewTimecode) { + return ( + + ); + } + + return ( + + ); +} diff --git a/desktop/src/shared/ui/VideoReviewTimecodeButton.tsx b/desktop/src/shared/ui/VideoReviewTimecodeButton.tsx index 53904f886f..5c1dedbe9c 100644 --- a/desktop/src/shared/ui/VideoReviewTimecodeButton.tsx +++ b/desktop/src/shared/ui/VideoReviewTimecodeButton.tsx @@ -9,6 +9,28 @@ const TIMECODE_ACCENT_HOVER_CLASS = const MESSAGE_TIMECODE_ACCENT_CLASS = "bg-primary/15 text-primary hover:bg-primary/30"; +function timecodeClasses({ + className, + interactive, + surface, +}: { + className?: string; + interactive: boolean; + surface: "message" | "review"; +}) { + return cn( + "inline-flex h-5 shrink-0 items-center rounded px-1.5 align-middle font-mono text-2xs font-semibold", + interactive && + "outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-white/60", + surface === "review" + ? [TIMECODE_ACCENT_CLASS, interactive && TIMECODE_ACCENT_HOVER_CLASS] + : interactive + ? MESSAGE_TIMECODE_ACCENT_CLASS + : "bg-primary/15 text-primary", + className, + ); +} + export function VideoReviewTimecodeButton({ className, onClick, @@ -23,13 +45,7 @@ export function VideoReviewTimecodeButton({ return ( + ); + } + + return ( + )} + className={classes} + > + {children} + + ); +} diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 670725f1f2..d040dd55b1 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -534,6 +534,7 @@ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; +import { isChannelLink } from "../../features/messages/lib/channelLink.ts"; import { isMessageLink } from "../../features/messages/lib/messageLink.ts"; import { parseEntityLink } from "../lib/entityLink.ts"; import remarkSpoilers from "../lib/remarkSpoilers.ts"; @@ -545,7 +546,7 @@ const EVENT_HEX = function buzzDeepLinkUrlTransform(value, key) { if (key !== "href") return defaultUrlTransform(value); - if (isMessageLink(value)) return value; + if (isMessageLink(value) || isChannelLink(value)) return value; if (parseEntityLink(value).ok) return value; return defaultUrlTransform(value); } @@ -580,6 +581,23 @@ test("messageLinkUrlTransform: preserves buzz://message href with thread", () => assert.match(html, /href="buzz:\/\/message\?[^"]*thread=t1"/); }); +test("messageLinkUrlTransform: preserves buzz://channel href", () => { + const html = renderMarkdown( + "Click [here](buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32)", + ); + assert.match( + html, + /href="buzz:\/\/channel\/580ca78b-9dae-46f3-8854-bd671853ba32"/, + ); +}); + +test("messageLinkUrlTransform: rejects malformed buzz://channel href", () => { + const html = renderMarkdown( + "Click [here](buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32?extra=true)", + ); + assert.match(html, /href=""/); +}); + test("messageLinkUrlTransform: still strips javascript: scheme", () => { const html = renderMarkdown("[xss](javascript:alert(1))"); // defaultUrlTransform replaces unsafe schemes with the empty string. @@ -654,13 +672,15 @@ test("buzzDeepLinkUrlTransform: strips malformed buzz://pr (unknown param)", () // the inline anchor click path (not just card extraction). import { renderEntityLinkAnchor } from "../ui/markdown/entityLinks.tsx"; +import { createMarkdownComponents } from "../ui/markdown.tsx"; +import { renderCachedMarkdown } from "../ui/markdown/nodeCache.ts"; +import { MarkdownRuntimeContext } from "../ui/markdown/runtimeContext.ts"; const CLONE_URL = `https://relay.example/git/${OWNER_HEX}/my-repo`; test("renderEntityLinkAnchor_matchingOriginCloneUrl_returnsEntityAnchor", () => { // Origin matches active relay — anchor should navigate in-app (non-null). const el = renderEntityLinkAnchor({ - anchorProps: {}, children: React.createElement("span", null, "my-repo"), href: CLONE_URL, onOpenEntityLink: () => {}, @@ -684,7 +704,6 @@ test("renderEntityLinkAnchor_matchingOriginCloneUrl_returnsEntityAnchor", () => test("renderEntityLinkAnchor_lookalikeDomainCloneUrl_returnsNull", () => { // Origin does NOT match active relay — must fall through to ExternalLinkAnchor. const el = renderEntityLinkAnchor({ - anchorProps: {}, children: React.createElement("span", null, "my-repo"), href: CLONE_URL, onOpenEntityLink: () => {}, @@ -700,7 +719,6 @@ test("renderEntityLinkAnchor_lookalikeDomainCloneUrl_returnsNull", () => { test("renderEntityLinkAnchor_noRelayOrigin_cloneUrlReturnsNull", () => { // No known relay origin — must fail closed, not guess. const el = renderEntityLinkAnchor({ - anchorProps: {}, children: React.createElement("span", null, "my-repo"), href: CLONE_URL, onOpenEntityLink: () => {}, @@ -717,7 +735,6 @@ test("renderEntityLinkAnchor_directEntityLink_returnsAnchorRegardlessOfOrigin", // A direct buzz://pr link always resolves in-app — it does not require origin. const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`; const el = renderEntityLinkAnchor({ - anchorProps: {}, children: React.createElement("span", null, "My PR"), href: prLink, onOpenEntityLink: () => {}, @@ -1042,3 +1059,237 @@ test("nudgeGuard_noSentinel_proseRenderedCardAbsent", () => { "markdownNode must render when no sentinel is present", ); }); + +test("bare Buzz permalinks render cohesive icon-prefixed chips", () => { + const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32"; + const messageLink = `buzz://message?channel=${channelId}&id=${EVENT_HEX}`; + const channelLink = `buzz://channel/${channelId}`; + const links = [ + messageLink, + channelLink, + `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`, + `buzz://issue?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`, + `buzz://repo?owner=${OWNER_HEX}&d=buzz-world`, + ]; + const markdown = renderCachedMarkdown({ + components: createMarkdownComponents(true, false), + content: links.join(" "), + variant: "entity-link-integration-test", + }); + const html = renderToStaticMarkup( + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [{ id: channelId, name: "engineering" }], + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, + }, + markdown, + ), + ); + + assert.equal((html.match(/data-buzz-link=""/g) ?? []).length, 5); + assert.match(html, /inline-chip-icon-message/); + assert.match(html, />engineering · c3b589faengineeringbuzz-world · c3b589fabuzz-world { + const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32"; + const links = [ + `[the message](buzz://message?channel=${channelId}&id=${EVENT_HEX})`, + `[**design discussion**](buzz://channel/${channelId})`, + `[the issue](buzz://issue?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world)`, + ]; + const markdown = renderCachedMarkdown({ + components: createMarkdownComponents(true, false), + content: links.join(" "), + variant: "authored-buzz-link-integration-test", + }); + const html = renderToStaticMarkup( + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [{ id: channelId, name: "engineering" }], + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, + }, + markdown, + ), + ); + + assert.equal((html.match(/data-buzz-link=""/g) ?? []).length, 0); + assert.match(html, />the messagedesign discussionthe issue { + const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32"; + const markdown = renderCachedMarkdown({ + components: createMarkdownComponents(true, false), + content: [ + `buzz://message?channel=${channelId}&id=${EVENT_HEX}`, + `buzz://channel/${channelId}`, + ].join(" "), + variant: "unknown-channel-buzz-link-integration-test", + }); + const html = renderToStaticMarkup( + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [], + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, + }, + markdown, + ), + ); + + assert.match(html, />580ca78b · c3b589fa580ca78b { + const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32"; + const markdown = renderCachedMarkdown({ + channelNames: ["engineering"], + components: createMarkdownComponents(true, false), + content: "See #engineering", + variant: "channel-reference-icon-integration-test", + }); + const html = renderToStaticMarkup( + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [{ id: channelId, name: "engineering" }], + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, + }, + markdown, + ), + ); + + assert.match(html, /inline-chip-icon-channel/); + assert.match(html, />engineering#engineering { + const markdown = renderCachedMarkdown({ + components: createMarkdownComponents(false, false), + content: "Ask @alice", + mentionNames: ["alice"], + variant: "human-mention-icon-integration-test", + }); + const html = renderToStaticMarkup( + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [], + mentionPubkeysByName: { alice: HUMAN_PUBKEY }, + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, + }, + markdown, + ), + ); + + assert.match(html, /data-mention=""/); + assert.match(html, /inline-chip-icon-human/); + assert.match(html, />alice@alice { + const markdown = renderCachedMarkdown({ + components: createMarkdownComponents(false, false), + content: "Ask @alice", + mentionNames: ["alice"], + variant: "agent-mention-icon-integration-test", + }); + const html = renderToStaticMarkup( + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + agentMentionPubkeysByName: { alice: AGENT_PUBKEY }, + channels: [], + mentionPubkeysByName: { alice: AGENT_PUBKEY }, + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, + }, + markdown, + ), + ); + + assert.match(html, /data-mention=""/); + assert.match(html, /agent-mention-highlight/); + assert.match(html, /inline-chip-icon-agent/); + assert.match(html, />alice@alice { + const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`; + const el = renderEntityLinkAnchor({ + children: "PR · abc123", + href: prLink, + interactive: true, + onOpenEntityLink: () => {}, + relayOrigin: null, + }); + const html = renderToStaticMarkup(el); + assert.match(html, /data-buzz-link=""/); + assert.match(html, / - ); - } - - return ( - - {children} - - ); + const { onOpenEntityLink, relayOrigin } = useMarkdownRuntime(); + const href = String(children ?? ""); + if (!parseEntityLink(href).ok) + return {href}; + return renderEntityLinkAnchor({ + children: href, + href, + interactive, + onOpenEntityLink, + relayOrigin, + }); }, "message-link": function MarkdownMessageLink({ children, @@ -1697,11 +1686,9 @@ function createMarkdownComponents( const href = String(children ?? ""); const parsed = parseMessageLink(href); if (!parsed.ok) { - // Malformed `buzz://message?…` — render the raw URL as plain text - // rather than a misleading clickable pill. + // Malformed link: render the raw URL rather than a misleading pill. return {href}; } - return ( (); type MarkdownComponentSet = { components: Components; variant: string }; diff --git a/desktop/src/shared/ui/markdown/BuzzLinkChip.tsx b/desktop/src/shared/ui/markdown/BuzzLinkChip.tsx new file mode 100644 index 0000000000..451bd9c303 --- /dev/null +++ b/desktop/src/shared/ui/markdown/BuzzLinkChip.tsx @@ -0,0 +1,152 @@ +import * as React from "react"; + +import { copyTextToClipboard } from "@/shared/lib/clipboard"; +import { InlineChip } from "@/shared/ui/InlineChip"; +import type { InlineChipIconKind } from "@/shared/ui/mentionChip"; + +import { + MediaContextMenu, + type MediaContextMenuPosition, + useDismissMediaContextMenu, +} from "./MediaContextMenu"; + +function useBuzzLinkContextMenu({ + href, + interactive, + onOpenLink, +}: { + href: string | undefined; + interactive: boolean; + onOpenLink: () => void; +}) { + const [position, setPosition] = + React.useState(null); + const closeMenu = React.useCallback(() => setPosition(null), []); + useDismissMediaContextMenu(Boolean(position), closeMenu); + + const onContextMenuCapture = React.useCallback( + (event: React.MouseEvent) => { + if (!interactive || !href) return; + event.preventDefault(); + setPosition({ x: event.clientX, y: event.clientY }); + }, + [href, interactive], + ); + + const contextMenu = + position && href ? ( + { + closeMenu(); + onOpenLink(); + }, + }, + { + label: "Copy link", + onSelect: () => { + closeMenu(); + copyTextToClipboard(href, "Link copied to clipboard"); + }, + }, + ]} + position={position} + /> + ) : null; + + return { contextMenu, onContextMenuCapture }; +} + +export function BuzzLinkChip({ + children, + className, + href, + icon: Icon, + interactive, + onOpenLink, + ...props +}: Omit, "onClick"> & { + href?: string; + icon: InlineChipIconKind; + interactive: boolean; + onOpenLink: () => void; +}) { + const { contextMenu, onContextMenuCapture } = useBuzzLinkContextMenu({ + href, + interactive, + onOpenLink, + }); + + if (!interactive) { + return ( + )} + data-buzz-link="" + className={className} + icon={Icon} + > + {children} + + ); + } + + return ( + <> + + {children} + + {contextMenu} + + ); +} + +export function BuzzInlineLink({ + children, + href, + interactive, + onOpenLink, + ...props +}: Omit, "onClick"> & { + href?: string; + interactive: boolean; + onOpenLink: () => void; +}) { + const contextMenuHref = + href ?? (typeof props.title === "string" ? props.title : undefined); + const { contextMenu, onContextMenuCapture } = useBuzzLinkContextMenu({ + href: contextMenuHref, + interactive, + onOpenLink, + }); + + if (!interactive) { + return {children}; + } + + return ( + <> + + {contextMenu} + + ); +} diff --git a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx new file mode 100644 index 0000000000..e5ca9eae89 --- /dev/null +++ b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx @@ -0,0 +1,118 @@ +import type * as React from "react"; + +import { + buildChannelLink, + parseChannelLink, +} from "@/features/messages/lib/channelLink"; + +import { BuzzInlineLink, BuzzLinkChip } from "./BuzzLinkChip"; +import { useMarkdownRuntime } from "./runtimeContext"; +import { getReactNodeText } from "./utils"; + +function channelPermalinkLabel( + channels: ReturnType["channels"], + channelId: string, +): string { + return ( + channels.find((candidate) => candidate.id === channelId)?.name ?? + channelId.slice(0, 8) + ); +} + +export function ChannelDeepLinkAnchor({ + children, + href, + interactive, +}: React.ComponentPropsWithoutRef<"a"> & { interactive: boolean }) { + const { channels, onOpenChannel } = useMarkdownRuntime(); + if (!href) return <>{children}; + const parsed = parseChannelLink(href); + if (!parsed.ok) return <>{children}; + const authoredLabel = getReactNodeText(children); + if (authoredLabel !== href) { + return ( + onOpenChannel(parsed.value.channelId)} + > + {children} + + ); + } + const label = channelPermalinkLabel(channels, parsed.value.channelId); + return ( + onOpenChannel(parsed.value.channelId)} + > + {label} + + ); +} + +export function MarkdownChannelDeepLink({ + children, + interactive, +}: { + children?: React.ReactNode; + interactive: boolean; +}) { + const { channels, onOpenChannel } = useMarkdownRuntime(); + const href = String(children ?? ""); + const parsed = parseChannelLink(href); + if (!parsed.ok) return {href}; + const label = channelPermalinkLabel(channels, parsed.value.channelId); + return ( + onOpenChannel(parsed.value.channelId)} + > + {label} + + ); +} + +export function MarkdownChannelReference({ + children, + interactive, +}: { + children?: React.ReactNode; + interactive: boolean; +}) { + const { channels, onOpenChannel } = useMarkdownRuntime(); + const text = String(children ?? ""); + const channelName = text.startsWith("#") ? text.slice(1) : text; + const channel = channels.find( + (candidate) => + candidate.channelType !== "dm" && + candidate.name.toLowerCase() === channelName.toLowerCase(), + ); + return ( + { + if (channel) onOpenChannel(channel.id); + }} + > + {channelName} + + ); +} diff --git a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx index 73824d58c7..1bbe33d957 100644 --- a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx +++ b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx @@ -1,17 +1,11 @@ import * as React from "react"; +import { buildMessageLink } from "@/features/messages/lib/messageLink"; import { cn } from "@/shared/lib/cn"; -import { - MENTION_CHIP_BASE_CLASSES, - MENTION_CHIP_HOVER_CLASSES, -} from "@/shared/ui/mentionChip"; +import { BuzzLinkChip } from "./BuzzLinkChip"; import type { MessageLinkPillProps } from "./types"; -import { - getMessageLinkChannelLabel, - getMessageLinkLabel, - MESSAGE_LINK_PREFIX, -} from "@/features/messages/lib/messageLinkLabel"; +import { getMessageLinkLabel } from "@/features/messages/lib/messageLinkLabel"; const graphemeSegmenter = typeof Intl.Segmenter === "function" @@ -46,6 +40,7 @@ function segmentLinkLabel(label: string): Array<{ export function MessageLinkPill({ channels, + href, interactive, link, onOpenMessageLink, @@ -54,65 +49,38 @@ export function MessageLinkPill({ }: MessageLinkPillProps) { const [isHovered, setIsHovered] = React.useState(false); const channel = channels.find((c) => c.id === link.channelId); - const channelLabel = channel?.name ?? "channel"; + const channelLabel = channel?.name ?? link.channelId.slice(0, 8); + const shortId = link.messageId.slice(0, 8); const isSentFromThread = variant === "sent-from-thread"; + const permalink = href ?? buildMessageLink(link); const label = getMessageLinkLabel({ channelName: channelLabel, threadExcerpt, variant, }); - const channelLinkLabel = getMessageLinkChannelLabel(channelLabel); - if (!interactive) { - if (!isSentFromThread) { - return ( - - {MESSAGE_LINK_PREFIX} - - {channelLinkLabel} - - - ); - } + if (!isSentFromThread) { return ( - - {label} - + { + onOpenMessageLink(link); + }} + > + {channelLabel} · {shortId} + ); } - if (!isSentFromThread) { + if (!interactive) { return ( - - {MESSAGE_LINK_PREFIX} - + + {label} ); } diff --git a/desktop/src/shared/ui/markdown/entityLinks.tsx b/desktop/src/shared/ui/markdown/entityLinks.tsx index b215110b86..d7d65e7249 100644 --- a/desktop/src/shared/ui/markdown/entityLinks.tsx +++ b/desktop/src/shared/ui/markdown/entityLinks.tsx @@ -12,6 +12,31 @@ import { type SupportedLinkPreview, } from "@/shared/lib/linkPreview"; +import { BuzzInlineLink, BuzzLinkChip } from "./BuzzLinkChip"; + +function entityLinkPresentation(link: ParsedEntityLink) { + switch (link.type) { + case "repo": + return { + ariaLabel: `Open repository ${link.dtag}`, + icon: "repo" as const, + label: link.dtag, + }; + case "pr": + return { + ariaLabel: `Open pull request ${link.id.slice(0, 8)} in repository ${link.dtag}`, + icon: "pr" as const, + label: `${link.dtag} · ${link.id.slice(0, 8)}`, + }; + case "issue": + return { + ariaLabel: `Open issue ${link.id.slice(0, 8)} in repository ${link.dtag}`, + icon: "issue" as const, + label: `${link.dtag} · ${link.id.slice(0, 8)}`, + }; + } +} + /** * Navigate to the project detail view for a `buzz://pr|issue|repo` link. * The link's (owner, d) coordinate is exactly the `/projects/$projectId` @@ -76,17 +101,19 @@ function resolveEntityHref( * default anchor. */ export function renderEntityLinkAnchor({ - anchorProps, children, href, onOpenEntityLink, relayOrigin, + interactive = true, + asChip = true, }: { - anchorProps: React.ComponentPropsWithoutRef<"a">; children: React.ReactNode; href: string | undefined; onOpenEntityLink: (link: ParsedEntityLink) => void; relayOrigin: string | null; + interactive?: boolean; + asChip?: boolean; }): React.ReactElement | null { if (!href) return null; @@ -95,18 +122,33 @@ export function renderEntityLinkAnchor({ const parsed = parseEntityLink(canonicalHref); if (!parsed.ok) return null; + const presentation = entityLinkPresentation(parsed.value); + + if (!asChip) { + return ( + onOpenEntityLink(parsed.value)} + > + {children} + + ); + } return ( - { - event.preventDefault(); - onOpenEntityLink(parsed.value); - }} + icon={presentation.icon} + title={href} + aria-label={presentation.ariaLabel} + interactive={interactive} + onOpenLink={() => onOpenEntityLink(parsed.value)} > - {children} - + {presentation.label} + ); } diff --git a/desktop/src/shared/ui/markdown/nodeCache.ts b/desktop/src/shared/ui/markdown/nodeCache.ts index 1e20693420..a3ae0f5e20 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.ts +++ b/desktop/src/shared/ui/markdown/nodeCache.ts @@ -3,7 +3,9 @@ import ReactMarkdown, { type Components } from "react-markdown"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; +import remarkChannelDeepLinks from "@/features/messages/lib/remarkChannelDeepLinks"; import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks"; +import remarkEntityLinks from "@/features/messages/lib/remarkEntityLinks"; import rehypeImageGallery from "@/shared/lib/rehypeImageGallery"; import rehypeLeadingInlineContent from "@/shared/lib/rehypeLeadingInlineContent"; import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight"; @@ -104,7 +106,9 @@ function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement { remarkGfm, remarkBreaks, remarkSpoilers, + remarkChannelDeepLinks, remarkMessageLinks, + remarkEntityLinks, [remarkMentions, { mentionNames: input.mentionNames }], [remarkChannelLinks, { channelNames: input.channelNames }], [remarkCustomEmoji, { customEmoji: input.customEmoji }], diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index 947c06d34a..69e575acbe 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -22,6 +22,8 @@ export type ImetaLookup = Map; export type MessageLinkPillProps = { channels: Channel[]; + /** Original permalink text, preserved for the context menu's Copy action. */ + href?: string; interactive: boolean; link: ParsedMessageLink; onOpenMessageLink: (link: ParsedMessageLink) => void; diff --git a/desktop/src/shared/ui/markdown/utils.ts b/desktop/src/shared/ui/markdown/utils.ts index a35e60cadc..f84984d51e 100644 --- a/desktop/src/shared/ui/markdown/utils.ts +++ b/desktop/src/shared/ui/markdown/utils.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { defaultUrlTransform } from "react-markdown"; +import { isChannelLink } from "@/features/messages/lib/channelLink"; import { isMessageLink } from "@/features/messages/lib/messageLink"; import { parseEntityLink } from "@/shared/lib/entityLink"; @@ -182,7 +183,7 @@ export function isInsideHiddenSpoiler(element: Element): boolean { */ export function buzzDeepLinkUrlTransform(value: string, key: string): string { if (key !== "href") return defaultUrlTransform(value); - if (isMessageLink(value)) return value; + if (isMessageLink(value) || isChannelLink(value)) return value; if (parseEntityLink(value).ok) return value; return defaultUrlTransform(value); } diff --git a/desktop/src/shared/ui/mentionChip.ts b/desktop/src/shared/ui/mentionChip.ts index e2f7c98803..0fa18f5603 100644 --- a/desktop/src/shared/ui/mentionChip.ts +++ b/desktop/src/shared/ui/mentionChip.ts @@ -2,7 +2,29 @@ export const MENTION_CHIP_BASE_CLASSES = "mention-chip"; export const MENTION_CHIP_HOVER_CLASSES = "mention-chip-hover"; -export const MENTION_CHIP_PREFIX_CLASS = "mention-chip-prefix"; +export type InlineChipIconKind = + | "agent" + | "human" + | "channel" + | "message" + | "repo" + | "pr" + | "issue"; + +const INLINE_CHIP_ICON_KIND_CLASSES: Record = { + agent: "inline-chip-icon-agent agent-mention-highlight", + human: "inline-chip-icon-human human-mention-highlight", + channel: "inline-chip-icon-channel", + message: "inline-chip-icon-message", + repo: "inline-chip-icon-repo", + pr: "inline-chip-icon-pr", + issue: "inline-chip-icon-issue", +}; + +/** Shared icon-box contract for React chips and ProseMirror decorations. */ +export function inlineChipIconClasses(kind: InlineChipIconKind): string { + return `inline-chip-with-icon ${INLINE_CHIP_ICON_KIND_CLASSES[kind]}`; +} /** Wrapper on rendered message Markdown — scopes inline chip CSS. */ export const MESSAGE_MARKDOWN_CLASS = "message-markdown"; diff --git a/desktop/src/shared/useMessageDeepLinks.ts b/desktop/src/shared/useMessageDeepLinks.ts index d4478a4422..fbbe4b9f67 100644 --- a/desktop/src/shared/useMessageDeepLinks.ts +++ b/desktop/src/shared/useMessageDeepLinks.ts @@ -1,7 +1,7 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { listenForMessageDeepLinks } from "@/shared/deep-link"; +import { listenForNavigationDeepLinks } from "@/shared/deep-link"; /** * Subscribe to `buzz://message` deep links emitted by the Tauri backend @@ -24,16 +24,24 @@ export function useMessageDeepLinks(enabled = true) { if (!enabled) return; let cancelled = false; - const unlistenPromise = listenForMessageDeepLinks((payload) => { - if (cancelled) return; - void goChannel(payload.channelId, { - messageId: payload.messageId, - threadRootId: payload.threadRootId, - }); - }); + const unlistenPromise = listenForNavigationDeepLinks( + async (payload) => { + if (cancelled) return false; + await goChannel(payload.channelId); + return true; + }, + async (payload) => { + if (cancelled) return false; + await goChannel(payload.channelId, { + messageId: payload.messageId, + threadRootId: payload.threadRootId, + }); + return true; + }, + ); return () => { cancelled = true; - void unlistenPromise.then((fn) => fn()); + void unlistenPromise.then((unlisten) => unlisten()); }; }, [enabled, goChannel]); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4a224709ea..20cc81a288 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -326,6 +326,8 @@ type E2eConfig = { /** Delay (ms) for `apply_workspace` so e2e tests can observe the * community-switch gate. 0/undefined = instant. */ applyCommunityDelayMs?: number; + /** Reject `clear_pending_navigation_deep_links` with this message. */ + clearPendingNavigationDeepLinksError?: string; openDmDelayMs?: number; sendMessageDelayMs?: number; /** Hold the media proxy at port 0 until the E2E release seam is invoked. */ @@ -476,6 +478,13 @@ type E2eConfig = { code?: string | null; name?: string | null; }>; + pendingNavigationDeepLinks?: Array<{ + id: string; + kind: "channel" | "message"; + channelId: string; + messageId?: string | null; + threadRootId?: string | null; + }>; // When true, `get_identity` returns `lost: true` until `persist_current_identity` // or `import_identity` is called. Drives the identity-lost recovery UX in tests. identityLost?: boolean; @@ -4363,6 +4372,24 @@ function resetMockPendingCommunityDeepLinks(config: E2eConfig | null) { })); } +let mockPendingNavigationDeepLinks: Array<{ + id: string; + kind: "channel" | "message"; + channelId: string; + messageId: string | null; + threadRootId: string | null; +}> = []; + +function resetMockPendingNavigationDeepLinks(config: E2eConfig | null) { + mockPendingNavigationDeepLinks = ( + config?.mock?.pendingNavigationDeepLinks ?? [] + ).map((pending) => ({ + ...pending, + messageId: pending.messageId ?? null, + threadRootId: pending.threadRootId ?? null, + })); +} + function recordMockUserStatus(event: RelayEvent) { const dTag = event.tags.find((tag) => tag[0] === "d")?.[1]; if (dTag) { @@ -10176,6 +10203,7 @@ export function maybeInstallE2eTauriMocks() { resetMockPersonaCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockPendingCommunityDeepLinks(config); + resetMockPendingNavigationDeepLinks(config); initializeMockHuddle(config.mock?.huddle, config); mockWebsocketSendMutexWedged = false; if (config.mock?.windowLabel) { @@ -11926,6 +11954,22 @@ export function maybeInstallE2eTauriMocks() { mockPendingCommunityDeepLinks.splice(index, 1); return true; } + case "clear_pending_navigation_deep_links": + if (activeConfig?.mock?.clearPendingNavigationDeepLinksError) { + throw new Error( + activeConfig.mock.clearPendingNavigationDeepLinksError, + ); + } + mockPendingNavigationDeepLinks.length = 0; + return; + case "take_pending_navigation_deep_link": + return mockPendingNavigationDeepLinks[0] ?? null; + case "acknowledge_pending_navigation_deep_link": { + const { id } = payload as { id: string }; + if (mockPendingNavigationDeepLinks[0]?.id !== id) return false; + mockPendingNavigationDeepLinks.shift(); + return true; + } case "get_relay_http_url": return getRelayHttpUrl(activeConfig); case "relay_requires_membership": diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index f80f3588ef..074b13cfe2 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1871,7 +1871,7 @@ test("channel with messages shows content", async ({ page }) => { ); await expect(page.getByTestId("message-timeline-day-divider")).toBeVisible(); await expect(page.getByTestId("message-timeline")).toContainText( - "Welcome to #general", + "Welcome to general", ); }); @@ -2384,7 +2384,7 @@ test("sidebar shows unread indicator for newly active channels", async ({ await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); await expect(page.getByTestId("message-timeline")).toContainText( - "Unread update for #random", + "Unread update for random", ); await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); }); diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 51b4867bf4..4e6c737190 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -834,6 +834,16 @@ test.describe("community rail", () => { // The app settles into the new community once apply completes. await expect(buttonB).toHaveAttribute("aria-current", "true"); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "clear_pending_navigation_deep_links", + ).length ?? 0, + ), + ) + .toBe(1); }); test("leaving the final community returns to setup without resetting identity", async ({ @@ -904,6 +914,37 @@ test.describe("community rail", () => { .toEqual(identityBefore); }); + test("shows a recoverable error when leaving the final community cannot clear navigation", async ({ + page, + }) => { + await installMockBridge( + page, + { clearPendingNavigationDeepLinksError: "queue unavailable" }, + { skipCommunitySeed: true }, + ); + await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); + await page.goto("/"); + + await page.getByTestId("sidebar-profile-avatar-button").click(); + await page.getByTestId("community-switcher").click(); + await page + .getByRole("menu", { name: "Community actions" }) + .getByRole("menuitem", { name: "Leave community" }) + .click(); + + const error = page.getByTestId("community-apply-error"); + await expect(error).toBeVisible(); + await expect(error).toContainText( + "Could not safely leave community: queue unavailable", + ); + await expect(page.getByText("Join or create a community")).toHaveCount(0); + await expect(page.getByTestId("community-switch-gate")).toHaveCount(0); + await expect(page.getByTestId("community-apply-error-retry")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Change community" }), + ).toBeVisible(); + }); + test("hides the rail with a single community", async ({ page }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); diff --git a/desktop/tests/e2e/empty-edit-delete.spec.ts b/desktop/tests/e2e/empty-edit-delete.spec.ts index 23a890ee29..2d8149966f 100644 --- a/desktop/tests/e2e/empty-edit-delete.spec.ts +++ b/desktop/tests/e2e/empty-edit-delete.spec.ts @@ -7,6 +7,7 @@ import { installMockBridge } from "../helpers/bridge"; // message is exactly Sam's workflow: "delete a message by clearing its edit." const OWN_MESSAGE_ID = "mock-general-welcome"; const ORIGINAL_CONTENT = "Welcome to #general"; +const RENDERED_ORIGINAL_CONTENT = "Welcome to general"; // Open the more-actions menu for a message row and wait for the menu to mount. async function openMoreActionsMenu( @@ -87,8 +88,9 @@ test("cancelling the empty-edit delete keeps the message", async ({ page }) => { await expect(page.getByTestId("edit-target")).toBeVisible(); await expect(row).toBeVisible(); await expect(page.getByTestId("message-timeline")).toContainText( - ORIGINAL_CONTENT, + RENDERED_ORIGINAL_CONTENT, ); + await expect(row.getByLabel("Open channel general")).toBeVisible(); }); test("a non-empty edit still edits and never deletes", async ({ page }) => { @@ -115,6 +117,6 @@ test("a non-empty edit still edits and never deletes", async ({ page }) => { editedContent, ); await expect(page.getByTestId("message-timeline")).not.toContainText( - ORIGINAL_CONTENT, + RENDERED_ORIGINAL_CONTENT, ); }); diff --git a/desktop/tests/e2e/integration.spec.ts b/desktop/tests/e2e/integration.spec.ts index 688749e944..730bf54608 100644 --- a/desktop/tests/e2e/integration.spec.ts +++ b/desktop/tests/e2e/integration.spec.ts @@ -314,7 +314,7 @@ test("live mentions refetch the home feed without waiting for polling", async ({ .click(); await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible(); await expect(targetPage.getByTestId("home-inbox-list")).toContainText( - message, + message.replace("@tyler", "tyler"), ); await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0); await expect.poll(() => getLoggedNotificationCount(targetPage)).toBe(1); @@ -371,7 +371,7 @@ test("live forum mentions refetch the home feed without waiting for polling", as await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible(); await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible(); await expect(targetPage.getByTestId("home-inbox-list")).toContainText( - message, + message.replace("@tyler", "tyler"), ); await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0); await expect.poll(() => getLoggedNotificationCount(targetPage)).toBe(1); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 06fa8cc706..801000189f 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -634,11 +634,56 @@ test("selecting a person mention inserts @Name into input", async ({ await dropdown.getByText("bob").click(); await expect(input).toHaveText("Hey @bob "); - const mentionChip = input.locator(".mention-chip", { - hasText: "@bob", + const mentionChip = input.locator(".human-mention-highlight", { + hasText: "bob", }); await expect(mentionChip).toBeVisible(); + await expect(mentionChip).toHaveText("bob"); await expect(mentionChip).not.toHaveClass(/agent-mention-highlight/); + await expect(mentionChip).toHaveCSS("display", "inline-flex"); + await expect( + input.locator(".mention-prefix-hidden", { hasText: "@" }), + ).toHaveCount(1); + const iconMask = await mentionChip.evaluate((element) => + getComputedStyle(element, "::before").getPropertyValue( + "-webkit-mask-image", + ), + ); + expect(iconMask).toContain("data:image/svg+xml"); +}); + +test("channel references keep caret movement through the channel name", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("#general"); + + const channelChip = input.locator(".inline-chip-icon-channel", { + hasText: "general", + }); + await expect(channelChip).toBeVisible(); + await expect(channelChip).toHaveText("general"); + await expect( + input.locator(".mention-prefix-hidden", { hasText: "#" }), + ).toHaveCount(1); + const iconMask = await channelChip.evaluate((element) => + getComputedStyle(element, "::before").getPropertyValue( + "-webkit-mask-image", + ), + ); + expect(iconMask).toContain("data:image/svg+xml"); + + await input.focus(); + await input.press("ArrowLeft"); + await input.press("ArrowLeft"); + await input.press("ArrowLeft"); + await page.keyboard.type("X"); + + await expect(input).toHaveText("#geneXral"); }); test("selecting a managed agent mention inserts @Name into input", async ({ @@ -2112,9 +2157,9 @@ test("sent non-member person mention uses the normal mention style", async ({ const mentionChip = page .getByTestId("message-row") .last() - .locator("[data-mention]", { hasText: "@outsider" }); + .locator("[data-mention]", { hasText: "outsider" }); await expect(mentionChip).toBeVisible(); - await expect(mentionChip.locator("svg")).toHaveCount(0); + await expect(mentionChip).toHaveClass(/inline-chip-icon-human/); }); test("sent managed non-member agent mention uses the agent mention style", async ({ @@ -2252,8 +2297,8 @@ test("mention text is highlighted in sent messages", async ({ page }) => { .last() .locator("[data-mention].mention-chip", { hasText: "bob" }); await expect(mentionChip).toBeVisible(); - await expect(mentionChip.locator(".mention-chip-prefix")).toHaveText("@"); - await expect(mentionChip.locator("svg")).toHaveCount(0); + await expect(mentionChip).toHaveText("bob"); + await expect(mentionChip).toHaveClass(/inline-chip-icon-human/); }); test("clicking author name opens user profile panel", async ({ page }) => { @@ -2312,8 +2357,8 @@ test("clicking a mention chip in the timeline opens the profile panel", async ({ const mentionChip = page .getByTestId("message-row") - .filter({ hasText: "Ping @bob about the launch" }) - .locator("[data-mention]", { hasText: "@bob" }); + .filter({ hasText: "Ping bob about the launch" }) + .locator("[data-mention]", { hasText: "bob" }); await expect(mentionChip).toBeVisible(); await mentionChip.click(); @@ -2340,8 +2385,8 @@ test("mention text matching the kind-0 name alias resolves and opens the profile const mentionChip = page .getByTestId("message-row") - .filter({ hasText: "Ask @bobby to review the doc" }) - .locator("[data-mention]", { hasText: "@bobby" }); + .filter({ hasText: "Ask bobby to review the doc" }) + .locator("[data-mention]", { hasText: "bobby" }); await expect(mentionChip).toBeVisible(); await mentionChip.click(); @@ -2366,7 +2411,7 @@ test("clicking a mention chip in a forum post opens the profile panel", async ({ await page.getByTestId("channel-watercooler").click(); await expect(page.getByTestId("chat-title")).toHaveText("watercooler"); - const mentionChip = page.locator("[data-mention]", { hasText: "@bob" }); + const mentionChip = page.locator("[data-mention]", { hasText: "bob" }); await expect(mentionChip).toBeVisible(); await mentionChip.click(); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index cdfe8e84b6..6e003195b3 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -1852,7 +1852,7 @@ test("day divider appears in timeline", async ({ page }) => { await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText( - "Welcome to #general", + "Welcome to general", ); await expect(page.getByTestId("message-timeline-day-divider")).toBeVisible(); }); @@ -2210,7 +2210,7 @@ test("opens a single-level thread panel with inline expansion", async ({ await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText( - "Welcome to #general", + "Welcome to general", ); const timeline = page.getByTestId("message-timeline"); @@ -2233,7 +2233,7 @@ test("opens a single-level thread panel with inline expansion", async ({ await rootMessage.getByRole("button", { name: "Reply" }).click(); await expect(threadPanel).toBeVisible(); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - "Welcome to #general", + "Welcome to general", ); await threadComposer.fill(firstReply); @@ -2379,7 +2379,7 @@ test("opens a single-level thread panel with inline expansion", async ({ await rootSummaryRow.click(); await expect(threadPanel).toBeVisible(); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - "Welcome to #general", + "Welcome to general", ); const firstReplyRow = threadReplies @@ -2390,7 +2390,7 @@ test("opens a single-level thread panel with inline expansion", async ({ await firstReplyRow.getByRole("button", { name: "Reply" }).click(); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - "Welcome to #general", + "Welcome to general", ); await expect(threadPanel.getByTestId("message-thread-back")).toHaveCount(0); diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index eb76ef3a4f..8adee2c0cf 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -337,6 +337,61 @@ test("settings shortcut returns without opening search dialog", async ({ await expect(page.getByTestId("search-results")).not.toBeVisible(); }); +test("mixed Buzz permalinks render as chips in the composer", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const channelId = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + const owner = "a".repeat(64); + const pullRequestId = "c".repeat(64); + const issueId = "b".repeat(64); + const links = [ + `buzz://message?channel=${channelId}&id=mock-general-welcome`, + `buzz://channel/${channelId}`, + `buzz://repo?owner=${owner}&d=buzz-world`, + `buzz://pr?id=${pullRequestId}&owner=${owner}&d=buzz-world`, + `buzz://issue?id=${issueId}&owner=${owner}&d=buzz-world`, + ].join(" "); + const composerInput = page.getByTestId("message-input"); + await composerInput.evaluate((element, text) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + element.dispatchEvent( + new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); + }, links); + + const chips = composerInput.locator('[data-composer-buzz-link=""]'); + await expect(chips).toHaveCount(5); + await expect(chips.nth(0)).toHaveText("general · mock-gen"); + await expect(chips.nth(1)).toHaveText("general"); + await expect(chips.nth(2)).toHaveText("buzz-world"); + await expect(chips.nth(3)).toHaveText("buzz-world · cccccccc"); + await expect(chips.nth(4)).toHaveText("buzz-world · bbbbbbbb"); + await expect(chips.nth(1)).toHaveClass(/inline-chip-icon-channel/); + await expect(chips.nth(2)).toHaveClass(/inline-chip-icon-repo/); + await expect(chips.nth(3)).toHaveClass(/inline-chip-icon-pr/); + await expect(chips.nth(4)).toHaveClass(/inline-chip-icon-issue/); + for (const index of [0, 1, 2, 3, 4]) { + const iconMask = await chips + .nth(index) + .evaluate((element) => + getComputedStyle(element, "::before").getPropertyValue( + "-webkit-mask-image", + ), + ); + expect(iconMask).toContain("data:image/svg+xml"); + } + await expect(composerInput).not.toContainText("buzz://"); +}); + test("message links to visible root messages open the thread panel", async ({ page, }) => { @@ -344,13 +399,13 @@ test("message links to visible root messages open the thread panel", async ({ await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText( - "Welcome to #general", + "Welcome to general", ); const link = "buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=mock-general-welcome"; const composerInput = page.getByTestId("message-input"); - await composerInput.fill("Root link repro "); + await composerInput.fill("Root link repro #random "); await composerInput.focus(); await composerInput.evaluate((element, href) => { const clipboardData = new DataTransfer(); @@ -364,11 +419,10 @@ test("message links to visible root messages open the thread panel", async ({ ); }, link); const composerLink = composerInput.locator('[data-composer-message-link=""]'); - await expect(composerLink).toContainText("Thread in"); - const composerChannelLink = composerLink.locator('[data-channel-link=""]'); - await expect(composerChannelLink).toHaveText("#general"); - await expect(composerChannelLink).toHaveClass(/mention-chip/); - await expect(composerLink).not.toHaveClass(/mention-chip/); + await expect(composerLink).toHaveText("general · mock-gen"); + await expect(composerLink).toHaveClass(/mention-chip/); + await expect(composerLink).toHaveClass(/inline-chip-icon-message/); + await expect(composerLink).toHaveAttribute("data-buzz-link", ""); await expect(composerLink).toHaveAttribute("title", "Thread in #general"); await expect(composerInput).not.toContainText("buzz://message"); await page.getByTestId("send-message").click(); @@ -379,20 +433,51 @@ test("message links to visible root messages open the thread panel", async ({ .last(); await expect(linkMessage).toBeVisible(); const rootThreadLink = linkMessage.getByRole("button", { - name: "Open thread in general", + name: "Open message mock-gen in channel general", }); - await expect(linkMessage.locator('[data-message-link=""]')).toContainText( - "Thread in", - ); - await expect(rootThreadLink).toHaveText("#general"); + await expect(rootThreadLink).toHaveText("general · mock-gen"); await expect(rootThreadLink).toHaveClass(/mention-chip/); - await rootThreadLink.click(); + const randomChannelLink = linkMessage.getByRole("button", { + name: "Open channel random", + }); + await expect(randomChannelLink).toBeVisible(); + await rootThreadLink.click({ button: "right" }); + + const linkMenu = page.locator("[data-buzz-link-context-menu]"); + await expect(linkMenu).toBeVisible(); + await randomChannelLink.click({ button: "right" }); + await expect(linkMenu).toHaveCount(1); + await rootThreadLink.click({ button: "right" }); + await expect(linkMenu).toHaveCount(1); + await expect( + linkMenu.getByRole("button", { name: "Open link" }), + ).toBeVisible(); + await linkMenu.getByRole("button", { name: "Copy link" }).click(); + await expect + .poll(() => + page.evaluate(() => { + return ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: { text?: string }; + }>; + } + ).__BUZZ_E2E_COMMAND_LOG__?.findLast( + ({ command }) => command === "copy_text_to_clipboard", + )?.payload.text; + }), + ) + .toBe(link); + + await rootThreadLink.click({ button: "right" }); + await linkMenu.getByRole("button", { name: "Open link" }).click(); const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); await expect(page).toHaveURL(/thread=mock-general-welcome/); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - "Welcome to #general", + "Welcome to general", ); }); @@ -407,7 +492,7 @@ test("message links reopen a closed thread when the same messageId is already in const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - "Welcome to #general", + "Welcome to general", ); await threadPanel.getByRole("button", { name: "Close panel" }).click(); @@ -426,14 +511,14 @@ test("message links reopen a closed thread when the same messageId is already in .last(); await expect(linkMessage).toBeVisible(); const rootThreadLink = linkMessage.getByRole("button", { - name: "Open thread in general", + name: "Open message mock-gen in channel general", }); - await expect(rootThreadLink).toHaveText("#general"); + await expect(rootThreadLink).toHaveText("general · mock-gen"); await rootThreadLink.click(); await expect(threadPanel).toBeVisible(); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - "Welcome to #general", + "Welcome to general", ); }); @@ -454,3 +539,63 @@ test("message deep links survive reload", async ({ page }) => { "Engineering shipped the desktop build.", ); }); + +// Cold-start OS links are queued natively until AppShell mounts its router listener. + +test("cold-start channel deep link drains after the router mounts", async ({ + page, +}) => { + await installMockBridge(page, { + pendingNavigationDeepLinks: [ + { + id: "navigation-channel-1", + kind: "channel", + channelId: ENGINEERING_CHANNEL_ID, + }, + ], + }); + + await page.goto("/"); + + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); + await expect(page).toHaveURL( + new RegExp(`#/channels/${ENGINEERING_CHANNEL_ID}$`), + ); + await expect + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (entry) => + entry.command === "acknowledge_pending_navigation_deep_link", + ), + ), + ) + .toEqual([ + { + command: "acknowledge_pending_navigation_deep_link", + payload: { id: "navigation-channel-1" }, + }, + ]); +}); + +test("cold-start message deep link preserves its thread target", async ({ + page, +}) => { + await installMockBridge(page, { + pendingNavigationDeepLinks: [ + { + id: "navigation-message-1", + kind: "message", + channelId: WATERCOLOR_CHANNEL_ID, + messageId: "mock-forum-release-reply", + threadRootId: "mock-forum-release-thread", + }, + ], + }); + + await page.goto("/"); + + await expect(page.getByTestId("chat-title")).toHaveText("watercooler"); + await expect(page).toHaveURL(/messageId=mock-forum-release-reply/); + await expect(page).toHaveURL(/threadRootId=mock-forum-release-thread/); +}); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 2ef3d5825c..2d8e58492f 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -3214,7 +3214,7 @@ test("first-run onboarding posts the live Fizz kickoff", async ({ page }) => { // Greeted by the name typed above — the @mention pill also files the opener // into the new user's Inbox mentions feed. await expect(page.getByTestId("message-timeline")).toContainText( - "Hi @Morty QA, I'm Fizz. Welcome to Buzz.", + "Hi Morty QA, I'm Fizz. Welcome to Buzz.", ); await expect(page.getByTestId("message-timeline")).toContainText( "Honey and Bumble, introduce yourselves", @@ -3238,7 +3238,7 @@ test("first-run onboarding lands before Welcome team bootstrap completes", async await expectPrivateWelcomeLanding(page); await expect(page.getByTestId("app-loading-gate")).toHaveCount(0); await expect(page.getByTestId("message-timeline")).toContainText( - "Hi @Morty QA, I'm Fizz. Welcome to Buzz.", + "Hi Morty QA, I'm Fizz. Welcome to Buzz.", ); await page.waitForTimeout(1_500); expect(await commandCount(page, "create_managed_agent")).toBe(3); diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index 289d8ef318..74f819cc51 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -349,7 +349,7 @@ test("passive relay watchdog does not write while the websocket is half-open", a await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText( - "Welcome to #general", + "Welcome to general", ); await setMockWebsocketSendsStalled(page, true); diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 11de497f4c..b3ea8bea61 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -437,7 +437,7 @@ test("global search offers an optional current-channel scope", async ({ const firstScopedResult = page .locator('[data-search-section="messages"] .search-result-row') .first(); - await expect(page.getByText("Welcome to #general")).toBeVisible(); + await expect(page.getByText("Welcome to general")).toBeVisible(); await expect(page.getByText(/Searching messages in/)).toHaveCount(0); await expect(relevantHeader).toBeVisible(); await expect(firstScopedResult).toBeVisible(); @@ -756,7 +756,7 @@ test("replaces the channel pane when switching channels", async ({ page }) => { await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText( - "Welcome to #general", + "Welcome to general", ); await page.getByTestId("channel-random").click(); @@ -766,7 +766,7 @@ test("replaces the channel pane when switching channels", async ({ page }) => { "This is the beginning of the regular channel.", ); await expect(page.getByTestId("message-timeline")).not.toContainText( - "Welcome to #general", + "Welcome to general", ); await expect(page.getByTestId("message-timeline")).toHaveCount(1); await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(0); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index c7a8ba1a08..79ada74c86 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -280,6 +280,8 @@ type MockBridgeOptions = { canvasReadError?: string; /** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */ applyCommunityDelayMs?: number; + /** Reject `clear_pending_navigation_deep_links` with this message. */ + clearPendingNavigationDeepLinksError?: string; openDmDelayMs?: number; sendMessageDelayMs?: number; /** Hold the media proxy at port 0 until the E2E release seam is invoked. */ @@ -463,6 +465,14 @@ type MockBridgeOptions = { code?: string | null; name?: string | null; }>; + /** Pending channel/message links that arrived before AppShell mounted. */ + pendingNavigationDeepLinks?: Array<{ + id: string; + kind: "channel" | "message"; + channelId: string; + messageId?: string | null; + threadRootId?: string | null; + }>; /** * Global agent config returned by `get_global_agent_config`. Defaults to * an empty config (no provider, model, or env vars) if not specified. From fd0ab47a1b5526d7496b5a6d731f3c8d7e4dbe9f Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 14 Aug 2026 10:18:54 -0700 Subject: [PATCH 14/33] fix(link-preview): refetch a link when it re-enters the composer (#5510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Overview **Category:** fix **User Impact:** When a user re-pastes (or finishes typing) a link that previously failed to load a preview, the composer now refetches it immediately and can never send a snapshot preview built from the old, stale metadata. **Problem:** The link-preview cache is shared with passive message-list scroll, so a URL that resolved to a negative result (a hard `null` miss or a transient fetch failure) stayed cached and re-usable. Re-pasting that exact link into the composer served the stale negative and never refetched. Worse, the stale metadata was still `snapshotReady`, so a fast clear-then-repaste could attach a **stale snapshot preview tag** to the sent message — a preview that no longer matched the link. **Solution:** A freshly-entering link is forced to refetch, and the composer is fenced against ever shipping a tag built from pre-re-entry metadata. This closes three distinct races surfaced over successive review passes: (1) the shared negative cache being reused on re-entry; (2) the resolver's debounce swallowing a fast clear+re-paste so the re-entry was invisible and the stale tag stayed sendable; and (3) an in-flight media upload started from the stale metadata publishing its tag after fresh metadata had already arrived. Healthy cached hits are never touched (instant card, no redundant fetch), and passive message-list scroll — which never opts in — keeps riding the shared cache exactly as before.
    File changes **desktop/src/shared/lib/useResolvedLinkPreviews.ts** Adds a loader `invalidateNegative(href)` that drops a cached negative result (resolved `null` or transient fail) while leaving healthy hits and in-flight promises alone, and a `refetchNewNegatives` option that invalidates each newly-present href's negative entry before the peek/load loop reads the cache. Also adds an optional `liveHrefs` input so newness is judged against the caller's LIVE (undebounced) content — a debounce-swallowed leave/re-entry of the same URL still counts as new. Because the hook retains its own resolved metadata (the render that scheduled the effect already read the stale negative from it), it also clears its OWN negative key for every re-entered href, so the link renders as pending until the fresh load wins. `buzz://` entity links are skipped (they resolve off the relay, not this cache). **desktop/src/features/messages/ui/useComposerLinkPreviews.tsx** Opts the composer into `refetchNewNegatives` and feeds it the live hrefs. Detects a same-URL re-entry at render time (React batches the empty→repaste renders, so an effect keyed on the live set never observes the transition), then blocks the re-entered href until the resolver's forced refetch visibly cycles through pending: its stale ready tag is dropped from state and excluded from the sendable output until a fresh result re-tags. Only the sendable negative case (`fallback`) is blocked; a healthy (`image`) re-entry keeps its instant card. Adds a per-href upload generation token (`uploadsRef` becomes `Map`): a live re-entry bumps the generation, the upload effect's dedup guard and completion are generation-aware, so an in-flight upload from stale metadata cannot publish its tag after settling and a fresh upload can start even while the superseded one is still in flight. **desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs** Adds resolver-level regressions: `invalidateNegative` drops a cached miss (next load refetches) but preserves a healthy hit (no redundant fetch); transient failure → URL removed → re-entered renders pending/not-`snapshotReady` until a successful retry; and the retained-negative + shared in-flight-fetch + re-entry interleaving clears the local negative regardless of the shared entry's shape. **desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs** Adds composer-hook regressions driving the REAL hook through the hostile gestures: a fast clear+re-paste inside the debounce window drops the stale tag and holds Send pending until a fresh tag carrying the newly-fetched media lands; and a stale in-flight upload held across the clear+re-paste and fresh-metadata resolution cannot publish its pre-clear tag, while a fresh upload starts and its tag wins.
    ### Reproduction Steps 1. Paste a link whose preview fails to resolve (force a transient fetch failure) so the composer shows a blank/collapsed card. 2. Clear the composer and re-paste the same link (quickly, within the ~350ms debounce window). 3. Observe the preview refetches immediately rather than reusing the stale negative result, and Send stays disabled until a fresh tag lands. 4. Send the message and confirm the attached preview tag reflects the fresh fetch, never the stale pre-clear metadata. 5. Confirm passive message-list scroll of already-resolved links still shows cards instantly with no extra fetches. ### Notes Scope grew across three review passes from the original single resolver opt-in into a full defense against shipping stale snapshot tags on link re-entry — see the scope-adjustment comment on this PR for the detail. Stacked on #5245 (`tho/link-preview-snapshot-race`), whose rewrite of `useComposerLinkPreviews.tsx` is the sole overlapping file. The transient-retry work stays in #5502, which touches no composer file and remains based on main. --------- Signed-off-by: Taylor Ho Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Carl --- .../features/messages/ui/MessageComposer.tsx | 8 +- .../ui/useComposerLinkPreviews.test.mjs | 752 ++++++++++++++++++ .../messages/ui/useComposerLinkPreviews.tsx | 280 ++++++- .../lib/useResolvedLinkPreviews.test.mjs | 415 +++++++++- .../src/shared/lib/useResolvedLinkPreviews.ts | 141 +++- 5 files changed, 1561 insertions(+), 35 deletions(-) create mode 100644 desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index d13cec95eb..022d60b4d1 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -57,7 +57,7 @@ import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionH import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; import { submitMessageEdit } from "./submitMessageEdit"; -import { useComposerLinkPreviews } from "./useComposerLinkPreviews"; +import { useManagedComposerLinkPreviews } from "./useComposerLinkPreviews"; import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit"; import type { MessageComposerProps } from "./MessageComposer.types"; function MessageComposerImpl({ @@ -100,14 +100,14 @@ function MessageComposerImpl({ syncComposerContentFromEditor, syncContentRefFromEditorRef, } = useComposerContentState(); - const [previewContent, setPreviewContent] = React.useState(""); const { previewList: composerLinkPreviews, getReadyTags: getReadyLinkPreviewTags, hasPendingSnapshots: hasPendingLinkPreviewSnapshots, // Ref lets the submit guard block Enter/form/auto-submit until snapshots settle. hasPendingSnapshotsRef: hasPendingLinkPreviewSnapshotsRef, - } = useComposerLinkPreviews(previewContent, editTarget == null); + updateContent: updateLinkPreviewContent, + } = useManagedComposerLinkPreviews(editTarget == null); const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false); const [isFormattingOpen, setIsFormattingOpen] = React.useState(false); const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState< @@ -269,7 +269,7 @@ function MessageComposerImpl({ onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, onUpdate: ({ cursor, linkPreviewContent, text }) => { setComposerContentFromText(text); - setPreviewContent(linkPreviewContent); + updateLinkPreviewContent(linkPreviewContent); mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); emojiAutocomplete.updateEmojiQuery(text, cursor); diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs b/desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs new file mode 100644 index 0000000000..049c57f70c --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs @@ -0,0 +1,752 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// ── Composer-hook regression: fast clear + re-paste of the same URL ─────────── +// +// useComposerLinkPreviews feeds useResolvedLinkPreviews from DEBOUNCED content +// (350ms) but tracks URL-presence newness from the LIVE content. Without that +// live-href signal a fast clear-then-repaste of the same URL inside the debounce +// window never commits an empty debounced set, so the resolver never sees the +// URL leave and never refetches — and the stale snapshot tag built from the +// pre-clear metadata stays sendable. This drives the real composer hook through +// that gesture and asserts the stale tag is not sendable and a fresh fetch is +// forced. (PR #5510, second follow-up.) + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +/** @type {Map Promise>} */ +const ipcHandlers = new Map(); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + dom.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + const handler = ipcHandlers.get(cmd); + return handler + ? handler(args) + : Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: () => Math.random(), + }; +}); + +after(() => dom.window.close()); + +const HREF = "https://example.com/composer-re-entry"; +const DEBOUNCE_WAIT_MS = 400; // > LINK_PREVIEW_DEBOUNCE_MS (350) + +function metadata(overrides = {}) { + return { + title: "A story", + siteName: "Example", + description: "Story description", + imageDataUrl: null, + imageDomain: null, + imageFetchState: "none", + imageRetryAfterMs: null, + faviconDataUrl: null, + ...overrides, + }; +} + +test("composer input versions retain only active hrefs while re-entry advances", async () => { + const { updateComposerLinkPreviewInput } = await import( + "./useComposerLinkPreviews.tsx" + ); + const secondHref = "https://example.com/second"; + let input = { + content: "", + hrefs: new Set(), + hrefVersions: new Map(), + nextHrefVersion: 0, + }; + + input = updateComposerLinkPreviewInput(input, `see ${HREF}`); + const firstVersion = input.hrefVersions.get(HREF); + assert.equal(input.hrefVersions.size, 1); + + input = updateComposerLinkPreviewInput(input, `see ${secondHref}`); + assert.deepEqual( + [...input.hrefVersions.keys()], + [secondHref], + "departed href history is pruned instead of retained for the composer lifetime", + ); + + input = updateComposerLinkPreviewInput(input, `see ${HREF}`); + assert.deepEqual([...input.hrefVersions.keys()], [HREF]); + assert.ok( + input.hrefVersions.get(HREF) > firstVersion, + "a re-entered href receives a new monotonic version after its old entry was pruned", + ); +}); + +test("composer forces a refetch and drops the stale tag on a fast clear+re-paste of the same URL", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { resetLinkPreviewMetadataCache } = await import( + "@/shared/lib/useResolvedLinkPreviews.ts" + ); + const { updateComposerLinkPreviewInput, useComposerLinkPreviews } = + await import("./useComposerLinkPreviews.tsx"); + + resetLinkPreviewMetadataCache(); + ipcHandlers.clear(); + + // Relay origin for media URLs (composer fetches it once on mount). + ipcHandlers.set("get_relay_http_url", () => + Promise.resolve("https://relay.example.com"), + ); + // Media upload always succeeds instantly so a snapshot tag can be built. + ipcHandlers.set("upload_media_bytes", () => + Promise.resolve({ + url: "https://relay.example.com/media/x", + sha256: "deadbeef", + size: 1, + type: "image/png", + uploaded: 0, + }), + ); + // Call 1 (initial paste) resolves to a transient failure -> sendable fallback, + // instantly. Call 2 (the forced re-entry refetch) resolves to a success but + // only when we release `resolveRefetch`, so the test can observe the + // intermediate window where the stale tag is gone and Send is held pending + // BEFORE the fresh result lands (in the app the refetch is a real network + // round-trip; instant resolution would collapse the window under test). + let fetchCalls = 0; + let resolveRefetch; + ipcHandlers.set("fetch_link_preview_metadata", () => { + fetchCalls += 1; + if (fetchCalls === 1) { + return Promise.resolve( + metadata({ + imageFetchState: "transient_failure", + imageRetryAfterMs: 900_000, + }), + ); + } + return new Promise((resolve) => { + resolveRefetch = () => + resolve( + metadata({ + imageDataUrl: "data:image/png;base64,QQ==", + imageDomain: "images.example.com", + imageFetchState: "image", + }), + ); + }); + }); + + const flushDebounceAndSettle = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS)); + }); + }; + + try { + let previewInput = updateComposerLinkPreviewInput( + { + content: "", + hrefs: new Set(), + hrefVersions: new Map(), + nextHrefVersion: 0, + }, + `see ${HREF}`, + ); + const { result, rerender, unmount } = renderHook( + ({ content, hrefVersions }) => + useComposerLinkPreviews(content, true, hrefVersions), + { initialProps: previewInput }, + ); + + // 1. Paste settles to a transient-failure fallback with a ready snapshot tag. + await flushDebounceAndSettle(); + assert.equal(fetchCalls, 1); + assert.equal( + result.current.getReadyTags().length, + 1, + "the transient fallback produced a sendable snapshot tag", + ); + assert.equal(result.current.hasPendingSnapshots, false); + + // 2. Fast gesture: clear the URL, then re-paste the SAME URL, with both + // editor updates folded into one React batch. The debounced candidates + // and the committed live href set therefore never observe empty. + // Model two editor onUpdate calls folded into one React batch. The final + // href set equals the previous commit, but the update-boundary version has + // advanced because the URL left and re-entered between those updates. + await act(async () => { + previewInput = updateComposerLinkPreviewInput(previewInput, "see "); + previewInput = updateComposerLinkPreviewInput( + previewInput, + `see ${HREF}`, + ); + rerender(previewInput); + }); + + // 3a. The stale tag must be gone AS SOON AS the same URL re-enters — this is + // the core invariant and it holds synchronously (render-time detection + // drops the tag and excludes the href from sendable output), so no timer + // needs to fire first. Send is held pending until a fresh tag lands. + await act(async () => {}); + assert.equal( + result.current.getReadyTags().length, + 0, + "stale snapshot tag must not be sendable the moment the URL re-enters", + ); + assert.equal( + result.current.hasPendingSnapshots, + true, + "Send must be held pending after a clear+re-paste", + ); + + // 3b. Once the debounce settles, the re-entry has forced a fresh fetch. It is + // still in flight (the deferred refetch has NOT resolved), so the stale + // tag stays gone and Send stays pending. Pre-fix the re-entry is + // invisible, so no refetch starts and this fails fast. + await flushDebounceAndSettle(); + assert.equal( + fetchCalls, + 2, + "the re-entry forced a fresh fetch (still in flight)", + ); + assert.equal( + result.current.getReadyTags().length, + 0, + "stale snapshot tag must not be sendable while the re-entry refetches", + ); + assert.equal( + result.current.hasPendingSnapshots, + true, + "Send must be held pending while the re-entry refetches", + ); + + // 4. The forced refetch resolves (success); a fresh tag becomes sendable and + // its media is the freshly-fetched image, not the pre-clear empty + // fallback (proving the tag was rebuilt from new metadata). + await act(async () => { + resolveRefetch(); + }); + await flushDebounceAndSettle(); + const [freshTag] = result.current.getReadyTags(); + assert.equal( + result.current.getReadyTags().length, + 1, + "a fresh sendable tag lands after the refetch", + ); + assert.ok( + freshTag?.includes("https://relay.example.com/media/x"), + "the sendable tag carries the freshly-fetched snapshot media", + ); + assert.equal(result.current.hasPendingSnapshots, false); + + unmount(); + } finally { + cleanup(); + ipcHandlers.clear(); + } +}); + +// A blocked re-entry can leave again before its forced refetch settles. The +// abandoned phase must not poison a later paste of the now-healthy cached result. +test("a removed blocked re-entry can later use metadata that resolved while absent", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { resetLinkPreviewMetadataCache } = await import( + "@/shared/lib/useResolvedLinkPreviews.ts" + ); + const { updateComposerLinkPreviewInput, useComposerLinkPreviews } = + await import("./useComposerLinkPreviews.tsx"); + + resetLinkPreviewMetadataCache(); + ipcHandlers.clear(); + ipcHandlers.set("get_relay_http_url", () => + Promise.resolve("https://relay.example.com"), + ); + ipcHandlers.set("upload_media_bytes", () => + Promise.resolve({ + url: "https://relay.example.com/media/fresh-after-absence", + sha256: "f8e5", + size: 1, + type: "image/png", + uploaded: 0, + }), + ); + + let fetchCalls = 0; + let resolveRefetch; + ipcHandlers.set("fetch_link_preview_metadata", () => { + fetchCalls += 1; + if (fetchCalls === 1) { + return Promise.resolve( + metadata({ + imageFetchState: "transient_failure", + imageRetryAfterMs: 900_000, + }), + ); + } + return new Promise((resolve) => { + resolveRefetch = () => + resolve( + metadata({ + imageDataUrl: "data:image/png;base64,QQ==", + imageDomain: "images.example.com", + imageFetchState: "image", + }), + ); + }); + }); + + const settle = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS)); + }); + }; + + try { + let previewInput = updateComposerLinkPreviewInput( + { + content: "", + hrefs: new Set(), + hrefVersions: new Map(), + nextHrefVersion: 0, + }, + `see ${HREF}`, + ); + const { result, rerender, unmount } = renderHook( + ({ content, hrefVersions }) => + useComposerLinkPreviews(content, true, hrefVersions), + { initialProps: previewInput }, + ); + + await settle(); + assert.equal(result.current.getReadyTags().length, 1); + + // Re-enter the cached negative and wait until its forced refetch is in flight. + await act(async () => { + previewInput = updateComposerLinkPreviewInput(previewInput, "see "); + previewInput = updateComposerLinkPreviewInput( + previewInput, + `see ${HREF}`, + ); + rerender(previewInput); + }); + await settle(); + assert.equal(fetchCalls, 2); + assert.equal(result.current.getReadyTags().length, 0); + assert.equal(result.current.hasPendingSnapshots, true); + + // Remove the blocked href, then let its refetch populate healthy metadata + // while no candidate is active. + await act(async () => { + previewInput = updateComposerLinkPreviewInput(previewInput, "see "); + rerender(previewInput); + }); + await settle(); + await act(async () => resolveRefetch()); + await settle(); + assert.equal(result.current.getReadyTags().length, 0); + + // A later paste should use the healthy result immediately after debounce; + // the abandoned "blocked" phase must not survive and suppress its tag. + await act(async () => { + previewInput = updateComposerLinkPreviewInput( + previewInput, + `see ${HREF}`, + ); + rerender(previewInput); + }); + await settle(); + const [freshTag] = result.current.getReadyTags(); + assert.equal( + fetchCalls, + 2, + "healthy metadata is preserved without a third fetch", + ); + assert.equal(result.current.getReadyTags().length, 1); + assert.ok( + freshTag?.includes("https://relay.example.com/media/fresh-after-absence"), + "the later paste becomes sendable with metadata resolved while absent", + ); + assert.equal(result.current.hasPendingSnapshots, false); + + unmount(); + } finally { + cleanup(); + ipcHandlers.clear(); + } +}); + +// ── Composer-hook regression: stale in-flight upload after re-entry ─────────── +// +// A pre-clear transient-fallback snapshot upload (U1) can still be in flight +// when the URL is cleared and re-pasted. The re-entry forces a refetch to fresh +// metadata, and the upload effect starts a fresh upload (U2). When the stale U1 +// finally settles it must NOT publish a snapshot tag built from the pre-clear +// metadata — even though its re-entry phase marker was already cleared once the +// refetch reached fresh ready. The per-href upload generation fence makes the +// stale completion a no-op, and the generation-aware dedup guard lets U2 start +// even while U1's slot is still occupied. Reverting either the generation fence +// in `.then` or the generation-aware dedup guard makes this fail (U1 publishes +// its stale favicon, or U2 never starts). (PR #5510, third follow-up.) +test("a stale in-flight upload cannot publish after the URL re-enters and a fresh upload wins", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { resetLinkPreviewMetadataCache } = await import( + "@/shared/lib/useResolvedLinkPreviews.ts" + ); + const { updateComposerLinkPreviewInput, useComposerLinkPreviews } = + await import("./useComposerLinkPreviews.tsx"); + + resetLinkPreviewMetadataCache(); + ipcHandlers.clear(); + + ipcHandlers.set("get_relay_http_url", () => + Promise.resolve("https://relay.example.com"), + ); + + // Gate the FIRST media upload (U1's favicon — its image is null in the + // transient fallback, so the favicon is U1's only upload) so it stays in + // flight across the clear + re-paste and the fresh-metadata resolution. Every + // later upload resolves instantly with a "fresh" URL. If a stale tag ever + // reaches the sendable set it will carry the STALE favicon URL, which the + // assertions forbid. + let uploadCalls = 0; + let releaseStaleUpload; + ipcHandlers.set("upload_media_bytes", () => { + uploadCalls += 1; + if (uploadCalls === 1) { + return new Promise((resolve) => { + releaseStaleUpload = () => + resolve({ + url: "https://relay.example.com/media/STALE", + sha256: "5741313", + size: 1, + type: "image/png", + uploaded: 0, + }); + }); + } + return Promise.resolve({ + url: "https://relay.example.com/media/FRESH", + sha256: "f8e5", + size: 1, + type: "image/png", + uploaded: 0, + }); + }); + + // Call 1 (initial paste): transient failure WITH a favicon, so it produces a + // sendable fallback whose upload (the favicon) is the gated U1. Call 2 (the + // forced re-entry refetch): a full success that only resolves when released, + // so the intermediate window (U1 in flight, refetch pending) is observable. + let fetchCalls = 0; + let resolveRefetch; + ipcHandlers.set("fetch_link_preview_metadata", () => { + fetchCalls += 1; + if (fetchCalls === 1) { + return Promise.resolve( + metadata({ + faviconDataUrl: "data:image/png;base64,QQ==", + imageFetchState: "transient_failure", + imageRetryAfterMs: 900_000, + }), + ); + } + return new Promise((resolve) => { + resolveRefetch = () => + resolve( + metadata({ + faviconDataUrl: "data:image/png;base64,Qg==", + imageDataUrl: "data:image/png;base64,Qw==", + imageDomain: "images.example.com", + imageFetchState: "image", + }), + ); + }); + }); + + const flushDebounceAndSettle = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS)); + }); + }; + + try { + let previewInput = updateComposerLinkPreviewInput( + { + content: "", + hrefs: new Set(), + hrefVersions: new Map(), + nextHrefVersion: 0, + }, + `see ${HREF}`, + ); + const { result, rerender, unmount } = renderHook( + ({ content, hrefVersions }) => + useComposerLinkPreviews(content, true, hrefVersions), + { initialProps: previewInput }, + ); + + // 1. Paste settles to a transient-failure fallback. Its favicon upload (U1) + // is gated and stays in flight, so no sendable tag exists yet. + await flushDebounceAndSettle(); + assert.equal(fetchCalls, 1); + assert.equal( + uploadCalls, + 1, + "U1 (the stale fallback favicon) is in flight", + ); + assert.equal( + result.current.getReadyTags().length, + 0, + "U1 has not settled, so no snapshot tag is sendable yet", + ); + + // 2. Fast gesture: clear then re-paste the SAME URL inside the debounce. + await act(async () => { + previewInput = updateComposerLinkPreviewInput(previewInput, "see "); + previewInput = updateComposerLinkPreviewInput( + previewInput, + `see ${HREF}`, + ); + rerender(previewInput); + }); + + // 3. The re-entry forces a fresh fetch; resolve it to fresh success. The + // upload effect must start a FRESH upload (U2) even though U1 still holds + // the slot, then produce a fresh sendable tag. + await flushDebounceAndSettle(); + assert.equal(fetchCalls, 2, "the re-entry forced a fresh fetch"); + await act(async () => { + resolveRefetch(); + }); + await flushDebounceAndSettle(); + assert.ok( + uploadCalls >= 2, + "a fresh upload (U2) started despite U1 still holding the slot", + ); + const [freshTag] = result.current.getReadyTags(); + assert.equal( + result.current.getReadyTags().length, + 1, + "the fresh upload produced a sendable tag", + ); + assert.ok( + freshTag?.includes("https://relay.example.com/media/FRESH"), + "the sendable tag carries the freshly-uploaded media", + ); + assert.ok( + !freshTag?.includes("https://relay.example.com/media/STALE"), + "the sendable tag must not carry the stale pre-clear media", + ); + + // 4. Release the stale U1. Its completion must be a no-op: it cannot + // overwrite the fresh tag with one built from pre-clear metadata. + await act(async () => { + releaseStaleUpload(); + }); + await flushDebounceAndSettle(); + const [tagAfterStale] = result.current.getReadyTags(); + assert.equal( + result.current.getReadyTags().length, + 1, + "still exactly one sendable tag after the stale upload settles", + ); + assert.ok( + tagAfterStale?.includes("https://relay.example.com/media/FRESH") && + !tagAfterStale?.includes("https://relay.example.com/media/STALE"), + "the stale upload cannot publish its pre-clear tag after settling", + ); + + unmount(); + } finally { + cleanup(); + ipcHandlers.clear(); + } +}); + +// The ordinary production gesture starts from a mounted empty composer. Live +// href tracking must not mark the pasted href handled before the debounced +// candidate exists, or the eventual resolver pass will reuse a cached negative. +test("composer refetches a cached negative when a link is pasted into an empty draft", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { resetLinkPreviewMetadataCache } = await import( + "@/shared/lib/useResolvedLinkPreviews.ts" + ); + const { useComposerLinkPreviews } = await import( + "./useComposerLinkPreviews.tsx" + ); + + resetLinkPreviewMetadataCache(); + ipcHandlers.clear(); + ipcHandlers.set("get_relay_http_url", () => + Promise.resolve("https://relay.example.com"), + ); + ipcHandlers.set("upload_media_bytes", () => + Promise.resolve({ + url: "https://relay.example.com/media/fresh", + sha256: "f8e5", + size: 1, + type: "image/png", + uploaded: 0, + }), + ); + + let fetchCalls = 0; + ipcHandlers.set("fetch_link_preview_metadata", () => { + fetchCalls += 1; + return Promise.resolve( + fetchCalls === 1 + ? metadata({ + imageFetchState: "transient_failure", + imageRetryAfterMs: 900_000, + }) + : metadata({ + imageDataUrl: "data:image/png;base64,QQ==", + imageDomain: "images.example.com", + imageFetchState: "image", + }), + ); + }); + + try { + // Seed the shared cache with the negative result before the composer sees A. + const { useResolvedLinkPreviews } = await import( + "@/shared/lib/useResolvedLinkPreviews.ts" + ); + const cached = renderHook(() => + useResolvedLinkPreviews([ + { + kind: "generic-link", + href: HREF, + title: HREF, + provider: "example.com", + imageUrl: null, + }, + ]), + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + assert.equal(fetchCalls, 1, "the negative was cached before paste"); + cached.unmount(); + + const { result, rerender, unmount } = renderHook( + ({ content }) => useComposerLinkPreviews(content), + { initialProps: { content: "" } }, + ); + await act(async () => rerender({ content: `see ${HREF}` })); + assert.equal( + fetchCalls, + 1, + "debounce has not resolved the pasted href yet", + ); + assert.equal(result.current.hasPendingSnapshots, true); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS)); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + assert.equal(fetchCalls, 2, "paste invalidated and refetched the negative"); + assert.equal(result.current.getReadyTags().length, 1); + unmount(); + } finally { + cleanup(); + ipcHandlers.clear(); + } +}); +// A concurrent render may execute the hook and then suspend before commit. No +// href presence, block, generation, or tag mutation from that abandoned render +// may affect the previously committed composer. +test("an abandoned concurrent render cannot invalidate the committed snapshot tag", async () => { + const React = await import("react"); + const { act, cleanup, render } = await import("@testing-library/react"); + const { resetLinkPreviewMetadataCache } = await import( + "@/shared/lib/useResolvedLinkPreviews.ts" + ); + const { useComposerLinkPreviews } = await import( + "./useComposerLinkPreviews.tsx" + ); + + resetLinkPreviewMetadataCache(); + ipcHandlers.clear(); + ipcHandlers.set("get_relay_http_url", () => + Promise.resolve("https://relay.example.com"), + ); + ipcHandlers.set("upload_media_bytes", () => + Promise.resolve({ + url: "https://relay.example.com/media/stable", + sha256: "57ab1e", + size: 1, + type: "image/png", + uploaded: 0, + }), + ); + ipcHandlers.set("fetch_link_preview_metadata", () => + Promise.resolve( + metadata({ + imageFetchState: "transient_failure", + imageRetryAfterMs: 900_000, + }), + ), + ); + + let latest; + const never = new Promise(() => {}); + function Suspender() { + throw never; + } + function Harness({ content, suspend }) { + latest = useComposerLinkPreviews(content); + return suspend ? React.createElement(Suspender) : null; + } + + try { + const view = render( + React.createElement(Harness, { content: `see ${HREF}`, suspend: false }), + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS)); + }); + assert.equal(latest.getReadyTags().length, 1); + + // Render a clear that executes the hook but suspends before commit, then + // supersede it with the unchanged committed content. + await act(async () => { + React.startTransition(() => + view.rerender( + React.createElement(Harness, { content: "", suspend: true }), + ), + ); + await Promise.resolve(); + }); + await act(async () => { + view.rerender( + React.createElement(Harness, { + content: `see ${HREF}`, + suspend: false, + }), + ); + await Promise.resolve(); + }); + + assert.equal( + latest.getReadyTags().length, + 1, + "the committed tag remains sendable after the abandoned render", + ); + assert.equal(latest.hasPendingSnapshots, false); + view.unmount(); + } finally { + cleanup(); + ipcHandlers.clear(); + } +}); diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx index 3f251a719d..1ea94821cf 100644 --- a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx @@ -192,7 +192,75 @@ async function uploadSnapshotMedia( } } -export function useComposerLinkPreviews(content: string, enabled = true) { +export function extractComposerLinkPreviewHrefs(content: string): string[] { + return extractSupportedLinkPreviews(content) + .filter((preview) => + preview.href.startsWith("buzz://") + ? true + : isValidLinkPreviewSnapshotCanonicalUrl(preview.href), + ) + .map((preview) => preview.href); +} + +export interface ComposerLinkPreviewInput { + content: string; + hrefs: Set; + hrefVersions: Map; + nextHrefVersion: number; +} + +export function updateComposerLinkPreviewInput( + current: ComposerLinkPreviewInput, + content: string, +): ComposerLinkPreviewInput { + const nextHrefs = new Set(extractComposerLinkPreviewHrefs(content)); + const nextVersions = new Map(); + let nextHrefVersion = current.nextHrefVersion; + for (const href of nextHrefs) { + if (current.hrefs.has(href)) { + const version = current.hrefVersions.get(href); + if (version !== undefined) nextVersions.set(href, version); + continue; + } + nextHrefVersion += 1; + nextVersions.set(href, nextHrefVersion); + } + return { + content, + hrefs: nextHrefs, + hrefVersions: nextVersions, + nextHrefVersion, + }; +} + +export function useComposerLinkPreviewInput() { + const [input, setInput] = React.useState(() => ({ + content: "", + hrefs: new Set(), + hrefVersions: new Map(), + nextHrefVersion: 0, + })); + const update = React.useCallback( + (content: string) => + setInput((current) => updateComposerLinkPreviewInput(current, content)), + [], + ); + return [input, update] as const; +} + +export function useManagedComposerLinkPreviews(enabled = true) { + const [input, updateContent] = useComposerLinkPreviewInput(); + return { + ...useComposerLinkPreviews(input.content, enabled, input.hrefVersions), + updateContent, + }; +} + +export function useComposerLinkPreviews( + content: string, + enabled = true, + liveHrefVersions?: ReadonlyMap, +) { const [suppressed, setSuppressed] = React.useState(false); // Debounce the content that drives resolution so typing a URL character by // character does not churn a new candidate href (and a flickering card) per @@ -201,16 +269,14 @@ export function useComposerLinkPreviews(content: string, enabled = true) { // `hasUnresolvedLiveCandidates` below, which keeps Send disabled until the // live candidates resolve — so no synchronous flush is needed at submit. const [debounced, setDebounced] = React.useState(content); - const debouncedRef = React.useRef(debounced); - debouncedRef.current = debounced; React.useEffect(() => { - if (content === debouncedRef.current) return; + if (content === debounced) return; const timer = window.setTimeout( () => setDebounced(content), LINK_PREVIEW_DEBOUNCE_MS, ); return () => window.clearTimeout(timer); - }, [content]); + }, [content, debounced]); const extractCandidates = React.useCallback( (source: string) => enabled @@ -230,12 +296,37 @@ export function useComposerLinkPreviews(content: string, enabled = true) { // resolved (debounce not yet fired after a paste/keystroke), Send must still // treat the preview as pending so a fast Enter cannot ship a bare link ahead // of resolution. - const liveCandidatesRef = React.useRef([]); - liveCandidatesRef.current = extractCandidates(content).map( - (preview) => preview.href, + const liveCandidates = React.useMemo( + () => extractCandidates(content).map((preview) => preview.href), + [extractCandidates, content], ); + const liveCandidatesKey = liveCandidates.join("\n"); + const liveHrefVersionsKey = liveCandidates + .map((href) => `${href}\0${liveHrefVersions?.get(href) ?? ""}`) + .join("\n"); + // Submit and async-completion paths read only the last COMMITTED live set. + // Updating this during render would let an abandoned concurrent render leak + // uncommitted editor content into a later submit or upload completion. + const liveCandidatesRef = React.useRef([]); + React.useLayoutEffect(() => { + liveCandidatesRef.current = liveCandidates; + }, [liveCandidates]); + // A URL freshly entering the composer (paste, or finishing typing one) should + // get a fresh fetch rather than a stale negative cache hit — the user is + // actively asking for this link's card now. useResolvedLinkPreviews handles + // the timing (invalidate a newly-present href's NEGATIVE cache entry before + // it reads the cache); healthy hits and passive message-list scroll are + // untouched, so the shared cache still does its job everywhere else. Pass the + // LIVE hrefs for newness tracking so a fast clear-then-repaste of the same URL + // within the debounce window (which never commits an empty `candidates`) is + // still seen as a re-entry and refetched — not served the stale negative. const resolvedPreviews = useResolvedLinkPreviews( suppressed ? [] : candidates, + { + refetchNewNegatives: true, + liveHrefs: liveCandidates, + liveHrefVersions, + }, ); // Entity links resolve to null metadata when the relay lookup has nothing // for them; keep their safe fallback cards rather than dropping them. @@ -246,7 +337,7 @@ export function useComposerLinkPreviews(content: string, enabled = true) { // Clear a "hide previews" suppression as soon as the LIVE draft has no // supported candidates — not the debounced set, whose lag would otherwise let // a clear-then-retype race keep suppression stuck on after the draft changed. - const liveCandidatesEmpty = liveCandidatesRef.current.length === 0; + const liveCandidatesEmpty = liveCandidates.length === 0; React.useEffect(() => { if (liveCandidatesEmpty) setSuppressed(false); }, [liveCandidatesEmpty]); @@ -258,9 +349,17 @@ export function useComposerLinkPreviews(content: string, enabled = true) { readyTagsByHrefRef.current = readyTags; const suppressedRef = React.useRef(suppressed); suppressedRef.current = suppressed; - const uploadsRef = React.useRef(new Set()); + const uploadsRef = React.useRef(new Map()); + // Per-href upload generation. Bumped whenever a stale-negative href re-enters + // (below), so an upload started before a re-entry can be recognized as stale + // when it settles and dropped without publishing its pre-re-entry tag — the + // `reenteringHrefsRef` phase marker alone is not enough, since it is cleared + // the moment fresh metadata arrives, which can be before the OLD upload + // resolves. Keyed uploads also let a fresh upload start while a superseded one + // is still in flight (its generation no longer matches), so the composer is + // never left tagless waiting on a doomed upload. + const uploadGenerationRef = React.useRef(new Map()); const activeHrefsRef = React.useRef(new Set()); - activeHrefsRef.current = new Set(candidates.map((preview) => preview.href)); React.useEffect(() => { if (getCachedRelayOrigin()) return; @@ -279,15 +378,117 @@ export function useComposerLinkPreviews(content: string, enabled = true) { ); }, [candidates]); + // Compare live content with the LAST COMMITTED href set during render, but do + // not mutate anything here. This gives synchronous submit/pending selectors a + // pure fence for a stale fallback on the candidate render. The layout effect + // below commits the block, generation bump, and tag removal only if React + // actually commits this render; an abandoned concurrent render leaks nothing. + const committedLiveHrefsRef = React.useRef>(new Set()); + const committedLiveHrefVersionsRef = React.useRef>( + new Map(), + ); + // Hrefs that re-entered with a STALE negative fallback stay blocked until the + // resolver visibly cycles pending -> ready. Healthy image re-entries are not + // blocked because their cached metadata remains valid and does not refetch. + const reenteringHrefsRef = React.useRef< + Map + >(new Map()); + const reenteredLiveHrefs = liveCandidates.filter((href) => { + const version = liveHrefVersions?.get(href); + return version === undefined + ? !committedLiveHrefsRef.current.has(href) + : committedLiveHrefVersionsRef.current.get(href) !== version; + }); + const staleReenteredHrefs = reenteredLiveHrefs.filter( + (href) => + !reenteringHrefsRef.current.has(href) && + previews.some( + (preview) => + preview.href === href && + preview.snapshotReady && + preview.imageState === "fallback", + ), + ); + const staleReenteredKey = staleReenteredHrefs.join("\n"); + + // biome-ignore lint/correctness/useExhaustiveDependencies: stable href keys intentionally represent the live/stale sets; the arrays are rebuilt each render. + React.useLayoutEffect(() => { + const previousLiveHrefs = committedLiveHrefsRef.current; + const activeLiveHrefs = new Set(liveCandidates); + activeHrefsRef.current = activeLiveHrefs; + committedLiveHrefsRef.current = activeLiveHrefs; + committedLiveHrefVersionsRef.current = new Map(liveHrefVersions); + + // Leaving the live draft ends the current re-entry cycle. Prune its phase so + // a later paste can consume healthy metadata that resolved while absent. + // Also advance the upload generation: an upload started before removal must + // never publish into a later incarnation of the same href. + for (const href of previousLiveHrefs) { + if (activeLiveHrefs.has(href)) continue; + reenteringHrefsRef.current.delete(href); + uploadGenerationRef.current.set( + href, + (uploadGenerationRef.current.get(href) ?? 0) + 1, + ); + } + + if (staleReenteredHrefs.length === 0) return; + + for (const href of staleReenteredHrefs) { + reenteringHrefsRef.current.set(href, "blocked"); + // Fence any upload built from pre-re-entry metadata. A fresh generation + // can start while the superseded upload is still in flight. + uploadGenerationRef.current.set( + href, + (uploadGenerationRef.current.get(href) ?? 0) + 1, + ); + } + const drop = new Set(staleReenteredHrefs); + setReadyTags((current) => { + let changed = false; + const next = { ...current }; + for (const href of drop) + if (href in next) { + delete next[href]; + changed = true; + } + return changed ? next : current; + }); + }, [liveCandidatesKey, liveHrefVersionsKey, staleReenteredKey]); + + const isHrefReentering = (href: string) => + reenteringHrefsRef.current.has(href) || staleReenteredHrefs.includes(href); + React.useEffect(() => { for (const preview of previews) { + // A re-entering href stays blocked until the resolver's forced refetch has + // visibly cycled through pending: seeing `!snapshotReady` (pending) marks + // "refetching"; only once it is ready AGAIN after that is the block lifted + // and a tag built from the fresh metadata. The stale pre-clear fallback + // (still `snapshotReady` and never pending) can never rebuild the tag. + const phase = reenteringHrefsRef.current.get(preview.href); + if (phase !== undefined) { + if (!preview.snapshotReady) { + reenteringHrefsRef.current.set(preview.href, "refetching"); + continue; + } + if (phase === "blocked") continue; + reenteringHrefsRef.current.delete(preview.href); + } + // The generation captured here fences this upload's completion: a live + // re-entry bumps `uploadGenerationRef` (above), so an in-flight upload + // started from stale pre-clear metadata carries an older generation and + // its `.then` (below) becomes a no-op. The dedup guard is generation-aware + // too, so a superseded in-flight upload does not block starting the fresh + // one at the new generation. + const generation = uploadGenerationRef.current.get(preview.href) ?? 0; if ( !preview.snapshotReady || readyTags[preview.href] || - uploadsRef.current.has(preview.href) + uploadsRef.current.get(preview.href) === generation ) continue; - uploadsRef.current.add(preview.href); + uploadsRef.current.set(preview.href, generation); // Upload image and favicon independently so one failure degrades to the // surviving media instead of dropping the whole preview. A snapshot tag // with empty media fields is valid (renders as text + favicon, or @@ -307,6 +508,20 @@ export function useComposerLinkPreviews(content: string, enabled = true) { ]) .then(([image, favicon]) => { if (!activeHrefsRef.current.has(preview.href)) return; + // If this href re-entered while the upload was in flight, its metadata + // is stale (the resolver is refetching). Drop the result rather than + // writing back a snapshot tag built from the pre-re-entry metadata; + // the forced refetch's own upload will produce the fresh tag. + if (reenteringHrefsRef.current.has(preview.href)) return; + // Durable generation fence, independent of the phase marker: if this + // href re-entered while the upload was in flight, its generation was + // bumped, so this stale completion is dropped even if the marker has + // already been cleared (e.g. the forced refetch reached fresh ready + // and the effect deleted the marker before U1 settled). + if ( + (uploadGenerationRef.current.get(preview.href) ?? 0) !== generation + ) + return; const failedMedia = [image.failed, favicon.failed].filter( (label): label is "thumbnail" | "favicon" => label !== null, ); @@ -335,7 +550,12 @@ export function useComposerLinkPreviews(content: string, enabled = true) { setReadyTags((current) => ({ ...current, [preview.href]: tag })); }) .finally(() => { - uploadsRef.current.delete(preview.href); + // Only clear the slot if this upload is still the current one for the + // href. A superseded upload (older generation) must not delete the + // entry belonging to the fresh upload (U2) that replaced it, or the + // dedup guard would let a third upload start and race again. + if (uploadsRef.current.get(preview.href) === generation) + uploadsRef.current.delete(preview.href); }); void uploadPromise; } @@ -344,7 +564,9 @@ export function useComposerLinkPreviews(content: string, enabled = true) { readyTagsRef.current = suppressed ? [["link-preview", "none"]] : candidates.flatMap((candidate) => - readyTags[candidate.href] ? [readyTags[candidate.href]] : [], + readyTags[candidate.href] && !isHrefReentering(candidate.href) + ? [readyTags[candidate.href]] + : [], ); // A preview is "settling" from paste until its sendable tag exists: metadata // is still resolving, or it resolved and the snapshot media is uploading. @@ -358,6 +580,7 @@ export function useComposerLinkPreviews(content: string, enabled = true) { (preview) => !preview.href.startsWith("buzz://") && (preview.imageState === "pending" || + isHrefReentering(preview.href) || (preview.snapshotReady && !readyTags[preview.href])), ); // A supported link in the LIVE content that resolution has not caught up to @@ -366,7 +589,7 @@ export function useComposerLinkPreviews(content: string, enabled = true) { // before resolution even starts. buzz:// links never snapshot, so ignore them. const hasUnresolvedLiveCandidates = !suppressed && - liveCandidatesRef.current.some( + liveCandidates.some( (href) => !href.startsWith("buzz://") && !readyTags[href] && @@ -378,7 +601,6 @@ export function useComposerLinkPreviews(content: string, enabled = true) { // settling, so a link whose metadata or upload stalls never traps the // composer. Resets whenever settling ends or the live candidate set changes. const [settleDisableExpired, setSettleDisableExpired] = React.useState(false); - const liveCandidatesKey = liveCandidatesRef.current.join("\n"); // biome-ignore lint/correctness/useExhaustiveDependencies: liveCandidatesKey intentionally restarts the anti-trap cap when the link set changes while still settling, so a replaced/added link gets a fresh disable window rather than inheriting the prior link's near-expired timer. React.useEffect(() => { if (!hasSettlingSnapshots) { @@ -433,18 +655,20 @@ export function useComposerLinkPreviews(content: string, enabled = true) { // Snapshot tags for a submit, read synchronously at submit start from the // LIVE candidate set (liveCandidatesRef) via `selectSubmitTags` — so the tags // always correspond to the content actually being sent, never a debounced set - // that still holds a just-removed URL. No await: Send is disabled until every - // settling preview has its tag (or the anti-trap cap fires), so at submit time - // the tags that will ever exist already exist. - const getReadyTags = React.useCallback( - () => - selectSubmitTags( - liveCandidatesRef.current, - readyTagsByHrefRef.current, - suppressedRef.current, + // that still holds a just-removed URL. Re-entering hrefs are excluded: their + // retained tag was built from stale metadata the resolver is refetching, and + // it must not ship until a fresh tag replaces it. No await: Send is disabled + // until every settling preview has its tag (or the anti-trap cap fires), so at + // submit time the tags that will ever exist already exist. + const getReadyTags = React.useCallback(() => { + return selectSubmitTags( + liveCandidatesRef.current.filter( + (href) => !reenteringHrefsRef.current.has(href), ), - [], - ); + readyTagsByHrefRef.current, + suppressedRef.current, + ); + }, []); return { previewList, getReadyTags, diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs index c0c91d9f16..a34806ffa0 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs @@ -1,10 +1,13 @@ import assert from "node:assert/strict"; -import test from "node:test"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; import { __linkPreviewMetadataTest, fetchBuzzEntityMetadata, isBuzzEntityPreview, + resetLinkPreviewMetadataCache, resolveLinkPreview, withEntityFallbacks, } from "./useResolvedLinkPreviews.ts"; @@ -432,3 +435,413 @@ test("Buzz repository metadata stays image-less and exposes default branch", asy assert.equal(result?.imageDataUrl, null); assert.equal(result?.imageDomain, null); }); + +test("invalidateNegative drops a cached null miss so the next load refetches", async () => { + // A URL freshly entering the composer clears a stale hard miss (null) so it + // refetches, instead of riding the cached blank. + const now = 1_000; + let calls = 0; + let nextResult = null; + const loader = __linkPreviewMetadataTest.createMetadataLoader({ + fetcher: async () => { + calls += 1; + return nextResult; + }, + now: () => now, + }); + + assert.equal((await loader.load(preview.href)).metadata, null); + assert.equal(calls, 1); + + loader.invalidateNegative(preview.href); + nextResult = metadata(); + assert.deepEqual((await loader.load(preview.href)).metadata, metadata()); + assert.equal(calls, 2); +}); + +test("invalidateNegative drops a cached transient failure so the next load refetches", async () => { + // A transient image failure is a NEGATIVE by contract (option docs + the + // loader's own retry boundary), so re-entering the composer must refetch it + // — not reuse the cached transient entry. Regression for the leak where + // invalidateNegative only cleared hard `null` misses. (PR #5510) + const now = 1_000; + let calls = 0; + const loader = __linkPreviewMetadataTest.createMetadataLoader({ + fetcher: async () => { + calls += 1; + return calls === 1 + ? metadata({ + imageFetchState: "transient_failure", + imageRetryAfterMs: 10_000, + }) + : metadata({ + imageDataUrl: "data:image/jpeg;base64,abc", + imageDomain: "images.example.com", + imageFetchState: "image", + }); + }, + now: () => now, + }); + + assert.equal( + (await loader.load(preview.href)).metadata?.imageFetchState, + "transient_failure", + ); + assert.equal(calls, 1); + + // Bust well before the retry boundary (now is frozen); the cache-bust — not + // the cooldown — is what forces the refetch. + loader.invalidateNegative(preview.href); + assert.equal( + (await loader.load(preview.href)).metadata?.imageFetchState, + "image", + ); + assert.equal(calls, 2); +}); + +test("invalidateNegative leaves a healthy cached hit untouched", async () => { + // A settled positive (instant card, no redundant fetch) must survive a bust + // so passive scroll re-renders keep riding the cache. + const now = 1_000; + let calls = 0; + const loader = __linkPreviewMetadataTest.createMetadataLoader({ + fetcher: async () => { + calls += 1; + return metadata(); + }, + now: () => now, + }); + + assert.deepEqual((await loader.load(preview.href)).metadata, metadata()); + assert.equal(calls, 1); + + loader.invalidateNegative(preview.href); + assert.deepEqual((await loader.load(preview.href)).metadata, metadata()); + assert.equal(calls, 1); +}); + +test("invalidateNegative leaves an in-flight fetch untouched", async () => { + // A fetch still in flight is cached as a Promise, not a resolved entry. + // Busting mid-flight must not cancel or duplicate it: the pending load + // resolves normally and no second fetch is started. + const now = 1_000; + let calls = 0; + let releaseFetch; + const loader = __linkPreviewMetadataTest.createMetadataLoader({ + fetcher: () => { + calls += 1; + return new Promise((resolve) => { + releaseFetch = () => resolve(metadata()); + }); + }, + now: () => now, + }); + + const pending = loader.load(preview.href); + assert.equal(calls, 1); + + // Bust while the fetch is still in flight — the Promise entry is left alone. + loader.invalidateNegative(preview.href); + assert.equal(calls, 1, "no redundant fetch started by the bust"); + + releaseFetch(); + assert.deepEqual((await pending).metadata, metadata()); + assert.equal(calls, 1, "the original in-flight fetch resolved, not a retry"); +}); + +// ── Hook-level regression: retained resolved-state invalidation on re-entry ─── +// +// The loader tests above prove `invalidateNegative` drops the SHARED loader +// cache entry. But `useResolvedLinkPreviews` also retains its OWN +// `resolvedMetadata` React state, and the render that scheduled the +// invalidating effect has already read the stale negative from it. Dropping the +// loader key alone leaves that local key in place, so a re-entered +// `transient_failure` still resolves to a `snapshotReady` fallback the composer +// can turn into a sendable snapshot tag from STALE metadata before the retry +// lands. This drives the REAL hook to prove the local key is cleared too, so +// the re-entry renders as pending (no `snapshotReady`) until the retry wins. +// (PR #5510) Regression: without the local-state clear the re-entry assertion +// below sees `snapshotReady: true` instead of pending. + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + // @tauri-apps/api/core reads window.__TAURI_INTERNALS__.invoke at call time. + // A per-test handler map lets each test control the fetch resolution timing. + dom.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + const handler = ipcHandlers.get(cmd); + return handler + ? handler(args) + : Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: () => Math.random(), + }; +}); + +after(() => dom.window.close()); + +/** @type {Map Promise>} */ +const ipcHandlers = new Map(); + +const hookPreview = { + kind: "generic-link", + href: "https://example.com/hook-re-entry", + provider: "example.com", + title: "example.com/hook-re-entry", + typeLabel: "link", +}; + +test("hook clears retained resolved state so a re-entered transient failure renders pending until the retry wins", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { useResolvedLinkPreviews } = await import( + "./useResolvedLinkPreviews.ts" + ); + + // Isolate from any loader state leaked by earlier tests in this process. + resetLinkPreviewMetadataCache(); + ipcHandlers.clear(); + + // Gate every fetch so we can observe the state between renders. Call 1 caches + // a transient failure; call 2 (the re-entry retry) succeeds. + let calls = 0; + const releases = []; + ipcHandlers.set("fetch_link_preview_metadata", () => { + calls += 1; + const attempt = calls; + return new Promise((resolve) => { + releases.push(() => + resolve( + attempt === 1 + ? metadata({ + imageFetchState: "transient_failure", + imageRetryAfterMs: 900_000, + }) + : metadata({ + imageDataUrl: "data:image/jpeg;base64,abc", + imageDomain: "images.example.com", + imageFetchState: "image", + }), + ), + ); + }); + }); + + // scheduleAfterPaint uses requestAnimationFrame -> setTimeout(0); flush both + // plus a microtask turn so the queued load() actually fires. + const flushScheduledLoads = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }; + + const settle = async () => { + await act(async () => { + // Release any pending fetch and let its .then() commit setResolvedMetadata. + while (releases.length > 0) releases.shift()(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }; + + try { + const { result, rerender, unmount } = renderHook( + ({ previews }) => + useResolvedLinkPreviews(previews, { refetchNewNegatives: true }), + { initialProps: { previews: [hookPreview] } }, + ); + + // 1. Initial paste: pending until the fetch resolves to a transient failure. + assert.equal(result.current[0].imageState, "pending"); + assert.equal(result.current[0].snapshotReady, undefined); + + await flushScheduledLoads(); + await settle(); + + // Transient failure cached: a sendable fallback (this is the state that must + // NOT survive re-entry as ready). + assert.equal(result.current[0].imageState, "fallback"); + assert.equal(result.current[0].snapshotReady, true); + assert.equal(calls, 1); + + // 2. URL removed from the composer. + rerender({ previews: [] }); + assert.deepEqual(result.current, []); + + // 3. URL re-entered. The invalidation effect must drop BOTH the loader entry + // and the retained local key, so this settles to pending with no + // snapshotReady — not the stale fallback. Without the local-state clear + // the hook returns snapshotReady: true here. + rerender({ previews: [hookPreview] }); + await act(async () => {}); + assert.equal( + result.current[0].imageState, + "pending", + "re-entered transient failure must render pending, not a stale fallback", + ); + assert.equal( + result.current[0].snapshotReady, + undefined, + "no snapshotReady before the retry resolves — nothing sendable from stale metadata", + ); + + // 4. Successful retry wins. + await flushScheduledLoads(); + await settle(); + assert.equal(result.current[0].imageState, "image"); + assert.equal(result.current[0].snapshotReady, true); + assert.equal(calls, 2, "the re-entry triggered a fresh fetch"); + + unmount(); + } finally { + cleanup(); + ipcHandlers.clear(); + } +}); + +// ── Hook-level regression: in-flight shared entry defeats the local clear ───── +// +// The shared metadataLoader is intentionally shared across every hook instance +// (composer + message list). `invalidateNegative` deliberately leaves an +// in-flight Promise entry alone — but that means when a URL re-enters the +// composer WHILE another instance has a fetch in flight for the same canonical +// URL, the shared drop is a no-op. Gating the local-state clear on that drop +// (the earlier fix) leaves this hook's retained `transient_failure` in place, +// so it keeps returning `snapshotReady: true` — a sendable tag built from stale +// metadata — until that shared fetch resolves. This drives the real hook +// through that exact interleaving to prove the local negative is cleared on +// re-entry regardless of the shared entry's shape. (PR #5510, follow-up.) + +const otherHookPreview = { + kind: "generic-link", + href: "https://example.com/hook-inflight-re-entry", + provider: "example.com", + title: "example.com/hook-inflight-re-entry", + typeLabel: "link", +}; + +test("hook clears a re-entered negative even when the shared cache holds an in-flight fetch, not a settled entry", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { useResolvedLinkPreviews } = await import( + "./useResolvedLinkPreviews.ts" + ); + + resetLinkPreviewMetadataCache(); + ipcHandlers.clear(); + + // Gate every fetch. Call 1 (composer's initial paste) resolves to a transient + // failure. Call 2 is the SHARED in-flight fetch a message-list instance starts + // after the shared cache is dropped; it is left unreleased so it is still a + // Promise in the shared cache when the composer re-enters. + let calls = 0; + const releases = []; + ipcHandlers.set("fetch_link_preview_metadata", () => { + calls += 1; + const attempt = calls; + return new Promise((resolve) => { + releases.push(() => + resolve( + attempt === 1 + ? metadata({ + imageFetchState: "transient_failure", + imageRetryAfterMs: 900_000, + }) + : metadata({ + imageDataUrl: "data:image/jpeg;base64,abc", + imageDomain: "images.example.com", + imageFetchState: "image", + }), + ), + ); + }); + }); + + const flushScheduledLoads = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }; + + const releaseAll = async () => { + await act(async () => { + while (releases.length > 0) releases.shift()(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }; + + const composer = renderHook( + ({ previews }) => + useResolvedLinkPreviews(previews, { refetchNewNegatives: true }), + { initialProps: { previews: [otherHookPreview] } }, + ); + // A passive message-list instance sharing the same loader (no refetch). + const messageList = renderHook( + ({ previews }) => useResolvedLinkPreviews(previews, {}), + { initialProps: { previews: [] } }, + ); + + try { + // 1. Composer paste resolves to a transient failure -> sendable fallback. + await flushScheduledLoads(); + await act(async () => { + releases.shift()(); // release call 1 only + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + assert.equal(composer.result.current[0].imageState, "fallback"); + assert.equal(composer.result.current[0].snapshotReady, true); + assert.equal(calls, 1); + + // 2. Composer URL leaves. + composer.rerender({ previews: [] }); + assert.deepEqual(composer.result.current, []); + + // 3. The shared negative is dropped (simulating expiry/eviction), then a + // message-list instance starts a fresh fetch for the SAME canonical URL + // and it is left IN FLIGHT — a Promise, not a settled entry, in the + // shared cache. + act(() => { + resetLinkPreviewMetadataCache(); + }); + messageList.rerender({ previews: [otherHookPreview] }); + await flushScheduledLoads(); + assert.equal(calls, 2, "message-list started the shared in-flight fetch"); + + // 4. Composer re-enters WHILE that fetch is in flight. invalidateNegative is + // a no-op (the shared entry is a Promise), so the earlier drop-gated clear + // would leave the retained transient failure — a sendable tag from stale + // metadata. The local negative must be cleared regardless: pending, no + // snapshotReady. + composer.rerender({ previews: [otherHookPreview] }); + await act(async () => {}); + assert.equal( + composer.result.current[0].imageState, + "pending", + "re-entry during a shared in-flight fetch must render pending, not a stale fallback", + ); + assert.equal( + composer.result.current[0].snapshotReady, + undefined, + "no snapshotReady while the shared fetch is in flight — nothing sendable from stale metadata", + ); + // The composer coalesced onto the in-flight fetch — no third request. + assert.equal(calls, 2, "re-entry coalesced onto the in-flight fetch"); + + // 5. The shared fetch resolves successfully; both instances settle. + await releaseAll(); + await flushScheduledLoads(); + assert.equal(composer.result.current[0].imageState, "image"); + assert.equal(composer.result.current[0].snapshotReady, true); + } finally { + composer.unmount(); + messageList.unmount(); + cleanup(); + ipcHandlers.clear(); + } +}); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 5f2a5535b5..8ef93e7d13 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -86,6 +86,13 @@ function metadataCacheKey(href: string): string { } } +function isNegativeMetadata(metadata: LinkPreviewMetadata | null): boolean { + // A cached NEGATIVE is a result that should be retried when the URL freshly + // re-enters the composer: a hard miss (null) or a transient image failure. + // A healthy hit (`image`/`rejected`/no state) is a settled positive. + return metadata === null || metadata.imageFetchState === "transient_failure"; +} + function metadataExpiry( metadata: LinkPreviewMetadata | null, now: number, @@ -186,6 +193,23 @@ function createMetadataLoader({ deleteKey(key: string) { cache.delete(key); }, + /** + * Drop a cached NEGATIVE result (a resolved null or a transient failure) so + * the next load refetches. Used when a URL freshly enters the composer: a + * user pasting a link that previously blanked should get a new attempt now, + * not the stale miss. A healthy cached hit and an in-flight fetch are left + * untouched, so passive scroll re-renders still ride the cache as before. + * Returns whether a negative entry was actually dropped, so callers can + * invalidate their own derived state (e.g. retained React metadata) in step. + */ + invalidateNegative(href: string): boolean { + const key = metadataCacheKey(href); + const cached = cache.get(key); + if (!cached || cached instanceof Promise) return false; + if (!isNegativeMetadata(cached.metadata)) return false; + cache.delete(key); + return true; + }, load, peek, reset() { @@ -417,16 +441,129 @@ export function withEntityFallbacks( export function useResolvedLinkPreviews( previews: SupportedLinkPreview[], + { + refetchNewNegatives = false, + liveHrefs, + liveHrefVersions, + }: { + /** + * When a preview href is newly present since the last run, drop any cached + * NEGATIVE (null/transient-fail) metadata for it so it refetches instead of + * resolving to a stale miss. Used by the composer: a freshly pasted link + * should get a new attempt. Off by default so passive renders (the message + * list) keep riding the cache. Healthy cached hits are never invalidated. + */ + refetchNewNegatives?: boolean; + /** + * The hrefs present in the caller's LIVE (undebounced) content. When given, + * newness is judged against this set instead of the resolved `previews`, so + * a URL that leaves and re-enters the live content is treated as re-entered + * even when a debounce swallowed the intermediate empty state (the composer + * debounces resolution, so `previews` may never observe the URL leaving). + * Resolution timing still follows `previews`; only the invalidation decision + * uses this. Omit to track newness against `previews` (the default). + */ + liveHrefs?: readonly string[]; + /** + * Per-href entry versions captured at the live editor-update boundary. + * Unlike committed href-set equality, a bumped version preserves an + * intermediate leave/re-entry even when React batches both updates into one + * commit with the same final href set. + */ + liveHrefVersions?: ReadonlyMap; + } = {}, ): ResolvedLinkPreview[] { const [resolvedMetadata, setResolvedMetadata] = React.useState({}); const [retryGeneration, setRetryGeneration] = React.useState(0); - + const seenHrefsRef = React.useRef>(new Set()); + const handledHrefVersionsRef = React.useRef>(new Map()); + // Drive newness tracking from a stable string key so an equivalent href list + // does not restart the effect. The effect closes over the committed render's + // hrefs; do not mirror them into a ref during render, because an abandoned + // concurrent render could otherwise leak uncommitted presence into the live + // effect from the previous commit. + const currentHrefs = liveHrefs ?? previews.map((preview) => preview.href); + const newnessKey = currentHrefs + .map((href) => `${href}\0${liveHrefVersions?.get(href) ?? ""}`) + .join("\n"); + // biome-ignore lint/correctness/useExhaustiveDependencies: newnessKey is the stable identity for currentHrefs; depending on the freshly allocated array would rerun this effect every render. React.useEffect(() => { let cancelled = false; let retryAt = Number.POSITIVE_INFINITY; let retryTimer: ReturnType | null = null; + if (refetchNewNegatives) { + // Invalidate first, before the peek/load loop below reads the cache, so a + // newly-present href loads fresh instead of resolving to its stale miss. + // buzz:// entity links resolve off the relay, not this cache — skip them. + // Newness is judged against the live href set when supplied (so a + // debounce-swallowed leave/re-entry still counts), else against previews. + const seen = seenHrefsRef.current; + const handledVersions = handledHrefVersionsRef.current; + const liveNow = currentHrefs; + // Preserve handled hrefs only while they remain live. A live href is not + // marked handled until its debounced preview exists and invalidation has + // actually been attempted; otherwise blank -> paste would consume + // newness during the 350ms debounce and later reuse the stale negative. + const next = new Set( + [...seen].filter((href) => liveNow.includes(href)), + ); + const nextVersions = new Map( + [...handledVersions].filter(([href]) => liveNow.includes(href)), + ); + const reenteredKeys: string[] = []; + for (const preview of previews) { + const version = liveHrefVersions?.get(preview.href); + const alreadyHandled = + version === undefined + ? seen.has(preview.href) + : handledVersions.get(preview.href) === version; + if ( + alreadyHandled || + !liveNow.includes(preview.href) || + preview.href.startsWith("buzz://") + ) { + continue; + } + // Drop any settled NEGATIVE from the SHARED loader cache so the load + // below refetches instead of resolving to the stale miss. (A no-op when + // the shared entry is healthy, in-flight, or absent.) + metadataLoader.invalidateNegative(preview.href); + reenteredKeys.push(metadataCacheKey(preview.href)); + next.add(preview.href); + if (version !== undefined) nextVersions.set(preview.href, version); + } + seenHrefsRef.current = next; + handledHrefVersionsRef.current = nextVersions; + // Dropping the loader entry alone is not enough: this hook retains its own + // resolved metadata, and the render that scheduled this effect already + // read the stale negative from it. Clear this hook's OWN negative key for + // every re-entered href — gating on whether the shared loader dropped a + // settled entry misses the case where another hook left an in-flight + // Promise in the shared cache (invalidateNegative leaves Promises alone + // and the loop below merely coalesces onto it), which would otherwise + // keep this hook's retained `transient_failure` as a `snapshotReady` + // fallback the composer could turn into a sendable snapshot tag from stale + // metadata until that fetch resolves. Clearing the local negative renders + // the re-entered link as pending until the fresh load wins. Healthy local + // hits are kept, so passive re-renders still show their card instantly. + if (reenteredKeys.length > 0) { + setResolvedMetadata((current) => { + let changed = false; + const nextMetadata = { ...current }; + for (const key of reenteredKeys) { + const value = nextMetadata[key]; + if (value !== undefined && isNegativeMetadata(value)) { + delete nextMetadata[key]; + changed = true; + } + } + return changed ? nextMetadata : current; + }); + } + } + const scheduleRetry = ( { expiresAt, key }: Pick, loader: typeof metadataLoader, @@ -485,7 +622,7 @@ export function useResolvedLinkPreviews( for (const cancel of cancelScheduledLoads) cancel(); if (retryTimer !== null) clearTimeout(retryTimer); }; - }, [previews, retryGeneration]); + }, [previews, refetchNewNegatives, retryGeneration, newnessKey]); return React.useMemo( () => From dbee2914ad806c7f038389eb95c7513f5df4e0d2 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 14 Aug 2026 11:29:37 -0600 Subject: [PATCH 15/33] test(desktop): await channel E2E bridge readiness (#5886) ## Summary - wait for the channel mutation and cache invalidation E2E hooks before using them - make those hooks required after readiness instead of silently skipping fixture setup - keep the production channel settings behavior and assertion unchanged ## Why On slower CI startup, `page.goto()` can resolve before the E2E bridge installs its globals. The test used optional calls, so all three fixture operations could silently do nothing and leave the seeded `General discussion for everyone` description in React Query. The assertion then failed deterministically, including both retries. ## Validation At commit `5b4d5d290b316db5eef78c3596a17c7a270c8163`: - `pnpm -C desktop build:e2e` - focused Playwright test repeated 30 times: 30 passed - `pnpm -C desktop exec biome check tests/e2e/channels.spec.ts` - mandatory pre-push hooks passed on the exact pushed head: `branch-skew`, `desktop-check`, `desktop-typecheck`, `mobile-test`, `desktop-test`, `rust-tests`, and `desktop-tauri-checks` - `git diff --check origin/main...HEAD` Signed-off-by: Wes Co-authored-by: Carl --- desktop/tests/e2e/channels.spec.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 074b13cfe2..74ccce73a9 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -2724,24 +2724,29 @@ test("channel settings only prompt editors to add an empty description", async ( page, }) => { await page.goto("/"); + await page.waitForFunction( + () => + typeof window.__BUZZ_E2E_MUTATE_CHANNEL__ === "function" && + typeof window.__BUZZ_E2E_INVALIDATE_CHANNELS__ === "function", + ); await page.evaluate( async ({ generalChannelId, randomChannelId }) => { const bridge = window as Window & { - __BUZZ_E2E_INVALIDATE_CHANNELS__?: () => Promise; - __BUZZ_E2E_MUTATE_CHANNEL__?: (options: { + __BUZZ_E2E_INVALIDATE_CHANNELS__: () => Promise; + __BUZZ_E2E_MUTATE_CHANNEL__: (options: { channelId: string; description?: string; }) => void; }; - bridge.__BUZZ_E2E_MUTATE_CHANNEL__?.({ + bridge.__BUZZ_E2E_MUTATE_CHANNEL__({ channelId: generalChannelId, description: "", }); - bridge.__BUZZ_E2E_MUTATE_CHANNEL__?.({ + bridge.__BUZZ_E2E_MUTATE_CHANNEL__({ channelId: randomChannelId, description: "", }); - await bridge.__BUZZ_E2E_INVALIDATE_CHANNELS__?.(); + await bridge.__BUZZ_E2E_INVALIDATE_CHANNELS__(); }, { generalChannelId: GENERAL_CHANNEL_ID, From 5ddf23d700abdd96622de2d39750c56509a7561f Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 14 Aug 2026 10:44:58 -0700 Subject: [PATCH 16/33] feat(mobile-messages): render compact Buzz permalink chips (#5639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** improvement **User Impact:** Buzz channel, message, repository, pull request, and issue links now display recognizable context and navigate reliably in the mobile app. **Problem:** Bare Buzz permalinks appeared as raw or ambiguous URLs on mobile, while channel and message links were not handled consistently across Markdown forms and startup states. **Solution:** Normalize eligible bare Buzz URLs without consuming Markdown syntax, render them as semantic icon-prefixed chips, and route channel/message targets through the mobile deep-link dispatcher while preserving authored Markdown labels as ordinary links.
    File changes **mobile/lib/features/channels/deep_link_dispatcher.dart** Routes parsed channel and message links through the appropriate in-app navigation callbacks. **mobile/lib/features/channels/message_content.dart** Presents all bare Buzz permalinks as semantic icon chips and keeps authored labels as ordinary links. **mobile/lib/features/channels/message_content/link_normalizer.dart** Normalizes bare and autolinked Buzz URLs without consuming Markdown delimiters, code, or punctuation. **mobile/lib/shared/deeplink/deep_link.dart** Adds strict channel and project-entity parsing alongside message deep links. **mobile/lib/shared/deeplink/pending_deep_link_provider.dart** Preserves pending navigation until the mobile routing surface is ready. **mobile/test/features/channels/channel_detail_page_test.dart** Updates navigation integration coverage for icon-prefixed channel chips. **mobile/test/features/channels/deep_link_dispatcher_test.dart** Covers channel/message dispatch and missing-target behavior. **mobile/test/features/channels/message_content/link_normalizer_test.dart** Exercises Markdown-safe normalization across the full Buzz link suite. **mobile/test/features/channels/message_content_test.dart** Verifies chip labels, icons, semantics, authored-label opt-out, and navigation callbacks. **mobile/test/shared/deeplink/deep_link_test.dart** Covers strict parsing for channel, message, repository, pull-request, and issue links.
    ## Reproduction steps 1. Run the mobile app and open a channel containing bare `buzz://channel`, `buzz://message`, `buzz://repo`, `buzz://pr`, and `buzz://issue` URLs. 2. Confirm each bare URL renders as one cohesive chip with a type icon, a useful name or shortened identifier, and no duplicated channel `#` character. 3. Add an authored Markdown link such as `[design discussion](buzz://issue?...)` and confirm the supplied label remains an ordinary link rather than becoming a chip. 4. Select channel and message links and confirm they navigate correctly from inline and autolinked forms. ## Screenshots / demos **iOS Simulator — channel, message, repository, pull request, and issue permalink chips** Real app build (`37b2cb5eb`) running on an iPhone 17 Pro simulator. ![Mobile permalink chips on iOS Simulator](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5639/mobile-permalink-chips-simulator.png) --------- Signed-off-by: Taylor Ho Signed-off-by: Wes Co-authored-by: Carl Co-authored-by: Wes Co-authored-by: Carl --- mobile/lib/features/channels/compose_bar.dart | 1 + .../compose_bar/compose_bar_widget.dart | 1 + .../channels/compose_bar/helpers.dart | 17 + .../markdown_editing_controller.dart | 178 +++++- .../channels/deep_link_dispatcher.dart | 36 +- .../features/channels/message_content.dart | 297 +++++----- .../message_content/link_normalizer.dart | 188 ++++++ .../channels/message_content/token_pill.dart | 138 +++++ mobile/lib/shared/deeplink/deep_link.dart | 144 ++++- .../deeplink/pending_deep_link_provider.dart | 35 +- .../channels/channel_detail_page_test.dart | 6 +- .../features/channels/compose_bar_test.dart | 102 ++++ .../channels/deep_link_dispatcher_test.dart | 227 ++++++- .../message_content/link_normalizer_test.dart | 101 ++++ .../channels/message_content_test.dart | 554 +++++++++++++++++- .../test/shared/deeplink/deep_link_test.dart | 215 +++++-- 16 files changed, 1980 insertions(+), 260 deletions(-) create mode 100644 mobile/lib/features/channels/message_content/link_normalizer.dart create mode 100644 mobile/lib/features/channels/message_content/token_pill.dart create mode 100644 mobile/test/features/channels/message_content/link_normalizer_test.dart diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 2840a69049..7d294c5f1a 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -30,6 +30,7 @@ import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; +import '../../shared/deeplink/deep_link.dart'; import '../activity/compose_drafts_provider.dart'; import 'camera_capture_cleanup.dart'; import 'channel.dart'; diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index d6cea8bd03..f922c7c017 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -199,6 +199,7 @@ class ComposeBar extends HookConsumerWidget { final channelQuery = useState(null); final channelStartIdx = useState(-1); final channelsAsync = ref.watch(channelsProvider); + _useComposerChannelNames(controller, channelsAsync); final membersAsync = ref.watch(channelMembersProvider(channelId)); final sessionStatus = ref.watch(relaySessionProvider).status; diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 1c324a6457..ff60d7444f 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -1,5 +1,22 @@ part of '../compose_bar.dart'; +void _useComposerChannelNames( + _MarkdownEditingController controller, + AsyncValue> channelsAsync, +) { + final channelNames = { + for (final channel in channelsAsync.asData?.value ?? const []) + channel.name.toLowerCase(): channel.id, + }; + final channelNamesKey = channelNames.entries + .map((entry) => '${entry.key}\u0000${entry.value}') + .join('\u0001'); + useEffect(() { + controller.setChannelNames(channelNames); + return null; + }, [controller, channelNamesKey]); +} + const _typingThrottleMs = 3000; class _ComposerKeyboardMetricsObserver with WidgetsBindingObserver { diff --git a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart index ebd4cd1902..2ae71ac110 100644 --- a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart +++ b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart @@ -22,6 +22,14 @@ class _MarkdownEditingController extends TextEditingController { TextStyle? _cachedBaseStyle; Color? _cachedOnSurface; Color? _cachedSurface; + Map _channelNames = const {}; + + void setChannelNames(Map names) { + if (mapEquals(_channelNames, names)) return; + _channelNames = Map.unmodifiable(names); + _cachedTextSpan = null; + notifyListeners(); + } static final _rules = [ _MarkdownRule( @@ -242,8 +250,13 @@ class _MarkdownEditingController extends TextEditingController { List _buildAgentMentionSpans( BuildContext context, String source, - TextStyle style, - ) { + TextStyle style, { + bool renderLinks = true, + }) { + final tokenSpans = renderLinks + ? _buildComposerTokenSpans(context, source, style) + : null; + if (tokenSpans != null) return tokenSpans; if (_agentMentionNames.isEmpty) { return [TextSpan(text: source, style: style)]; } @@ -294,6 +307,120 @@ class _MarkdownEditingController extends TextEditingController { return spans.isEmpty ? [TextSpan(text: source, style: style)] : spans; } + List? _buildComposerTokenSpans( + BuildContext context, + String source, + TextStyle style, + ) { + final expression = RegExp( + r'''buzz://(?:message\?|channel/|(?:repo|pr|issue)\?)[^\s<>"']+''', + caseSensitive: false, + ); + final matches = expression.allMatches(source).toList(); + if (matches.isEmpty) return null; + + final spans = []; + var offset = 0; + for (final match in matches) { + if (match.start > offset) { + spans.addAll( + _buildAgentMentionSpans( + context, + source.substring(offset, match.start), + style, + renderLinks: false, + ), + ); + } + final raw = match.group(0)!; + final url = raw.replaceFirst(RegExp(r'[.,!?:;)\]}*]+$'), ''); + final trailing = raw.substring(url.length); + final presentation = _composerLinkPresentation(url); + if (presentation == null) { + spans.add(TextSpan(text: raw, style: style)); + } else { + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: _ComposerBuzzLinkChip( + icon: presentation.$1, + label: presentation.$2, + semanticLabel: presentation.$3, + textStyle: style, + ), + ), + ); + // Preserve one source character per input character for native cursor + // movement while the visible atomic chip replaces the URL. + spans.add( + TextSpan( + text: url.substring(1), + semanticsLabel: '', + style: _hiddenMentionTextStyle(style), + ), + ); + if (trailing.isNotEmpty) { + spans.add(TextSpan(text: trailing, style: style)); + } + } + offset = match.end; + } + if (offset < source.length) { + spans.addAll( + _buildAgentMentionSpans( + context, + source.substring(offset), + style, + renderLinks: false, + ), + ); + } + return spans; + } + + (IconData, String, String)? _composerLinkPresentation(String raw) { + final uri = Uri.tryParse(raw); + if (uri == null) return null; + final link = parseBuzzDeepLink(uri) ?? parseEntityDeepLink(uri); + return switch (link) { + ChannelDeepLink(:final channelId) => ( + LucideIcons.hash, + _resolvedChannelName(channelId), + 'Channel ${_resolvedChannelName(channelId)}', + ), + MessageDeepLink(:final channelId, :final messageId) => ( + LucideIcons.messageSquare, + '${_resolvedChannelName(channelId)} · ${messageId.substring(0, 8)}', + 'Message ${messageId.substring(0, 8)} in channel ${_resolvedChannelName(channelId)}', + ), + EntityDeepLink(:final type, :final repository, :final eventId) => ( + switch (type) { + 'repo' => LucideIcons.folderGit2, + 'pr' => LucideIcons.gitPullRequest, + _ => LucideIcons.circleDot, + }, + type == 'repo' + ? repository + : '$repository · ${eventId!.substring(0, 8)}', + switch (type) { + 'repo' => 'Repository $repository', + 'pr' => + 'Pull request ${eventId!.substring(0, 8)} in repository $repository', + _ => 'Issue ${eventId!.substring(0, 8)} in repository $repository', + }, + ), + _ => null, + }; + } + + String _resolvedChannelName(String channelId) { + for (final entry in _channelNames.entries) { + if (entry.value == channelId) return entry.key; + } + return channelId.substring(0, math.min(8, channelId.length)); + } + TextStyle _hiddenMentionTextStyle(TextStyle inheritedStyle) => inheritedStyle.copyWith( color: Colors.transparent, @@ -363,6 +490,53 @@ class _MarkdownEditingController extends TextEditingController { } } +class _ComposerBuzzLinkChip extends StatelessWidget { + final IconData icon; + final String label; + final String semanticLabel; + final TextStyle textStyle; + + const _ComposerBuzzLinkChip({ + required this.icon, + required this.label, + required this.semanticLabel, + required this.textStyle, + }); + + @override + Widget build(BuildContext context) { + final style = textStyle.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w600, + height: 1, + ); + final fontSize = style.fontSize ?? 16; + return Semantics( + label: semanticLabel, + excludeSemantics: true, + child: Container( + key: ValueKey('composer-buzz-link-chip:$label'), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: context.colors.primary.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(Radii.sm), + border: Border.all( + color: context.colors.primary.withValues(alpha: 0.12), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: fontSize * 0.95, color: context.colors.primary), + const SizedBox(width: Grid.quarter + 1), + Text(label, style: style), + ], + ), + ), + ); + } +} + class _ComposerAgentMentionChip extends StatelessWidget { final String label; final TextStyle textStyle; diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index b264b31b69..391a4f1a49 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -17,7 +17,7 @@ import 'channels_provider.dart'; /// held (not dropped) while channels are still loading, so cold-start links /// dispatch as soon as the first channel fetch completes. typedef DeepLinkDestinationBuilder = - Widget Function(Channel channel, MessageDeepLink link); + Widget Function(Channel channel, BuzzDeepLink link); class DeepLinkDispatcher extends ConsumerStatefulWidget { final Widget child; @@ -63,28 +63,34 @@ class _DeepLinkDispatcherState extends ConsumerState { } void _maybeDispatch(BuzzDeepLink? link) { - if (link == null) return; + if (link == null || _preparingInvite) return; if (link is InviteDeepLink) { _maybeDispatchInvite(link); return; } - if (link is! MessageDeepLink || !widget.dispatchMessageLinks) return; + if ((link is! MessageDeepLink && link is! ChannelDeepLink) || + !widget.dispatchMessageLinks) { + return; + } + final channelId = switch (link) { + MessageDeepLink(:final channelId) => channelId, + ChannelDeepLink(:final channelId) => channelId, + _ => throw StateError('unsupported navigable deep link: $link'), + }; final channels = ref.read(channelsProvider).asData?.value; // Channels not loaded yet — keep the link parked; the channelsProvider // listener re-attempts once data arrives. if (channels == null) return; - ref.read(pendingDeepLinkProvider.notifier).consume(); - final channel = channels - .where((c) => c.id == link.channelId) + .where((c) => c.id == channelId) .cast() .firstOrNull; if (channel == null) { + ref.read(pendingDeepLinkProvider.notifier).consume(); debugPrint( - 'deep-link: channel ${link.channelId} not found in workspace; ' - 'dropping link', + 'deep-link: channel $channelId not found in workspace; dropping link', ); ScaffoldMessenger.maybeOf(context)?.showSnackBar( const SnackBar(content: Text('Channel not found in this workspace')), @@ -99,11 +105,14 @@ class _DeepLinkDispatcherState extends ConsumerState { widget.destinationBuilder?.call(channel, link) ?? ChannelDetailPage( channel: channel, - initialMessageId: link.messageId, - initialThreadRootId: link.threadRootId, + initialMessageId: link is MessageDeepLink ? link.messageId : null, + initialThreadRootId: link is MessageDeepLink + ? link.threadRootId + : null, ), ), ); + ref.read(pendingDeepLinkProvider.notifier).consume(); } void _maybeDispatchInvite(InviteDeepLink link) { @@ -112,13 +121,15 @@ class _DeepLinkDispatcherState extends ConsumerState { final navigatorContext = context; final messenger = ScaffoldMessenger.maybeOf(context); Future.microtask(() async { + var consumed = false; try { await ref.read(inviteJoinProvider.notifier).prepare(link); ref.read(pendingDeepLinkProvider.notifier).consume(); + consumed = true; if (!navigatorContext.mounted) return; final status = ref.read(inviteJoinProvider).status; if (status == InviteJoinStatus.confirming) { - showInviteJoinSheet(navigatorContext, ref); + await showInviteJoinSheet(navigatorContext, ref); } else if (status == InviteJoinStatus.switchedExisting) { messenger?.showSnackBar( const SnackBar(content: Text('Switched to this community')), @@ -137,6 +148,9 @@ class _DeepLinkDispatcherState extends ConsumerState { } } finally { _preparingInvite = false; + if (mounted && consumed) { + _maybeDispatch(ref.read(pendingDeepLinkProvider)); + } } }); } diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index e2c86b2fb0..64938b5c87 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -15,6 +15,8 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:video_player/video_player.dart'; import '../../shared/clipboard_utils.dart'; +import '../../shared/deeplink/deep_link.dart'; +import '../../shared/deeplink/pending_deep_link_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/syntax_highlight.dart'; import '../../shared/theme/theme.dart'; @@ -23,10 +25,13 @@ import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/emoji/emoji_only.dart'; +import 'channels_provider.dart'; import 'media_viewer_page.dart'; +import 'message_content/link_normalizer.dart'; import 'message_media.dart'; part 'message_content/media_carousel.dart'; +part 'message_content/token_pill.dart'; part 'message_content/video_preview.dart'; const _messageMediaMaxInlineWidth = 320.0; @@ -156,6 +161,26 @@ class MessageContent extends HookConsumerWidget { final resolvedAgentMentionPubkeys = { ...agentMentionPubkeys.map((pubkey) => pubkey.toLowerCase()), }; + final resolvedChannelNames = channelNames.isNotEmpty + ? channelNames + : { + for (final channel + in ref.watch(channelsProvider).asData?.value ?? const []) + channel.name.toLowerCase(): channel.id, + }; + final resolvedChannelTap = + onChannelTap ?? + (String channelId) { + ref + .read(pendingDeepLinkProvider.notifier) + .open(Uri(scheme: 'buzz', host: 'channel', path: channelId)); + }; + final channelPresentationKey = [ + for (final entry + in (resolvedChannelNames.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)))) + '${entry.key}\u0000${entry.value}', + ].join('\u0001'); final imetaByUrl = parseImetaTags(tags); final trailingGallery = maxLines == null ? _extractTrailingImageGallery(content, imetaByUrl) @@ -193,45 +218,17 @@ class MessageContent extends HookConsumerWidget { ? kEmojiOnlyCustomEmojiSize : kCustomEmojiInlineSize; - final finalContent = useMemoized(() { - // Convert autolinks and bare URLs to standard markdown links, - // but skip content inside backticks (inline code / fenced blocks). - final buffer = StringBuffer(); - final parts = markdownContent.split('`'); - for (var i = 0; i < parts.length; i++) { - if (i.isOdd) { - // Inside backticks — preserve as-is. - buffer.write('`${parts[i]}`'); - } else { - // 1. Angle-bracket autolinks: - var segment = parts[i].replaceAllMapped( - RegExp(r'<(https?://[^>]+)>'), - (m) => '[${m[1]}](${m[1]})', - ); - // 2. Bare URLs not already inside markdown link/image syntax. - // Negative lookbehind avoids matching URLs preceded by ]( or = - // which are already part of markdown links or imeta tags. - segment = segment.replaceAllMapped( - RegExp(r'(?\]]+'), - (m) { - final url = m[0]!; - // Skip if this URL is already a markdown link label that equals - // the URL (produced by step 1 or authored as [url](url)). - final start = m.start; - if (start >= 1 && segment[start - 1] == '[') return url; - return '[$url]($url)'; - }, - ); - buffer.write(segment); - } - } - final processed = buffer.toString(); + final linkNormalizedContent = useMemoized( + () => normalizeBareLinks(markdownContent), + [markdownContent], + ); + final finalContent = useMemoized(() { // Replace spaces with non-breaking spaces inside known mention names // so the gpt_markdown combined regex can match multi-word names // even when caseSensitive is not preserved. // Skip content inside backticks to avoid altering inline code. - final mentionParts = processed.split('`'); + final mentionParts = linkNormalizedContent.split('`'); final mentionBuf = StringBuffer(); for (var i = 0; i < mentionParts.length; i++) { if (i.isOdd) { @@ -250,27 +247,36 @@ class MessageContent extends HookConsumerWidget { mentionBuf.write(segment); } } - final mentionProcessed = mentionBuf.toString(); + var result = mentionBuf.toString(); // Ensure channel links at the very start of content don't get // swallowed by markdown processing. - var result = mentionProcessed; if (RegExp(r'^#[A-Za-z0-9_]').hasMatch(result)) { result = '\u200B$result'; } return result; - }, [markdownContent, resolvedMentionNames]); + }, [linkNormalizedContent, resolvedMentionNames]); final markdown = KeyedSubtree( - key: ValueKey('$finalContent\u0000$mentionPresentationKey'), + key: ValueKey( + '$finalContent\u0000$mentionPresentationKey\u0000$channelPresentationKey', + ), child: GptMarkdown( finalContent, style: style, followLinkColor: false, codeBuilder: (context, name, code, closed) => _MessageCodeBlock(name: name, code: code), - linkBuilder: (context, linkText, url, linkStyle) => - _buildLink(context, ref, linkText, url, linkStyle, style), + linkBuilder: (context, linkText, url, linkStyle) => _buildLink( + context, + ref, + linkText, + url, + linkStyle, + style, + resolvedChannelTap, + resolvedChannelNames, + ), imageBuilder: (context, imageUrl) => _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), textAlign: textAlign, @@ -283,8 +289,8 @@ class MessageContent extends HookConsumerWidget { ), CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize), _ChannelLinkMd( - channelNames: channelNames, - onChannelTap: onChannelTap, + channelNames: resolvedChannelNames, + onChannelTap: resolvedChannelTap, ), ...MarkdownComponent.inlineComponents, ], @@ -336,6 +342,8 @@ class MessageContent extends HookConsumerWidget { String url, TextStyle linkStyle, TextStyle? fallbackStyle, + void Function(String channelId) resolvedChannelTap, + Map resolvedChannelNames, ) { String text = ''; linkText.visitChildren((span) { @@ -346,13 +354,105 @@ class MessageContent extends HookConsumerWidget { }); final baseStyle = fallbackStyle ?? linkStyle; + final uri = Uri.tryParse(url); + final buzzLink = uri?.scheme == 'buzz' + ? parseBuzzDeepLink(uri!) ?? parseEntityDeepLink(uri) + : null; + final isBuzzLink = + buzzLink is ChannelDeepLink || + buzzLink is MessageDeepLink || + buzzLink is EntityDeepLink; + final isCanonicalBuzzLabel = isBuzzLink && text == url; + final buzzPresentation = switch (buzzLink) { + ChannelDeepLink(:final channelId) => ( + icon: LucideIcons.hash, + label: + _channelNameForId(resolvedChannelNames, channelId) ?? + channelId.substring(0, math.min(8, channelId.length)), + semanticLabel: + 'Open channel ${_channelNameForId(resolvedChannelNames, channelId) ?? channelId.substring(0, math.min(8, channelId.length))}', + interactive: true, + ), + MessageDeepLink(:final channelId, :final messageId) => ( + icon: LucideIcons.messageSquare, + label: + '${_channelNameForId(resolvedChannelNames, channelId) ?? channelId.substring(0, math.min(8, channelId.length))} · ${messageId.substring(0, math.min(8, messageId.length))}', + semanticLabel: + 'Open message ${messageId.substring(0, math.min(8, messageId.length))} in channel ${_channelNameForId(resolvedChannelNames, channelId) ?? channelId.substring(0, math.min(8, channelId.length))}', + interactive: true, + ), + EntityDeepLink(:final type, :final repository, :final eventId) => ( + icon: switch (type) { + 'repo' => LucideIcons.folderGit2, + 'pr' => LucideIcons.gitPullRequest, + _ => LucideIcons.circleDot, + }, + label: type == 'repo' + ? repository + : '$repository · ${eventId!.substring(0, 8)}', + semanticLabel: switch (type) { + 'repo' => 'Repository $repository', + 'pr' => + 'Pull request ${eventId!.substring(0, 8)} in repository $repository', + _ => 'Issue ${eventId!.substring(0, 8)} in repository $repository', + }, + interactive: false, + ), + _ => null, + }; + + final authoredLinkStyle = baseStyle.copyWith( + color: context.colors.primary, + decoration: TextDecoration.underline, + decorationColor: context.colors.primary, + ); + final linkTextWidget = isCanonicalBuzzLabel + ? Text( + text, + style: baseStyle.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w600, + ), + ) + : Text.rich(TextSpan(style: authoredLinkStyle, children: [linkText])); + + final renderedLink = isCanonicalBuzzLabel && buzzPresentation != null + ? _TokenPill( + key: ValueKey('buzz-link-chip:$url'), + icon: buzzPresentation.icon, + interactive: buzzPresentation.interactive, + semanticLabel: buzzPresentation.semanticLabel, + text: buzzPresentation.label, + textStyle: baseStyle.copyWith(fontWeight: FontWeight.w600), + ) + : linkTextWidget; + + // Mobile has no repo/PR/issue destination yet. Keep these presentation-only + // instead of exposing a control whose tap cannot do anything. + if (buzzLink is EntityDeepLink) { + return IgnorePointer(child: renderedLink); + } return GestureDetector( onTap: () async { final uri = Uri.tryParse(url); - if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) { + if (uri == null) return; + + // Rendered channel URLs must use the same callback as `#channel` + // references so detail-page callers can suppress self-navigation. + // Message and join links still need the top-level authenticated + // dispatcher. + if (uri.scheme == 'buzz') { + final deepLink = parseBuzzDeepLink(uri); + if (deepLink case ChannelDeepLink(:final channelId)) { + resolvedChannelTap(channelId); + } else if (deepLink is MessageDeepLink || + deepLink is InviteDeepLink) { + ref.read(pendingDeepLinkProvider.notifier).open(uri); + } return; } + if (uri.scheme != 'http' && uri.scheme != 'https') return; final auth = ref.read(mediaGetAuthServiceProvider); if (!auth.isRelayMediaUrl(url)) { @@ -373,14 +473,7 @@ class MessageContent extends HookConsumerWidget { ); } }, - child: Text( - text, - style: baseStyle.copyWith( - color: context.colors.primary, - decoration: TextDecoration.underline, - decorationColor: context.colors.primary, - ), - ), + child: renderedLink, ); } } @@ -799,7 +892,7 @@ class _MentionPill extends StatelessWidget { size: fontSize * 0.95, color: context.colors.primary, ), - const SizedBox(width: Grid.quarter), + const SizedBox(width: Grid.quarter + 1), ] else Transform.translate( offset: const Offset(0, -Grid.quarter), @@ -812,106 +905,6 @@ class _MentionPill extends StatelessWidget { } } -class _ChannelLinkMd extends InlineMd { - final Map channelNames; - final void Function(String channelId)? onChannelTap; - late final RegExp _exp = _buildPrefixPattern( - prefix: '#', - knownNames: channelNames.keys, - genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_-]*', - ); - - _ChannelLinkMd({required this.channelNames, this.onChannelTap}); - - @override - RegExp get exp => _exp; - - @override - InlineSpan span( - BuildContext context, - String text, - final GptMarkdownConfig config, - ) { - final raw = exp.firstMatch(text.trim())?.group(0); - if (raw == null) { - return TextSpan(text: text, style: config.style); - } - - final channelId = channelNames[raw.substring(1).toLowerCase()]; - final child = _TokenPill( - text: raw, - textStyle: config.style?.copyWith(fontWeight: FontWeight.w500), - ); - - return WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: channelId != null && onChannelTap != null - ? GestureDetector(onTap: () => onChannelTap!(channelId), child: child) - : child, - ); - } -} - -class _TokenPill extends StatelessWidget { - final String text; - final TextStyle? textStyle; - - const _TokenPill({required this.text, this.textStyle}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: context.colors.primary.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(Radii.sm), - ), - child: Text( - text, - style: - textStyle?.copyWith(color: context.colors.primary) ?? - context.textTheme.bodyMedium?.copyWith( - color: context.colors.primary, - ), - ), - ); - } -} - -RegExp _buildPrefixPattern({ - required String prefix, - required Iterable knownNames, - required String genericTokenPattern, -}) { - final names = - knownNames - .map((name) => name.trim()) - .where((name) => name.isNotEmpty) - .toSet() - .toList() - ..sort((a, b) => b.length.compareTo(a.length)); - - final escapedPrefix = RegExp.escape(prefix); - const leadingBoundary = r'(? name.replaceAll(' ', '\u00A0'); Iterable _mentionAliases(Iterable mentionNames) sync* { diff --git a/mobile/lib/features/channels/message_content/link_normalizer.dart b/mobile/lib/features/channels/message_content/link_normalizer.dart new file mode 100644 index 0000000000..622d7339ed --- /dev/null +++ b/mobile/lib/features/channels/message_content/link_normalizer.dart @@ -0,0 +1,188 @@ +const _markdownDelimiters = ['***', '___', '**', '__', '~~', '*', '_']; + +final _autolinkPattern = RegExp( + r'<((?:https?://|buzz://(?:message\?|join\?|channel/|(?:pr|issue|repo)\?))[^>]+)>', +); +final _bareLinkPattern = RegExp( + r'(?\]]+', +); +final _trailingPunctuationPattern = RegExp(r'[.,!?:;]+$'); +final _backtickRunPattern = RegExp(r'`+'); + +/// Converts supported Buzz and HTTP(S) autolinks and bare links into Markdown +/// links while leaving inline and fenced code untouched. Punctuation peeling +/// is limited to Buzz URLs so existing HTTP(S) destinations stay unchanged. +String normalizeBareLinks(String content) { + final buffer = StringBuffer(); + var offset = 0; + var proseStart = 0; + var codeStart = 0; + var inlineDelimiterLength = 0; + var fenceDelimiterLength = 0; + + while (offset < content.length) { + final run = _backtickRunPattern.matchAsPrefix(content, offset); + if (run == null) { + offset++; + continue; + } + + final runLength = run.end - run.start; + if (fenceDelimiterLength > 0) { + if (_isClosingFence(content, run.start, run.end, fenceDelimiterLength)) { + buffer.write(content.substring(codeStart, run.end)); + fenceDelimiterLength = 0; + proseStart = run.end; + } + } else if (inlineDelimiterLength > 0) { + if (runLength == inlineDelimiterLength) { + buffer.write(content.substring(codeStart, run.end)); + inlineDelimiterLength = 0; + proseStart = run.end; + } + } else if (_hasInlineCloserOnLine(content, run.end, runLength) || + (!_isOpeningFence(content, run.start, runLength) && + _hasInlineCloser(content, run.end, runLength))) { + buffer.write( + _normalizeLinkSegment(content.substring(proseStart, run.start)), + ); + codeStart = run.start; + inlineDelimiterLength = runLength; + } else if (_isOpeningFence(content, run.start, runLength)) { + buffer.write( + _normalizeLinkSegment(content.substring(proseStart, run.start)), + ); + codeStart = run.start; + fenceDelimiterLength = runLength; + } + + offset = run.end; + } + + if (inlineDelimiterLength > 0 || fenceDelimiterLength > 0) { + buffer.write(content.substring(codeStart)); + } else { + buffer.write(_normalizeLinkSegment(content.substring(proseStart))); + } + return buffer.toString(); +} + +bool _isOpeningFence(String content, int runStart, int runLength) { + if (runLength < 3) return false; + final lineStart = runStart == 0 + ? 0 + : content.lastIndexOf('\n', runStart - 1) + 1; + final indentation = content.substring(lineStart, runStart); + return indentation.length <= 3 && indentation.trim().isEmpty; +} + +bool _isClosingFence( + String content, + int runStart, + int runEnd, + int openerLength, +) { + if (runEnd - runStart < openerLength) return false; + final lineStart = runStart == 0 + ? 0 + : content.lastIndexOf('\n', runStart - 1) + 1; + final indentation = content.substring(lineStart, runStart); + if (indentation.length > 3 || indentation.trim().isNotEmpty) return false; + final newline = content.indexOf('\n', runEnd); + final lineEnd = newline < 0 ? content.length : newline; + return content.substring(runEnd, lineEnd).trim().isEmpty; +} + +bool _hasInlineCloserOnLine(String content, int start, int delimiterLength) { + final newline = content.indexOf('\n', start); + final lineEnd = newline < 0 ? content.length : newline; + for (final run in _backtickRunPattern.allMatches(content, start)) { + if (run.start >= lineEnd) return false; + if (run.end - run.start == delimiterLength) return true; + } + return false; +} + +bool _hasInlineCloser(String content, int start, int delimiterLength) { + for (final run in _backtickRunPattern.allMatches(content, start)) { + if (run.end - run.start == delimiterLength) return true; + } + return false; +} + +String _normalizeLinkSegment(String segment) { + var normalized = segment.replaceAllMapped( + _autolinkPattern, + (match) => '[${match[1]}](${match[1]})', + ); + normalized = normalized.replaceAllMapped( + _bareLinkPattern, + (match) => _normalizeBareLink(normalized, match), + ); + return normalized; +} + +String _normalizeBareLink(String segment, Match match) { + final matched = match[0]!; + var url = matched; + var trailing = ''; + final isBuzzUrl = matched.startsWith('buzz://'); + final start = match.start; + + if (isBuzzUrl) { + final outsidePunctuation = _trailingPunctuationPattern.firstMatch(url); + if (outsidePunctuation != null) { + url = url.substring(0, outsidePunctuation.start); + trailing = outsidePunctuation[0]!; + } + } + + var strippedDelimiter = true; + while (strippedDelimiter) { + strippedDelimiter = false; + for (final delimiter in _markdownDelimiters) { + if (url.endsWith(delimiter) && + _hasUnclosedMarkdownDelimiter( + segment.substring(0, start), + delimiter, + )) { + url = url.substring(0, url.length - delimiter.length); + trailing = '$delimiter$trailing'; + strippedDelimiter = true; + break; + } + } + } + + if (isBuzzUrl) { + final punctuation = _trailingPunctuationPattern.firstMatch(url); + if (punctuation != null) { + url = url.substring(0, punctuation.start); + trailing = '${punctuation[0]}$trailing'; + } + } + + // Preserve a URL already used as its own Markdown label. This covers both + // converted autolinks and authored `[url](url)` links. + if (start >= 1 && segment[start - 1] == '[') return matched; + return '[$url]($url)$trailing'; +} + +bool _hasUnclosedMarkdownDelimiter(String prefix, String delimiter) { + var open = false; + var offset = 0; + while (true) { + final index = prefix.indexOf(delimiter, offset); + if (index < 0) return open; + final before = index == 0 ? null : prefix[index - 1]; + final afterIndex = index + delimiter.length; + final after = afterIndex == prefix.length ? null : prefix[afterIndex]; + final canOpen = + (after == null || after.trim().isNotEmpty) && + (before == null || + before.trim().isEmpty || + RegExp(r'[^\w]').hasMatch(before)); + if (open || canOpen) open = !open; + offset = afterIndex; + } +} diff --git a/mobile/lib/features/channels/message_content/token_pill.dart b/mobile/lib/features/channels/message_content/token_pill.dart new file mode 100644 index 0000000000..8c812d9b95 --- /dev/null +++ b/mobile/lib/features/channels/message_content/token_pill.dart @@ -0,0 +1,138 @@ +part of '../message_content.dart'; + +String? _channelNameForId(Map channels, String channelId) { + for (final entry in channels.entries) { + if (entry.value == channelId) return entry.key; + } + return null; +} + +class _ChannelLinkMd extends InlineMd { + final Map channelNames; + final void Function(String channelId)? onChannelTap; + late final RegExp _exp = _buildPrefixPattern( + prefix: '#', + knownNames: channelNames.keys, + genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_-]*', + ); + + _ChannelLinkMd({required this.channelNames, this.onChannelTap}); + + @override + RegExp get exp => _exp; + + @override + InlineSpan span( + BuildContext context, + String text, + final GptMarkdownConfig config, + ) { + final raw = exp.firstMatch(text.trim())?.group(0); + if (raw == null) { + return TextSpan(text: text, style: config.style); + } + + final channelId = channelNames[raw.substring(1).toLowerCase()]; + final channelName = raw.substring(1); + final opensChannel = channelId != null && onChannelTap != null; + final child = _TokenPill( + icon: LucideIcons.hash, + interactive: opensChannel, + semanticLabel: opensChannel + ? 'Open channel $channelName' + : 'Channel $channelName', + text: channelName, + textStyle: config.style?.copyWith(fontWeight: FontWeight.w500), + ); + + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: opensChannel + ? GestureDetector(onTap: () => onChannelTap!(channelId), child: child) + : child, + ); + } +} + +class _TokenPill extends StatelessWidget { + final IconData? icon; + final bool interactive; + final String? semanticLabel; + final String text; + final TextStyle? textStyle; + + const _TokenPill({ + super.key, + this.icon, + this.interactive = false, + this.semanticLabel, + required this.text, + this.textStyle, + }); + + @override + Widget build(BuildContext context) { + final style = + textStyle?.copyWith(color: context.colors.primary) ?? + context.textTheme.bodyMedium?.copyWith(color: context.colors.primary); + final fontSize = style?.fontSize ?? 16; + final pill = Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: context.colors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(Radii.sm), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon(icon, size: fontSize * 0.95, color: context.colors.primary), + const SizedBox(width: Grid.quarter + 1), + ], + Text(text, style: style), + ], + ), + ); + if (semanticLabel == null) return pill; + return Semantics( + label: semanticLabel, + button: interactive, + child: ExcludeSemantics(child: pill), + ); + } +} + +RegExp _buildPrefixPattern({ + required String prefix, + required Iterable knownNames, + required String genericTokenPattern, +}) { + final names = + knownNames + .map((name) => name.trim()) + .where((name) => name.isNotEmpty) + .toSet() + .toList() + ..sort((a, b) => b.length.compareTo(a.length)); + + final escapedPrefix = RegExp.escape(prefix); + const leadingBoundary = r'(?`. +class ChannelDeepLink extends BuzzDeepLink { + /// Channel UUID from the sole path segment. + final String channelId; + + const ChannelDeepLink({required this.channelId}); + + @override + bool operator ==(Object other) => + other is ChannelDeepLink && other.channelId == channelId; + + @override + int get hashCode => channelId.hashCode; + + @override + String toString() => 'ChannelDeepLink(channel: $channelId)'; +} + /// A parsed `buzz://message` deep link. class MessageDeepLink extends BuzzDeepLink { /// Channel UUID from the `channel` query param. @@ -115,25 +135,72 @@ String buildMessageLink({ ).toString(); } +/// Parse a canonical `buzz://channel/` URI. +/// +/// The channel ID must be the URI's sole non-empty path segment. Query +/// parameters and fragments are rejected so malformed or ambiguous links never +/// become navigation targets. +ChannelDeepLink? parseChannelDeepLink(Uri uri) { + if (uri.scheme != 'buzz' || uri.host != 'channel') return null; + if (uri.hasQuery || + uri.hasFragment || + uri.userInfo.isNotEmpty || + uri.hasPort) { + return null; + } + if (uri.pathSegments.length != 1 || uri.pathSegments.single.isEmpty) { + return null; + } + final channelId = uri.pathSegments.single; + if (!RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + caseSensitive: false, + ).hasMatch(channelId)) { + return null; + } + return ChannelDeepLink(channelId: channelId.toLowerCase()); +} + /// Parse a `buzz://message?…` URI into a [MessageDeepLink]. /// -/// Returns `null` for non-`buzz` schemes, non-`message` hosts (e.g. -/// `buzz://connect` which is desktop-only), or links missing a non-empty -/// `channel` or `id` param. +/// Returns `null` unless the URI exactly matches the canonical message-link +/// shape: no path, fragment, credentials, duplicate or unknown parameters; a +/// UUID channel; and 64-character hexadecimal message/thread event IDs. MessageDeepLink? parseMessageDeepLink(Uri uri) { if (uri.scheme != 'buzz' || uri.host != 'message') return null; + if (uri.path.isNotEmpty || + uri.hasFragment || + uri.userInfo.isNotEmpty || + uri.hasPort) { + return null; + } + + const allowedParams = {'channel', 'id', 'thread'}; + if (uri.queryParametersAll.keys.any((key) => !allowedParams.contains(key)) || + uri.queryParametersAll.values.any((values) => values.length != 1)) { + return null; + } final channel = uri.queryParameters['channel']; final id = uri.queryParameters['id']; - if (channel == null || channel.isEmpty || id == null || id.isEmpty) { + final thread = uri.queryParameters['thread']; + final uuid = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + caseSensitive: false, + ); + final eventId = RegExp(r'^[0-9a-f]{64}$', caseSensitive: false); + if (channel == null || + !uuid.hasMatch(channel) || + id == null || + !eventId.hasMatch(id) || + (thread != null && !eventId.hasMatch(thread))) { return null; } - final thread = uri.queryParameters['thread']; return MessageDeepLink( - channelId: channel, - messageId: id, - threadRootId: (thread == null || thread.isEmpty) ? null : thread, + channelId: channel.toLowerCase(), + messageId: id.toLowerCase(), + threadRootId: thread?.toLowerCase(), ); } @@ -218,4 +285,63 @@ InviteDeepLink? parseInviteDeepLink(Uri uri) { /// Parse any supported Buzz deep link. BuzzDeepLink? parseBuzzDeepLink(Uri uri) => - parseInviteDeepLink(uri) ?? parseMessageDeepLink(uri); + parseInviteDeepLink(uri) ?? + parseChannelDeepLink(uri) ?? + parseMessageDeepLink(uri); + +/// A validated Buzz repository, pull request, or issue permalink. +class EntityDeepLink extends BuzzDeepLink { + final String type; + final String owner; + final String repository; + final String? eventId; + + const EntityDeepLink({ + required this.type, + required this.owner, + required this.repository, + this.eventId, + }); +} + +/// Parse canonical `buzz://repo|pr|issue` permalinks for inline presentation. +EntityDeepLink? parseEntityDeepLink(Uri uri) { + if (uri.scheme != 'buzz' || !{'repo', 'pr', 'issue'}.contains(uri.host)) { + return null; + } + if (uri.path.isNotEmpty || + uri.hasFragment || + uri.userInfo.isNotEmpty || + uri.hasPort) { + return null; + } + final allowed = uri.host == 'repo' ? {'owner', 'd'} : {'id', 'owner', 'd'}; + final queryParameters = uri.queryParametersAll; + final parameterKeys = queryParameters.keys.toSet(); + if (parameterKeys.difference(allowed).isNotEmpty || + allowed.difference(parameterKeys).isNotEmpty || + allowed.any((key) => queryParameters[key]?.length != 1)) { + return null; + } + final owner = uri.queryParameters['owner']; + final repository = uri.queryParameters['d']; + final eventId = uri.queryParameters['id']; + final hex = RegExp(r'^[0-9a-f]{64}$', caseSensitive: false); + final repositoryName = RegExp(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$'); + if (owner == null || + !hex.hasMatch(owner) || + repository == null || + !repositoryName.hasMatch(repository) || + repository.contains('..')) { + return null; + } + if (uri.host != 'repo' && (eventId == null || !hex.hasMatch(eventId))) { + return null; + } + return EntityDeepLink( + type: uri.host, + owner: owner.toLowerCase(), + repository: repository, + eventId: eventId?.toLowerCase(), + ); +} diff --git a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart index 8dc46d9f10..4875d94fcf 100644 --- a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart +++ b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:collection'; import 'package:app_links/app_links.dart'; import 'package:flutter/foundation.dart'; @@ -6,24 +7,27 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'deep_link.dart'; -/// Holds the most recent supported deep link that has not been -/// dispatched yet. +/// Holds supported deep links until they can be dispatched. /// -/// Listens to [AppLinks.uriLinkStream], which delivers both the cold-start -/// link (the URL that launched the app) and links received while running. -/// Navigation cannot always happen the moment a link arrives — the user may -/// not be authenticated yet, or channels may still be loading — so the parsed -/// link is parked here and consumed by the dispatcher once the app is ready. +/// Links are queued in arrival order. Navigation cannot always happen the +/// moment a link arrives — the user may not be authenticated yet, channels may +/// still be loading, or another link may already be dispatching. Keeping a FIFO +/// prevents a later app-link event from silently replacing an earlier one. +/// +/// Listens to [AppLinks.uriLinkStream], which delivers both the cold-start link +/// (the URL that launched the app) and links received while running. class PendingDeepLinkNotifier extends Notifier { @visibleForTesting static Stream? debugUriStreamOverride; StreamSubscription? _subscription; + final Queue _waiting = Queue(); @override BuzzDeepLink? build() { + _waiting.clear(); final stream = debugUriStreamOverride ?? AppLinks().uriLinkStream; - _subscription = stream.listen(handleUri); + _subscription = stream.listen(open); ref.onDispose(() { _subscription?.cancel(); _subscription = null; @@ -32,18 +36,23 @@ class PendingDeepLinkNotifier extends Notifier { } /// Parse and park an incoming URI. Unsupported links are ignored loudly. - @visibleForTesting - void handleUri(Uri uri) { + void open(Uri uri) { final link = parseBuzzDeepLink(uri); if (link == null) { debugPrint('deep-link: ignoring unsupported link: $uri'); return; } - state = link; + if (state == null) { + state = link; + } else { + _waiting.addLast(link); + } } - /// Clear the pending link after it has been dispatched (or dropped). - void consume() => state = null; + /// Acknowledge the current link and expose the next queued link, if any. + void consume() { + state = _waiting.isEmpty ? null : _waiting.removeFirst(); + } } final pendingDeepLinkProvider = diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index a0a832c866..365befcff0 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4102,7 +4102,7 @@ void main() { await tester.pumpAndSettle(); final initialPushCount = observer.pushCount; - await tester.tap(find.text('#random')); + await tester.tap(find.text('random')); await tester.pumpAndSettle(); expect(observer.pushCount, initialPushCount + 1); @@ -4134,7 +4134,7 @@ void main() { await tester.pumpAndSettle(); channelsNotifier.setChannels([_testChannel]); - await tester.tap(find.text('#random')); + await tester.tap(find.text('random')); await tester.pump(); expect(find.text('Channel could not be opened'), findsOneWidget); @@ -4188,7 +4188,7 @@ void main() { await tester.pumpAndSettle(); final initialPushCount = observer.pushCount; - await tester.tap(find.text('#random').last); + await tester.tap(find.text('random').last); await tester.pumpAndSettle(); expect(observer.pushCount, initialPushCount + 1); diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index beb83784cb..df84bf073e 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -2627,6 +2627,108 @@ void main() { ); }); + testWidgets('renders all five permalink types as composer chips', ( + tester, + ) async { + final owner = 'ab' * 32; + final id = 'cd' * 32; + const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32'; + final urls = [ + 'buzz://message?channel=$channelId&id=$id', + 'buzz://channel/$channelId', + 'buzz://repo?owner=$owner&d=buzz', + 'buzz://pr?id=$id&owner=$owner&d=buzz', + 'buzz://issue?id=$id&owner=$owner&d=buzz', + ]; + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + channels: [ + Channel( + id: channelId, + name: 'engineering', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'creator', + createdAt: DateTime(2026), + memberCount: 1, + isMember: true, + ), + ], + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), urls.join(' ')); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'composer-buzz-link-chip:', + ), + ), + findsNWidgets(5), + ); + expect( + find.byKey( + const ValueKey('composer-buzz-link-chip:engineering · cdcdcdcd'), + ), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('composer-buzz-link-chip:engineering')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('composer-buzz-link-chip:buzz')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('composer-buzz-link-chip:buzz · cdcdcdcd')), + findsNWidgets(2), + ); + expect( + tester.widget(find.byType(TextField)).controller!.text, + urls.join(' '), + ); + }); + + testWidgets('preserves underscore d-tags and Markdown delimiters', ( + tester, + ) async { + final owner = 'ab' * 32; + final url = 'buzz://repo?owner=$owner&d=my_repo'; + final source = '**$url**'; + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), source); + await tester.pump(); + + expect( + find.byKey(const ValueKey('composer-buzz-link-chip:my_repo')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('composer-buzz-link-chip:my')), + findsNothing, + ); + expect( + tester.widget(find.byType(TextField)).controller!.text, + source, + ); + }); + testWidgets('uses the primary color for formatting actions', ( tester, ) async { diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index 0771a7bb38..ea6bc226c2 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/deep_link_dispatcher.dart'; @@ -12,6 +14,75 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/community/community_storage_test.dart'; void main() { + testWidgets('queues deep links in arrival order until acknowledged', ( + tester, + ) async { + final controller = StreamController(); + PendingDeepLinkNotifier.debugUriStreamOverride = controller.stream; + addTearDown(() async { + PendingDeepLinkNotifier.debugUriStreamOverride = null; + await controller.close(); + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + container.read(pendingDeepLinkProvider); + + const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32'; + final firstId = 'aa' * 32; + final secondId = 'bb' * 32; + controller + ..add(Uri.parse('buzz://message?channel=$channelId&id=$firstId')) + ..add(Uri.parse('buzz://message?channel=$channelId&id=$secondId')); + await tester.pump(); + + expect( + container.read(pendingDeepLinkProvider), + MessageDeepLink(channelId: channelId, messageId: firstId), + ); + container.read(pendingDeepLinkProvider.notifier).consume(); + expect( + container.read(pendingDeepLinkProvider), + MessageDeepLink(channelId: channelId, messageId: secondId), + ); + container.read(pendingDeepLinkProvider.notifier).consume(); + expect(container.read(pendingDeepLinkProvider), isNull); + }); + + testWidgets('drops a missing channel and dispatches the next queued link', ( + tester, + ) async { + const missing = ChannelDeepLink(channelId: 'missing-channel'); + const next = ChannelDeepLink(channelId: 'channel-1'); + final pending = _QueuedPendingDeepLinkNotifier([missing, next]); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + pendingDeepLinkProvider.overrideWith(() => pending), + channelsProvider.overrideWith( + () => _FakeChannelsNotifier(Future.value([_channel])), + ), + ], + child: MaterialApp( + home: DeepLinkDispatcher( + destinationBuilder: (channel, link) => + _CapturedDestination(channel: channel, link: link), + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(pending.consumeCalls, 2); + final destination = tester.widget<_CapturedDestination>( + find.byType(_CapturedDestination), + ); + expect(destination.channel.id, 'channel-1'); + expect(destination.link, same(next)); + }); + testWidgets('dispatches a link that is already ready on mount', ( tester, ) async { @@ -43,12 +114,47 @@ void main() { await tester.pumpAndSettle(); + final destination = tester.widget<_CapturedDestination>( + find.byType(_CapturedDestination), + ); + final messageLink = destination.link as MessageDeepLink; + expect(destination.channel.id, 'channel-1'); + expect(messageLink.messageId, 'message-2'); + expect(messageLink.threadRootId, 'message-1'); + }); + + testWidgets('dispatches a channel-only link to the channel root', ( + tester, + ) async { + const link = ChannelDeepLink(channelId: 'channel-1'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + pendingDeepLinkProvider.overrideWith( + () => _FakePendingDeepLinkNotifier(link), + ), + channelsProvider.overrideWith( + () => _FakeChannelsNotifier(Future.value([_channel])), + ), + ], + child: MaterialApp( + home: DeepLinkDispatcher( + destinationBuilder: (channel, link) => + _CapturedDestination(channel: channel, link: link), + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + final destination = tester.widget<_CapturedDestination>( find.byType(_CapturedDestination), ); expect(destination.channel.id, 'channel-1'); - expect(destination.link.messageId, 'message-2'); - expect(destination.link.threadRootId, 'message-1'); + expect(destination.link, same(link)); }); testWidgets('retains invite and surfaces prepare failure', (tester) async { @@ -122,6 +228,100 @@ void main() { expect(find.text('Join this Buzz community?'), findsOneWidget); }); + testWidgets('waits for an invite modal before preparing the next invite', ( + tester, + ) async { + const first = InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'invite-one', + ); + const second = InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'invite-two', + ); + final pending = _QueuedPendingDeepLinkNotifier([first, second]); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue( + CommunityStorage(secure: FakeSecureStorage()), + ), + pendingDeepLinkProvider.overrideWith(() => pending), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: DeepLinkDispatcher(child: Scaffold(body: SizedBox())), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(pending.consumeCalls, 1); + expect(pending.current, same(second)); + expect(container.read(inviteJoinProvider).invite, same(first)); + expect(find.text('Join this Buzz community?'), findsOneWidget); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Cancel')); + await tester.pumpAndSettle(); + + expect(pending.consumeCalls, 2); + expect(pending.current, isNull); + expect(container.read(inviteJoinProvider).invite, same(second)); + expect(find.text('Join this Buzz community?'), findsOneWidget); + }); + + testWidgets('dispatches a queued channel after preparing an invite', ( + tester, + ) async { + const invite = InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'invite-code', + ); + const channelLink = ChannelDeepLink(channelId: 'channel-1'); + final pending = _QueuedPendingDeepLinkNotifier([invite, channelLink]); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + communityStorageProvider.overrideWithValue( + CommunityStorage(secure: FakeSecureStorage()), + ), + pendingDeepLinkProvider.overrideWith(() => pending), + channelsProvider.overrideWith( + () => _FakeChannelsNotifier(Future.value([_channel])), + ), + ], + child: MaterialApp( + home: DeepLinkDispatcher( + destinationBuilder: (channel, link) => + _CapturedDestination(channel: channel, link: link), + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(pending.consumeCalls, 1); + expect(pending.current, same(channelLink)); + expect(find.text('Join this Buzz community?'), findsOneWidget); + expect(find.byType(_CapturedDestination), findsNothing); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Cancel')); + await tester.pumpAndSettle(); + + expect(pending.consumeCalls, 2); + expect(pending.current, isNull); + final destination = tester.widget<_CapturedDestination>( + find.byType(_CapturedDestination), + ); + expect(destination.link, same(channelLink)); + }); + testWidgets( 'dispatches invite before auth while leaving message links parked', (tester) async { @@ -221,6 +421,27 @@ class _ThrowingCommunityStorage extends CommunityStorage { } } +class _QueuedPendingDeepLinkNotifier extends PendingDeepLinkNotifier { + _QueuedPendingDeepLinkNotifier(List links) + : _links = List.of(links); + + final List _links; + int consumeCalls = 0; + + BuzzDeepLink? get _firstOrNull => _links.isEmpty ? null : _links.first; + BuzzDeepLink? get current => _firstOrNull; + + @override + BuzzDeepLink? build() => _firstOrNull; + + @override + void consume() { + consumeCalls++; + _links.removeAt(0); + state = _firstOrNull; + } +} + class _RecordingPendingDeepLinkNotifier extends PendingDeepLinkNotifier { _RecordingPendingDeepLinkNotifier(this.link); @@ -259,7 +480,7 @@ class _CapturedDestination extends StatelessWidget { const _CapturedDestination({required this.channel, required this.link}); final Channel channel; - final MessageDeepLink link; + final BuzzDeepLink link; @override Widget build(BuildContext context) => const SizedBox(); diff --git a/mobile/test/features/channels/message_content/link_normalizer_test.dart b/mobile/test/features/channels/message_content/link_normalizer_test.dart new file mode 100644 index 0000000000..f7a118eb81 --- /dev/null +++ b/mobile/test/features/channels/message_content/link_normalizer_test.dart @@ -0,0 +1,101 @@ +import 'package:buzz/features/channels/message_content/link_normalizer.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const url = 'buzz://message?channel=channel-1&id=message-1'; + + test('normalizes supported bare and autolinked Buzz URLs', () { + expect( + normalizeBareLinks('See $url and <$url>'), + 'See [$url]($url) and [$url]($url)', + ); + }); + + test('keeps punctuation and open Markdown delimiters outside links', () { + expect( + normalizeBareLinks('**open $url**. and **_${url}_**!'), + '**open [$url]($url)**. and **_[$url]($url)_**!', + ); + }); + + test('preserves URL suffix characters without a matching opener', () { + expect( + normalizeBareLinks( + 'See $url' + '_ and $url~~', + ), + 'See [$url' + '_]($url' + '_) and [$url~~]($url~~)', + ); + }); + + group('code boundaries', () { + final cases = <({String name, String input, String expected})>[ + ( + name: 'single-backtick inline span', + input: '`$url` then $url', + expected: '`$url` then [$url]($url)', + ), + ( + name: 'matching multi-backtick inline span', + input: '``$url`` then $url', + expected: '``$url`` then [$url]($url)', + ), + ( + name: 'literal shorter backtick run in inline span', + input: '``inside ` $url`` then $url', + expected: '``inside ` $url`` then [$url]($url)', + ), + ( + name: 'inline closer must have equal length', + input: '``$url``` still code`` then $url', + expected: '``$url``` still code`` then [$url]($url)', + ), + ( + name: 'fence accepts a longer line-start closer', + input: '```\n$url\n````\n$url', + expected: '```\n$url\n````\n[$url]($url)', + ), + ( + name: 'fence ignores an inline-looking backtick run', + input: '```\n$url ``` still code\n```\n$url', + expected: '```\n$url ``` still code\n```\n[$url]($url)', + ), + ( + name: 'unclosed backticks remain prose', + input: '$url then `$url', + expected: '[$url]($url) then `[$url]($url)', + ), + ]; + + for (final testCase in cases) { + test(testCase.name, () { + expect(normalizeBareLinks(testCase.input), testCase.expected); + }); + } + }); + + test( + 'preserves HTTP(S) destinations while retaining bare-link rendering', + () { + const httpUrl = 'https://example.com/search?q=why?'; + expect( + normalizeBareLinks('See $httpUrl and <$httpUrl>'), + 'See [$httpUrl]($httpUrl) and [$httpUrl]($httpUrl)', + ); + }, + ); + test('normalizes every bare Buzz entity permalink family', () { + final owner = 'ab' * 32; + final id = 'cd' * 32; + final links = [ + 'buzz://repo?owner=$owner&d=buzz', + 'buzz://pr?id=$id&owner=$owner&d=buzz', + 'buzz://issue?id=$id&owner=$owner&d=buzz', + ]; + for (final link in links) { + expect(normalizeBareLinks('$link.'), '[$link]($link).'); + } + }); +} diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 0d904960c2..c4d624b22d 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -5,8 +5,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/misc.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/media_viewer_page.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; import 'package:buzz/shared/emoji/emoji_only.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; @@ -139,19 +143,31 @@ bool _spanHasStyle( String text, bool Function(TextStyle) check, ) { - var found = false; - root.visitChildren((span) { - if (span is TextSpan && - span.text != null && + bool visit(InlineSpan span, TextStyle? inheritedStyle) { + if (span is! TextSpan) return false; + final effectiveStyle = inheritedStyle?.merge(span.style) ?? span.style; + if (span.text != null && span.text!.contains(text) && - span.style != null && - check(span.style!)) { - found = true; - return false; // stop visiting + effectiveStyle != null && + check(effectiveStyle)) { + return true; } - return true; - }); - return found; + for (final child in span.children ?? const []) { + if (visit(child, effectiveStyle)) return true; + } + return false; + } + + return visit(root, null); +} + +class _TestChannelsNotifier extends ChannelsNotifier { + _TestChannelsNotifier(this.channels); + + final Future> channels; + + @override + Future> build() => channels; } void main() { @@ -431,6 +447,333 @@ void main() { expect(allText, isNot(contains('(https://example.com)'))); }); + testWidgets('renders and routes a buzz message link', (tester) async { + const url = + 'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&thread=dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'; + + await tester.pumpWidget( + _testable(const MessageContent(content: '[Open message]($url)')), + ); + + expect(find.text('Open message'), findsOneWidget); + await tester.tap(find.text('Open message')); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + messageId: + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + threadRootId: + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + ), + ); + }); + + testWidgets('renders and routes bare Buzz message links', (tester) async { + const url = + 'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + await tester.pumpWidget( + _testable(const MessageContent(content: 'See $url now')), + ); + + expect(find.byKey(ValueKey('buzz-link-chip:$url')), findsOneWidget); + await tester.tap(find.byKey(ValueKey('buzz-link-chip:$url'))); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + messageId: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ), + ); + }); + + testWidgets('keeps Markdown delimiters outside bare Buzz links', ( + tester, + ) async { + const url = + 'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + await tester.pumpWidget( + _testable(const MessageContent(content: '**$url**. and _${url}_')), + ); + + expect(find.byKey(ValueKey('buzz-link-chip:$url')), findsNWidgets(2)); + + await tester.tap(find.byKey(ValueKey('buzz-link-chip:$url')).first); + await tester.pump(); + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + messageId: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ), + ); + }); + + testWidgets('keeps non-adjacent Markdown delimiters outside links', ( + tester, + ) async { + const url = + 'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + await tester.pumpWidget( + _testable( + const MessageContent( + content: + '*join $url* and **open $url** and ' + '~~visit $url~~ and **_${url}_**.', + ), + ), + ); + + expect(find.byKey(ValueKey('buzz-link-chip:$url')), findsNWidgets(4)); + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + for (final link + in find.byKey(ValueKey('buzz-link-chip:$url')).evaluate()) { + await tester.tap(find.byWidget(link.widget)); + await tester.pump(); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + messageId: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ), + ); + container.read(pendingDeepLinkProvider.notifier).state = null; + } + }); + + testWidgets('excludes sentence punctuation from bare Buzz links', ( + tester, + ) async { + const messageUrl = + 'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const joinUrl = + 'buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=invite-1'; + + await tester.pumpWidget( + _testable( + const MessageContent(content: 'See $messageUrl. Then $joinUrl!'), + ), + ); + + expect( + find.byKey(ValueKey('buzz-link-chip:$messageUrl')), + findsOneWidget, + ); + expect(find.text(joinUrl), findsOneWidget); + expect(_allRichText(tester), contains('See \u{FFFC}. Then \u{FFFC}!')); + + await tester.tap(find.byKey(ValueKey('buzz-link-chip:$messageUrl'))); + await tester.pump(); + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + messageId: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ), + ); + + container.read(pendingDeepLinkProvider.notifier).consume(); + await tester.tap(find.text(joinUrl)); + await tester.pump(); + expect( + container.read(pendingDeepLinkProvider), + const InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'invite-1', + ), + ); + }); + + testWidgets('renders and routes autolinked Buzz thread links', ( + tester, + ) async { + const url = + 'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc&thread=dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'; + + await tester.pumpWidget( + _testable(const MessageContent(content: '<$url>')), + ); + + expect(find.byKey(ValueKey('buzz-link-chip:$url')), findsOneWidget); + await tester.tap(find.byKey(ValueKey('buzz-link-chip:$url'))); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + messageId: + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + threadRootId: + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + ), + ); + }); + + testWidgets('renders and routes bare Buzz join links', (tester) async { + const url = + 'buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=invite-1'; + + await tester.pumpWidget( + _testable(const MessageContent(content: 'Join with $url')), + ); + + expect(find.text(url), findsOneWidget); + await tester.tap(find.text(url)); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'invite-1', + ), + ); + }); + + testWidgets('renders and routes bare Buzz channel links', (tester) async { + const url = 'buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'; + + await tester.pumpWidget( + _testable(const MessageContent(content: 'See $url now')), + ); + + expect(find.byKey(ValueKey('buzz-link-chip:$url')), findsOneWidget); + await tester.tap(find.byKey(ValueKey('buzz-link-chip:$url'))); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + + testWidgets('renders and routes labeled Buzz channel links', ( + tester, + ) async { + const url = 'buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'; + + await tester.pumpWidget( + _testable(const MessageContent(content: '[Open channel]($url)')), + ); + + await tester.tap(find.text('Open channel')); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + + testWidgets('routes rendered Buzz channel links through callback', ( + tester, + ) async { + const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32'; + const url = 'buzz://channel/$channelId'; + String? tappedChannelId; + + await tester.pumpWidget( + _testable( + MessageContent( + content: '[Open channel]($url)', + onChannelTap: (id) => tappedChannelId = id, + ), + ), + ); + + await tester.tap(find.text('Open channel')); + await tester.pump(); + + expect(tappedChannelId, channelId); + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect(container.read(pendingDeepLinkProvider), isNull); + }); + + testWidgets('renders and routes autolinked Buzz channel links', ( + tester, + ) async { + const url = 'buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'; + + await tester.pumpWidget( + _testable(const MessageContent(content: '<$url>')), + ); + + await tester.tap(find.byKey(ValueKey('buzz-link-chip:$url'))); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + + testWidgets('leaves malformed Buzz channel forms as plain text', ( + tester, + ) async { + const url = + 'buzz://channel?channel=580ca78b-9dae-46f3-8854-bd671853ba32'; + + await tester.pumpWidget( + _testable(const MessageContent(content: 'See $url now')), + ); + + expect(find.text(url), findsNothing); + expect(_allRichText(tester), contains(url)); + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect(container.read(pendingDeepLinkProvider), isNull); + }); + testWidgets('renders bare URL as link', (tester) async { await tester.pumpWidget( _testable( @@ -440,8 +783,11 @@ void main() { // The URL text should be rendered and tappable. expect(find.text('https://example.com'), findsOneWidget); - final urlWidget = tester.widget(find.text('https://example.com')); - expect(urlWidget.style?.decoration, TextDecoration.underline); + final linkText = tester.widget(find.text('https://example.com')); + expect( + linkText.style?.decoration ?? linkText.textSpan?.style?.decoration, + TextDecoration.underline, + ); }); }); @@ -1344,6 +1690,136 @@ Photos }); }); + group('Buzz permalink chips', () { + testWidgets('keeps authored Buzz labels as ordinary links', ( + tester, + ) async { + final owner = 'ab' * 32; + final id = 'cd' * 32; + const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32'; + final links = { + 'Open message': 'buzz://message?channel=$channelId&id=$id', + 'Open channel': 'buzz://channel/$channelId', + 'Release candidate': 'buzz://pr?id=$id&owner=$owner&d=buzz', + }; + + await tester.pumpWidget( + _testable( + MessageContent( + content: links.entries + .map((entry) => '[${entry.key}](${entry.value})') + .join(' '), + ), + ), + ); + await tester.pump(); + + for (final entry in links.entries) { + expect( + find.byKey(ValueKey('buzz-link-chip:${entry.value}')), + findsNothing, + ); + expect(find.text(entry.key), findsOneWidget); + } + }); + + testWidgets('preserves formatting in authored Buzz labels', ( + tester, + ) async { + const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32'; + await tester.pumpWidget( + _testable( + const MessageContent( + content: '[**design discussion**](buzz://channel/$channelId)', + ), + ), + ); + await tester.pump(); + + expect(_hasBoldSpan(tester, 'design discussion'), isTrue); + }); + + testWidgets( + 'renders message, channel, repo, PR, and issue links as chips', + (tester) async { + final owner = 'ab' * 32; + final id = 'cd' * 32; + const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32'; + final urls = [ + 'buzz://message?channel=$channelId&id=$id', + 'buzz://channel/$channelId', + 'buzz://repo?owner=$owner&d=buzz', + 'buzz://pr?id=$id&owner=$owner&d=buzz', + 'buzz://issue?id=$id&owner=$owner&d=buzz', + ]; + await tester.pumpWidget( + _testable( + MessageContent( + content: urls.join(' '), + channelNames: const {'engineering': channelId}, + ), + ), + ); + await tester.pump(); + + for (final url in urls) { + expect(find.byKey(ValueKey('buzz-link-chip:$url')), findsOneWidget); + } + expect(find.text('engineering · cdcdcdcd'), findsOneWidget); + expect(find.text('engineering'), findsOneWidget); + expect(find.text('buzz'), findsOneWidget); + expect(find.text('buzz · cdcdcdcd'), findsNWidgets(2)); + expect(find.byIcon(LucideIcons.messageSquare), findsOneWidget); + expect(find.byIcon(LucideIcons.hash), findsOneWidget); + expect(find.byIcon(LucideIcons.folderGit2), findsOneWidget); + expect(find.byIcon(LucideIcons.gitPullRequest), findsOneWidget); + expect(find.byIcon(LucideIcons.circleDot), findsOneWidget); + expect( + find.bySemanticsLabel( + 'Open message cdcdcdcd in channel engineering', + ), + findsOneWidget, + ); + expect( + find.bySemanticsLabel('Pull request cdcdcdcd in repository buzz'), + findsOneWidget, + ); + for (final url in urls.skip(2)) { + final chipKey = ValueKey('buzz-link-chip:$url'); + final ignoredChip = find.ancestor( + of: find.byKey(chipKey), + matching: find.byWidgetPredicate( + (widget) => widget is IgnorePointer && widget.ignoring, + ), + ); + expect(ignoredChip, findsOneWidget, reason: url); + expect( + tester.widget(ignoredChip).ignoring, + isTrue, + reason: url, + ); + } + }, + ); + + testWidgets('uses shortened channel identifiers when names are missing', ( + tester, + ) async { + final id = 'cd' * 32; + const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32'; + final messageUrl = 'buzz://message?channel=$channelId&id=$id'; + const channelUrl = 'buzz://channel/$channelId'; + + await tester.pumpWidget( + _testable(MessageContent(content: '$messageUrl $channelUrl')), + ); + await tester.pump(); + + expect(find.text('580ca78b · cdcdcdcd'), findsOneWidget); + expect(find.text('580ca78b'), findsOneWidget); + }); + }); + group('@mentions', () { testWidgets('renders @mention with highlight', (tester) async { await tester.pumpWidget( @@ -1506,7 +1982,9 @@ Photos ), ); - expect(find.text('#general'), findsOneWidget); + expect(find.byIcon(LucideIcons.hash), findsOneWidget); + expect(find.text('general'), findsOneWidget); + expect(find.text('#general'), findsNothing); }); testWidgets('channel tap callback fires', (tester) async { @@ -1521,10 +1999,52 @@ Photos ), ); - await tester.tap(find.text('#general')); + await tester.tap(find.text('general')); expect(tappedId, 'ch-id-1'); }); + testWidgets('resolved #channel defaults to in-app navigation', ( + tester, + ) async { + final channels = Future.value([ + Channel( + id: '580ca78b-9dae-46f3-8854-bd671853ba32', + name: 'general', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'creator', + createdAt: DateTime(2026), + memberCount: 1, + isMember: true, + ), + ]); + await tester.pumpWidget( + _testable( + const MessageContent(content: 'See #general'), + overrides: [ + channelsProvider.overrideWith( + () => _TestChannelsNotifier(channels), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('general')); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + testWidgets('unknown channel renders without tap', (tester) async { await tester.pumpWidget( _testable( @@ -1532,7 +2052,9 @@ Photos ), ); - expect(find.text('#unknown'), findsOneWidget); + expect(find.byIcon(LucideIcons.hash), findsOneWidget); + expect(find.text('unknown'), findsOneWidget); + expect(find.text('#unknown'), findsNothing); }); testWidgets('does not treat URL fragments as channel links', ( diff --git a/mobile/test/shared/deeplink/deep_link_test.dart b/mobile/test/shared/deeplink/deep_link_test.dart index 70e2066308..2eacca6bd3 100644 --- a/mobile/test/shared/deeplink/deep_link_test.dart +++ b/mobile/test/shared/deeplink/deep_link_test.dart @@ -3,63 +3,111 @@ import 'package:flutter_test/flutter_test.dart'; void main() { _inviteTests(); + _channelTests(); _buildMessageLinkTests(); group('parseMessageDeepLink', () { - test('parses channel and id', () { - final link = parseMessageDeepLink( - Uri.parse('buzz://message?channel=d14cd131&id=abc123'), - ); + const channel = '580ca78b-9dae-46f3-8854-bd671853ba32'; + const id = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const thread = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + + test('parses canonical channel, id, and optional thread', () { expect( - link, - const MessageDeepLink(channelId: 'd14cd131', messageId: 'abc123'), + parseMessageDeepLink( + Uri.parse('buzz://message?channel=$channel&id=$id&thread=$thread'), + ), + const MessageDeepLink( + channelId: channel, + messageId: id, + threadRootId: thread, + ), ); }); - test('parses optional thread param', () { - final link = parseMessageDeepLink( - Uri.parse('buzz://message?channel=d14cd131&id=abc123&thread=root99'), - ); - expect(link?.threadRootId, 'root99'); + test('rejects malformed or ambiguous forms', () { + for (final url in [ + 'buzz://message?id=$id', + 'buzz://message?channel=&id=$id', + 'buzz://message?channel=$channel', + 'https://message?channel=$channel&id=$id', + 'buzz://connect?channel=$channel&id=$id', + 'buzz://message:1234?channel=$channel&id=$id', + 'buzz://message/path?channel=$channel&id=$id', + 'buzz://message?channel=$channel&id=$id#fragment', + 'buzz://user@message?channel=$channel&id=$id', + 'buzz://message?channel=$channel&id=$id&extra=true', + 'buzz://message?channel=$channel&channel=$channel&id=$id', + 'buzz://message?channel=$channel&id=$id&id=$id', + 'buzz://message?channel=$channel&id=$id&thread=', + 'buzz://message?channel=not-a-uuid&id=$id', + 'buzz://message?channel=$channel&id=not-hex', + 'buzz://message?channel=$channel&id=$id&thread=not-hex', + ]) { + expect(parseMessageDeepLink(Uri.parse(url)), isNull, reason: url); + } }); + }); +} - test('treats empty thread as absent', () { - final link = parseMessageDeepLink( - Uri.parse('buzz://message?channel=d14cd131&id=abc123&thread='), +void _channelTests() { + group('parseChannelDeepLink', () { + test('parses canonical channel path', () { + expect( + parseChannelDeepLink( + Uri.parse('buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'), + ), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), ); - expect(link, isNotNull); - expect(link?.threadRootId, isNull); - }); - - test('rejects missing channel', () { - expect(parseMessageDeepLink(Uri.parse('buzz://message?id=abc')), isNull); }); - test('rejects empty channel', () { + test('accepts v7 and canonicalizes uppercase UUIDs', () { expect( - parseMessageDeepLink(Uri.parse('buzz://message?channel=&id=abc')), - isNull, + parseChannelDeepLink( + Uri.parse('buzz://channel/018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9'), + ), + const ChannelDeepLink( + channelId: '018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9', + ), ); - }); - - test('rejects missing id', () { expect( - parseMessageDeepLink(Uri.parse('buzz://message?channel=d14cd131')), - isNull, + parseChannelDeepLink( + Uri.parse('buzz://channel/580CA78B-9DAE-46F3-8854-BD671853BA32'), + ), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), ); }); - test('rejects non-buzz scheme', () { - expect( - parseMessageDeepLink(Uri.parse('https://message?channel=a&id=b')), - isNull, - ); + test('rejects missing, extra, query, and fragment forms', () { + for (final url in [ + 'buzz://channel', + 'buzz://channel/', + 'buzz://channel/one/two', + 'buzz://channel:1234/580ca78b-9dae-46f3-8854-bd671853ba32', + 'buzz://channel/one?extra=true', + 'buzz://channel/one#fragment', + 'https://channel/one', + 'buzz://channel/not-a-uuid', + 'buzz://channel/%2F', + 'buzz://channel/%00', + ]) { + expect(parseChannelDeepLink(Uri.parse(url)), isNull, reason: url); + } }); - test('rejects non-message host (connect is desktop-only)', () { + test('is included in the top-level parser', () { expect( - parseMessageDeepLink(Uri.parse('buzz://connect?relay=wss://x')), - isNull, + parseBuzzDeepLink( + Uri.parse('buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'), + ), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), ); }); }); @@ -237,46 +285,57 @@ void _buildMessageLinkTests() { group('buildMessageLink', () { test('builds channel + id link', () { expect( - buildMessageLink(channelId: 'd14cd131', messageId: 'abc123'), - 'buzz://message?channel=d14cd131&id=abc123', + buildMessageLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + messageId: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ), + 'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', ); }); test('includes thread root when present', () { expect( buildMessageLink( - channelId: 'd14cd131', - messageId: 'abc123', - threadRootId: 'root99', + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + messageId: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + threadRootId: + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', ), - 'buzz://message?channel=d14cd131&id=abc123&thread=root99', + 'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&thread=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', ); }); test('treats empty thread root as absent', () { expect( buildMessageLink( - channelId: 'd14cd131', - messageId: 'abc123', + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + messageId: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', threadRootId: '', ), - 'buzz://message?channel=d14cd131&id=abc123', + 'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', ); }); test('round-trips through parseMessageDeepLink', () { final url = buildMessageLink( - channelId: 'chan-1', - messageId: 'msg-1', - threadRootId: 'root-1', + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + messageId: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + threadRootId: + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', ); final parsed = parseMessageDeepLink(Uri.parse(url)); expect( parsed, const MessageDeepLink( - channelId: 'chan-1', - messageId: 'msg-1', - threadRootId: 'root-1', + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + messageId: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + threadRootId: + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', ), ); }); @@ -292,4 +351,58 @@ void _buildMessageLinkTests() { ); }); }); + group('entity deep links', () { + final owner = 'ab' * 32; + final id = 'cd' * 32; + + test('parses repo, PR, and issue permalinks', () { + expect( + parseEntityDeepLink(Uri.parse('buzz://repo?owner=$owner&d=buzz'))?.type, + 'repo', + ); + expect( + parseEntityDeepLink( + Uri.parse('buzz://pr?id=$id&owner=$owner&d=buzz'), + )?.eventId, + id, + ); + expect( + parseEntityDeepLink( + Uri.parse('buzz://issue?id=$id&owner=$owner&d=buzz'), + )?.type, + 'issue', + ); + }); + + test('rejects malformed entity permalinks', () { + expect( + parseEntityDeepLink(Uri.parse('buzz://repo?owner=short&d=buzz')), + isNull, + ); + expect( + parseEntityDeepLink( + Uri.parse('buzz://pr?id=$id&owner=$owner&d=buzz&extra=true'), + ), + isNull, + ); + expect( + parseEntityDeepLink(Uri.parse('buzz://repo?owner=$owner&d=a..b')), + isNull, + ); + expect( + parseEntityDeepLink( + Uri.parse('buzz://repo?owner=$owner&d=${'a' * 65}'), + ), + isNull, + ); + for (final url in [ + 'buzz://repo?owner=$owner&owner=$owner&d=buzz', + 'buzz://repo?owner=$owner&d=buzz&d=other', + 'buzz://pr?id=$id&id=$id&owner=$owner&d=buzz', + 'buzz://issue?id=$id&owner=$owner&owner=$owner&d=buzz', + ]) { + expect(parseEntityDeepLink(Uri.parse(url)), isNull, reason: url); + } + }); + }); } From 207154706c87cbf207f2a2abbc096d17737b091a Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 14 Aug 2026 12:25:23 -0600 Subject: [PATCH 17/33] fix(desktop): support channel message path links (#5889) ## Summary - accept `buzz://channel//<64-hex-event-id>` as a compatibility message deep link - activate the desktop window and route path-form message links through the existing durable message-navigation queue - support the same path form when rendered or pasted inside Buzz, while canonicalizing composer output to `buzz://message?...` - retain the existing one-segment channel-link behavior and reject malformed event IDs or extra segments ## Context Buzz Desktop 0.5.11 has no native `channel` route. The recently merged channel-link handling on main recognizes `buzz://channel/`, but rejects the externally shared `/` form before window activation. On macOS that presents as Buzz taking the menu bar while its window neither foregrounds nor navigates. ## Test plan - `cargo test --manifest-path desktop/src-tauri/Cargo.toml parse_channel_deep_link` - focused channel-link, composer-link, and markdown unit tests - `pnpm typecheck` - mandatory pre-push hook: desktop checks, full desktop unit tests, and Tauri/Rust checks Installed-app external-open behavior requires a build containing this change; 0.5.11 cannot exercise it because that release predates native channel-link handling. --------- Signed-off-by: Wes Co-authored-by: Carl --- desktop/src-tauri/src/deep_link.rs | 46 +++++++++++++++-- .../messages/lib/channelLink.test.mjs | 19 ++++++- .../src/features/messages/lib/channelLink.ts | 25 ++++++++-- .../lib/composerMessageLinkNode.test.mjs | 11 +++++ .../messages/lib/composerMessageLinkNode.ts | 7 ++- desktop/src/shared/ui/markdown.test.mjs | 15 ++++-- .../shared/ui/markdown/ChannelDeepLink.tsx | 49 +++++++++++++++++-- 7 files changed, 152 insertions(+), 20 deletions(-) diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 098b3f1e79..08366673e4 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -192,16 +192,32 @@ fn activate_main_window(app: &tauri::AppHandle) { } fn parse_channel_deep_link(url: &Url) -> Option { - if url.query().is_some() || url.fragment().is_some() || !url.username().is_empty() { + if url.query().is_some() + || url.fragment().is_some() + || !url.username().is_empty() + || url.password().is_some() + { return None; } let mut segments = url.path_segments()?; let channel_id = segments.next()?; + let message_id = segments.next(); if segments.next().is_some() { return None; } let channel_id = uuid::Uuid::parse_str(channel_id).ok()?.to_string(); - Some(serde_json::json!({ "channelId": channel_id })) + if message_id.is_some_and(|value| { + value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) { + return None; + } + Some(match message_id { + Some(message_id) => serde_json::json!({ + "channelId": channel_id, + "messageId": message_id.to_ascii_lowercase(), + }), + None => serde_json::json!({ "channelId": channel_id }), + }) } /// Parse the query string of a `buzz://message?…` URL into the JSON @@ -456,8 +472,13 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { return; }; activate_main_window(app); - queue_navigation_deep_link(app, "channel", &payload); - let _ = app.emit("deep-link-channel", payload); + if payload["messageId"].is_string() { + queue_navigation_deep_link(app, "message", &payload); + let _ = app.emit("deep-link-message", payload); + } else { + queue_navigation_deep_link(app, "channel", &payload); + let _ = app.emit("deep-link-channel", payload); + } } Some("message") => { // `buzz://message?channel=&id=[&thread=]` @@ -689,6 +710,18 @@ mod tests { assert_eq!(payload["channelId"], "580ca78b-9dae-46f3-8854-bd671853ba32"); } + #[test] + fn parse_channel_deep_link_accepts_message_path() { + let message_id = "8455293f0123456789abcdef0123456789abcdef0123456789abcdef01234567"; + let url = Url::parse(&format!( + "buzz://channel/a372f080-5961-4535-b1a3-edffface377d/{message_id}" + )) + .unwrap(); + let payload = parse_channel_deep_link(&url).unwrap(); + assert_eq!(payload["channelId"], "a372f080-5961-4535-b1a3-edffface377d"); + assert_eq!(payload["messageId"], message_id); + } + #[test] fn parse_channel_deep_link_accepts_v7_and_normalizes_uppercase() { for (raw, expected) in [ @@ -712,8 +745,13 @@ mod tests { "buzz://channel", "buzz://channel/", "buzz://channel/one/two", + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/not-hex", + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/extra", + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/", "buzz://channel/one?extra=true", "buzz://channel/one#fragment", + "buzz://:pass@channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "buzz://channel/not-a-uuid", "buzz://channel/%2F", "buzz://channel/%00", diff --git a/desktop/src/features/messages/lib/channelLink.test.mjs b/desktop/src/features/messages/lib/channelLink.test.mjs index 7f51a39cef..7852bfab38 100644 --- a/desktop/src/features/messages/lib/channelLink.test.mjs +++ b/desktop/src/features/messages/lib/channelLink.test.mjs @@ -3,12 +3,23 @@ import test from "node:test"; import { isChannelLink, parseChannelLink } from "./channelLink.ts"; +const CHANNEL_ID = "580ca78b-9dae-46f3-8854-bd671853ba32"; +const MESSAGE_ID = + "8455293f0123456789abcdef0123456789abcdef0123456789abcdef01234567"; + test("parseChannelLink accepts the canonical channel path", () => { + assert.deepEqual(parseChannelLink(`buzz://channel/${CHANNEL_ID}`), { + ok: true, + value: { channelId: CHANNEL_ID }, + }); +}); + +test("parseChannelLink accepts a channel message path", () => { assert.deepEqual( - parseChannelLink("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32"), + parseChannelLink(`buzz://channel/${CHANNEL_ID}/${MESSAGE_ID}`), { ok: true, - value: { channelId: "580ca78b-9dae-46f3-8854-bd671853ba32" }, + value: { channelId: CHANNEL_ID, messageId: MESSAGE_ID }, }, ); }); @@ -35,6 +46,10 @@ test("parseChannelLink rejects malformed channel links", () => { "buzz://channel", "buzz://channel/", "buzz://channel/one/two", + `buzz://channel/${CHANNEL_ID}/not-hex`, + `buzz://channel/${CHANNEL_ID}/${"a".repeat(63)}`, + `buzz://channel/${CHANNEL_ID}/${MESSAGE_ID}/extra`, + `buzz://channel/${CHANNEL_ID}/`, "buzz://channel/one?extra=true", "buzz://channel/one#fragment", "https://channel/one", diff --git a/desktop/src/features/messages/lib/channelLink.ts b/desktop/src/features/messages/lib/channelLink.ts index 4bfc15deba..08281697ea 100644 --- a/desktop/src/features/messages/lib/channelLink.ts +++ b/desktop/src/features/messages/lib/channelLink.ts @@ -1,11 +1,15 @@ -/** `buzz://channel/` link encoding and parsing. */ +/** `buzz://channel/[/]` link encoding and parsing. */ const CHANNEL_LINK_SCHEME = "buzz:"; const CHANNEL_LINK_HOST = "channel"; const CHANNEL_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; +const EVENT_ID_PATTERN = /^[0-9a-f]{64}$/iu; -export type ParsedChannelLink = { channelId: string }; +export type ParsedChannelLink = { + channelId: string; + messageId?: string; +}; export type ChannelLinkParseResult = | { ok: true; value: ParsedChannelLink } @@ -34,20 +38,31 @@ export function parseChannelLink(url: string): ChannelLinkParseResult { if (parsed.search || parsed.hash || parsed.username || parsed.password) { return { ok: false, reason: "unexpected-components" }; } - const segments = parsed.pathname.split("/").filter(Boolean); - if (segments.length !== 1) { + const segments = parsed.pathname.split("/").slice(1); + if (segments.length < 1 || segments.length > 2 || segments.includes("")) { return { ok: false, reason: "missing-or-extra-channel" }; } let channelId: string; + let messageId: string | null = null; try { channelId = decodeURIComponent(segments[0]); + messageId = segments[1] ? decodeURIComponent(segments[1]) : null; } catch { return { ok: false, reason: "invalid-channel-encoding" }; } if (!CHANNEL_UUID_PATTERN.test(channelId)) { return { ok: false, reason: "invalid-channel-uuid" }; } - return { ok: true, value: { channelId: channelId.toLowerCase() } }; + if (messageId !== null && !EVENT_ID_PATTERN.test(messageId)) { + return { ok: false, reason: "invalid-message-id" }; + } + return { + ok: true, + value: { + channelId: channelId.toLowerCase(), + ...(messageId ? { messageId: messageId.toLowerCase() } : {}), + }, + }; } export function isChannelLink(href: string | undefined | null): boolean { diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs index 85bcb54552..5aee675592 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs @@ -15,6 +15,8 @@ const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const MESSAGE_ID = "root-event"; const HREF = `buzz://message?channel=${CHANNEL_ID}&id=${MESSAGE_ID}`; const CHANNEL_HREF = `buzz://channel/${CHANNEL_ID}`; +const CHANNEL_MESSAGE_ID = "a".repeat(64); +const CHANNEL_MESSAGE_HREF = `buzz://channel/${CHANNEL_ID}/${CHANNEL_MESSAGE_ID}`; const OWNER = "a".repeat(64); const REPO_HREF = `buzz://repo?owner=${OWNER}&d=buzz-world`; const ISSUE_ID = "b".repeat(64); @@ -47,6 +49,15 @@ test("resolves channel and entity links as composer chips", () => { ), { channelName: "general", href: CHANNEL_HREF }, ); + assert.deepEqual( + resolveComposerMessageLinkAttributes(CHANNEL_MESSAGE_HREF, (channelId) => + channelId === CHANNEL_ID ? "general" : undefined, + ), + { + channelName: "general", + href: `buzz://message?channel=${CHANNEL_ID}&id=${CHANNEL_MESSAGE_ID}`, + }, + ); assert.deepEqual( resolveComposerMessageLinkAttributes(REPO_HREF, () => undefined), { channelName: "", href: REPO_HREF }, diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.ts b/desktop/src/features/messages/lib/composerMessageLinkNode.ts index 83cc0aa58d..fb5cf3ed2b 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.ts +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.ts @@ -66,7 +66,12 @@ export function resolveComposerMessageLinkAttributes( if (channel.ok) { return { channelName: resolveChannelName(channel.value.channelId) ?? "", - href: buildChannelLink(channel.value.channelId), + href: channel.value.messageId + ? buildMessageLink({ + channelId: channel.value.channelId, + messageId: channel.value.messageId, + }) + : buildChannelLink(channel.value.channelId), }; } diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index d040dd55b1..5e829f6243 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -1063,9 +1063,11 @@ test("nudgeGuard_noSentinel_proseRenderedCardAbsent", () => { test("bare Buzz permalinks render cohesive icon-prefixed chips", () => { const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32"; const messageLink = `buzz://message?channel=${channelId}&id=${EVENT_HEX}`; + const compatibilityMessageLink = `buzz://channel/${channelId}/${EVENT_HEX}`; const channelLink = `buzz://channel/${channelId}`; const links = [ messageLink, + compatibilityMessageLink, channelLink, `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`, `buzz://issue?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`, @@ -1092,9 +1094,11 @@ test("bare Buzz permalinks render cohesive icon-prefixed chips", () => { ), ); - assert.equal((html.match(/data-buzz-link=""/g) ?? []).length, 5); - assert.match(html, /inline-chip-icon-message/); - assert.match(html, />engineering · c3b589faengineering · c3b589faengineering { const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32"; const links = [ `[the message](buzz://message?channel=${channelId}&id=${EVENT_HEX})`, + `[the compatibility message](buzz://channel/${channelId}/${EVENT_HEX})`, `[**design discussion**](buzz://channel/${channelId})`, `[the issue](buzz://issue?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world)`, ]; @@ -1134,11 +1139,13 @@ test("authored Buzz permalink labels remain ordinary links", () => { assert.equal((html.match(/data-buzz-link=""/g) ?? []).length, 0); assert.match(html, />the messagethe compatibility messagedesign discussionthe issue { diff --git a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx index e5ca9eae89..ae9a2384f5 100644 --- a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx +++ b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx @@ -6,6 +6,7 @@ import { } from "@/features/messages/lib/channelLink"; import { BuzzInlineLink, BuzzLinkChip } from "./BuzzLinkChip"; +import { MessageLinkPill } from "./MessageLinkPill"; import { useMarkdownRuntime } from "./runtimeContext"; import { getReactNodeText } from "./utils"; @@ -24,24 +25,46 @@ export function ChannelDeepLinkAnchor({ href, interactive, }: React.ComponentPropsWithoutRef<"a"> & { interactive: boolean }) { - const { channels, onOpenChannel } = useMarkdownRuntime(); + const { channels, onOpenChannel, onOpenMessageLink } = useMarkdownRuntime(); if (!href) return <>{children}; const parsed = parseChannelLink(href); if (!parsed.ok) return <>{children}; + const messageLink = parsed.value.messageId + ? { + channelId: parsed.value.channelId, + messageId: parsed.value.messageId, + threadRootId: null, + } + : null; + const openLink = () => + messageLink + ? onOpenMessageLink(messageLink) + : onOpenChannel(parsed.value.channelId); const authoredLabel = getReactNodeText(children); if (authoredLabel !== href) { return ( onOpenChannel(parsed.value.channelId)} + onOpenLink={openLink} > {children} ); } + if (messageLink) { + return ( + + ); + } const label = channelPermalinkLabel(channels, parsed.value.channelId); return ( {href}
    ; + const messageLink = parsed.value.messageId + ? { + channelId: parsed.value.channelId, + messageId: parsed.value.messageId, + threadRootId: null, + } + : null; + if (messageLink) { + return ( + + ); + } const label = channelPermalinkLabel(channels, parsed.value.channelId); return ( Date: Fri, 14 Aug 2026 14:38:33 -0400 Subject: [PATCH 18/33] fix(desktop): cut steady-state relay traffic from polls and read-state echo (#5879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Desktop webview CPU stayed high after the presence-scope fix (#5830) and the shared useNow ticker (#5861). A per-kind byte tap hot-patched into `relayClientSession.ts` on a live desktop (~500 channels, large agent fleet; 850 s capture correlated with CPU sampling) showed the remaining steady-state relay traffic is mostly self-inflicted: | kind | what | share of inbound bytes | shape | |------|------|-----------------------|-------| | 30078 | read-state | **34%** | our own ~44 KB nip44 blob echoed back every ~10-30 s while reading | | 30030 | emoji union | **33%** | 2-min poll refetching every member's full set (~300 KB burst) | | 30175 | persona catalog | **13%** | same 2-min backstop pattern, ~150 KB per walk | CPU tracked the bursts directly: 3-5% in quiet 10 s buckets vs 44-54% in buckets containing a poll burst or read-state echo. (The kind-24200 observer-frame theory was tested and disproven by the same tap: 9.7% of bytes, steady trickle.) ## Outcome - **Read-state echo drop.** `ReadStateManager` remembers the ids of events it just published (FIFO set capped at 64) and drops their relay echoes before the nip44-decrypt + `JSON.parse` step. Ids are recorded *before* publishing so relay fan-out can't race the OK. The drop consumes the id, so a reconnect replay of the same event still parses normally. Events from other clients of the same pubkey are untouched. - **Poll backstops stretched 2 min → 20 min** for the emoji union and persona catalog queries. The live subscriptions (invalidate on any new 30030/30175) and the reconnect invalidations remain the freshness paths; the poll only exists to cover a silently dropped live event. Behavior on publish, focus, and reconnect is unchanged. - Mechanical: localStorage identity helpers moved to `readStateIdentity.ts` (no behavior change) to keep `readStateManager.ts` under the file-size ratchet. Expected effect on the measured profile: the poll stretch cuts the 30030/30175 bursts (46% of inbound bytes) by 10x; the echo drop removes the recurring ~44 KB nip44-decrypt + parse per publish cycle (the echo still arrives on the wire — nostr filters cannot exclude own-author events — so this is a CPU/IPC saving, not a bandwidth one). ## Acceptance - New tests: echo dropped **before** decrypt (mutation-checked: disabling the drop fails the test), replayed duplicate of the same id still parses, foreign-client events always parse, published-id set stays capped when publishes fail (never-echoed ids). - Full desktop suite **4794/4794**, `tsc --noEmit` clean, `pnpm check` (biome + ratchets) clean at head. ## Not addressed (follow-ups) - The 44 KB blob itself (one read-state event carries all ~500 channels; a delta or per-channel-shard format is a protocol change). - Duplicate delivery of the same events on concurrent `history-` subscriptions (relay/client dedupe). - Webview RSS of 12.5 GB observed on the same machine — retention hunt is separate work; shrinking the heap multiplies the value of this PR since the GC floor scales with live-heap size. Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .../agents/lib/usePersonaCatalogRelay.ts | 8 +- .../channels/readState/readStateIdentity.ts | 58 ++++++++++ .../readState/readStateManager.test.mjs | 68 ++++++++++++ .../channels/readState/readStateManager.ts | 100 +++++++++--------- desktop/src/features/custom-emoji/hooks.ts | 8 +- 5 files changed, 188 insertions(+), 54 deletions(-) create mode 100644 desktop/src/features/channels/readState/readStateIdentity.ts diff --git a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts index 898848ec5f..781b555bca 100644 --- a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts @@ -15,8 +15,12 @@ import { import type { AgentPersona, UpdatePersonaInput } from "@/shared/api/types"; import { KIND_PERSONA } from "@/shared/constants/kinds"; -/** Keeps focused polling at the established 2-minute backstop cadence. */ -export const PERSONA_CATALOG_REFETCH_INTERVAL_MS = 120_000; +/** Poll backstop cadence. The live subscription (invalidate on any new + * 30175) and the reconnect invalidation are the freshness paths; this poll + * exists only to cover a silently dropped live event, so it can be rare. At + * the previous 2-minute cadence it re-walked the entire persona catalog + * (~150 KB burst) often enough to be a top-three desktop traffic source. */ +export const PERSONA_CATALOG_REFETCH_INTERVAL_MS = 20 * 60_000; /** Suppresses the focus refetch until persona catalog data is genuinely stale. * The live subscription (invalidateQueries) is the primary freshness path. */ export const PERSONA_CATALOG_FOCUS_STALE_TIME_MS = 5 * 60_000; diff --git a/desktop/src/features/channels/readState/readStateIdentity.ts b/desktop/src/features/channels/readState/readStateIdentity.ts new file mode 100644 index 0000000000..c6ab730041 --- /dev/null +++ b/desktop/src/features/channels/readState/readStateIdentity.ts @@ -0,0 +1,58 @@ +import { localExtraSlotIdsKey } from "@/features/channels/readState/readStateFormat"; +import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; + +/** + * localStorage-persisted identity for the read-state manager: the stable + * client id, this client's slot id, and any extra slot ids allocated when the + * blob outgrows the single-slot budget (NIP-RS multi-slot mode). + */ + +const CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.client-id"; +const SLOT_ID_KEY_PREFIX = "buzz.nip-rs.slot-id"; + +export function generateHex(bytes: number): string { + const arr = new Uint8Array(bytes); + crypto.getRandomValues(arr); + return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +export function getOrCreatePersisted( + key: string, + generator: () => string, +): string { + let value = localStorage.getItem(key); + if (!value) { + value = generator(); + setLocalStorageItemWithRecovery(key, value); + } + return value; +} + +export function clientIdKey(pubkey: string): string { + return `${CLIENT_ID_KEY_PREFIX}:${pubkey}`; +} + +export function slotIdKey(pubkey: string): string { + return `${SLOT_ID_KEY_PREFIX}:${pubkey}`; +} + +export function loadExtraSlotIds(pubkey: string): string[] { + try { + const raw = localStorage.getItem(localExtraSlotIdsKey(pubkey)); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (v): v is string => typeof v === "string" && v.length > 0, + ); + } catch { + return []; + } +} + +export function saveExtraSlotIds(pubkey: string, ids: string[]): void { + setLocalStorageItemWithRecovery( + localExtraSlotIdsKey(pubkey), + JSON.stringify(ids), + ); +} diff --git a/desktop/src/features/channels/readState/readStateManager.test.mjs b/desktop/src/features/channels/readState/readStateManager.test.mjs index 8831a952bf..c46654f32e 100644 --- a/desktop/src/features/channels/readState/readStateManager.test.mjs +++ b/desktop/src/features/channels/readState/readStateManager.test.mjs @@ -841,3 +841,71 @@ test("publishSplitSlots_noopSuppression_skipsWhenUnchanged", async () => { mgr.destroy(); }); + +// ── ReadStateManager — self-echo drop ───────────────────────────────────────── + +// The live subscription (authors=[us]) echoes back every event we publish. +// handleIncomingEvent must drop those echoes by id BEFORE the decrypt/parse +// step: with hundreds of channels a blob is tens of KB, so decrypting our own +// echo on every read was a large recurring cost. Strategy mirrors the split- +// mode test above: stub the private parseEvent seam to count decrypt attempts. +test("handleIncomingEvent_dropsSelfEchoBeforeDecrypt", async () => { + globalThis.window.localStorage = makeLocalStorage(); + + const pubkey = "c".repeat(64); + const mgr = new ReadStateManager(pubkey, makeFakeRelay()); + + let parseCount = 0; + mgr.parseEvent = async () => { + parseCount++; + return null; + }; + + const makeEvent = (id) => ({ + id, + pubkey, + kind: 30078, + created_at: 1_000, + content: "ciphertext", + tags: [ + ["d", "read-state:slot"], + ["t", "read-state"], + ], + }); + + // An event we published ourselves: echo must be dropped without a parse. + const ownId = "e".repeat(64); + mgr.rememberPublishedId(ownId); + await mgr.handleIncomingEvent(makeEvent(ownId)); + assert.equal(parseCount, 0, "self-echo must not reach decrypt/parse"); + + // The drop consumes the remembered id — a replayed duplicate (e.g. from a + // reconnect catch-up) goes through the normal parse path. + await mgr.handleIncomingEvent(makeEvent(ownId)); + assert.equal(parseCount, 1, "second delivery of same id must parse"); + + // An event from another client of the same pubkey must always parse. + await mgr.handleIncomingEvent(makeEvent("f".repeat(64))); + assert.equal(parseCount, 2, "foreign-client event must parse"); + + mgr.destroy(); +}); + +// The remembered-id set must stay bounded even if publishes fail (a failed +// publish leaves an id that is never echoed back, so nothing deletes it). +test("rememberPublishedId_evictsOldestBeyondCap", () => { + globalThis.window.localStorage = makeLocalStorage(); + + const mgr = new ReadStateManager("d".repeat(64), makeFakeRelay()); + + const total = 100; // beyond the 64-id cap + for (let i = 0; i < total; i++) { + mgr.rememberPublishedId(`id-${i}`); + } + const ids = mgr.recentlyPublishedIds; + assert.equal(ids.size, 64, "set must be capped"); + assert.ok(!ids.has("id-0"), "oldest id must be evicted"); + assert.ok(ids.has(`id-${total - 1}`), "newest id must be retained"); + + mgr.destroy(); +}); diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index 3ba382dc61..87fc1ceaac 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -10,10 +10,17 @@ import { READ_STATE_MAX_SLOTS, MSG_PREFIX, THREAD_PREFIX, - localExtraSlotIdsKey, type ReadStateBlob, } from "@/features/channels/readState/readStateFormat"; import { parseReadStateEvent } from "@/features/channels/readState/readStateSnapshot"; +import { + clientIdKey, + generateHex, + getOrCreatePersisted, + loadExtraSlotIds, + saveExtraSlotIds, + slotIdKey, +} from "@/features/channels/readState/readStateIdentity"; import { readStoredReadState, writeStoredReadState, @@ -21,54 +28,12 @@ import { import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; import { truncatePubkey } from "@/shared/lib/pubkey"; -const CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.client-id"; -const SLOT_ID_KEY_PREFIX = "buzz.nip-rs.slot-id"; const PUBLISH_DEBOUNCE_MS = 5_000; const LOCAL_PERSIST_MAX_WAIT_MS = 1_000; - -function generateHex(bytes: number): string { - const arr = new Uint8Array(bytes); - crypto.getRandomValues(arr); - return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); -} - -function getOrCreatePersisted(key: string, generator: () => string): string { - let value = localStorage.getItem(key); - if (!value) { - value = generator(); - setLocalStorageItemWithRecovery(key, value); - } - return value; -} - -function clientIdKey(pubkey: string): string { - return `${CLIENT_ID_KEY_PREFIX}:${pubkey}`; -} - -function slotIdKey(pubkey: string): string { - return `${SLOT_ID_KEY_PREFIX}:${pubkey}`; -} - -function loadExtraSlotIds(pubkey: string): string[] { - try { - const raw = localStorage.getItem(localExtraSlotIdsKey(pubkey)); - if (!raw) return []; - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - return parsed.filter( - (v): v is string => typeof v === "string" && v.length > 0, - ); - } catch { - return []; - } -} - -function saveExtraSlotIds(pubkey: string, ids: string[]): void { - setLocalStorageItemWithRecovery( - localExtraSlotIdsKey(pubkey), - JSON.stringify(ids), - ); -} +/** How many of our own just-published event ids to remember so the live + * subscription can drop their relay echoes before the nip44 decrypt. A + * publish cycle emits at most a handful of slot events; 64 is generous. */ +const PUBLISHED_ID_MEMORY = 64; export type ApplyRemoteContextResult = "unchanged" | "advanced"; @@ -322,6 +287,8 @@ export class ReadStateManager { private pendingSyncedAdvances = new Set(); private destroyed = false; private parentResolver: ContextParentResolver | null = null; + /** Event ids we published ourselves; used to skip decrypting their echoes. */ + private recentlyPublishedIds = new Set(); constructor(pubkey: string, relayClient: RelayClient) { this.pubkey = pubkey; @@ -496,7 +463,7 @@ export class ReadStateManager { >(); for (const event of events) { - const parsed = await parseReadStateEvent(event, this.pubkey); + const parsed = await this.parseEvent(event); if (this.destroyed) return; if (!parsed) continue; @@ -533,7 +500,7 @@ export class ReadStateManager { // Conflict detection: check if another client_id is squatting on our // d-tag coordinate. If so, rotate our slotId to avoid clobbering. for (const event of events) { - const parsed = await parseReadStateEvent(event, this.pubkey); + const parsed = await this.parseEvent(event); if (this.destroyed) return; if (!parsed || parsed.dTag !== `read-state:${this.slotId}`) continue; if (parsed.blob.client_id !== this.clientId) { @@ -588,11 +555,24 @@ export class ReadStateManager { private async handleIncomingEvent(event: RelayEvent): Promise { if (this.destroyed || event.pubkey !== this.pubkey) return; + + // Echo drop: the live subscription (authors=[us]) receives every event we + // publish right back from the relay. Decrypting and re-parsing our own + // blob is pure waste — with hundreds of channels a single blob runs tens + // of KB, so on an actively-reading client the echo was a large recurring + // nip44-decrypt + JSON.parse for information we already hold. + if (this.recentlyPublishedIds.delete(event.id)) { + console.debug( + `[ReadStateManager] dropped self-echo event=${event.id.substring(0, 8)}…`, + ); + return; + } + console.debug( `[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.created_at}`, ); - const parsed = await parseReadStateEvent(event, this.pubkey); + const parsed = await this.parseEvent(event); if (!parsed || this.destroyed) return; this.maxFetchedCreatedAt = Math.max( @@ -635,6 +615,22 @@ export class ReadStateManager { } } + /** Seam over `parseReadStateEvent` so tests can count/stub decrypts + * (see readStateManager.test.mjs echo-drop tests). */ + private parseEvent(event: RelayEvent) { + return parseReadStateEvent(event, this.pubkey); + } + + /** Record an id we just published, capped so failed publishes can't grow + * the set unboundedly. Set preserves insertion order, so eviction is FIFO. */ + private rememberPublishedId(id: string): void { + this.recentlyPublishedIds.add(id); + if (this.recentlyPublishedIds.size > PUBLISHED_ID_MEMORY) { + const oldest = this.recentlyPublishedIds.values().next().value; + if (oldest !== undefined) this.recentlyPublishedIds.delete(oldest); + } + } + private schedulePublish(): void { if (this.destroyed) return; if (this.debounceTimer !== null) { @@ -713,6 +709,10 @@ export class ReadStateManager { tags, }); + // Remember the id BEFORE publishing: the relay may fan the event out to + // our own live subscription before the publish OK resolves. A failed + // publish leaves a never-echoed id in the set; the size cap evicts it. + this.rememberPublishedId(event.id); await this.relayClient.publishEvent( event, "Timed out publishing read state.", diff --git a/desktop/src/features/custom-emoji/hooks.ts b/desktop/src/features/custom-emoji/hooks.ts index 22016496f2..8e6d1f1831 100644 --- a/desktop/src/features/custom-emoji/hooks.ts +++ b/desktop/src/features/custom-emoji/hooks.ts @@ -23,8 +23,12 @@ import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; * live event is missed. Mirrors `user-status/hooks.ts`. */ -/** Keeps focused polling at the established 2-minute backstop cadence. */ -export const CUSTOM_EMOJI_REFETCH_INTERVAL_MS = 120_000; +/** Poll backstop cadence. The live subscription (invalidate on any member's + * new 30030) and the reconnect invalidation are the freshness paths; this + * poll exists only to cover a silently dropped live event, so it can be + * rare. At the previous 2-minute cadence it refetched every member's full + * set (~300 KB burst) often enough to dominate desktop relay traffic. */ +export const CUSTOM_EMOJI_REFETCH_INTERVAL_MS = 20 * 60_000; /** Suppresses the focus refetch until emoji data is genuinely stale. * The live subscription (invalidateQueries) is the primary freshness path. */ export const CUSTOM_EMOJI_FOCUS_STALE_TIME_MS = 5 * 60_000; From f086eb6544fd9f450832ea22de74b5418d1f85a1 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 14 Aug 2026 12:16:36 -0700 Subject: [PATCH 19/33] fix(link-previews): send while previews finish in background (#5697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Messages send immediately after submission while link previews finish in the background, with an option to skip delayed preview preparation. **Problem:** Waiting for link-preview metadata or snapshot uploads kept the composer occupied after users pressed Send, while races between completion, timeout, and cancellation risked inconsistent payloads. **Solution:** Freeze and promote speculative preview work into a bounded background send task, clear the composer immediately, and publish exactly once with prepared previews or gracefully without them when skipped, failed, or timed out. https://github.com/user-attachments/assets/987d2f2c-679f-473a-965f-dfb279951e52
    File changes **desktop/src/features/communities/useCommunityInit.ts** Resets pending link-preview preparation when community context changes so work cannot cross community boundaries. **desktop/src/features/messages/lib/linkPreviewPreparationStore.ts** Adds the coordinator-owned preparation state machine, bounded fallback, Skip behavior, and exactly-once terminal publication handling. **desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx** Extends floating background progress UI to include link-preview preparation. **desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx** Adds the preparing-link-preview label and Skip action to the progress pill. **desktop/src/features/messages/ui/MessageComposer.tsx** Hands submitted preview work to the background coordinator and clears the composer immediately. **desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs** Updates auto-submit unit coverage for coordinator-owned preview preparation. **desktop/src/features/messages/ui/messageComposerAutoSubmit.ts** Allows submit to promote unfinished preview work instead of blocking composer submission. **desktop/src/features/messages/ui/useComposerLinkPreviews.tsx** Starts preview work speculatively and exposes frozen preparation jobs for adoption by the send flow. **desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts** Carries prepared preview tags through the mention and media payload helpers. **desktop/src/features/messages/ui/useMentionSendFlow.ts** Integrates prepared preview tags into final message publication. **desktop/src/shared/lib/useResolvedLinkPreviews.ts** Exposes the in-flight metadata promise so promoted work can be adopted rather than restarted. **desktop/tests/e2e/messaging.spec.ts** Covers immediate submit, upload handoff, Skip/completion races, failure fallback, auto-send, and exactly-once publication.
    ## Reproduction steps 1. Enter a supported link and press Send while preview metadata or snapshot upload is still pending. 2. Confirm the composer clears immediately and the floating progress UI shows **Preparing link preview · Skip**. 3. Let preparation finish and confirm one message is published with its preview. 4. Repeat and choose **Skip**; confirm one message is published without waiting for the preview. 5. Simulate preview failure or timeout and confirm the message still publishes once without preview tags. ## Validation - TypeScript, Biome/format, file-size, px-text, and pubkey checks - Full desktop unit suite: 4,734 passed - Focused Playwright messaging suite: 5 passed - Push hooks at `86c0aa7de2ff81b79286c99bf23db12345adc6ca`: desktop check, typecheck, and tests passed --------- Signed-off-by: Taylor Ho Signed-off-by: Wes Co-authored-by: Carl Co-authored-by: Wes Co-authored-by: Carl --- .../src-tauri/src/commands/link_preview.rs | 38 +- .../src/commands/link_preview_image_retry.rs | 75 +++ .../src/commands/link_preview_youtube.rs | 5 +- desktop/src-tauri/src/commands/media_raw.rs | 19 +- .../src/commands/media_upload_progress.rs | 128 +++- desktop/src-tauri/src/lib.rs | 1 + .../src/features/channels/ui/ChannelPane.tsx | 14 +- .../features/channels/ui/ChannelPane.types.ts | 5 + .../channels/useChannelPaneHandlers.ts | 8 + .../features/communities/useCommunityInit.ts | 2 + desktop/src/features/messages/hooks.ts | 3 + .../lib/backgroundMediaUploadStore.test.mjs | 117 ++++ .../lib/backgroundMediaUploadStore.ts | 51 +- .../lib/linkPreviewPreparationStore.test.mjs | 274 +++++++++ .../lib/linkPreviewPreparationStore.ts | 356 +++++++++++ .../ui/ComposerUploadProgressOverlay.tsx | 31 +- .../ui/ComposerUploadProgressPill.tsx | 10 +- .../features/messages/ui/MessageComposer.tsx | 38 +- .../messages/ui/MessageComposer.types.ts | 2 + .../messages/ui/MessageThreadPanel.tsx | 1 + .../ui/messageComposerAutoSubmit.test.mjs | 64 +- .../messages/ui/messageComposerAutoSubmit.ts | 36 +- .../ui/useActivePreparedLinkPreviews.ts | 14 + .../messages/ui/useComposerLinkPreviews.tsx | 580 +++++++----------- .../messages/ui/useMentionSendFlow.helpers.ts | 23 +- .../messages/ui/useMentionSendFlow.ts | 150 ++--- desktop/src/shared/api/tauriMedia.ts | 8 +- .../src/shared/lib/useResolvedLinkPreviews.ts | 7 + desktop/src/testing/e2eBridge.ts | 70 ++- desktop/tests/e2e/community-rail.spec.ts | 188 ++++++ desktop/tests/e2e/messaging.spec.ts | 400 ++++++++---- 31 files changed, 1999 insertions(+), 719 deletions(-) create mode 100644 desktop/src-tauri/src/commands/link_preview_image_retry.rs create mode 100644 desktop/src/features/messages/lib/backgroundMediaUploadStore.test.mjs create mode 100644 desktop/src/features/messages/lib/linkPreviewPreparationStore.test.mjs create mode 100644 desktop/src/features/messages/lib/linkPreviewPreparationStore.ts create mode 100644 desktop/src/features/messages/ui/useActivePreparedLinkPreviews.ts diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 732c750a13..b781f8d9e6 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -10,6 +10,8 @@ use reqwest::{ use serde::Serialize; use url::Url; +#[path = "link_preview_image_retry.rs"] +mod image_retry; #[path = "link_preview_rate_limit.rs"] mod rate_limit; #[path = "link_preview_youtube.rs"] @@ -108,10 +110,13 @@ async fn fetch_link_preview_metadata_inner( Some(image_url) => Some( tokio::time::timeout( PREVIEW_FETCH_TIMEOUT, - fetch_sanitized_image(image_url, false), + fetch_sanitized_image_with_retry(image_url, false), ) .await - .unwrap_or(Err(ImageFetchError::Transient { retry_after: None })), + .unwrap_or(Err(ImageFetchError::Transient { + retry_after: None, + retry_inline: false, + })), ), None => None, } @@ -149,7 +154,7 @@ fn apply_image_result( metadata.image_domain = Some(domain); metadata.image_fetch_state = LinkPreviewImageFetchState::Image; } - Some(Err(ImageFetchError::Transient { retry_after })) => { + Some(Err(ImageFetchError::Transient { retry_after, .. })) => { metadata.image_fetch_state = LinkPreviewImageFetchState::TransientFailure; metadata.image_retry_after_ms = retry_after.and_then(|duration| u64::try_from(duration.as_millis()).ok()); @@ -318,10 +323,23 @@ fn extract_image_url(html: &str, page_url: &Url) -> Option { #[derive(Debug, PartialEq)] enum ImageFetchError { - Transient { retry_after: Option }, + Transient { + retry_after: Option, + retry_inline: bool, + }, Rejected, } +async fn fetch_sanitized_image_with_retry( + url: Url, + preserve_transparency: bool, +) -> Result<(String, String), ImageFetchError> { + image_retry::retry_transient_image_fetch(|| { + fetch_sanitized_image(url.clone(), preserve_transparency) + }) + .await +} + async fn fetch_sanitized_image( mut url: Url, preserve_transparency: bool, @@ -333,11 +351,15 @@ async fn fetch_sanitized_image( if let Some(retry_after) = image_host_cooldown_remaining(&url) { return Err(ImageFetchError::Transient { retry_after: Some(retry_after), + retry_inline: false, }); } let response = send_pinned_request(&url, "image/jpeg,image/png,image/webp") .await - .map_err(|_| ImageFetchError::Transient { retry_after: None })?; + .map_err(|_| ImageFetchError::Transient { + retry_after: None, + retry_inline: true, + })?; if response.status().is_redirection() { if redirect_count == MAX_REDIRECTS { return Err(ImageFetchError::Rejected); @@ -364,7 +386,10 @@ async fn fetch_sanitized_image( if let Some(retry_after) = retry_after { set_image_host_cooldown(&url, retry_after); } - return Err(ImageFetchError::Transient { retry_after }); + return Err(ImageFetchError::Transient { + retry_after, + retry_inline: status != reqwest::StatusCode::TOO_MANY_REQUESTS, + }); } return Err(ImageFetchError::Rejected); } @@ -710,6 +735,7 @@ mod tests { &mut metadata, Some(Err(ImageFetchError::Transient { retry_after: Some(std::time::Duration::from_secs(15)), + retry_inline: false, })), ); assert_eq!( diff --git a/desktop/src-tauri/src/commands/link_preview_image_retry.rs b/desktop/src-tauri/src/commands/link_preview_image_retry.rs new file mode 100644 index 0000000000..f397c51636 --- /dev/null +++ b/desktop/src-tauri/src/commands/link_preview_image_retry.rs @@ -0,0 +1,75 @@ +use super::ImageFetchError; + +pub(super) async fn retry_transient_image_fetch( + mut fetch: F, +) -> Result<(String, String), ImageFetchError> +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let first = fetch().await; + if matches!( + first, + Err(ImageFetchError::Transient { + retry_inline: true, + .. + }) + ) { + return fetch().await; + } + first +} + +#[cfg(test)] +mod tests { + use super::retry_transient_image_fetch; + use crate::commands::link_preview::ImageFetchError; + use std::{cell::Cell, time::Duration}; + + #[tokio::test] + async fn retries_one_transient_failure_inline() { + let attempts = Cell::new(0); + let result = retry_transient_image_fetch(|| { + let attempt = attempts.get() + 1; + attempts.set(attempt); + async move { + if attempt == 1 { + Err(ImageFetchError::Transient { + retry_after: None, + retry_inline: true, + }) + } else { + Ok(("image".to_string(), "example.com".to_string())) + } + } + }) + .await; + + assert!(result.is_ok()); + assert_eq!(attempts.get(), 2); + } + + #[tokio::test] + async fn does_not_retry_rate_limits_inline() { + let attempts = Cell::new(0); + let result = retry_transient_image_fetch(|| { + attempts.set(attempts.get() + 1); + async { + Err(ImageFetchError::Transient { + retry_after: Some(Duration::from_secs(60)), + retry_inline: false, + }) + } + }) + .await; + + assert_eq!( + result, + Err(ImageFetchError::Transient { + retry_after: Some(Duration::from_secs(60)), + retry_inline: false, + }) + ); + assert_eq!(attempts.get(), 1); + } +} diff --git a/desktop/src-tauri/src/commands/link_preview_youtube.rs b/desktop/src-tauri/src/commands/link_preview_youtube.rs index 722c481b5e..a0a5a753dc 100644 --- a/desktop/src-tauri/src/commands/link_preview_youtube.rs +++ b/desktop/src-tauri/src/commands/link_preview_youtube.rs @@ -60,7 +60,10 @@ pub(super) async fn fetch_oembed_metadata( fetch_sanitized_image(thumbnail_url, false), ) .await - .unwrap_or(Err(ImageFetchError::Transient { retry_after: None })), + .unwrap_or(Err(ImageFetchError::Transient { + retry_after: None, + retry_inline: false, + })), ), None => None, }; diff --git a/desktop/src-tauri/src/commands/media_raw.rs b/desktop/src-tauri/src/commands/media_raw.rs index a74ccd4dfe..9708157162 100644 --- a/desktop/src-tauri/src/commands/media_raw.rs +++ b/desktop/src-tauri/src/commands/media_raw.rs @@ -27,7 +27,18 @@ pub async fn upload_media_bytes( app: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { - upload_media_bytes_inner(data, filename, progress_id, app, state, None).await + let cancellation = begin_media_upload(progress_id.as_deref()); + let result = upload_media_bytes_inner( + data, + filename, + progress_id.clone(), + app, + state, + cancellation.as_ref(), + ) + .await; + finish_media_upload(progress_id.as_deref()); + result } fn decode_raw_upload_header(value: &str) -> Result { @@ -56,6 +67,12 @@ pub fn cancel_media_upload(progress_id: String) { cancel_registered_media_upload(&progress_id); } +/// Release the renderer's ownership after its upload promise settles. +#[tauri::command] +pub fn release_media_upload(progress_id: String) { + finish_media_upload(Some(&progress_id)); +} + /// Upload raw IPC bytes without expanding a large browser File into JSON. #[tauri::command] pub async fn upload_media_bytes_raw( diff --git a/desktop/src-tauri/src/commands/media_upload_progress.rs b/desktop/src-tauri/src/commands/media_upload_progress.rs index 850afe1b12..5ed3f78652 100644 --- a/desktop/src-tauri/src/commands/media_upload_progress.rs +++ b/desktop/src-tauri/src/commands/media_upload_progress.rs @@ -8,23 +8,45 @@ use tokio_util::sync::CancellationToken; use crate::{app_state::AppState, relay::classify_request_error}; -static MEDIA_UPLOAD_CANCELLATIONS: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::new())); +#[derive(Default)] +struct MediaUploadCancellations { + tokens: HashMap, +} + +impl MediaUploadCancellations { + fn begin(&mut self, progress_id: &str) -> CancellationToken { + if let Some(cancel) = self.tokens.get(progress_id).cloned() { + return cancel; + } + let cancel = CancellationToken::new(); + self.tokens.insert(progress_id.to_string(), cancel.clone()); + cancel + } + + fn cancel(&mut self, progress_id: &str) { + let cancel = self.tokens.entry(progress_id.to_string()).or_default(); + cancel.cancel(); + } + + fn finish(&mut self, progress_id: &str) { + self.tokens.remove(progress_id); + } +} + +static MEDIA_UPLOAD_CANCELLATIONS: LazyLock> = + LazyLock::new(|| Mutex::new(MediaUploadCancellations::default())); pub(super) fn begin_media_upload(progress_id: Option<&str>) -> Option { let progress_id = progress_id?; - let cancel = CancellationToken::new(); - if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { - uploads.insert(progress_id.to_string(), cancel.clone()); - } - Some(cancel) + MEDIA_UPLOAD_CANCELLATIONS + .lock() + .ok() + .map(|mut uploads| uploads.begin(progress_id)) } pub(super) fn cancel_media_upload(progress_id: &str) { - if let Ok(uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { - if let Some(cancel) = uploads.get(progress_id) { - cancel.cancel(); - } + if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { + uploads.cancel(progress_id); } } @@ -33,7 +55,7 @@ pub(super) fn finish_media_upload(progress_id: Option<&str>) { return; }; if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { - uploads.remove(progress_id); + uploads.finish(progress_id); } } @@ -124,3 +146,85 @@ pub(super) fn emit_media_upload_phase( serde_json::json!({ "id": id, "phase": phase }), ); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cancellation_before_begin_is_retained() { + let progress_id = format!("cancel-before-begin-{}", uuid::Uuid::new_v4()); + + cancel_media_upload(&progress_id); + let cancellation = begin_media_upload(Some(&progress_id)).expect("cancellation token"); + + assert!(cancellation.is_cancelled()); + finish_media_upload(Some(&progress_id)); + } + + #[test] + fn cancellation_after_begin_reaches_registered_token() { + let progress_id = format!("cancel-after-begin-{}", uuid::Uuid::new_v4()); + let cancellation = begin_media_upload(Some(&progress_id)).expect("cancellation token"); + + cancel_media_upload(&progress_id); + + assert!(cancellation.is_cancelled()); + finish_media_upload(Some(&progress_id)); + } + + #[test] + fn late_cancellation_after_native_finish_is_removed_on_release() { + let mut uploads = MediaUploadCancellations::default(); + let id = "late-cancel"; + + uploads.begin(id); + uploads.finish(id); + uploads.cancel(id); + assert!(uploads.tokens.contains_key(id)); + + uploads.finish(id); + assert!(!uploads.tokens.contains_key(id)); + } + + #[test] + fn repeated_concurrent_cycles_leave_no_registry_entries() { + let mut uploads = MediaUploadCancellations::default(); + let ids = (0..256) + .map(|index| format!("cycle-{index}")) + .collect::>(); + + for id in &ids { + uploads.begin(id); + } + for id in &ids { + uploads.cancel(id); + } + for id in &ids { + uploads.finish(id); + } + + assert!(uploads.tokens.is_empty()); + } + + #[test] + fn dispatched_cancellations_are_not_evicted_before_begin() { + let mut uploads = MediaUploadCancellations::default(); + let ids = (0..129) + .map(|index| format!("dispatched-{index}")) + .collect::>(); + + for id in &ids { + uploads.cancel(id); + } + + let oldest = uploads.begin(&ids[0]); + assert!(oldest.is_cancelled()); + assert_eq!(uploads.tokens.len(), ids.len()); + + for id in &ids { + uploads.finish(id); + } + assert!(uploads.tokens.is_empty()); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0dd0ee717b..436d9c63ba 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -740,6 +740,7 @@ pub fn run() { upload_media_bytes, upload_media_bytes_raw, cancel_media_upload, + release_media_upload, download_image, save_png_data_url, download_file, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 2acf4fe29b..1ec6cee95e 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -303,6 +303,11 @@ export const ChannelPane = React.memo(function ChannelPane({ mentionPubkeys: string[], mediaTags?: string[][], channelId?: string | null, + threadContext?: { + parentEventId: string | null; + threadHeadId: string | null; + } | null, + forceRest?: boolean, ) => { const shouldCompleteWelcomeBanner = isActiveWelcomeChannel && @@ -310,7 +315,14 @@ export const ChannelPane = React.memo(function ChannelPane({ mentionsKnownAgent(mentionPubkeys, knownAgentPubkeys)); messageTimelineRef.current?.scrollToBottomOnNextUpdate(); - await onSendMessage(content, mentionPubkeys, mediaTags, channelId); + await onSendMessage( + content, + mentionPubkeys, + mediaTags, + channelId, + threadContext, + forceRest, + ); if ( channelId && diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 63db6a8bce..760ef58073 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -110,6 +110,11 @@ export type ChannelPaneProps = { mentionPubkeys: string[], mediaTags?: string[][], channelId?: string | null, + threadContext?: { + parentEventId: string | null; + threadHeadId: string | null; + } | null, + forceRest?: boolean, ) => Promise; onSendToChannel: ( message: TimelineMessage, diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index d7b2e5a6fd..f9c57f6656 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -284,12 +284,18 @@ export function useChannelPaneHandlers({ mentionPubkeys: string[], mediaTags?: string[][], channelId?: string | null, + _threadContext?: { + parentEventId: string | null; + threadHeadId: string | null; + } | null, + forceRest?: boolean, ) => { await sendMutateRef.current({ content, mentionPubkeys, mediaTags, channelId: channelId ?? undefined, + forceRest, }); }, [], @@ -327,6 +333,7 @@ export function useChannelPaneHandlers({ parentEventId: string | null; threadHeadId: string | null; } | null, + forceRest?: boolean, ) => { // Resolve target using captured submit-time context (race-free) or live // refs (legacy path). When threadContext is supplied, no live-ref reads @@ -359,6 +366,7 @@ export function useChannelPaneHandlers({ parentEventId, mediaTags, channelId: channelId ?? undefined, + forceRest, }); // Only update thread UI state if the user is still viewing the same diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index dc0ac167c3..93255fc8ba 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -22,6 +22,7 @@ import { } from "@/features/messages/lib/useDrafts"; import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; import { resetBackgroundMediaUploads } from "@/features/messages/lib/backgroundMediaUploadStore"; +import { resetLinkPreviewPreparations } from "@/features/messages/lib/linkPreviewPreparationStore"; import { resetActiveAgentTurnsStore, saveActiveAgentTurnsForCommunity, @@ -73,6 +74,7 @@ async function resetCommunityState({ resetVideoPlayerState(); resetRenderScopedReactionHydration(); resetBackgroundMediaUploads(); + resetLinkPreviewPreparations(); clearSearchHitEventCache(); clearMarkdownNodeCache(); } diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index e595c30323..8b457a7adf 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -450,6 +450,7 @@ export function useSendMessageMutation( mentionPubkeys?: string[]; parentEventId?: string | null; mediaTags?: string[][]; + forceRest?: boolean; sentFromThreadRootId?: string | null; sentFromThreadRootExcerpt?: string | null; transport?: "auto" | "http"; @@ -463,6 +464,7 @@ export function useSendMessageMutation( mentionPubkeys, parentEventId, mediaTags, + forceRest, sentFromThreadRootId, sentFromThreadRootExcerpt, transport = "auto", @@ -525,6 +527,7 @@ export function useSendMessageMutation( // the relay's tag validation runs. The WebSocket path emits no extra // tags, so emoji-only messages would otherwise lose their emoji tag. if ( + forceRest || transport === "http" || parentEventId || imetaTags.length > 0 || diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.test.mjs b/desktop/src/features/messages/lib/backgroundMediaUploadStore.test.mjs new file mode 100644 index 0000000000..2a4366be5a --- /dev/null +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + cancelStartedMediaUploads, + dispatchTrackedMediaUpload, +} from "./backgroundMediaUploadStore.ts"; + +const descriptor = { + url: "https://relay.example/media/file.bin", + sha256: "a".repeat(64), + size: 1, + type: "application/octet-stream", + uploaded: 0, +}; + +function deferred() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +test("cancels only uploads whose native commands were dispatched", async () => { + const releaseUpload = deferred(); + const startedProgressIds = new Map(); + const cancelled = []; + const dispatched = []; + const released = []; + const upload = async (_file, id, _signal, onDispatch) => { + dispatched.push(id); + onDispatch(); + await releaseUpload.promise; + return descriptor; + }; + const ids = Array.from({ length: 129 }, (_, index) => `attachment-${index}`); + + const uploadPromise = dispatchTrackedMediaUpload( + {}, + ids[0], + new AbortController().signal, + startedProgressIds, + upload, + async (id) => released.push(id), + ); + await Promise.resolve(); + + cancelStartedMediaUploads(startedProgressIds, async (id) => { + cancelled.push(id); + }); + + assert.deepEqual(dispatched, [ids[0]]); + assert.deepEqual(cancelled, [ids[0]]); + assert.equal(startedProgressIds.size, 1); + + releaseUpload.resolve(); + await uploadPromise; + assert.equal(startedProgressIds.size, 0); + assert.deepEqual(released, [ids[0]]); +}); + +test("releases ownership when dispatch rejects", async () => { + const startedProgressIds = new Map(); + const released = []; + + await assert.rejects( + dispatchTrackedMediaUpload( + {}, + "rejected", + new AbortController().signal, + startedProgressIds, + async (_file, id, _signal, onDispatch) => { + onDispatch(); + throw new Error(`rejected ${id}`); + }, + async (id) => released.push(id), + ), + /rejected rejected/, + ); + + assert.equal(startedProgressIds.size, 0); + assert.deepEqual(released, ["rejected"]); +}); + +test("waits for cancellation before releasing renderer ownership", async () => { + const releaseUpload = deferred(); + const releaseCancellation = deferred(); + const startedProgressIds = new Map(); + const events = []; + const uploadPromise = dispatchTrackedMediaUpload( + {}, + "ordered", + new AbortController().signal, + startedProgressIds, + async (_file, _id, _signal, onDispatch) => { + onDispatch(); + await releaseUpload.promise; + return descriptor; + }, + async () => events.push("release"), + ); + await Promise.resolve(); + + cancelStartedMediaUploads(startedProgressIds, async () => { + events.push("cancel-start"); + await releaseCancellation.promise; + events.push("cancel-finish"); + }); + releaseUpload.resolve(); + await Promise.resolve(); + assert.deepEqual(events, ["cancel-start"]); + + releaseCancellation.resolve(); + await uploadPromise; + assert.deepEqual(events, ["cancel-start", "cancel-finish", "release"]); +}); diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts index ace711985a..8066926067 100644 --- a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts @@ -1,7 +1,11 @@ import * as React from "react"; import type { BlobDescriptor } from "@/shared/api/tauri"; -import { cancelMediaUpload, uploadMediaFile } from "@/shared/api/tauriMedia"; +import { + cancelMediaUpload, + releaseMediaUpload, + uploadMediaFile, +} from "@/shared/api/tauriMedia"; import { type BackgroundMediaUploadPhase, isNativeMediaUploadPhase, @@ -23,6 +27,7 @@ type BackgroundUploadTask = { id: number; isCompleting: boolean; onCancel?: () => void; + startedProgressIds: Map | null>; }; type BackgroundUploadSnapshot = { @@ -180,12 +185,43 @@ function cancelTask( task.canceled = true; task.abortController.abort(); if (notify) task.onCancel?.(); - for (let index = 0; index < task.fileProgress.length; index += 1) { - void cancelMediaUpload(progressId(task.id, index)).catch(() => undefined); - } + cancelStartedMediaUploads(task.startedProgressIds); finishTask(task.id); } +export function cancelStartedMediaUploads( + startedProgressIds: Map | null>, + cancel: (progressId: string) => Promise = cancelMediaUpload, +): void { + for (const [id, cancellation] of startedProgressIds) { + if (cancellation) continue; + startedProgressIds.set( + id, + cancel(id).catch(() => undefined), + ); + } +} + +export async function dispatchTrackedMediaUpload( + file: File, + id: string, + signal: AbortSignal, + startedProgressIds: Map | null>, + upload: typeof uploadMediaFile = uploadMediaFile, + release: (progressId: string) => Promise = releaseMediaUpload, +): Promise { + try { + return await upload(file, id, signal, () => + startedProgressIds.set(id, null), + ); + } finally { + const cancellation = startedProgressIds.get(id); + startedProgressIds.delete(id); + if (cancellation) await cancellation; + await release(id).catch(() => undefined); + } +} + function yieldForUploadFeedback(): Promise { if ( typeof window === "undefined" || @@ -228,6 +264,7 @@ export function prepareBackgroundMediaUpload( })), id: taskId, isCompleting: false, + startedProgressIds: new Map(), }; let started = false; tasks.set(taskId, task); @@ -252,10 +289,12 @@ export function prepareBackgroundMediaUpload( for (let index = 0; index < attachments.length; index += 1) { if (task.canceled) return; const attachment = attachments[index]; - const descriptor = await uploadMediaFile( + const id = progressId(taskId, index); + const descriptor = await dispatchTrackedMediaUpload( attachment.file, - progressId(taskId, index), + id, task.abortController.signal, + task.startedProgressIds, ); if (task.canceled) return; task.filePhases[index] = "finishing"; diff --git a/desktop/src/features/messages/lib/linkPreviewPreparationStore.test.mjs b/desktop/src/features/messages/lib/linkPreviewPreparationStore.test.mjs new file mode 100644 index 0000000000..293fb63e15 --- /dev/null +++ b/desktop/src/features/messages/lib/linkPreviewPreparationStore.test.mjs @@ -0,0 +1,274 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + __linkPreviewPreparationTest, + prepareBackgroundLinkPreviews, + prepareLinkPreview, + resetLinkPreviewPreparations, + skipBackgroundLinkPreviews, +} from "./linkPreviewPreparationStore.ts"; + +const first = { href: "https://example.com/first" }; +const second = { href: "https://example.com/second" }; +const firstTag = ["link-preview", "snapshot", first.href]; + +function deferred() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function seed( + candidate, + promise, + settled = false, + settledAt = Date.now(), + fallbackTag = null, + resolvedTag = null, +) { + __linkPreviewPreparationTest.jobs.set(candidate.href, { + controller: new AbortController(), + promise, + fallbackTag, + resolvedTag, + settled, + settledAt: settled ? settledAt : null, + }); +} + +test.afterEach(() => { + __linkPreviewPreparationTest.reset(); +}); + +test("adopts one in-flight job for the same canonical URL", () => { + const pending = deferred(); + seed(first, pending.promise); + + assert.equal(prepareLinkPreview(first), pending.promise); + assert.equal(prepareLinkPreview(first), pending.promise); + pending.resolve(firstTag); +}); + +test("expires settled jobs while retaining in-flight and recent work", () => { + const now = 1_000_000; + assert.equal( + __linkPreviewPreparationTest.isReusableJob( + { + controller: new AbortController(), + promise: Promise.resolve(firstTag), + fallbackTag: null, + resolvedTag: null, + settled: false, + settledAt: null, + }, + now, + ), + true, + ); + assert.equal( + __linkPreviewPreparationTest.isReusableJob( + { + controller: new AbortController(), + promise: Promise.resolve(firstTag), + fallbackTag: null, + resolvedTag: null, + settled: true, + settledAt: now - 1, + }, + now, + ), + true, + ); + assert.equal( + __linkPreviewPreparationTest.isReusableJob( + { + controller: new AbortController(), + promise: Promise.resolve(firstTag), + fallbackTag: null, + resolvedTag: null, + settled: true, + settledAt: now - 5 * 60_000, + }, + now, + ), + false, + ); +}); + +test("keeps successful sibling tags when another URL fails", async () => { + const pending = deferred(); + seed(first, Promise.resolve(firstTag), true); + seed(second, pending.promise); + + const preparation = prepareBackgroundLinkPreviews([first, second], 1_000); + assert.ok(preparation); + pending.resolve(null); + + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [firstTag], + }); +}); + +test("total deadline keeps full and fallback sibling tags", async () => { + const pending = deferred(); + const fallbackTag = ["link-preview", "snapshot", second.href, "metadata"]; + seed(first, Promise.resolve(firstTag), true, Date.now(), null, firstTag); + seed(second, pending.promise, false, Date.now(), fallbackTag); + + const preparation = prepareBackgroundLinkPreviews([first, second], 0); + assert.ok(preparation); + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [firstTag, fallbackTag], + }); + + const lateTag = ["link-preview", "snapshot", second.href, "image"]; + pending.resolve(lateTag); + await pending.promise; + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [firstTag, fallbackTag], + }); +}); + +test("timeout keeps metadata-only fallback and ignores late upload completion", async () => { + const pending = deferred(); + const fallbackTag = [ + "link-preview", + "snapshot", + "1", + first.href, + "First", + "Example", + "", + "", + "", + "", + "", + ]; + seed(first, pending.promise, false, Date.now(), fallbackTag); + + const preparation = prepareBackgroundLinkPreviews([first], 0); + assert.ok(preparation); + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [fallbackTag], + }); + + pending.resolve(firstTag); + await pending.promise; + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [fallbackTag], + }); +}); + +test("Skip wins completion and resolves exactly once", async () => { + const pending = deferred(); + seed(first, pending.promise); + + const preparation = prepareBackgroundLinkPreviews([first], 1_000); + assert.ok(preparation); + preparation.skip(); + pending.resolve(firstTag); + + assert.deepEqual(await preparation.promise, { status: "ready", tags: [] }); +}); + +test("Skip only settles the latest concurrent preparation", async () => { + const firstPending = deferred(); + const secondPending = deferred(); + seed(first, firstPending.promise); + seed(second, secondPending.promise); + + const firstPreparation = prepareBackgroundLinkPreviews([first], 1_000); + const secondPreparation = prepareBackgroundLinkPreviews([second], 1_000); + assert.ok(firstPreparation); + assert.ok(secondPreparation); + + skipBackgroundLinkPreviews(); + firstPending.resolve(firstTag); + secondPending.resolve(["link-preview", "snapshot", second.href]); + + assert.deepEqual(await secondPreparation.promise, { + status: "ready", + tags: [], + }); + assert.deepEqual(await firstPreparation.promise, { + status: "ready", + tags: [firstTag], + }); +}); + +test("Skip after completion cannot replace finalized tags", async () => { + const pending = deferred(); + seed(first, pending.promise); + + const preparation = prepareBackgroundLinkPreviews([first], 1_000); + assert.ok(preparation); + pending.resolve(firstTag); + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [firstTag], + }); + + preparation.skip(); + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [firstTag], + }); +}); + +test("already-settled partial results contain only successful tags", async () => { + seed(first, Promise.resolve(firstTag), true); + seed(second, Promise.resolve(null), true); + + const preparation = prepareBackgroundLinkPreviews([first, second]); + assert.ok(preparation); + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [firstTag], + }); +}); + +test("reset aborts a promoted send after preview preparation settles", async () => { + seed(first, Promise.resolve(firstTag), true); + + const preparation = prepareBackgroundLinkPreviews([first]); + assert.ok(preparation); + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [firstTag], + }); + + resetLinkPreviewPreparations(); + assert.equal(preparation.signal.aborted, true); +}); + +test("released promoted sends are no longer cancelled by reset", async () => { + seed(first, Promise.resolve(firstTag), true); + + const preparation = prepareBackgroundLinkPreviews([first]); + assert.ok(preparation); + await preparation.promise; + preparation.release(); + + resetLinkPreviewPreparations(); + assert.equal(preparation.signal.aborted, false); +}); + +test("reset cancels pending preparations instead of authorizing send", async () => { + const pending = deferred(); + seed(first, pending.promise); + + const preparation = prepareBackgroundLinkPreviews([first], 1_000); + assert.ok(preparation); + resetLinkPreviewPreparations(); + pending.resolve(firstTag); + + assert.deepEqual(await preparation.promise, { status: "cancelled" }); +}); diff --git a/desktop/src/features/messages/lib/linkPreviewPreparationStore.ts b/desktop/src/features/messages/lib/linkPreviewPreparationStore.ts new file mode 100644 index 0000000000..d61750fcd8 --- /dev/null +++ b/desktop/src/features/messages/lib/linkPreviewPreparationStore.ts @@ -0,0 +1,356 @@ +import * as React from "react"; + +import { uploadMediaBytes } from "@/shared/api/tauri"; +import { cancelMediaUpload, releaseMediaUpload } from "@/shared/api/tauriMedia"; +import type { SupportedLinkPreview } from "@/shared/lib/linkPreview"; +import { + buildLinkPreviewSnapshotTag, + isValidLinkPreviewSnapshotCanonicalUrl, +} from "@/shared/lib/linkPreviewSnapshot"; +import { + loadLinkPreviewMetadata, + resolveLinkPreview, +} from "@/shared/lib/useResolvedLinkPreviews"; + +const POST_SUBMIT_PREVIEW_BUDGET_MS = 10_000; +const SETTLED_PREVIEW_JOB_TTL_MS = 5 * 60_000; + +type PreviewJob = { + controller: AbortController; + promise: Promise; + fingerprint: string | null; + fallbackTag: string[] | null; + resolvedTag: string[] | null; + settled: boolean; + settledAt: number | null; +}; + +type BackgroundPreviewTask = { + cancel: () => void; + id: number; + skip: () => void; +}; + +type BackgroundPreviewSnapshot = { + canSkip: boolean; + isPreparing: boolean; +}; + +export type BackgroundLinkPreviewResult = + | { status: "cancelled" } + | { status: "ready"; tags: string[][] }; + +export type PreparedBackgroundLinkPreviews = { + cancel: () => void; + promise: Promise; + signal: AbortSignal; + release: () => void; + skip: () => void; +}; + +const jobs = new Map(); +const tasks = new Map(); +const promotedSends = new Map(); +const listeners = new Set<() => void>(); +let nextTaskId = 0; +let nextPromotedSendId = 0; +let nextUploadId = 0; +let snapshot: BackgroundPreviewSnapshot = { + canSkip: false, + isPreparing: false, +}; + +function publishSnapshot(): void { + snapshot = { + canSkip: tasks.size > 0, + isPreparing: tasks.size > 0, + }; + for (const listener of listeners) listener(); +} + +function dataUrlBytes(dataUrl: string | null | undefined): Uint8Array | null { + if (!dataUrl) return null; + const comma = dataUrl.indexOf(","); + if (comma < 0) return null; + try { + const binary = atob(dataUrl.slice(comma + 1)); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); + } catch { + return null; + } +} + +async function uploadDataUrl( + dataUrl: string | null | undefined, + filename: string, + signal: AbortSignal, +): Promise<{ failed: boolean; sha256: string; url: string }> { + const bytes = dataUrlBytes(dataUrl); + if (!bytes) return { failed: false, sha256: "", url: "" }; + if (signal.aborted) return { failed: true, sha256: "", url: "" }; + + const progressId = `link-preview-${nextUploadId++}`; + let cancellation: Promise | null = null; + const cancel = () => { + cancellation ??= cancelMediaUpload(progressId).catch(() => undefined); + }; + signal.addEventListener("abort", cancel, { once: true }); + try { + const uploaded = await uploadMediaBytes([...bytes], filename, progressId); + if (signal.aborted) return { failed: true, sha256: "", url: "" }; + return { failed: false, sha256: uploaded.sha256, url: uploaded.url }; + } catch { + return { failed: true, sha256: "", url: "" }; + } finally { + signal.removeEventListener("abort", cancel); + if (cancellation) await cancellation; + await releaseMediaUpload(progressId).catch(() => undefined); + } +} + +async function buildSnapshot( + candidate: SupportedLinkPreview, + signal: AbortSignal, + onMetadataReady: (tag: string[]) => void, +): Promise { + const metadata = await loadLinkPreviewMetadata(candidate.href); + if (signal.aborted || !metadata) return null; + const preview = resolveLinkPreview(candidate, metadata); + if (!preview.snapshotReady) return null; + const fallbackTag = buildLinkPreviewSnapshotTag({ + canonicalUrl: preview.href, + title: preview.title, + siteName: preview.provider, + description: preview.description ?? "", + imageUrl: "", + imageSha256: "", + faviconUrl: "", + faviconSha256: "", + }); + if (!fallbackTag || signal.aborted) return null; + onMetadataReady(fallbackTag); + const [image, favicon] = await Promise.all([ + uploadDataUrl(preview.imageDataUrl, "link-preview-image.png", signal), + uploadDataUrl(preview.faviconDataUrl, "link-preview-favicon.png", signal), + ]); + if (signal.aborted) return null; + if (image.failed || favicon.failed) return fallbackTag; + return ( + buildLinkPreviewSnapshotTag({ + canonicalUrl: preview.href, + title: preview.title, + siteName: preview.provider, + description: preview.description ?? "", + imageUrl: image.url, + imageSha256: image.sha256, + faviconUrl: favicon.url, + faviconSha256: favicon.sha256, + }) ?? fallbackTag + ); +} + +function isReusableJob(job: PreviewJob, now = Date.now()): boolean { + return ( + !job.settled || + (job.settledAt !== null && now - job.settledAt < SETTLED_PREVIEW_JOB_TTL_MS) + ); +} + +/** + * Supersede preparation derived from an older composer incarnation. Deleting + * before aborting lets a fresh job for the same URL start immediately while + * the old upload unwinds; the old job's identity check prevents it from + * deleting or publishing over its replacement. + */ +export function invalidateLinkPreviewPreparation(href: string): void { + const job = jobs.get(href); + if (!job) return; + jobs.delete(href); + job.controller.abort(); +} + +function previewFingerprint(candidate: SupportedLinkPreview): string | null { + if (!("snapshotReady" in candidate) || !candidate.snapshotReady) return null; + return JSON.stringify([ + candidate.title, + candidate.provider, + "description" in candidate ? candidate.description : null, + "imageDataUrl" in candidate ? candidate.imageDataUrl : null, + "faviconDataUrl" in candidate ? candidate.faviconDataUrl : null, + ]); +} + +/** Start or adopt the one preparation job for this exact canonical URL. */ +export function prepareLinkPreview( + candidate: SupportedLinkPreview, +): Promise { + if ( + candidate.href.startsWith("buzz://") || + !isValidLinkPreviewSnapshotCanonicalUrl(candidate.href) + ) { + return Promise.resolve(null); + } + const fingerprint = previewFingerprint(candidate); + const existing = jobs.get(candidate.href); + if ( + existing && + isReusableJob(existing) && + (fingerprint === null || existing.fingerprint === fingerprint) + ) { + return existing.promise; + } + if (existing) invalidateLinkPreviewPreparation(candidate.href); + + const controller = new AbortController(); + const job: PreviewJob = { + controller, + promise: Promise.resolve(null), + fingerprint, + fallbackTag: null, + resolvedTag: null, + settled: false, + settledAt: null, + }; + job.promise = buildSnapshot(candidate, controller.signal, (fallbackTag) => { + job.fallbackTag = fallbackTag; + }) + .catch(() => null) + .then((tag) => { + job.resolvedTag = tag; + if (tag === null && jobs.get(candidate.href) === job) { + jobs.delete(candidate.href); + } + return tag; + }) + .finally(() => { + job.settled = true; + job.settledAt = Date.now(); + }); + jobs.set(candidate.href, job); + return job.promise; +} + +/** + * Promote the frozen composer generation into a navigation-safe send task. + * Preparation is best effort: Skip, timeout, or failure all authorize the + * already-requested send without previews. + */ +export function prepareBackgroundLinkPreviews( + candidates: readonly SupportedLinkPreview[], + timeoutMs = POST_SUBMIT_PREVIEW_BUDGET_MS, +): PreparedBackgroundLinkPreviews | null { + const external = candidates.filter( + (candidate) => + !candidate.href.startsWith("buzz://") && + isValidLinkPreviewSnapshotCanonicalUrl(candidate.href), + ); + if (external.length === 0) return null; + + const sendId = nextPromotedSendId++; + const controller = new AbortController(); + promotedSends.set(sendId, controller); + const release = () => { + if (promotedSends.get(sendId) === controller) { + promotedSends.delete(sendId); + } + }; + const preparedSend = ( + promise: Promise, + skip: () => void, + ): PreparedBackgroundLinkPreviews => ({ + cancel: () => controller.abort(), + promise, + signal: controller.signal, + release, + skip, + }); + + const pending = external.some( + (candidate) => !jobs.get(candidate.href)?.settled, + ); + if (!pending) { + return preparedSend( + Promise.all(external.map(prepareLinkPreview)).then((tags) => ({ + status: "ready" as const, + tags: tags.filter((tag): tag is string[] => tag !== null), + })), + () => undefined, + ); + } + + const availableTags = () => + external.flatMap((candidate) => { + const job = jobs.get(candidate.href); + const tag = job?.resolvedTag ?? job?.fallbackTag; + return tag ? [tag] : []; + }); + const taskId = nextTaskId++; + let finish: ((result: BackgroundLinkPreviewResult) => void) | null = null; + let terminal = false; + let timer: ReturnType | null = null; + const complete = (result: BackgroundLinkPreviewResult) => { + if (terminal) return; + terminal = true; + if (timer !== null) clearTimeout(timer); + tasks.delete(taskId); + publishSnapshot(); + finish?.(result); + }; + const promise = new Promise((resolve) => { + finish = resolve; + }); + const cancel = () => complete({ status: "cancelled" }); + const skip = () => complete({ status: "ready", tags: [] }); + tasks.set(taskId, { cancel, id: taskId, skip }); + publishSnapshot(); + + timer = setTimeout( + () => complete({ status: "ready", tags: availableTags() }), + timeoutMs, + ); + void Promise.all(external.map(prepareLinkPreview)).then((tags) => { + complete({ + status: "ready", + tags: tags.filter((tag): tag is string[] => tag !== null), + }); + }); + + return preparedSend(promise, skip); +} + +export function skipBackgroundLinkPreviews(): void { + const latestTask = [...tasks.values()].reduce< + BackgroundPreviewTask | undefined + >( + (latest, task) => (!latest || task.id > latest.id ? task : latest), + undefined, + ); + latestTask?.skip(); +} + +export function resetLinkPreviewPreparations(): void { + for (const controller of promotedSends.values()) controller.abort(); + promotedSends.clear(); + for (const task of [...tasks.values()]) task.cancel(); + for (const job of jobs.values()) job.controller.abort(); + jobs.clear(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getSnapshot(): BackgroundPreviewSnapshot { + return snapshot; +} + +export function useBackgroundLinkPreviewPreparation(): BackgroundPreviewSnapshot { + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +export const __linkPreviewPreparationTest = { + isReusableJob, + jobs, + reset: resetLinkPreviewPreparations, +}; diff --git a/desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx b/desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx index d021e49783..65f1853887 100644 --- a/desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx +++ b/desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx @@ -2,20 +2,37 @@ import { cancelBackgroundMediaUploads, useBackgroundMediaUpload, } from "@/features/messages/lib/backgroundMediaUploadStore"; +import { + skipBackgroundLinkPreviews, + useBackgroundLinkPreviewPreparation, +} from "@/features/messages/lib/linkPreviewPreparationStore"; import { ComposerUploadProgressPill } from "@/features/messages/ui/ComposerUploadProgressPill"; export function ComposerUploadProgressOverlay() { const backgroundUpload = useBackgroundMediaUpload(); + const linkPreviews = useBackgroundLinkPreviewPreparation(); return (
    - + {linkPreviews.isPreparing ? ( + + ) : ( + + )}
    ); } diff --git a/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx index 288b32f8f1..1478005156 100644 --- a/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx +++ b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx @@ -8,20 +8,25 @@ import { cn } from "@/shared/lib/cn"; import { Spinner } from "@/shared/ui/spinner"; export function ComposerUploadProgressPill({ + actionLabel = "Cancel", canCancel, isUploading, onCancel, phase, + phaseLabel: phaseLabelOverride, percentage, }: { + actionLabel?: string; canCancel: boolean; isUploading: boolean; onCancel: () => void; phase: BackgroundMediaUploadPhase; + phaseLabel?: string; percentage: number; }) { const reducedMotion = useReducedMotion(); - const phaseLabel = backgroundMediaUploadPhaseLabel(phase); + const phaseLabel = + phaseLabelOverride ?? backgroundMediaUploadPhaseLabel(phase); const isTransferring = phase === "uploading"; const phaseTransition = reducedMotion ? { duration: 0 } @@ -101,7 +106,6 @@ export function ComposerUploadProgressPill({ layout="position" transition={phaseTransition} > - {isTransferring ? ( - Cancel + {actionLabel} ) : null}
  • diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 022d60b4d1..31a40c86b6 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -57,7 +57,8 @@ import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionH import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; import { submitMessageEdit } from "./submitMessageEdit"; -import { useManagedComposerLinkPreviews } from "./useComposerLinkPreviews"; +import { prepareBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore"; +import { useComposerLinkPreviews } from "./useComposerLinkPreviews"; import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit"; import type { MessageComposerProps } from "./MessageComposer.types"; function MessageComposerImpl({ @@ -100,14 +101,12 @@ function MessageComposerImpl({ syncComposerContentFromEditor, syncContentRefFromEditorRef, } = useComposerContentState(); + const [previewContent, setPreviewContent] = React.useState(""); const { previewList: composerLinkPreviews, + getLiveCandidates: getLiveLinkPreviewCandidates, getReadyTags: getReadyLinkPreviewTags, - hasPendingSnapshots: hasPendingLinkPreviewSnapshots, - // Ref lets the submit guard block Enter/form/auto-submit until snapshots settle. - hasPendingSnapshotsRef: hasPendingLinkPreviewSnapshotsRef, - updateContent: updateLinkPreviewContent, - } = useManagedComposerLinkPreviews(editTarget == null); + } = useComposerLinkPreviews(previewContent, editTarget == null); const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false); const [isFormattingOpen, setIsFormattingOpen] = React.useState(false); const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState< @@ -269,7 +268,7 @@ function MessageComposerImpl({ onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, onUpdate: ({ cursor, linkPreviewContent, text }) => { setComposerContentFromText(text); - updateLinkPreviewContent(linkPreviewContent); + setPreviewContent(linkPreviewContent); mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); emojiAutocomplete.updateEmojiQuery(text, cursor); @@ -568,7 +567,6 @@ function MessageComposerImpl({ isSendingRef.current || isSubmitLockedRef.current || isUploadingRef.current || - hasPendingLinkPreviewSnapshotsRef.current || mentionSendFlow.isPreparingMentionSend ) { return; @@ -584,12 +582,18 @@ function MessageComposerImpl({ onPreparingMentionSendChange?.(true); persistentMentionHydration.beginSubmit(); try { + const preparedLinkPreviews = getReadyLinkPreviewTags().some( + (tag) => tag[1] === "none", + ) + ? null + : prepareBackgroundLinkPreviews(getLiveLinkPreviewCandidates()); await mentionSendFlow.sendMessageWithMentionFlow({ capturedChannelId: channelId, capturedThreadContext, pendingImeta: currentPendingImeta, queuedAttachments: currentQueuedAttachments, - linkPreviewTags: getReadyLinkPreviewTags(), + linkPreviewTags: preparedLinkPreviews ? [] : getReadyLinkPreviewTags(), + preparedLinkPreviews, sentDraftKey: resolveSentDraftKey( effectiveDraftKeyRef.current, drafts.loadDraft, @@ -611,8 +615,8 @@ function MessageComposerImpl({ customEmoji, drafts.loadDraft, emojiAutocomplete.clearEmojis, + getLiveLinkPreviewCandidates, getReadyLinkPreviewTags, - hasPendingLinkPreviewSnapshotsRef, media.clearQueuedAttachments, media.pendingImetaRef, media.queuedAttachmentsRef, @@ -640,17 +644,7 @@ function MessageComposerImpl({ mentions.revalidateMentionPubkeys, ]); submitMessageRef.current = submitMessage; - // ── Auto-submit on draft send ──────────────────────────────────────────── - // When `autoSubmitDraftKey` is set (the user clicked "Send message" in the - // Drafts panel and confirmed), fire `submitMessage` once after mount so the - // draft is sent through the real send path (mention resolution, media, etc.). - // - // Guard: only fire when the effective draft key matches the trigger so a - // stale URL param on a different channel never fires a spurious send. - // - // Fires at most once per mount (empty dep array after the key check) — the - // `onAutoSubmitComplete` callback clears the trigger before `submitMessage` - // runs, preventing re-fire on re-render or back-navigation. + // Draft auto-submit runs once after persisted editor state loads. const onAutoSubmitCompleteRef = React.useRef(onAutoSubmitComplete); onAutoSubmitCompleteRef.current = onAutoSubmitComplete; // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally fires once on mount only @@ -665,7 +659,6 @@ function MessageComposerImpl({ // loop back with the param still present. onAutoSubmitCompleteRef.current?.(); return scheduleSettleGatedAutoSubmit({ - isPending: () => hasPendingLinkPreviewSnapshotsRef.current, submit: () => submitMessageRef.current(), }); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -810,7 +803,6 @@ function MessageComposerImpl({ const sendDisabled = composerDisabled || media.isUploading || - hasPendingLinkPreviewSnapshots || mentionSendFlow.isPreparingMentionSend || (isContentEmpty && media.pendingImeta.length === 0 && diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts index ddf987d0f3..a24be0aeab 100644 --- a/desktop/src/features/messages/ui/MessageComposer.types.ts +++ b/desktop/src/features/messages/ui/MessageComposer.types.ts @@ -91,6 +91,8 @@ export type MessageComposerProps = { parentEventId: string | null; threadHeadId: string | null; } | null, + /** Route through the REST publisher even when best-effort enrichment settled empty. */ + forceRest?: boolean, ) => Promise; placeholder?: string; profiles?: UserProfileLookup; diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 8f88d8b63d..c98d0bc011 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -93,6 +93,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { parentEventId: string | null; threadHeadId: string | null; } | null, + forceRest?: boolean, ) => Promise; onSendToChannel?: ( message: TimelineMessage, diff --git a/desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs b/desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs index e9a62e42de..6190940ff0 100644 --- a/desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs +++ b/desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs @@ -1,32 +1,12 @@ -/** - * Unit tests for `scheduleSettleGatedAutoSubmit` — the auto-submit scheduler - * that fires a ?autoSend draft submit exactly once, after link-preview settling - * finishes. - * - * Imports and exercises the ACTUAL source helper. Regression guard for the - * auto-send-drop blocker (PR #5245, Blocker A): a confirmed draft with a - * supported link is normally still settling at mount, so an immediate submit - * bails on the pending guard. The prior one-shot `setTimeout(0)` consumed the - * trigger and silently dropped the draft. The scheduler must instead poll while - * pending and submit exactly once when settling clears — never zero, never - * twice. - * - * A controllable fake timer drives the poll deterministically, so there is no - * real-time flakiness (the E2E form could not reliably send inside the ~350 ms - * window headless). - */ - import assert from "node:assert/strict"; import test from "node:test"; import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit.ts"; -// Minimal deterministic timer: records scheduled callbacks so the test can -// advance them one "tick" at a time and assert exact call counts. function makeFakeTimers() { const pending = new Map(); let nextId = 1; return { - set(fn, _ms) { + set(fn) { const id = nextId++; pending.set(id, fn); return id; @@ -34,7 +14,6 @@ function makeFakeTimers() { clear(id) { pending.delete(id); }, - // Fire the earliest-scheduled still-pending callback. tick() { const [id, fn] = pending.entries().next().value ?? []; if (id === undefined) return false; @@ -48,56 +27,27 @@ function makeFakeTimers() { }; } -test("submits once immediately when nothing is pending", () => { - const timers = makeFakeTimers(); - let submits = 0; - scheduleSettleGatedAutoSubmit({ - isPending: () => false, - submit: () => submits++, - timers, - }); - timers.tick(); // fire the initial setTimeout(0) - assert.equal(submits, 1); - assert.equal(timers.pendingCount(), 0, "no retry should be scheduled"); -}); - -test("waits while settling then submits exactly once (the drop-guard)", () => { +test("submits a restored draft exactly once on the next task", () => { const timers = makeFakeTimers(); let submits = 0; - let pending = true; // still settling at mount scheduleSettleGatedAutoSubmit({ - isPending: () => pending, submit: () => submits++, timers, }); - timers.tick(); // initial attempt: pending → reschedules, does NOT submit - assert.equal(submits, 0, "must not send while a snapshot is still pending"); - assert.equal(timers.pendingCount(), 1, "a retry must be scheduled"); - - timers.tick(); // retry: still pending assert.equal(submits, 0); - - pending = false; // settling finished - timers.tick(); // retry: fires the send - assert.equal(submits, 1, "must send exactly once after settling clears"); + timers.tick(); + assert.equal(submits, 1); assert.equal(timers.pendingCount(), 0); }); -test("cleanup before settling finishes cancels the submit (no orphan send)", () => { +test("cleanup before the next task cancels the submit", () => { const timers = makeFakeTimers(); let submits = 0; const cleanup = scheduleSettleGatedAutoSubmit({ - isPending: () => true, submit: () => submits++, timers, }); - timers.tick(); // initial attempt reschedules a retry - assert.equal(timers.pendingCount(), 1); - cleanup(); // unmount - assert.equal( - timers.pendingCount(), - 0, - "cleanup must clear the pending retry", - ); + cleanup(); + assert.equal(timers.pendingCount(), 0); assert.equal(submits, 0); }); diff --git a/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts b/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts index f15422b93d..30c676384a 100644 --- a/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts +++ b/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts @@ -1,45 +1,19 @@ -// Auto-submit scheduler for a confirmed draft that arrived via ?autoSend. A -// draft containing a supported link is normally still settling (350 ms -// debounce + metadata/upload) at mount, so a submit fired immediately bails on -// the pending-snapshot guard. A one-shot `setTimeout(0)` would consume the -// trigger and silently drop the draft; instead poll until settling finishes -// (bounded by the preview hook's own anti-trap cap) then submit exactly once. -// The `didSubmit` guard prevents a double fire, and the initial defer lets the -// draft-persist lifecycle effect load the draft into the editor first. -// -// Extracted from MessageComposer as a pure, timer-injectable helper so the -// retry/one-shot contract is unit-testable without mounting the composer. +// Auto-submit a confirmed draft after the draft-persist lifecycle has restored +// it into the editor. Link-preview preparation is promoted by submit itself, so +// it must never hold this trigger in a polling loop. export function scheduleSettleGatedAutoSubmit({ - isPending, submit, - retryDelayMs = 50, timers = { set: (fn: () => void, ms: number) => window.setTimeout(fn, ms), clear: (id: number) => window.clearTimeout(id), }, }: { - isPending: () => boolean; submit: () => void; - retryDelayMs?: number; timers?: { set: (fn: () => void, ms: number) => number; clear: (id: number) => void; }; }): () => void { - let didSubmit = false; - let retryTimer = 0; - const attempt = () => { - if (didSubmit) return; - if (isPending()) { - retryTimer = timers.set(attempt, retryDelayMs); - return; - } - didSubmit = true; - submit(); - }; - const initialTimer = timers.set(attempt, 0); - return () => { - timers.clear(initialTimer); - timers.clear(retryTimer); - }; + const timer = timers.set(submit, 0); + return () => timers.clear(timer); } diff --git a/desktop/src/features/messages/ui/useActivePreparedLinkPreviews.ts b/desktop/src/features/messages/ui/useActivePreparedLinkPreviews.ts new file mode 100644 index 0000000000..80a11e1156 --- /dev/null +++ b/desktop/src/features/messages/ui/useActivePreparedLinkPreviews.ts @@ -0,0 +1,14 @@ +import * as React from "react"; +import type { PreparedBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore"; + +export function useActivePreparedLinkPreviews() { + const preparations = React.useRef(new Set()); + React.useEffect(() => { + const active = preparations.current; + return () => { + for (const preparation of active) preparation.cancel(); + active.clear(); + }; + }, []); + return preparations.current; +} diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx index 1ea94821cf..3c845d057f 100644 --- a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx @@ -1,13 +1,13 @@ import * as React from "react"; -import { ImageOff, LoaderCircle, X } from "lucide-react"; -import { toast } from "sonner"; +import { ImageOff, X } from "lucide-react"; -import { getRelayHttpUrl, uploadMediaBytes } from "@/shared/api/tauri"; +import { getRelayHttpUrl } from "@/shared/api/tauri"; import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview"; +import { isValidLinkPreviewSnapshotCanonicalUrl } from "@/shared/lib/linkPreviewSnapshot"; import { - buildLinkPreviewSnapshotTag, - isValidLinkPreviewSnapshotCanonicalUrl, -} from "@/shared/lib/linkPreviewSnapshot"; + invalidateLinkPreviewPreparation, + prepareLinkPreview, +} from "@/features/messages/lib/linkPreviewPreparationStore"; import { beginRelayOriginFetch, getCachedRelayOrigin, @@ -28,17 +28,17 @@ import { AttachmentTrigger, } from "@/shared/ui/attachment"; import { Button } from "@/shared/ui/button"; +import { Progress } from "@/shared/ui/progress"; +import { Skeleton } from "@/shared/ui/skeleton"; // Idle time after the last keystroke before link-preview resolution runs, so // typing a URL does not flicker a card per character (debounce, not throttle: // throttle would still fire mid-type). const LINK_PREVIEW_DEBOUNCE_MS = 350; -// Upper bound on how long Send stays disabled while a preview is still settling -// (metadata resolving, or snapshot media uploading). Past this the button -// re-enables even if the tag never lands, so a dead or slow link never traps -// the composer — the message then sends as a bare link. -const SNAPSHOT_SETTLE_DISABLE_CAP_MS = 2000; +// A preview stays pending until its metadata and snapshot media settle. The +// visible suppression control is the explicit escape for sending without +// previews; network timing must never silently change the submitted event. function previewHostname(href: string): string { try { @@ -54,8 +54,8 @@ function previewHostname(href: string): string { // debounce drops A, so keying off live hrefs is what stops "delete A, send // replacement text within the window" from leaking A's tag (and media refs) // onto a body that no longer contains A. When `suppressed`, emit only the -// "none" marker. Live hrefs without a ready tag (dead/slow link past the -// anti-trap cap) are omitted and the message sends as a bare link. +// "none" marker. An unsuppressed live href without a ready tag can only reach +// submit after a terminal miss; it is omitted and sends as a bare link. export function selectSubmitTags( liveHrefs: readonly string[], tagsByHref: Record, @@ -69,9 +69,11 @@ export function selectSubmitTags( } function ComposerLinkPreviewCard({ + onSuppress, preview, tagReady, }: { + onSuppress: () => void; preview: ResolvedLinkPreview; tagReady: boolean; }) { @@ -86,122 +88,107 @@ function ComposerLinkPreviewCard({ // are complete as soon as the recognized entity card exists. const snapshotTagReady = Boolean(preview.snapshotReady && tagReady); const done = snapshotTagReady || isBuzzEntityPreview(preview); - let path = ""; - try { - const url = new URL(preview.href); - path = `${url.pathname}${url.search}`; - } catch {} return ( - - - {showImage ? ( - setFailedImageSrc(imageSrc ?? null)} - src={imageSrc ?? undefined} - /> - ) : preview.imageState === "pending" ? ( - - ) : preview.faviconDataUrl ? ( - - ) : ( - - - - {done ? preview.title : hostname} - - - {done - ? preview.provider || hostname - : path && path !== "/" - ? path - : preview.typeLabel} - - - - - Open {preview.title} - - - + {showImage ? ( + setFailedImageSrc(imageSrc ?? null)} + src={imageSrc ?? undefined} + /> + ) : !done ? ( +
    + ) : preview.faviconDataUrl ? ( + + ) : ( +
    ); } -function dataUrlBytes(dataUrl: string): Uint8Array | null { - const match = /^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl); - if (!match) return null; - try { - return Uint8Array.from(atob(match[2]), (char) => char.charCodeAt(0)); - } catch { - return null; - } -} - -async function uploadDataUrl( - dataUrl: string | null | undefined, - filename: string, -) { - if (!dataUrl) return { url: "", sha256: "" }; - const bytes = dataUrlBytes(dataUrl); - if (!bytes) throw new Error("invalid preview media data"); - const uploaded = await uploadMediaBytes([...bytes], filename); - return { url: uploaded.url, sha256: uploaded.sha256 }; -} - -// Upload one snapshot media (image or favicon) independently so a single -// failure degrades gracefully instead of dropping the whole preview: on -// failure we return empty url/sha256 (a valid "no media" snapshot field) and -// report which media failed so the caller can toast the user once. -async function uploadSnapshotMedia( - dataUrl: string | null | undefined, - filename: string, - label: "thumbnail" | "favicon", -): Promise<{ url: string; sha256: string; failed: null | typeof label }> { - try { - const { url, sha256 } = await uploadDataUrl(dataUrl, filename); - return { url, sha256, failed: null }; - } catch { - return { url: "", sha256: "", failed: dataUrl ? label : null }; - } -} - -export function extractComposerLinkPreviewHrefs(content: string): string[] { - return extractSupportedLinkPreviews(content) - .filter((preview) => - preview.href.startsWith("buzz://") - ? true - : isValidLinkPreviewSnapshotCanonicalUrl(preview.href), - ) - .map((preview) => preview.href); -} - export interface ComposerLinkPreviewInput { content: string; hrefs: Set; @@ -213,7 +200,15 @@ export function updateComposerLinkPreviewInput( current: ComposerLinkPreviewInput, content: string, ): ComposerLinkPreviewInput { - const nextHrefs = new Set(extractComposerLinkPreviewHrefs(content)); + const nextHrefs = new Set( + extractSupportedLinkPreviews(content) + .filter((preview) => + preview.href.startsWith("buzz://") + ? true + : isValidLinkPreviewSnapshotCanonicalUrl(preview.href), + ) + .map((preview) => preview.href), + ); const nextVersions = new Map(); let nextHrefVersion = current.nextHrefVersion; for (const href of nextHrefs) { @@ -264,19 +259,20 @@ export function useComposerLinkPreviews( const [suppressed, setSuppressed] = React.useState(false); // Debounce the content that drives resolution so typing a URL character by // character does not churn a new candidate href (and a flickering card) per - // keystroke. `content` is the live editor value; `debounced` is what actually - // resolves. A fast paste-and-Enter before the debounce fires is held by - // `hasUnresolvedLiveCandidates` below, which keeps Send disabled until the - // live candidates resolve — so no synchronous flush is needed at submit. + // keystroke. `content` is the live editor value; `debounced` drives the + // speculative composer card. Submit independently freezes live candidates, + // so a fast paste-and-Enter promotes the exact URL without waiting here. const [debounced, setDebounced] = React.useState(content); + const debouncedRef = React.useRef(debounced); + debouncedRef.current = debounced; React.useEffect(() => { - if (content === debounced) return; + if (content === debouncedRef.current) return; const timer = window.setTimeout( () => setDebounced(content), LINK_PREVIEW_DEBOUNCE_MS, ); return () => window.clearTimeout(timer); - }, [content, debounced]); + }, [content]); const extractCandidates = React.useCallback( (source: string) => enabled @@ -292,34 +288,20 @@ export function useComposerLinkPreviews( () => extractCandidates(debounced), [extractCandidates, debounced], ); - // Supported candidates in the LIVE content. When these differ from what has - // resolved (debounce not yet fired after a paste/keystroke), Send must still - // treat the preview as pending so a fast Enter cannot ship a bare link ahead - // of resolution. const liveCandidates = React.useMemo( () => extractCandidates(content).map((preview) => preview.href), - [extractCandidates, content], + [content, extractCandidates], ); const liveCandidatesKey = liveCandidates.join("\n"); const liveHrefVersionsKey = liveCandidates .map((href) => `${href}\0${liveHrefVersions?.get(href) ?? ""}`) .join("\n"); - // Submit and async-completion paths read only the last COMMITTED live set. - // Updating this during render would let an abandoned concurrent render leak - // uncommitted editor content into a later submit or upload completion. + // Async completion and submit paths may only observe committed editor state. + // A render-time ref write can leak an abandoned concurrent render. const liveCandidatesRef = React.useRef([]); React.useLayoutEffect(() => { liveCandidatesRef.current = liveCandidates; }, [liveCandidates]); - // A URL freshly entering the composer (paste, or finishing typing one) should - // get a fresh fetch rather than a stale negative cache hit — the user is - // actively asking for this link's card now. useResolvedLinkPreviews handles - // the timing (invalidate a newly-present href's NEGATIVE cache entry before - // it reads the cache); healthy hits and passive message-list scroll are - // untouched, so the shared cache still does its job everywhere else. Pass the - // LIVE hrefs for newness tracking so a fast clear-then-repaste of the same URL - // within the debounce window (which never commits an empty `candidates`) is - // still seen as a re-entry and refetched — not served the stale negative. const resolvedPreviews = useResolvedLinkPreviews( suppressed ? [] : candidates, { @@ -349,50 +331,18 @@ export function useComposerLinkPreviews( readyTagsByHrefRef.current = readyTags; const suppressedRef = React.useRef(suppressed); suppressedRef.current = suppressed; - const uploadsRef = React.useRef(new Map()); - // Per-href upload generation. Bumped whenever a stale-negative href re-enters - // (below), so an upload started before a re-entry can be recognized as stale - // when it settles and dropped without publishing its pre-re-entry tag — the - // `reenteringHrefsRef` phase marker alone is not enough, since it is cleared - // the moment fresh metadata arrives, which can be before the OLD upload - // resolves. Keyed uploads also let a fresh upload start while a superseded one - // is still in flight (its generation no longer matches), so the composer is - // never left tagless waiting on a doomed upload. - const uploadGenerationRef = React.useRef(new Map()); - const activeHrefsRef = React.useRef(new Set()); - - React.useEffect(() => { - if (getCachedRelayOrigin()) return; - const publishRelayOrigin = beginRelayOriginFetch(); - void getRelayHttpUrl() - .then((url) => publishRelayOrigin(url)) - .catch(() => publishRelayOrigin(null)); - }, []); - - React.useEffect(() => { - const active = new Set(candidates.map((preview) => preview.href)); - setReadyTags((current) => - Object.fromEntries( - Object.entries(current).filter(([href]) => active.has(href)), - ), - ); - }, [candidates]); - - // Compare live content with the LAST COMMITTED href set during render, but do - // not mutate anything here. This gives synchronous submit/pending selectors a - // pure fence for a stale fallback on the candidate render. The layout effect - // below commits the block, generation bump, and tag removal only if React - // actually commits this render; an abandoned concurrent render leaks nothing. + // Track composer incarnations independently from the shared preparation + // store. Re-entry must supersede work built from the previous incarnation, + // even if React batched leave+enter into one commit. + const preparationGenerationRef = React.useRef(new Map()); const committedLiveHrefsRef = React.useRef>(new Set()); const committedLiveHrefVersionsRef = React.useRef>( new Map(), ); - // Hrefs that re-entered with a STALE negative fallback stay blocked until the - // resolver visibly cycles pending -> ready. Healthy image re-entries are not - // blocked because their cached metadata remains valid and does not refetch. const reenteringHrefsRef = React.useRef< Map >(new Map()); + const activeHrefsRef = React.useRef(new Set()); const reenteredLiveHrefs = liveCandidates.filter((href) => { const version = liveHrefVersions?.get(href); return version === undefined @@ -411,47 +361,41 @@ export function useComposerLinkPreviews( ); const staleReenteredKey = staleReenteredHrefs.join("\n"); - // biome-ignore lint/correctness/useExhaustiveDependencies: stable href keys intentionally represent the live/stale sets; the arrays are rebuilt each render. + // biome-ignore lint/correctness/useExhaustiveDependencies: stable keys represent the committed live/version/stale sets. React.useLayoutEffect(() => { - const previousLiveHrefs = committedLiveHrefsRef.current; - const activeLiveHrefs = new Set(liveCandidates); - activeHrefsRef.current = activeLiveHrefs; - committedLiveHrefsRef.current = activeLiveHrefs; + const previous = committedLiveHrefsRef.current; + const active = new Set(liveCandidates); + activeHrefsRef.current = active; + committedLiveHrefsRef.current = active; committedLiveHrefVersionsRef.current = new Map(liveHrefVersions); - // Leaving the live draft ends the current re-entry cycle. Prune its phase so - // a later paste can consume healthy metadata that resolved while absent. - // Also advance the upload generation: an upload started before removal must - // never publish into a later incarnation of the same href. - for (const href of previousLiveHrefs) { - if (activeLiveHrefs.has(href)) continue; + for (const href of previous) { + if (active.has(href)) continue; reenteringHrefsRef.current.delete(href); - uploadGenerationRef.current.set( + preparationGenerationRef.current.set( href, - (uploadGenerationRef.current.get(href) ?? 0) + 1, + (preparationGenerationRef.current.get(href) ?? 0) + 1, ); } if (staleReenteredHrefs.length === 0) return; - for (const href of staleReenteredHrefs) { reenteringHrefsRef.current.set(href, "blocked"); - // Fence any upload built from pre-re-entry metadata. A fresh generation - // can start while the superseded upload is still in flight. - uploadGenerationRef.current.set( + preparationGenerationRef.current.set( href, - (uploadGenerationRef.current.get(href) ?? 0) + 1, + (preparationGenerationRef.current.get(href) ?? 0) + 1, ); + invalidateLinkPreviewPreparation(href); } const drop = new Set(staleReenteredHrefs); setReadyTags((current) => { let changed = false; const next = { ...current }; - for (const href of drop) - if (href in next) { - delete next[href]; - changed = true; - } + for (const href of drop) { + if (!(href in next)) continue; + delete next[href]; + changed = true; + } return changed ? next : current; }); }, [liveCandidatesKey, liveHrefVersionsKey, staleReenteredKey]); @@ -459,13 +403,25 @@ export function useComposerLinkPreviews( const isHrefReentering = (href: string) => reenteringHrefsRef.current.has(href) || staleReenteredHrefs.includes(href); + React.useEffect(() => { + if (getCachedRelayOrigin()) return; + const publishRelayOrigin = beginRelayOriginFetch(); + void getRelayHttpUrl() + .then((url) => publishRelayOrigin(url)) + .catch(() => publishRelayOrigin(null)); + }, []); + + React.useEffect(() => { + const active = new Set(candidates.map((preview) => preview.href)); + setReadyTags((current) => + Object.fromEntries( + Object.entries(current).filter(([href]) => active.has(href)), + ), + ); + }, [candidates]); + React.useEffect(() => { for (const preview of previews) { - // A re-entering href stays blocked until the resolver's forced refetch has - // visibly cycled through pending: seeing `!snapshotReady` (pending) marks - // "refetching"; only once it is ready AGAIN after that is the block lifted - // and a tag built from the fresh metadata. The stale pre-clear fallback - // (still `snapshotReady` and never pending) can never rebuild the tag. const phase = reenteringHrefsRef.current.get(preview.href); if (phase !== undefined) { if (!preview.snapshotReady) { @@ -475,89 +431,26 @@ export function useComposerLinkPreviews( if (phase === "blocked") continue; reenteringHrefsRef.current.delete(preview.href); } - // The generation captured here fences this upload's completion: a live - // re-entry bumps `uploadGenerationRef` (above), so an in-flight upload - // started from stale pre-clear metadata carries an older generation and - // its `.then` (below) becomes a no-op. The dedup guard is generation-aware - // too, so a superseded in-flight upload does not block starting the fresh - // one at the new generation. - const generation = uploadGenerationRef.current.get(preview.href) ?? 0; - if ( - !preview.snapshotReady || - readyTags[preview.href] || - uploadsRef.current.get(preview.href) === generation - ) - continue; - uploadsRef.current.set(preview.href, generation); - // Upload image and favicon independently so one failure degrades to the - // surviving media instead of dropping the whole preview. A snapshot tag - // with empty media fields is valid (renders as text + favicon, or - // text-only), so a partial or total media failure still ships a real - // inline preview and the card never spins forever. - const uploadPromise = Promise.all([ - uploadSnapshotMedia( - preview.imageDataUrl, - "link-preview-image.png", - "thumbnail", - ), - uploadSnapshotMedia( - preview.faviconDataUrl, - "link-preview-favicon.png", - "favicon", - ), - ]) - .then(([image, favicon]) => { - if (!activeHrefsRef.current.has(preview.href)) return; - // If this href re-entered while the upload was in flight, its metadata - // is stale (the resolver is refetching). Drop the result rather than - // writing back a snapshot tag built from the pre-re-entry metadata; - // the forced refetch's own upload will produce the fresh tag. - if (reenteringHrefsRef.current.has(preview.href)) return; - // Durable generation fence, independent of the phase marker: if this - // href re-entered while the upload was in flight, its generation was - // bumped, so this stale completion is dropped even if the marker has - // already been cleared (e.g. the forced refetch reached fresh ready - // and the effect deleted the marker before U1 settled). - if ( - (uploadGenerationRef.current.get(preview.href) ?? 0) !== generation - ) - return; - const failedMedia = [image.failed, favicon.failed].filter( - (label): label is "thumbnail" | "favicon" => label !== null, - ); - if (failedMedia.length > 0) { - toast.error( - `Something went wrong with the ${failedMedia.join(" and ")}`, - ); - } - const tag = buildLinkPreviewSnapshotTag({ - canonicalUrl: preview.href, - title: preview.title, - siteName: preview.provider, - description: preview.description ?? "", - imageUrl: image.url, - imageSha256: image.sha256, - faviconUrl: favicon.url, - faviconSha256: favicon.sha256, - }); - if (!tag) return; - // Update the ref alongside state so a submit reading - // `readyTagsByHrefRef` sees the tag before the next render commits. - readyTagsByHrefRef.current = { - ...readyTagsByHrefRef.current, - [preview.href]: tag, - }; - setReadyTags((current) => ({ ...current, [preview.href]: tag })); - }) - .finally(() => { - // Only clear the slot if this upload is still the current one for the - // href. A superseded upload (older generation) must not delete the - // entry belonging to the fresh upload (U2) that replaced it, or the - // dedup guard would let a third upload start and race again. - if (uploadsRef.current.get(preview.href) === generation) - uploadsRef.current.delete(preview.href); - }); - void uploadPromise; + if (!preview.snapshotReady || readyTags[preview.href]) continue; + const generation = + preparationGenerationRef.current.get(preview.href) ?? 0; + void prepareLinkPreview(preview).then((tag) => { + if ( + !tag || + suppressedRef.current || + !activeHrefsRef.current.has(preview.href) || + reenteringHrefsRef.current.has(preview.href) || + (preparationGenerationRef.current.get(preview.href) ?? 0) !== + generation + ) { + return; + } + readyTagsByHrefRef.current = { + ...readyTagsByHrefRef.current, + [preview.href]: tag, + }; + setReadyTags((current) => ({ ...current, [preview.href]: tag })); + }); } }, [previews, readyTags]); @@ -568,12 +461,9 @@ export function useComposerLinkPreviews( ? [readyTags[candidate.href]] : [], ); - // A preview is "settling" from paste until its sendable tag exists: metadata - // is still resolving, or it resolved and the snapshot media is uploading. - // Send stays disabled across the whole window so the button never flickers - // ready -> not-ready -> ready (buzz:// links never snapshot, so they never - // report settling). `imageState === "none"` is terminal (no snapshot), so it - // does not block. See the disable cap below for the dead/slow-link escape. + // Expose speculative preparation state for card treatment and tests. It no + // longer gates Submit: the send flow promotes unfinished work into the + // navigation-safe preparation store. const hasResolvingSnapshots = !suppressed && previews.some( @@ -583,10 +473,8 @@ export function useComposerLinkPreviews( isHrefReentering(preview.href) || (preview.snapshotReady && !readyTags[preview.href])), ); - // A supported link in the LIVE content that resolution has not caught up to - // yet (debounce pending, or resolved for an older revision) also counts as - // settling — otherwise a paste-and-immediate-Enter would ship a bare link - // before resolution even starts. buzz:// links never snapshot, so ignore them. + // Include live candidates not reached by the debounce yet so the composer + // accurately reports whether its visible generation is still catching up. const hasUnresolvedLiveCandidates = !suppressed && liveCandidates.some( @@ -595,31 +483,14 @@ export function useComposerLinkPreviews( !readyTags[href] && !candidates.some((candidate) => candidate.href === href), ); - const hasSettlingSnapshots = + const hasPendingSnapshots = hasResolvingSnapshots || hasUnresolvedLiveCandidates; - // Re-enable Send once the disable cap elapses even if a preview is still - // settling, so a link whose metadata or upload stalls never traps the - // composer. Resets whenever settling ends or the live candidate set changes. - const [settleDisableExpired, setSettleDisableExpired] = React.useState(false); - // biome-ignore lint/correctness/useExhaustiveDependencies: liveCandidatesKey intentionally restarts the anti-trap cap when the link set changes while still settling, so a replaced/added link gets a fresh disable window rather than inheriting the prior link's near-expired timer. - React.useEffect(() => { - if (!hasSettlingSnapshots) { - setSettleDisableExpired(false); - return; - } - setSettleDisableExpired(false); - const timer = window.setTimeout( - () => setSettleDisableExpired(true), - SNAPSHOT_SETTLE_DISABLE_CAP_MS, - ); - return () => window.clearTimeout(timer); - }, [hasSettlingSnapshots, liveCandidatesKey]); - const hasPendingSnapshots = hasSettlingSnapshots && !settleDisableExpired; - // Ref mirror so a synchronous submit guard can read the pending state on any - // entry point (Enter, form, auto-submit), not just the reactive button prop. + // Ref mirror retained for consumers that need an imperative status read. const hasPendingSnapshotsRef = React.useRef(hasPendingSnapshots); hasPendingSnapshotsRef.current = hasPendingSnapshots; - const hideAll = React.useCallback(() => setSuppressed(true), []); + const hideAll = React.useCallback(() => { + setSuppressed(true); + }, []); const previewList = previews.length ? (
    -
    - - {previews.map((preview) => ( - - ))} - - -
    + + {previews.map((preview) => ( + + ))} +
    ) : null; - // Snapshot tags for a submit, read synchronously at submit start from the - // LIVE candidate set (liveCandidatesRef) via `selectSubmitTags` — so the tags - // always correspond to the content actually being sent, never a debounced set - // that still holds a just-removed URL. Re-entering hrefs are excluded: their - // retained tag was built from stale metadata the resolver is refetching, and - // it must not ship until a fresh tag replaces it. No await: Send is disabled - // until every settling preview has its tag (or the anti-trap cap fires), so at - // submit time the tags that will ever exist already exist. - const getReadyTags = React.useCallback(() => { - return selectSubmitTags( - liveCandidatesRef.current.filter( - (href) => !reenteringHrefsRef.current.has(href), + // Snapshot tags already available at submit, selected from the LIVE candidate + // set so a debounced, just-removed URL can never leak into the event. The send + // flow promotes any missing candidates and ignores this partial set. + const getReadyTags = React.useCallback( + () => + selectSubmitTags( + liveCandidatesRef.current.filter( + (href) => !reenteringHrefsRef.current.has(href), + ), + readyTagsByHrefRef.current, + suppressedRef.current, ), - readyTagsByHrefRef.current, - suppressedRef.current, - ); - }, []); + [], + ); + const getLiveCandidates = React.useCallback( + () => extractCandidates(content), + [content, extractCandidates], + ); return { previewList, + getLiveCandidates, getReadyTags, hasPendingSnapshots, hasPendingSnapshotsRef, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index b2eb3893b7..4bd87c15d6 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -1,6 +1,10 @@ import type { ManagedAgent } from "@/shared/api/types"; -import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import { + type ImetaMedia, + mergeOutgoingTags, +} from "@/features/messages/lib/imetaMediaMarkdown"; import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import type { PreparedBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore"; import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; @@ -17,6 +21,7 @@ export type PendingNonMemberMentionSend = { mentionPubkeys: string[]; nonMemberPubkeys: string[]; outgoingTags?: string[][]; + preparedLinkPreviews?: PreparedBackgroundLinkPreviews | null; preparedManagedAgents?: ManagedAgent[]; readyAgentPubkeys?: string[]; savedContent: string; @@ -37,6 +42,7 @@ export type SendMessageWithMentionFlowInput = { pendingImeta: ImetaMedia[]; queuedAttachments?: QueuedMediaAttachment[]; linkPreviewTags?: string[][]; + preparedLinkPreviews?: PreparedBackgroundLinkPreviews | null; sentDraftKey: string | null | undefined; recoveryDraftKey: string | null | undefined; spoileredAttachmentUrls?: ReadonlySet; @@ -45,6 +51,21 @@ export type SendMessageWithMentionFlowInput = { audienceRevision?: number | null; }; +export async function resolvePreviewTags( + draft: Pick, + mediaTags: string[][] | undefined, + outgoingTags: string[][] | undefined, +): Promise { + const result = await draft.preparedLinkPreviews?.promise; + if (result?.status === "cancelled") return null; + return ( + mergeOutgoingTags(mediaTags, [ + ...(outgoingTags ?? []), + ...(result?.tags ?? []), + ]) ?? [] + ); +} + export function mergeOutgoingTagsWithReferenceMentions( outgoingTags: string[][] | undefined, pubkeys: Iterable, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 369974d412..e322f91987 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -27,11 +27,11 @@ import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmoj import { buildOutgoingMessage, type ImetaMedia, - mergeOutgoingTags, } from "@/features/messages/lib/imetaMediaMarkdown"; import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; +import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; import { invokeTauri } from "@/shared/api/tauri"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; @@ -45,6 +45,7 @@ import { mergeOutgoingTagsWithReferenceMentions, type PendingNonMemberMentionSend, type SendMessageWithMentionFlowInput, + resolvePreviewTags, uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; type UseMentionSendFlowOptions = { @@ -56,9 +57,7 @@ type UseMentionSendFlowOptions = { drafts: Pick; emojiAutocomplete: Pick; mentions: UseMentionsResult; - onPrepareSendChannel?: ( - additionalParticipantPubkeys?: string[], - ) => Promise; + onPrepareSendChannel?: (pubkeys?: string[]) => Promise; onSendRef: React.MutableRefObject< ( content: string, @@ -69,6 +68,7 @@ type UseMentionSendFlowOptions = { parentEventId: string | null; threadHeadId: string | null; } | null, + forceRest?: boolean, ) => Promise >; richText: Pick< @@ -125,9 +125,8 @@ export function useMentionSendFlow({ const isMentionSendPendingRef = React.useRef(false); const isCompleteSendPendingRef = React.useRef(false); const isMountedRef = React.useRef(false); + const activePreparedLinkPreviews = useActivePreparedLinkPreviews(); const previousChannelIdRef = React.useRef(channelId); - // Tracks the live channel so completeSend can ask "is the user still here?" - // without being frozen to the compose-time closure. const channelIdRef = React.useRef(channelId); channelIdRef.current = channelId; React.useEffect(() => { @@ -375,6 +374,10 @@ export function useMentionSendFlow({ return; } + const sendSignal = draft.preparedLinkPreviews?.signal; + const isSendCancelled = () => sendSignal?.aborted === true; + if (isSendCancelled()) return draft.preparedLinkPreviews?.release(); + isCompleteSendPendingRef.current = true; setIsCompleteSendPending(true); const preparedUpload = @@ -382,7 +385,7 @@ export function useMentionSendFlow({ ? prepareBackgroundMediaUpload(draft.queuedAttachments) : null; const persistPreflightDraft = () => { - if (!draft.recoveryDraftKey) return; + if (isSendCancelled() || !draft.recoveryDraftKey) return; drafts.persistDraft( draft.recoveryDraftKey, draft.savedContent, @@ -401,6 +404,7 @@ export function useMentionSendFlow({ const admittedMentionPubkeys = uniqueNormalizedPubkeys( await mentions.revalidateMentionPubkeys(mentionPubkeys), ); + if (isSendCancelled()) return; if (!isMountedRef.current) return persistPreflightDraft(); const admittedMentionPubkeySet = new Set(admittedMentionPubkeys); const readyAgentPubkeys = new Set( @@ -409,6 +413,7 @@ export function useMentionSendFlow({ ), ); const managedAgentsByPubkey = await getManagedAgentsByPubkey(); + if (isSendCancelled()) return; if (!isMountedRef.current) { persistPreflightDraft(); return; @@ -431,6 +436,7 @@ export function useMentionSendFlow({ let sendChannelId = draft.capturedChannelId; if (preparedAgentPubkeys.length > 0 && onPrepareSendChannel) { sendChannelId = await onPrepareSendChannel(preparedAgentPubkeys); + if (isSendCancelled()) return; if (!sendChannelId) { return; } @@ -448,6 +454,7 @@ export function useMentionSendFlow({ onPrepareSendChannel ? preparedAgentPubkeys : [], [...managedAgentsByPubkey.values()], ); + if (isSendCancelled()) return; if (!isMountedRef.current) { persistPreflightDraft(); return; @@ -469,7 +476,9 @@ export function useMentionSendFlow({ channelId: sendChannelId, agentPubkeys: preparedAgentPubkeys, }); + if (isSendCancelled()) return; } catch (error) { + if (isSendCancelled()) return; const message = `Could not add mentioned agent to the Huddle: ${getErrorMessage( error, "Huddle enrollment failed.", @@ -486,7 +495,7 @@ export function useMentionSendFlow({ ); const send = onSendRef.current; const persistCanceledDraft = () => { - if (!draft.recoveryDraftKey) return; + if (isSendCancelled() || !draft.recoveryDraftKey) return; const existing = drafts.loadDraft(draft.recoveryDraftKey); if ( existing && @@ -510,6 +519,7 @@ export function useMentionSendFlow({ ); }; const restoreComposerAfterFailure = () => { + if (isSendCancelled()) return; persistCanceledDraft(); const canRestoreCurrentComposer = isMountedRef.current && @@ -552,14 +562,16 @@ export function useMentionSendFlow({ ), ]), ); - const finalOutgoingTags = mergeOutgoingTags( + const finalOutgoingTags = await resolvePreviewTags( + draft, mediaTags, - outgoingTags ?? [], + outgoingTags, ); - if (signal?.aborted) return; + if (!finalOutgoingTags || signal?.aborted || isSendCancelled()) + return; const revalidatedMentionPubkeys = await mentions.revalidateMentionPubkeys(mentionPubkeys); - if (signal?.aborted) return; + if (signal?.aborted || isSendCancelled()) return; const revalidatedExplicitAgentPubkeys = filterEffectiveExplicitAgentPubkeys( draft.explicitAgentPubkeys, @@ -571,8 +583,9 @@ export function useMentionSendFlow({ finalOutgoingTags, sendChannelId, draft.capturedThreadContext, + draft.preparedLinkPreviews != null, ); - if (signal?.aborted) return; + if (signal?.aborted || isSendCancelled()) return; if (revalidatedExplicitAgentPubkeys.length > 0) { onSuccessfulExplicitAgentAudience?.({ channelId: sendChannelId ?? draft.capturedChannelId ?? "", @@ -614,9 +627,6 @@ export function useMentionSendFlow({ return; } } - // Replace the sent body directly with its final post-send state before - // the async network send starts. This avoids an intermediate blank frame - // for persistent audiences while preserving the ordinary empty state. if ( draft.capturedChannelId === channelIdRef.current || channelIdRef.current === null @@ -634,6 +644,10 @@ export function useMentionSendFlow({ } } } finally { + if (draft.preparedLinkPreviews) { + activePreparedLinkPreviews.delete(draft.preparedLinkPreviews); + } + draft.preparedLinkPreviews?.release(); if (!uploadStarted) preparedUpload?.cancel(); isCompleteSendPendingRef.current = false; if (isMountedRef.current) { @@ -660,51 +674,9 @@ export function useMentionSendFlow({ setSpoileredAttachmentUrls, hasUnsavedMedia, mentions.restoreDraftMentionRefs, + activePreparedLinkPreviews, ], ); - - const getNonMemberMentionPubkeys = React.useCallback( - (pubkeys: string[]) => { - if ( - channelType === null || - channelType === "dm" || - !mentions.hasResolvedMembers - ) { - return []; - } - - return uniqueNormalizedPubkeys(pubkeys).filter( - (pubkey) => !mentions.memberPubkeys.has(pubkey), - ); - }, - [channelType, mentions.hasResolvedMembers, mentions.memberPubkeys], - ); - - const getDmThreadAgentMentionError = React.useCallback( - ( - trimmed: string, - capturedThreadContext: SendMessageWithMentionFlowInput["capturedThreadContext"], - ) => - dmThreadAgentMentionError({ - trimmed, - isThreadReply: capturedThreadContext != null, - channelType, - extractMentionPersonas: mentions.extractMentionPersonas, - extractMentionPubkeys: mentions.extractMentionPubkeys, - isAgentPubkey: mentions.isAgentPubkey, - hasResolvedMembers: mentions.hasResolvedMembers, - memberPubkeys: mentions.memberPubkeys, - }), - [ - channelType, - mentions.extractMentionPersonas, - mentions.extractMentionPubkeys, - mentions.hasResolvedMembers, - mentions.isAgentPubkey, - mentions.memberPubkeys, - ], - ); - const sendMessageWithMentionFlow = React.useCallback( async ({ capturedChannelId, @@ -712,6 +684,7 @@ export function useMentionSendFlow({ pendingImeta, queuedAttachments = [], linkPreviewTags = [], + preparedLinkPreviews = null, sentDraftKey, recoveryDraftKey, spoileredAttachmentUrls = new Set(), @@ -725,20 +698,34 @@ export function useMentionSendFlow({ isMentionSendPendingRef.current = true; setIsMentionSendPending(true); + const isSendCancelled = () => + preparedLinkPreviews?.signal.aborted === true; + let sendPromoted = false; + if (preparedLinkPreviews) { + activePreparedLinkPreviews.add(preparedLinkPreviews); + } try { - const dmThreadAgentMentionError = getDmThreadAgentMentionError( + if (isSendCancelled()) return; + const dmThreadAgentMentionErrorMessage = dmThreadAgentMentionError({ trimmed, - capturedThreadContext, - ); - if (dmThreadAgentMentionError) { - setNonMemberPromptError(dmThreadAgentMentionError); - toast.error(dmThreadAgentMentionError); + isThreadReply: capturedThreadContext != null, + channelType, + extractMentionPersonas: mentions.extractMentionPersonas, + extractMentionPubkeys: mentions.extractMentionPubkeys, + isAgentPubkey: mentions.isAgentPubkey, + hasResolvedMembers: mentions.hasResolvedMembers, + memberPubkeys: mentions.memberPubkeys, + }); + if (dmThreadAgentMentionErrorMessage) { + setNonMemberPromptError(dmThreadAgentMentionErrorMessage); + toast.error(dmThreadAgentMentionErrorMessage); return; } let effectiveChannelId = capturedChannelId; if (!effectiveChannelId && onPrepareSendChannel) { effectiveChannelId = await onPrepareSendChannel(); + if (isSendCancelled()) return; if (!effectiveChannelId) { return; } @@ -748,6 +735,7 @@ export function useMentionSendFlow({ trimmed, effectiveChannelId ?? "", ); + if (isSendCancelled()) return; if (personaMentionResult.errors.length > 0) { const message = personaMentionResult.errors.length === 1 @@ -778,7 +766,14 @@ export function useMentionSendFlow({ ...buildCustomEmojiTags(trimmed, customEmoji), ...linkPreviewTags, ]; - const nonMemberPubkeys = getNonMemberMentionPubkeys(pubkeys); + const nonMemberPubkeys = + channelType === null || + channelType === "dm" || + !mentions.hasResolvedMembers + ? [] + : uniqueNormalizedPubkeys(pubkeys).filter( + (pubkey) => !mentions.memberPubkeys.has(pubkey), + ); let promptNonMemberPubkeys = nonMemberPubkeys.filter( (pubkey) => !mentions.isManagedAgentPubkey(pubkey) && @@ -788,13 +783,11 @@ export function useMentionSendFlow({ if (promptNonMemberPubkeys.length > 0) { try { const managedAgentsByPubkey = await getManagedAgentsByPubkey(); + if (isSendCancelled()) return; promptNonMemberPubkeys = promptNonMemberPubkeys.filter( (pubkey) => !managedAgentsByPubkey.has(normalizePubkey(pubkey)), ); - } catch { - // Keep the hook-based managed-agent filtering even if the query - // fallback misses; ordinary non-members still get prompted. - } + } catch {} } const pendingDraft: PendingNonMemberMentionSend = { @@ -804,6 +797,7 @@ export function useMentionSendFlow({ mentionPubkeys: pubkeys, nonMemberPubkeys: promptNonMemberPubkeys, outgoingTags, + preparedLinkPreviews, preparedManagedAgents: personaMentionResult.agents, readyAgentPubkeys: channelType === "dm" && onPrepareSendChannel @@ -827,8 +821,15 @@ export function useMentionSendFlow({ return; } + sendPromoted = true; await completeSend(pendingDraft, pubkeys); } finally { + if (!sendPromoted) { + if (preparedLinkPreviews) { + activePreparedLinkPreviews.delete(preparedLinkPreviews); + } + preparedLinkPreviews?.release(); + } isMentionSendPendingRef.current = false; setIsMentionSendPending(false); } @@ -839,16 +840,17 @@ export function useMentionSendFlow({ createMentionedPersonaAgents, customEmoji, getManagedAgentsByPubkey, - getNonMemberMentionPubkeys, - getDmThreadAgentMentionError, + mentions.extractMentionPersonas, mentions.extractMentionPubkeys, + mentions.hasResolvedMembers, mentions.isAgentPubkey, mentions.isManagedAgentPubkey, + mentions.memberPubkeys, mentions.getDraftMentionRefs, onPrepareSendChannel, + activePreparedLinkPreviews, ], ); - const pendingNonMemberNames = React.useMemo(() => { if (!pendingNonMemberSend) return []; @@ -969,7 +971,6 @@ export function useMentionSendFlow({ setPendingNonMemberSend(null); setNonMemberPromptError(null); }, []); - return { isPreparingMentionSend: isMentionSendPending || @@ -977,7 +978,6 @@ export function useMentionSendFlow({ attachAgentMutation.isPending || createPersonaAgentMutation.isPending || startAgentMutation.isPending, - /** Spread straight into `NonMemberMentionDialog`. */ nonMemberPromptProps: { canInvite: canInviteNonMembers, error: nonMemberPromptError, diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index daedebde5c..2f0498769f 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -17,6 +17,7 @@ export async function uploadMediaFile( file: File, progressId?: string, signal?: AbortSignal, + onDispatch?: () => void, ): Promise { const headers: Record = { "x-buzz-filename": encodeRawIpcHeader(file.name), @@ -28,7 +29,7 @@ export async function uploadMediaFile( if (signal?.aborted) throw new Error("upload cancelled"); const bytes = new Uint8Array(await file.arrayBuffer()); if (signal?.aborted) throw new Error("upload cancelled"); - + onDispatch?.(); return invokeTauriRaw("upload_media_bytes_raw", bytes, { headers, }); @@ -39,6 +40,11 @@ export async function cancelMediaUpload(progressId: string): Promise { await invokeTauri("cancel_media_upload", { progressId }); } +/** Release the renderer's cancellation ownership after an upload settles. */ +export async function releaseMediaUpload(progressId: string): Promise { + await invokeTauri("release_media_upload", { progressId }); +} + /** * Open a native single-file picker constrained to images and upload the * chosen file. Non-image files are rejected in Rust (via MIME sniffing) diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 8ef93e7d13..ddafd9f459 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -233,6 +233,13 @@ function fetchLinkPreviewMetadata( const metadataLoader = createMetadataLoader({ fetcher: fetchLinkPreviewMetadata, }); + +/** Share the same deduplicated metadata job between composer rendering and send preparation. */ +export async function loadLinkPreviewMetadata( + href: string, +): Promise { + return (await metadataLoader.load(href)).metadata; +} const ENTITY_STATUS_KINDS = [ KIND_GIT_STATUS_OPEN, KIND_GIT_STATUS_MERGED, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 20cc81a288..bc785ac252 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -377,11 +377,15 @@ type E2eConfig = { } | null >; linkPreviewMetadataDelayMs?: number; + /** Hold metadata until the E2E release seam is invoked. */ + deferLinkPreviewMetadata?: boolean; /** Simulates native cold-cache startup work before the async response. */ linkPreviewMetadataStartBlockMs?: number; /** Delays link-preview snapshot media uploads so specs can exercise the * composer's settle-gated disabled state before the snapshot tag is ready. */ linkPreviewUploadDelayMs?: number; + /** Hold link-preview uploads before mock-native cancellation registration. */ + deferLinkPreviewUploadRegistration?: boolean; /** Substrings of `link-preview-*` upload filenames whose `upload_media_bytes` * call should reject, so specs can drive a per-media snapshot upload failure * (e.g. `["link-preview-image"]` fails only the thumbnail, favicon survives). */ @@ -1388,6 +1392,12 @@ declare global { __BUZZ_E2E_RELEASE_CHANNELS_READ__?: () => number; /** Number of channel reads currently held by the seam. */ __BUZZ_E2E_CHANNELS_READ_PENDING__?: number; + /** Release all link-preview metadata commands held by the mock bridge. */ + __BUZZ_E2E_RELEASE_LINK_PREVIEW_METADATA__?: () => number; + /** Release link-preview uploads held before mock-native registration. */ + __BUZZ_E2E_RELEASE_LINK_PREVIEW_UPLOADS__?: () => number; + /** Uploads that passed mock-native registration and began relay work. */ + __BUZZ_E2E_LINK_PREVIEW_UPLOAD_STARTS__?: number; } } @@ -1492,6 +1502,9 @@ type DeferredGetEvent = { run: () => Promise; }; let deferredGetEventQueue: DeferredGetEvent[] = []; +let deferredLinkPreviewMetadataQueue: Array<() => void> = []; +let deferredLinkPreviewUploadQueue: Array<() => void> = []; +let cancelledMediaUploadIds = new Set(); let deferNextChannelsRead = false; let deferredChannelsReadResolve: (() => void) | null = null; @@ -10188,6 +10201,20 @@ export function maybeInstallE2eTauriMocks() { mockChannelHistoryCloses.length = 0; relayWebsocketConnectAttemptStarts.length = 0; deferredSendMessageLiveEchoes.length = 0; + deferredLinkPreviewMetadataQueue = []; + deferredLinkPreviewUploadQueue = []; + cancelledMediaUploadIds = new Set(); + window.__BUZZ_E2E_LINK_PREVIEW_UPLOAD_STARTS__ = 0; + window.__BUZZ_E2E_RELEASE_LINK_PREVIEW_METADATA__ = () => { + const queued = deferredLinkPreviewMetadataQueue.splice(0); + for (const release of queued) release(); + return queued.length; + }; + window.__BUZZ_E2E_RELEASE_LINK_PREVIEW_UPLOADS__ = () => { + const queued = deferredLinkPreviewUploadQueue.splice(0); + for (const release of queued) release(); + return queued.length; + }; mockGlobalAgentConfig = config.mock?.globalAgentConfig ? { ...config.mock.globalAgentConfig } : null; @@ -11373,6 +11400,11 @@ export function maybeInstallE2eTauriMocks() { case "fetch_join_policy": return activeConfig?.mock?.joinPolicy ?? null; case "fetch_link_preview_metadata": { + if (activeConfig?.mock?.deferLinkPreviewMetadata) { + await new Promise((resolve) => { + deferredLinkPreviewMetadataQueue.push(resolve); + }); + } const startBlockMs = activeConfig?.mock?.linkPreviewMetadataStartBlockMs ?? 0; if (startBlockMs > 0) { @@ -12807,11 +12839,39 @@ export function maybeInstallE2eTauriMocks() { return await resolveMockUploadDescriptors(activeConfig); case "pick_and_upload_image": return (await resolveMockUploadDescriptors(activeConfig))[0] ?? null; - case "upload_media_bytes": - return resolveMockUploadDescriptorForBytes( - payload as { data: number[]; filename?: string | null }, - activeConfig, - ); + case "upload_media_bytes": { + const input = payload as { + data: number[]; + filename?: string | null; + progressId?: string | null; + }; + if ( + activeConfig?.mock?.deferLinkPreviewUploadRegistration && + input.filename?.startsWith("link-preview-") + ) { + await new Promise((resolve) => { + deferredLinkPreviewUploadQueue.push(resolve); + }); + } + if (input.progressId && cancelledMediaUploadIds.has(input.progressId)) { + throw new Error("upload cancelled"); + } + if (input.filename?.startsWith("link-preview-")) { + window.__BUZZ_E2E_LINK_PREVIEW_UPLOAD_STARTS__ = + (window.__BUZZ_E2E_LINK_PREVIEW_UPLOAD_STARTS__ ?? 0) + 1; + } + return resolveMockUploadDescriptorForBytes(input, activeConfig); + } + case "cancel_media_upload": { + const progressId = (payload as { progressId?: string }).progressId; + if (progressId) cancelledMediaUploadIds.add(progressId); + return null; + } + case "release_media_upload": { + const progressId = (payload as { progressId?: string }).progressId; + if (progressId) cancelledMediaUploadIds.delete(progressId); + return null; + } case "upload_media_bytes_raw": return resolveMockUploadDescriptorForBytes( { diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 4e6c737190..3f6d38602a 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -378,6 +378,194 @@ test.describe("community rail", () => { .toBe(COMMUNITY_B.id); }); + test("community switch cancels a send after its link preview settles", async ({ + page, + }) => { + const agentPubkey = + "ee00000000000000000000000000000000000000000000000000000000000001"; + await installMockBridge( + page, + { + addChannelMembersDelayMs: 10_000, + managedAgents: [ + { + pubkey: agentPubkey, + name: "SlowBot", + status: "running", + }, + ], + linkPreviewMetadata: { + title: "Ready preview", + siteName: "GitHub", + description: "Must not cross community boundaries.", + imageDataUrl: null, + imageDomain: null, + }, + }, + { skipCommunitySeed: true }, + ); + await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + const input = page.getByTestId("message-input"); + const previewUrl = + "https://github.com/block/buzz/pull/5697?community=reset"; + await input.fill("@SlowBot"); + await expect(page.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Enter"); + await page.keyboard.type(` ${previewUrl}`); + await page.getByTestId("send-message").click(); + await expect + .poll(() => + page.evaluate( + () => + (window.__BUZZ_E2E_COMMANDS__ ?? []).filter( + (command) => command === "add_channel_members", + ).length, + ), + ) + .toBeGreaterThan(0); + + await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click(); + await expect + .poll(() => + page.evaluate(() => + window.localStorage.getItem("buzz-active-community-id"), + ), + ) + .toBe(COMMUNITY_B.id); + await page.waitForTimeout(250); + + const publications = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter( + (entry) => entry.command === "send_channel_message", + ), + ); + expect(publications).toHaveLength(0); + await expect(page.getByTestId("message-input")).toHaveText(""); + }); + + test("community switch stops preview media before it reaches the new community", async ({ + page, + }) => { + await installMockBridge( + page, + { + deferLinkPreviewMetadata: true, + linkPreviewMetadata: { + title: "Old community preview", + siteName: "GitHub", + description: "Must not upload after reset.", + imageDataUrl: "data:image/png;base64,iVBORw0KGgo=", + imageDomain: "github.com", + faviconDataUrl: "data:image/png;base64,iVBORw0KGgo=", + }, + }, + { skipCommunitySeed: true }, + ); + await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + const input = page.getByTestId("message-input"); + await input.fill("https://github.com/block/buzz/pull/5697?media=reset"); + await page.getByTestId("send-message").click(); + await expect + .poll(() => + page.evaluate( + () => + (window.__BUZZ_E2E_COMMANDS__ ?? []).filter( + (command) => command === "fetch_link_preview_metadata", + ).length, + ), + ) + .toBeGreaterThan(0); + + await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click(); + await expect + .poll(() => + page.evaluate(() => + window.localStorage.getItem("buzz-active-community-id"), + ), + ) + .toBe(COMMUNITY_B.id); + expect( + await page.evaluate( + () => window.__BUZZ_E2E_RELEASE_LINK_PREVIEW_METADATA__?.() ?? 0, + ), + ).toBeGreaterThan(0); + await page.waitForTimeout(250); + + const uploadCalls = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter( + (entry) => entry.command === "upload_media_bytes", + ), + ); + expect(uploadCalls).toHaveLength(0); + }); + + test("community switch cancellation wins before native upload registration", async ({ + page, + }) => { + await installMockBridge( + page, + { + deferLinkPreviewUploadRegistration: true, + linkPreviewMetadata: { + title: "Old community preview", + siteName: "GitHub", + description: "Cancellation must survive native registration.", + imageDataUrl: "data:image/png;base64,iVBORw0KGgo=", + imageDomain: "github.com", + faviconDataUrl: "data:image/png;base64,iVBORw0KGgo=", + }, + }, + { skipCommunitySeed: true }, + ); + await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + const input = page.getByTestId("message-input"); + await input.fill("https://github.com/block/buzz/pull/5697?native=reset"); + await page.getByTestId("send-message").click(); + await expect + .poll(() => + page.evaluate( + () => + (window.__BUZZ_E2E_COMMANDS__ ?? []).filter( + (command) => command === "upload_media_bytes", + ).length, + ), + ) + .toBe(2); + + await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click(); + await expect + .poll(() => + page.evaluate( + () => + (window.__BUZZ_E2E_COMMANDS__ ?? []).filter( + (command) => command === "cancel_media_upload", + ).length, + ), + ) + .toBe(2); + expect( + await page.evaluate( + () => window.__BUZZ_E2E_RELEASE_LINK_PREVIEW_UPLOADS__?.() ?? 0, + ), + ).toBe(2); + await page.waitForTimeout(250); + + expect( + await page.evaluate( + () => window.__BUZZ_E2E_LINK_PREVIEW_UPLOAD_STARTS__ ?? 0, + ), + ).toBe(0); + }); + test("restores the last Home or channel destination per community", async ({ page, }) => { diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 6e003195b3..f93ce0450b 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -252,6 +252,16 @@ test.beforeEach(async ({ page }, testInfo) => { } : testInfo.title.includes( "Enter during an in-flight snapshot upload", + ) || + testInfo.title.includes("Skip wins the upload race") || + testInfo.title.includes( + "async metadata beyond old cutoff", + ) || + testInfo.title.includes( + "async upload beyond metadata budget", + ) || + testInfo.title.includes( + "immediately pressing Enter prepares", ) ? { linkPreviewMetadata: { @@ -262,11 +272,22 @@ test.beforeEach(async ({ page }, testInfo) => { "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", imageDomain: "opengraph.githubassets.com", }, - linkPreviewMetadataDelayMs: 300, - linkPreviewUploadDelayMs: 1_200, + linkPreviewMetadataDelayMs: testInfo.title.includes( + "async metadata beyond old cutoff", + ) + ? 4_000 + : 300, + linkPreviewUploadDelayMs: testInfo.title.includes( + "async upload beyond metadata budget", + ) + ? 4_000 + : 1_200, } : testInfo.title.includes( "snapshot thumbnail upload failure", + ) || + testInfo.title.includes( + "snapshot media upload failure", ) ? { linkPreviewMetadata: { @@ -320,9 +341,12 @@ test.beforeEach(async ({ page }, testInfo) => { ) ? 10_000 : testInfo.title.includes( - "send does not wait", + "explicit cancellation suppresses a pending", + ) || + testInfo.title.includes( + "settled-empty promoted link preview", ) - ? 3_000 + ? 10_000 : testInfo.title.includes( "draft auto-send", ) @@ -345,7 +369,12 @@ test.beforeEach(async ({ page }, testInfo) => { : undefined; const mock = testInfo.title.includes("unresolvable preview") ? { linkPreviewMetadata: null, linkPreviewMetadataDelayMs: 800 } - : baseMock; + : { + ...baseMock, + ...(testInfo.title.includes("clears Sending after") + ? { sendMessageDelayMs: 800 } + : {}), + }; await installMockBridge(page, mock); }); @@ -712,11 +741,9 @@ for (const [pasteShape, wrapUrl] of [ .locator("[data-composer-link-previews]") .locator('[data-link-preview="github-pull-request"]'); await expect(input).toContainText(previewUrl, { timeout: 1_000 }); - await expect(composerPreview).toHaveAttribute( - "data-state", - /^(processing|done)$/, - { timeout: 1_000 }, - ); + await expect( + composerPreview.locator('[data-slot="attachment"]'), + ).toHaveAttribute("data-state", /^(processing|done)$/, { timeout: 1_000 }); await expect(page.getByTestId("send-message")).toBeEnabled(); }); } @@ -864,7 +891,7 @@ test("unresolvable preview disappears after the terminal miss", async ({ await expect(row.locator("[data-link-preview]")).toHaveCount(0); }); -test("send does not wait for a pending link preview snapshot", async ({ +test("explicit cancellation suppresses a pending link preview and sends without it", async ({ page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246?send=pending"; @@ -873,29 +900,56 @@ test("send does not wait for a pending link preview snapshot", async ({ await page.getByTestId("message-input").fill(previewUrl); const composerPreviews = page.locator("[data-composer-link-previews]"); + const card = composerPreviews.locator( + '[data-link-preview="github-pull-request"]', + ); + const send = page.getByTestId("send-message"); + const cancel = card.getByTestId("composer-hide-link-previews"); + const progress = card.getByTestId("link-preview-progress"); + const textPlaceholder = card.getByTestId("link-preview-text-placeholder"); await expect(composerPreviews).toHaveAttribute( "data-ready-snapshot-count", "0", ); - await expect( - composerPreviews.locator('[data-link-preview="github-pull-request"]'), - ).toHaveAttribute("data-image-state", "pending"); - - // While metadata is still resolving Send is disabled so the button does not - // flicker ready -> not-ready. But a link whose metadata stalls must not trap - // the composer: past the disable cap Send re-enables even though the card is - // still pending, and sending ships a bare link with no snapshot tag. - await expect(page.getByTestId("send-message")).toBeDisabled(); + await expect(card).toHaveAttribute("data-image-state", "pending"); + await expect(cancel).toHaveAttribute( + "aria-label", + "Send without link previews", + ); + await expect(cancel).toHaveAttribute("title", "Send without link previews"); + await expect(progress).toBeVisible(); + await expect(progress).not.toHaveAttribute("aria-valuenow"); + await expect(textPlaceholder).toBeVisible(); + await expect(card.locator("[data-link-preview-hostname]")).toHaveCount(0); + await expect(card.getByText("github.com", { exact: true })).toHaveCount(0); + await expect(cancel).toHaveCSS("opacity", "0"); + await card.hover(); + await expect(cancel).toHaveCSS("opacity", "1"); + await expect(card).toHaveCSS("overflow", "visible"); + await expect(card.locator('[data-slot="attachment"]')).toHaveCSS( + "overflow", + "hidden", + ); + + // The mock metadata takes ten seconds. Submit is intentionally available: + // the pending work is promoted into the floating background preparation UI. + await expect(send).toBeEnabled(); + await page.waitForTimeout(2_200); await expect(composerPreviews).toHaveAttribute( "data-has-pending-snapshots", - "false", + "true", ); - await expect( - composerPreviews.locator('[data-link-preview="github-pull-request"]'), - ).toHaveAttribute("data-image-state", "pending"); - await expect(page.getByTestId("send-message")).toBeEnabled(); + await expect(card).toHaveAttribute("data-image-state", "pending"); + await expect(send).toBeEnabled(); + + // The explicit escape suppresses all previews, preserves the draft link, and + // makes the durable no-preview intent sendable immediately. + await cancel.click(); + await expect(composerPreviews).toHaveCount(0); + await expect(page.getByTestId("message-input")).toContainText(previewUrl); + await expect(send).toBeEnabled(); + await send.click(); - await page.getByTestId("send-message").click(); const row = page.getByTestId("message-row").last(); await expect(row).toContainText(previewUrl); await expect(row.locator("[data-link-preview]")).toHaveCount(0); @@ -908,10 +962,10 @@ test("send does not wait for a pending link preview snapshot", async ({ call?.payload as { linkPreviewTags?: string[][] | null } | undefined )?.linkPreviewTags; }); - expect(linkPreviewTags ?? []).toEqual([]); + expect(linkPreviewTags).toEqual([["link-preview", "none"]]); }); -test("Enter during an in-flight snapshot upload cannot ship a bare link", async ({ +test("Enter during an in-flight snapshot upload hands off and sends once", async ({ page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246"; @@ -920,27 +974,16 @@ test("Enter during an in-flight snapshot upload cannot ship a bare link", async const input = page.getByTestId("message-input"); await input.fill(previewUrl); - const composerPreviews = page.locator("[data-composer-link-previews]"); - const card = composerPreviews.locator("[data-link-preview-composer-card]"); - await expect(card).toBeVisible(); - // Metadata resolves (image painted) but the sendable tag is not ready yet: - // the snapshot media upload is still in flight (linkPreviewUploadDelayMs), so - // the composer reports the preview as still pending. + const card = page.locator("[data-link-preview-composer-card]"); await expect(card).toHaveAttribute("data-image-state", "image"); await expect(card).toHaveAttribute("data-snapshot-tag-ready", "false"); - await expect(composerPreviews).toHaveAttribute( - "data-has-pending-snapshots", - "true", - ); - // Drive Enter (not a disabled-button click, which the browser swallows on its - // own) while the upload is deterministically in flight. The synchronous submit - // guard must reject it: no send_channel_message call may occur before the tag - // is ready, or the link would ship bare. This is the core Enter-bypass fix — - // the disabled state is enforced on the keyboard path, not just the button. - await expect(input).toBeFocused(); - await input.press("Enter"); await input.press("Enter"); + await expect(input).toHaveText(""); + const progress = page.getByTestId("composer-upload-progress"); + await expect(progress).toHaveAccessibleName("Preparing link preview"); + await expect(page.getByTestId("composer-upload-cancel")).toHaveText("Skip"); + const sendsDuringUpload = await page.evaluate( () => (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter( @@ -949,37 +992,171 @@ test("Enter during an in-flight snapshot upload cannot ship a bare link", async ); expect(sendsDuringUpload).toBe(0); - // Once the upload settles the tag is captured and Send re-enables. Sending - // now lands the preview snapshot matching the body. - await expect(card).toHaveAttribute("data-snapshot-tag-ready", "true"); - await expect(page.getByTestId("send-message")).toBeEnabled(); - await input.press("Enter"); const row = page.getByTestId("message-row").last(); await expect(row).toContainText(previewUrl); await expect(row.locator("[data-link-preview]")).toBeVisible(); + await expect(progress).toHaveCount(0); + const sends = await page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter( + (entry) => entry.command === "send_channel_message", + ).length, + ); + expect(sends).toBe(1); +}); - const linkPreviewTags = await page.evaluate(() => { +test("async metadata beyond old cutoff still produces preview image", async ({ + page, +}) => { + const previewUrl = "https://github.com/block/buzz/pull/3246?slow=metadata"; + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const input = page.getByTestId("message-input"); + await input.fill(previewUrl); + await input.press("Enter"); + + const progress = page.getByTestId("composer-upload-progress"); + await expect(progress).toHaveAccessibleName("Preparing link preview"); + await page.waitForTimeout(3_200); + await expect(progress).toBeVisible(); + + const row = page.getByTestId("message-row").last(); + await expect(row).toContainText(previewUrl); + await expect(row.locator("[data-link-preview]")).toHaveAttribute( + "data-image-state", + "image", + ); +}); + +test("async upload beyond metadata budget retains preview image", async ({ + page, +}) => { + const previewUrl = "https://github.com/block/buzz/pull/3246?slow=image"; + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const input = page.getByTestId("message-input"); + await input.fill(previewUrl); + await input.press("Enter"); + + const progress = page.getByTestId("composer-upload-progress"); + await expect(progress).toHaveAccessibleName("Preparing link preview"); + await page.waitForTimeout(3_200); + await expect(progress).toBeVisible(); + + const row = page.getByTestId("message-row").last(); + await expect(row).toContainText(previewUrl); + await expect(row.locator("[data-link-preview]")).toHaveAttribute( + "data-image-state", + "image", + ); + const tags = await page.evaluate(() => { const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])] .reverse() .find((entry) => entry.command === "send_channel_message"); - return ( - call?.payload as { linkPreviewTags?: string[][] | null } | undefined - )?.linkPreviewTags; + return (call?.payload as { linkPreviewTags?: string[][] }).linkPreviewTags; }); - expect(linkPreviewTags?.map((tag) => tag[3])).toEqual([previewUrl]); + expect(tags?.[0]?.[7]).toContain("/media/"); + expect(tags?.[0]?.[8]).not.toBe(""); +}); + +test("Skip wins the upload race and sends without preview", async ({ + page, +}) => { + const previewUrl = "https://github.com/block/buzz/pull/3246?skip=1"; + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const input = page.getByTestId("message-input"); + await input.fill(previewUrl); + await expect( + page.locator("[data-link-preview-composer-card]"), + ).toHaveAttribute("data-snapshot-tag-ready", "false"); + + await input.press("Enter"); + const skip = page.getByTestId("composer-upload-cancel"); + await expect(skip).toHaveText("Skip"); + await skip.click(); + + const row = page.getByTestId("message-row").last(); + await expect(row).toContainText(previewUrl); + await expect(row.locator("[data-link-preview]")).toHaveCount(0); + await page.waitForTimeout(1_500); + const calls = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter( + (entry) => entry.command === "send_channel_message", + ), + ); + expect(calls).toHaveLength(1); + expect( + (calls[0]?.payload as { linkPreviewTags?: string[][] }).linkPreviewTags, + ).toEqual([]); +}); + +test("promoted link preview send clears Sending after REST publication", async ({ + page, +}) => { + const previewUrl = "https://github.com/block/buzz/pull/3246?pending=preview"; + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const input = page.getByTestId("message-input"); + await input.fill(previewUrl); + await input.press("Enter"); + + const row = page.getByTestId("message-row").last(); + await expect(row).toContainText(previewUrl); + await expect(row).toContainText("Sending…"); + await expect(row.locator("[data-link-preview]")).toBeVisible(); + await expect(row).not.toContainText("Sending…"); + + const restCalls = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter( + (entry) => entry.command === "send_channel_message", + ), + ); + expect(restCalls).toHaveLength(1); + expect( + (restCalls[0]?.payload as { linkPreviewTags?: string[][] }).linkPreviewTags, + ).toHaveLength(1); +}); + +test("settled-empty promoted link preview send uses REST and clears Sending after Skip", async ({ + page, +}) => { + const previewUrl = "https://github.com/block/buzz/pull/3246?pending=empty"; + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const input = page.getByTestId("message-input"); + await input.fill(previewUrl); + await input.press("Enter"); + await page.getByTestId("composer-upload-cancel").click(); + + const row = page.getByTestId("message-row").last(); + await expect(row).toContainText(previewUrl); + await expect(row).toContainText("Sending…"); + await expect(row).not.toContainText("Sending…"); + await expect(row.locator("[data-link-preview]")).toHaveCount(0); + + const result = await page.evaluate(() => ({ + restCalls: (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter( + (entry) => entry.command === "send_channel_message", + ), + websocketSends: (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []).filter( + (event) => event.kind === 9, + ), + })); + expect(result.restCalls).toHaveLength(1); + expect( + (result.restCalls[0]?.payload as { linkPreviewTags?: string[][] }) + .linkPreviewTags, + ).toEqual([]); + expect(result.websocketSends).toHaveLength(0); }); -test("draft auto-send with a link preview waits for settling and sends exactly once", async ({ +test("draft auto-send promotes link preview preparation and sends exactly once", async ({ page, }) => { - // Regression for the one-shot auto-submit blocker: a confirmed Drafts-panel - // "Send message" for a draft containing a supported link is normally still - // inside the preview settling window when the mount-only auto-submit effect - // fires. The old effect cleared the ?autoSend trigger then fired submit once - // at setTimeout(0); submit bailed at the pending-snapshot guard and the - // one-shot never retried, so the confirmed draft was silently never sent. - // The effect must instead wait until settling finishes, then send exactly - // once — with the resolved snapshot tag attached. + // A confirmed Drafts-panel send must fire once immediately, promote preview + // preparation into the background flow, and eventually publish one enriched + // event rather than consuming the one-shot trigger while the hook debounces. const previewUrl = "https://github.com/block/buzz/pull/3246?draft=autosend"; const channelId = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; @@ -1034,9 +1211,7 @@ test("draft auto-send with a link preview waits for settling and sends exactly o await expect(dialog).toBeVisible({ timeout: 4_000 }); await dialog.getByRole("button", { name: "Send", exact: true }).click(); - // Exactly one send eventually fires (after the ~500 ms metadata settle), and - // it carries the link preview snapshot tag — proving the draft was not - // dropped during the settling window and did not double-send on retry. + // Exactly one publish eventually fires and carries the promoted snapshot. await expect .poll(async () => page.evaluate( @@ -1096,7 +1271,7 @@ test("rapid Enter presses on a ready link preview send exactly once", async ({ .toBe(1); }); -test("pasting a link preview and immediately pressing Enter waits for resolution", async ({ +test("pasting a link and immediately pressing Enter prepares it after submit", async ({ page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246?fast=send"; @@ -1104,39 +1279,23 @@ test("pasting a link preview and immediately pressing Enter waits for resolution await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); - // Fill the URL and press Enter within the debounce window, before resolution - // has even started. The live-candidate guard must treat the unresolved link - // as pending and reject the Enter, so the message cannot ship bare. await input.fill(previewUrl); await input.press("Enter"); - const sendsBeforeResolution = await page.evaluate( - () => - (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter( - (entry) => entry.command === "send_channel_message", - ).length, - ); - expect(sendsBeforeResolution).toBe(0); + await expect(input).toHaveText(""); + await expect(page.getByTestId("composer-upload-cancel")).toHaveText("Skip"); - // The debounce fires, resolution + upload complete, and only then does Send - // become available. A press now lands the snapshot. - await waitForReadyComposerSnapshots(page); - await input.press("Enter"); const row = page.getByTestId("message-row").last(); await expect(row).toContainText(previewUrl); await expect(row.locator("[data-link-preview]")).toBeVisible(); - - const linkPreviewTags = await page.evaluate(() => { - const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])] - .reverse() - .find((entry) => entry.command === "send_channel_message"); - return ( - call?.payload as { linkPreviewTags?: string[][] | null } | undefined - )?.linkPreviewTags; - }); - expect(linkPreviewTags?.map((tag) => tag[3])).toEqual([previewUrl]); + const calls = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter( + (entry) => entry.command === "send_channel_message", + ), + ); + expect(calls).toHaveLength(1); }); -test("a snapshot thumbnail upload failure toasts and still sends with the favicon", async ({ +test("a snapshot media upload failure preserves a metadata-only preview", async ({ page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246?upload=fail"; @@ -1144,41 +1303,28 @@ test("a snapshot thumbnail upload failure toasts and still sends with the favico await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); await input.fill(previewUrl); - - // The thumbnail upload is configured to reject while the favicon succeeds. - // The preview must degrade to the surviving favicon rather than dropping the - // whole card or spinning forever: a tag still lands, Send still enables. - await waitForReadyComposerSnapshots(page); - await expect( - page - .locator("[data-sonner-toast]") - .filter({ hasText: "Something went wrong with the thumbnail" }), - ).toBeVisible(); - await expect(page.getByTestId("send-message")).toBeEnabled(); - await input.press("Enter"); + const row = page.getByTestId("message-row").last(); await expect(row).toContainText(previewUrl); await expect(row.locator("[data-link-preview]")).toBeVisible(); - - // The snapshot tag exists (survivor media) but carries no image url — proving - // the graceful per-media degrade rather than a dropped or all-or-nothing tag. - const imageUrl = await page.evaluate(() => { + const tags = await page.evaluate(() => { const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])] .reverse() .find((entry) => entry.command === "send_channel_message"); - const tags = ( - call?.payload as { linkPreviewTags?: string[][] | null } | undefined - )?.linkPreviewTags; - const snapshot = tags?.find( - (tag) => tag[0] === "link-preview" && tag[1] === "snapshot", - ); - // Snapshot tag layout: ["link-preview","snapshot",,,...pairs]. - const pairs = snapshot?.slice(4) ?? []; - const imageIndex = pairs.indexOf("image"); - return imageIndex >= 0 ? pairs[imageIndex + 1] : null; + return (call?.payload as { linkPreviewTags?: string[][] }).linkPreviewTags; }); - expect(imageUrl).toBeFalsy(); + expect(tags).toHaveLength(1); + expect(tags?.[0]?.slice(0, 7)).toEqual([ + "link-preview", + "snapshot", + "1", + previewUrl, + "Buzz pull request", + "GitHub", + "A sender-authored preview snapshot.", + ]); + expect(tags?.[0]?.slice(7)).toEqual(["", "", "", ""]); }); test("editing a message excludes link previews entirely", async ({ page }) => { @@ -1289,11 +1435,10 @@ test("composer link preview embeds stay attachment-sized while loading and ready }); } - await expect - .poll(() => - card.evaluate((element) => element.getAttribute("data-state")), - ) - .toBe("done"); + await expect(card.locator('[data-slot="attachment"]')).toHaveAttribute( + "data-state", + "done", + ); const ready = await card.evaluate((element) => ({ height: element.getBoundingClientRect().height, width: element.getBoundingClientRect().width, @@ -1346,10 +1491,7 @@ test("compact link preview image geometry truncates long titles to one line", as const title = card.locator('[data-slot="attachment-title"]'); const image = thumbnail.locator("img"); await expect(card).toHaveAttribute("data-image-state", "image"); - await expect(image).toBeVisible(); - await expect - .poll(() => image.evaluate((element) => element.naturalWidth)) - .toBeGreaterThan(0); + await expect(image).toHaveJSProperty("complete", true); await expect(card).toHaveCSS("height", "64px"); await expect(thumbnail).toHaveCSS("height", "64px"); await expect(thumbnail).toHaveCSS("width", "104px"); From 757779bb1ef22cc4a1c233344baa0946d907e5a6 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 14 Aug 2026 13:49:25 -0600 Subject: [PATCH 20/33] perf(desktop): update active turns incrementally (#5897) ## Problem Every observer-store publication made the active-turn bridge scan every running/deployed agent and replay each agent's retained observer journal. Watermarks kept the replay idempotent, but did not remove the repeated work. Under an active fleet, one changed agent therefore caused work proportional to the whole fleet and its retained history. ## Change - observer publications now identify the changed agent and only the newly admitted, retained events - the active-turn bridge still performs one full hydration when its agent list mounts or changes - steady-state publications process only that changed active agent's delta - other observer-store subscribers keep their existing notification behavior - duplicate-only envelopes still do not publish ## Correctness Regression coverage pins: - retained/duplicate history is omitted from deltas - stopped-agent updates do not enter active-turn state - an incremental terminal clears a turn hydrated from retained history - batching still publishes once and preserves transcript/terminal outcomes - existing watermark, tombstone, pruning, community restore, clear, and eviction suites remain green ## Validation Exact pushed head: `a480ffd2531023ea32b2a5518b5d9d41f04577c8` - focused active-turn + observer-retention suites: 90 passed - full desktop suite: 4,891 passed - `pnpm --dir desktop typecheck`: passed - `pnpm --dir desktop check`: passed (pre-existing repository warnings only) - mandatory pre-push hook at the exact pushed head: passed `branch-skew`, desktop check/typecheck/test, mobile tests, Rust tests, and Desktop Tauri checks Packaged same-fleet CPU/RSS validation is follow-up evidence; this PR proves the algorithmic amplification is removed without claiming an installed-app percentage from unit tests. Signed-off-by: Wes Co-authored-by: Carl --- .../agents/activeAgentTurnsStore.test.mjs | 77 +++++++++++++++++++ .../features/agents/activeAgentTurnsStore.ts | 38 +++++++-- .../src/features/agents/observerRelayStore.ts | 59 +++++++++----- 3 files changed, 148 insertions(+), 26 deletions(-) diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index 44e43525b6..2658469b91 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -12,6 +12,7 @@ import { restoreActiveAgentTurnsForCommunity, clearSavedCommunitySnapshot, clearActiveTurnsForAgent, + createActiveAgentTurnsObserverListener, } from "./activeAgentTurnsStore.ts"; import { injectObserverEventsForE2E, @@ -1625,6 +1626,82 @@ describe("observer → active-turns bridge sync", () => { ); }); + it("publishes only newly admitted events for the changed agent", () => { + const updates = []; + const unsubscribeObserver = subscribeAgentObserverStore((update) => { + updates.push(update); + }); + const retained = makeEvent({ seq: 1, kind: "turn_started" }); + const admitted = makeEvent({ + seq: 2, + kind: "acp_write", + timestamp: "2024-01-01T00:00:01Z", + }); + + injectObserverEventsForE2E(AGENT, [retained]); + injectObserverEventsForE2E(AGENT, [retained, admitted]); + unsubscribeObserver(); + + assert.equal(updates.length, 2); + assert.equal(updates[1].agentPubkey, AGENT); + assert.deepEqual( + updates[1].events.map((event) => event.seq), + [2], + "the publication must omit retained and duplicate history", + ); + }); + + it("steady-state listener processes the changed active agent only", () => { + const listener = createActiveAgentTurnsObserverListener([ + { pubkey: AGENT, status: "deployed" }, + { pubkey: AGENT_2, status: "stopped" }, + ]); + + listener({ + agentPubkey: AGENT, + events: [makeEvent({ seq: 1, turnId: "active-turn" })], + }); + listener({ + agentPubkey: AGENT_2, + events: [ + makeEvent({ + seq: 1, + turnId: "stopped-turn", + channelId: "stopped-channel", + }), + ], + }); + + assert.equal(getActiveTurnsForAgent(AGENT).length, 1); + assert.equal( + getActiveTurnsForAgent(AGENT_2).length, + 0, + "an unrelated stopped agent update must not enter turn state", + ); + }); + + it("incremental terminal update clears a hydrated turn without replay", () => { + injectObserverEventsForE2E(AGENT, [ + makeEvent({ seq: 1, kind: "turn_started" }), + ]); + syncActiveAgentTurnsFromObserver(bridgeAgents); + assert.equal(getActiveTurnsForAgent(AGENT).length, 1); + + const listener = createActiveAgentTurnsObserverListener(bridgeAgents); + listener({ + agentPubkey: AGENT, + events: [ + makeEvent({ + seq: 2, + kind: "turn_completed", + timestamp: "2024-01-01T00:00:05Z", + }), + ], + }); + + assert.equal(getActiveTurnsForAgent(AGENT).length, 0); + }); + it("publishes one observer update for a batch while preserving outcomes", () => { let observerNotifications = 0; const unsubscribeObserver = subscribeAgentObserverStore(() => { diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 40af42c794..ebafb3f197 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -4,6 +4,7 @@ import { subscribeAgentObserverStore, getAgentObserverSnapshot, compareObserverEvents, + type AgentObserverStoreUpdate, } from "@/features/agents/observerRelayStore"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { @@ -631,19 +632,40 @@ export function syncActiveAgentTurnsFromObserver( } /** - * Bridge hook: processes observer events into the active-turns store. - * Should be called by a parent component that has access to the observer events. + * Build the steady-state observer listener once per agent-list revision. Observer + * publications carry only newly admitted events for one agent, so this callback + * does not revisit unrelated agents or their retained journals. */ +export function createActiveAgentTurnsObserverListener( + agents: readonly { pubkey: string; status: string }[], +): (update?: AgentObserverStoreUpdate) => void { + const activeAgentPubkeys = new Set( + agents + .filter( + (agent) => agent.status === "running" || agent.status === "deployed", + ) + .map((agent) => normalizePubkey(agent.pubkey)), + ); + + return (update?: AgentObserverStoreUpdate) => { + if ( + !update || + !activeAgentPubkeys.has(normalizePubkey(update.agentPubkey)) + ) { + return; + } + syncAgentTurnsFromEvents(update.agentPubkey, [...update.events]); + }; +} + export function useActiveAgentTurnsBridge( agents: readonly { pubkey: string; status: string }[], ) { React.useEffect(() => { - function syncAll() { - syncActiveAgentTurnsFromObserver(agents); - } - - syncAll(); - return subscribeAgentObserverStore(syncAll); + syncActiveAgentTurnsFromObserver(agents); + return subscribeAgentObserverStore( + createActiveAgentTurnsObserverListener(agents), + ); }, [agents]); } diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index a5495d33e0..7ae4d0bfc8 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -53,7 +53,14 @@ const IDLE_SNAPSHOT: ObserverSnapshot = { const EMPTY_EVENTS: ObserverEvent[] = []; const EMPTY_TRANSCRIPT: TranscriptItem[] = []; -const listeners = new Set<() => void>(); +export type AgentObserverStoreUpdate = { + agentPubkey: string; + events: readonly ObserverEvent[]; +}; + +type AgentObserverStoreListener = (update?: AgentObserverStoreUpdate) => void; + +const listeners = new Set(); const eventsByAgent = new Map(); const transcriptByAgent = new Map(); const snapshotByAgent = new Map(); @@ -192,9 +199,9 @@ let startPromise: Promise | null = null; let eventProcessingQueue: Promise = Promise.resolve(); let generation = 0; -function notifyListeners() { +function notifyListeners(update?: AgentObserverStoreUpdate) { for (const listener of listeners) { - listener(); + listener(update); } } @@ -219,8 +226,8 @@ function observerTag(event: RelayEvent, tagName: string) { function appendAgentEvents( agentPubkey: string, events: readonly ObserverEvent[], -): boolean { - if (events.length === 0) return false; +): ObserverEvent[] | null { + if (events.length === 0) return null; const key = normalizePubkey(agentPubkey); const current = eventsByAgent.get(key) ?? []; @@ -234,7 +241,7 @@ function appendAgentEvents( const admissible = floor ? events.filter((event) => isObserverEventAfter(event, floor)) : events; - if (admissible.length === 0) return false; + if (admissible.length === 0) return null; const seen = new Set( current.map( @@ -248,7 +255,7 @@ function appendAgentEvents( seen.add(eventKey); added.push(event); } - if (added.length === 0) return false; + if (added.length === 0) return null; const sortedAdded = added.sort(compareObserverEvents); const sorted = [...current, ...sortedAdded].sort(compareObserverEvents); @@ -289,12 +296,24 @@ function appendAgentEvents( } invalidateSnapshot(key); - return true; + if (!trimmed) return sortedAdded; + + const retainedKeys = new Set( + final.map( + (event) => `${event.timestamp.length}:${event.timestamp}:${event.seq}`, + ), + ); + return sortedAdded.filter((event) => + retainedKeys.has( + `${event.timestamp.length}:${event.timestamp}:${event.seq}`, + ), + ); } function appendAgentEvent(agentPubkey: string, event: ObserverEvent) { - if (appendAgentEvents(agentPubkey, [event])) { - notifyListeners(); + const added = appendAgentEvents(agentPubkey, [event]); + if (added) { + notifyListeners({ agentPubkey, events: added }); } } @@ -435,7 +454,7 @@ function processLiveObserverEvents( // callbacks. Those callbacks historically observed their triggering frame // in the raw/transcript stores; batching must preserve that visibility while // deferring only the global external-store publication. - const observerChanged = appendAgentEvents(agentPubkey, events); + const addedEvents = appendAgentEvents(agentPubkey, events); for (const parsed of events) { // Track the latest-live-session-id per (agent, channel) on the live path. @@ -479,8 +498,8 @@ function processLiveObserverEvents( // Preserve the harness's envelope backpressure: retained state was committed // before specialized callbacks, but external-store subscribers publish once. - if (observerChanged) { - notifyListeners(); + if (addedEvents) { + notifyListeners({ agentPubkey, events: addedEvents }); } } @@ -588,7 +607,9 @@ export function ensureRelayObserverSubscription() { return startPromise; } -export function subscribeAgentObserverStore(listener: () => void) { +export function subscribeAgentObserverStore( + listener: AgentObserverStoreListener, +) { listeners.add(listener); return () => { listeners.delete(listener); @@ -827,8 +848,9 @@ export function injectObserverEventsForE2E( agentPubkey: string, events: ObserverEvent[], ) { - if (appendAgentEvents(agentPubkey, events)) { - notifyListeners(); + const added = appendAgentEvents(agentPubkey, events); + if (added) { + notifyListeners({ agentPubkey, events: added }); } } @@ -840,8 +862,9 @@ export function syncAgentObserverEvents( agentPubkey: string, events: ObserverEvent[], ) { - if (appendAgentEvents(agentPubkey, events)) { - notifyListeners(); + const added = appendAgentEvents(agentPubkey, events); + if (added) { + notifyListeners({ agentPubkey, events: added }); } } From 0bb7c60f824a05ac4d8c8569ee1e74d200069b45 Mon Sep 17 00:00:00 2001 From: Tom Brow <106167956+square-tomb@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:18:28 -0700 Subject: [PATCH 21/33] fix(mobile): unwrap batched observer telemetry (#5805) ## Summary Buzz Mobile now expands decrypted ACP observer batch envelopes into their inner telemetry frames before sending them through the existing per-agent dedupe, ordering, cap, and channel-filter pipeline. Singleton observer events keep their existing behavior. Malformed batch envelopes remain visible as outer frames, matching the desktop consumer convention, while invalid inner frames use the existing observer decrypt error path. This restores batched agent progress, tool activity, and incremental transcript updates that Mobile previously ignored. ### Related issue Related to #4917. ### Testing Added tests: - [`observer_subscription_test.dart`](https://github.com/block/buzz/blob/main/mobile/test/features/channels/agent_activity/observer_subscription_test.dart) covers valid batches, singleton behavior, malformed envelopes, and invalid inner frames. Full mobile analysis, formatting, file-size validation, and Flutter tests passed. The repository pre-push gate also passed. --------- Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: Codex --- .../agent_activity/observer_subscription.dart | 44 ++- .../observer_subscription_test.dart | 265 ++++++++++++++++++ 2 files changed, 302 insertions(+), 7 deletions(-) diff --git a/mobile/lib/features/channels/agent_activity/observer_subscription.dart b/mobile/lib/features/channels/agent_activity/observer_subscription.dart index c15686c582..bc469c5b32 100644 --- a/mobile/lib/features/channels/agent_activity/observer_subscription.dart +++ b/mobile/lib/features/channels/agent_activity/observer_subscription.dart @@ -11,6 +11,7 @@ import 'transcript_builder.dart'; /// Maximum observer events to keep per agent. const _maxObserverEvents = 800; +const _observerBatchKind = 'batch'; /// Key for channel-scoped transcript reads. typedef ObserverKey = ({String channelId, String agentPubkey}); @@ -189,16 +190,30 @@ class ObserverRelayNotifier extends Notifier { return; } - final frame = _decryptFrame(event, normalizedAgent, privHex); - if (frame == null) return; + final frames = _decryptFrames(event, normalizedAgent, privHex); + if (frames == null) return; + var storageChanged = false; + for (final frame in frames) { + if (_storeFrame(normalizedAgent, frame)) { + storageChanged = true; + } + } + + if (storageChanged) { + _errorMessage = null; + _emit(connection: ObserverConnectionState.open); + } + } + + bool _storeFrame(String normalizedAgent, ObserverFrame frame) { final dedupeKey = '${frame.seq}:${frame.timestamp}'; final dedupeKeys = _dedupeKeysByAgent.putIfAbsent( normalizedAgent, () => {}, ); if (!dedupeKeys.add(dedupeKey)) { - return; + return false; } final frames = _framesByAgent.putIfAbsent( @@ -216,11 +231,10 @@ class ObserverRelayNotifier extends Notifier { frames.removeRange(0, removeCount); } - _errorMessage = null; - _emit(connection: ObserverConnectionState.open); + return true; } - ObserverFrame? _decryptFrame( + List? _decryptFrames( NostrEvent event, String normalizedAgent, String privHex, @@ -232,7 +246,23 @@ class ObserverRelayNotifier extends Notifier { ); final plaintext = nip44Decrypt(conversationKey, event.content); final json = jsonDecode(plaintext) as Map; - return ObserverFrame.fromJson(json); + final frame = ObserverFrame.fromJson(json); + if (frame.kind != _observerBatchKind) { + return [frame]; + } + + final payload = frame.payload; + final events = payload is Map ? payload['events'] : null; + // Preserve malformed envelopes so publisher defects are not silently + // discarded, matching the desktop observer consumer. + if (events is! List || events.isEmpty) { + return [frame]; + } + + return [ + for (final inner in events) + ObserverFrame.fromJson(inner as Map), + ]; } catch (error) { _errorMessage = 'Observer event decrypt failed: $error'; _emit(connection: ObserverConnectionState.error); diff --git a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart index e7cdb03801..f54b0594ef 100644 --- a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart +++ b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart @@ -289,6 +289,271 @@ void main() { expect(otherChannelState.transcript, isEmpty); }, ); + + test('expands batch envelopes through ordering and dedupe', () async { + final ownerKeychain = nostr.Keys.generate(); + final agentKeychain = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + const channelId = 'test-channel'; + final key = (channelId: channelId, agentPubkey: agentKeychain.public); + container.read(observerSubscriptionProvider(key)); + await Future.delayed(Duration.zero); + + final laterFrame = _observerFrameJson( + seq: 2, + channelId: channelId, + turnId: 'turn-2', + ); + final earlierFrame = _observerFrameJson( + seq: 1, + channelId: channelId, + turnId: 'turn-1', + ); + relaySession.emit( + _observerEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: { + 'seq': 2, + 'timestamp': '2026-04-30T12:00:02.000Z', + 'kind': 'batch', + 'channelId': channelId, + 'turnId': 'turn-2', + 'payload': { + 'events': [laterFrame, earlierFrame, earlierFrame], + }, + }, + ), + ); + + final relayState = container.read(observerRelayProvider); + final frames = relayState.framesByAgent[agentKeychain.public]; + expect(frames?.map((frame) => frame.seq), [1, 2]); + + final state = container.read(observerSubscriptionProvider(key)); + expect(state.connection, ObserverConnectionState.open); + expect(state.transcript, hasLength(2)); + expect(state.transcript.map((item) => item.id), [ + 'turn:turn-1', + 'turn:turn-2', + ]); + + final otherChannelState = container.read( + observerSubscriptionProvider(( + channelId: 'other-channel', + agentPubkey: agentKeychain.public, + )), + ); + expect(otherChannelState.transcript, isEmpty); + }); + + test('publishes one open-state update for a changed batch', () async { + final ownerKeychain = nostr.Keys.generate(); + final agentKeychain = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + container.read( + observerSubscriptionProvider(( + channelId: 'test-channel', + agentPubkey: agentKeychain.public, + )), + ); + await Future.delayed(Duration.zero); + + var openStateUpdates = 0; + final listener = container.listen(observerRelayProvider, (_, next) { + if (next.connection == ObserverConnectionState.open) { + openStateUpdates += 1; + } + }); + addTearDown(listener.close); + + final event = _observerEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: { + 'seq': 2, + 'timestamp': '2026-04-30T12:00:02.000Z', + 'kind': 'batch', + 'channelId': 'test-channel', + 'turnId': 'turn-2', + 'payload': { + 'events': [ + _observerFrameJson( + seq: 1, + channelId: 'test-channel', + turnId: 'turn-1', + ), + _observerFrameJson( + seq: 2, + channelId: 'test-channel', + turnId: 'turn-2', + ), + ], + }, + }, + ); + + relaySession.emit(event); + expect(openStateUpdates, 1); + + relaySession.emit(event); + expect(openStateUpdates, 1); + }); + + test('keeps malformed batch envelopes as singleton frames', () async { + final ownerKeychain = nostr.Keys.generate(); + final agentKeychain = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + container.read( + observerSubscriptionProvider(( + channelId: 'test-channel', + agentPubkey: agentKeychain.public, + )), + ); + await Future.delayed(Duration.zero); + + relaySession.emit( + _observerEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: { + 'seq': 3, + 'timestamp': '2026-04-30T12:00:03.000Z', + 'kind': 'batch', + 'channelId': 'test-channel', + 'payload': {}, + }, + ), + ); + + final state = container.read(observerRelayProvider); + expect(state.connection, ObserverConnectionState.open); + expect(state.errorMessage, isNull); + expect(state.framesByAgent[agentKeychain.public], hasLength(1)); + expect(state.framesByAgent[agentKeychain.public]!.single.kind, 'batch'); + }); + + test( + 'rejects invalid inner batch frames without partial ingestion', + () async { + final ownerKeychain = nostr.Keys.generate(); + final agentKeychain = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + container.read( + observerSubscriptionProvider(( + channelId: 'test-channel', + agentPubkey: agentKeychain.public, + )), + ); + await Future.delayed(Duration.zero); + + relaySession.emit( + _observerEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: { + 'seq': 2, + 'timestamp': '2026-04-30T12:00:02.000Z', + 'kind': 'batch', + 'channelId': 'test-channel', + 'payload': { + 'events': [ + _observerFrameJson( + seq: 1, + channelId: 'test-channel', + turnId: 'turn-1', + ), + {'seq': 'invalid'}, + ], + }, + }, + ), + ); + + final state = container.read(observerRelayProvider); + expect(state.connection, ObserverConnectionState.error); + expect(state.errorMessage, contains('Observer event decrypt failed')); + expect(state.framesByAgent[agentKeychain.public], isNull); + }, + ); +} + +Map _observerFrameJson({ + required int seq, + required String channelId, + required String turnId, +}) => { + 'seq': seq, + 'timestamp': '2026-04-30T12:00:0$seq.000Z', + 'kind': 'turn_started', + 'channelId': channelId, + 'turnId': turnId, + 'payload': { + 'triggeringEventIds': ['$seq'], + }, +}; + +NostrEvent _observerEvent({ + required nostr.Keys ownerKeychain, + required nostr.Keys agentKeychain, + required Map payload, +}) { + final conversationKey = getConversationKey( + agentKeychain.secret, + ownerKeychain.public, + ); + final event = nostr.Event.from( + kind: EventKind.agentObserverFrame, + content: nip44Encrypt(conversationKey, jsonEncode(payload)), + tags: [ + ['p', ownerKeychain.public], + ['agent', agentKeychain.public], + ['frame', 'telemetry'], + ], + secretKey: agentKeychain.secret, + verify: false, + ); + return NostrEvent.fromJson(event.toMap()); } class _RecordingRelaySession extends RelaySessionNotifier { From 1f4c69eccf012dc58737e9265498397215c706c5 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 14 Aug 2026 14:20:24 -0600 Subject: [PATCH 22/33] chore(release): release Buzz Desktop version 0.5.12 (#5903) ## Buzz Desktop release v0.5.12 - **Frozen main:** `757779bb1ef22cc4a1c233344baa0946d907e5a6` - **Reviewed candidate:** `bfc34904adc414efcd8e9c5548dff82c3545b677` - **Previous desktop release:** `desktop-v0.5.11` - **Proposed immutable tag:** `desktop-v0.5.12` This PR may be **squash merged** after the Desktop Release Candidate check and all protected-branch checks pass. Merging authorizes publication of the exact reviewed candidate; later or unrelated changes on `main` cannot alter it. The checked-in changelog accounts for every non-merge commit in the release range. The Desktop tag points to the reviewed candidate commit, not the later squash commit. Publication remains bound to that immutable candidate tag. Signed-off-by: Wes Co-authored-by: Release Automation --- .release/desktop-candidate.json | 14 +++++----- CHANGELOG.md | 44 +++++++++++++++++++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 6 files changed, 55 insertions(+), 11 deletions(-) diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index b8516ee346..2cb8783288 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.11", - "base_sha": "4749bc7be3cdb78c2db4ce4864775ba7ab60b4cc", - "previous_tag": "desktop-v0.5.10", - "previous_base_sha": "f35930104bcbdb1332ff13735214ecb9fce1fc7b", - "previous_merge_sha": "4b3570671eb2786594267758af18784ac6e82972", - "tag": "desktop-v0.5.11", - "commit_count": 16 + "version": "0.5.12", + "base_sha": "757779bb1ef22cc4a1c233344baa0946d907e5a6", + "previous_tag": "desktop-v0.5.11", + "previous_base_sha": "4749bc7be3cdb78c2db4ce4864775ba7ab60b4cc", + "previous_merge_sha": "9e0c6b4320190f80b81998e9e5cbf2214d597dd2", + "tag": "desktop-v0.5.12", + "commit_count": 34 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 06a08ad5a4..d169c31f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## v0.5.12 + +### Desktop and shared changes + +- perf(desktop): update active turns incrementally ([#5897](https://github.com/block/buzz/pull/5897)) ([`757779bb1ef22cc4a1c233344baa0946d907e5a6`](https://github.com/block/buzz/commit/757779bb1ef22cc4a1c233344baa0946d907e5a6)) +- fix(link-previews): send while previews finish in background ([#5697](https://github.com/block/buzz/pull/5697)) ([`f086eb6544fd9f450832ea22de74b5418d1f85a1`](https://github.com/block/buzz/commit/f086eb6544fd9f450832ea22de74b5418d1f85a1)) +- fix(desktop): cut steady-state relay traffic from polls and read-state echo ([#5879](https://github.com/block/buzz/pull/5879)) ([`01f76ec9719ebdacce3f6e67d545692a90e10b06`](https://github.com/block/buzz/commit/01f76ec9719ebdacce3f6e67d545692a90e10b06)) +- fix(desktop): support channel message path links ([#5889](https://github.com/block/buzz/pull/5889)) ([`207154706c87cbf207f2a2abbc096d17737b091a`](https://github.com/block/buzz/commit/207154706c87cbf207f2a2abbc096d17737b091a)) +- test(desktop): await channel E2E bridge readiness ([#5886](https://github.com/block/buzz/pull/5886)) ([`dbee2914ad806c7f038389eb95c7513f5df4e0d2`](https://github.com/block/buzz/commit/dbee2914ad806c7f038389eb95c7513f5df4e0d2)) +- fix(link-preview): refetch a link when it re-enters the composer ([#5510](https://github.com/block/buzz/pull/5510)) ([`fd0ab47a1b5526d7496b5a6d731f3c8d7e4dbe9f`](https://github.com/block/buzz/commit/fd0ab47a1b5526d7496b5a6d731f3c8d7e4dbe9f)) +- feat(desktop-messages): render compact Buzz permalink chips ([#5638](https://github.com/block/buzz/pull/5638)) ([`5acb930821ba56b5f4d1b487bffd237dd3ebe76a`](https://github.com/block/buzz/commit/5acb930821ba56b5f4d1b487bffd237dd3ebe76a)) +- Fix video comment effect wrapping ([#5748](https://github.com/block/buzz/pull/5748)) ([`17d2147ecadaef5891da598cf8f5257f7787992b`](https://github.com/block/buzz/commit/17d2147ecadaef5891da598cf8f5257f7787992b)) +- feat(desktop): one relative date ladder across chat and the Inbox ([#3769](https://github.com/block/buzz/pull/3769)) ([`caa64b5e8f584a740e331887a5dd1cda32bcb958`](https://github.com/block/buzz/commit/caa64b5e8f584a740e331887a5dd1cda32bcb958)) +- fix(desktop): amortize observer journal eviction with a low-water mark ([#5808](https://github.com/block/buzz/pull/5808)) ([`17977814d38a841ed475b318a5dfd4bc8405d049`](https://github.com/block/buzz/commit/17977814d38a841ed475b318a5dfd4bc8405d049)) +- Unify agent profile content ([#5788](https://github.com/block/buzz/pull/5788)) ([`34a7f2fb917cff8afd86bb59f6abcfa4cb8981d5`](https://github.com/block/buzz/commit/34a7f2fb917cff8afd86bb59f6abcfa4cb8981d5)) +- Standardize settings section layout ([#5855](https://github.com/block/buzz/pull/5855)) ([`43e53fc3491ecbd1def14ede3fb8c9e2d44e84d8`](https://github.com/block/buzz/commit/43e53fc3491ecbd1def14ede3fb8c9e2d44e84d8)) +- fix(desktop): share one timer across same-interval useNow consumers ([#5861](https://github.com/block/buzz/pull/5861)) ([`8b8445f5ef3338c58825194ebc008b98111a0962`](https://github.com/block/buzz/commit/8b8445f5ef3338c58825194ebc008b98111a0962)) +- Clarify immediate spoken huddle replies ([#5863](https://github.com/block/buzz/pull/5863)) ([`ea0960f8d0221de18d7d3504607594035519f33f`](https://github.com/block/buzz/commit/ea0960f8d0221de18d7d3504607594035519f33f)) +- Scope desktop presence subscriptions to active demand ([#5830](https://github.com/block/buzz/pull/5830)) ([`df9e773a13f17a270fd6531fc74948b8059d58c3`](https://github.com/block/buzz/commit/df9e773a13f17a270fd6531fc74948b8059d58c3)) +- fix(huddle): stop 20 Hz speaker-level churn from re-rendering the whole app ([#5825](https://github.com/block/buzz/pull/5825)) ([`57435628961d25bd24689cee82f1373e7a074040`](https://github.com/block/buzz/commit/57435628961d25bd24689cee82f1373e7a074040)) +- fix(desktop): match compact link preview thumbnail corners to card shell ([#5711](https://github.com/block/buzz/pull/5711)) ([`eedcd886a04833a78c18f49931abe68792518f97`](https://github.com/block/buzz/commit/eedcd886a04833a78c18f49931abe68792518f97)) +- feat(huddle): cut voice-turn time-to-first-audio from ~1.0 s to ~0.35 s (env-gated latency levers) ([#5671](https://github.com/block/buzz/pull/5671)) ([`068a83b09712703c71923fb22601dffd96554c91`](https://github.com/block/buzz/commit/068a83b09712703c71923fb22601dffd96554c91)) +- Speed up initial direct messages ([#5658](https://github.com/block/buzz/pull/5658)) ([`c8da06c5e9e92b2441927cdb4619318b4328c2bd`](https://github.com/block/buzz/commit/c8da06c5e9e92b2441927cdb4619318b4328c2bd)) +- Polish glass Huddle tray behavior ([#5590](https://github.com/block/buzz/pull/5590)) ([`0571f5455b1b2aeea7334082f0df9d1f19b22f7d`](https://github.com/block/buzz/commit/0571f5455b1b2aeea7334082f0df9d1f19b22f7d)) +- test: add deterministic desktop release smoke ([#5699](https://github.com/block/buzz/pull/5699)) ([`76f114a252866f17003520db0a11a8b6f5b3da0c`](https://github.com/block/buzz/commit/76f114a252866f17003520db0a11a8b6f5b3da0c)) +- feat(desktop): add Inbox message delete action ([#5779](https://github.com/block/buzz/pull/5779)) ([`514195b1d58d1a8679bfc8c63a2b410b6a227489`](https://github.com/block/buzz/commit/514195b1d58d1a8679bfc8c63a2b410b6a227489)) +- fix(desktop): enforce agent mention authorization at send boundaries ([#5681](https://github.com/block/buzz/pull/5681)) ([`bcf353c969b91991c22d0715aa2d7a618d630e1d`](https://github.com/block/buzz/commit/bcf353c969b91991c22d0715aa2d7a618d630e1d)) +- fix(desktop): route compact preview geometry fixture through media proxy ([#5799](https://github.com/block/buzz/pull/5799)) ([`b269e8df7e6ed3e1910b6f6eeef08fa4b89778bd`](https://github.com/block/buzz/commit/b269e8df7e6ed3e1910b6f6eeef08fa4b89778bd)) +- Make workflow run history authoritative in Desktop ([#5780](https://github.com/block/buzz/pull/5780)) ([`2693e0db1fc4980a551c2492031812dc4bad985f`](https://github.com/block/buzz/commit/2693e0db1fc4980a551c2492031812dc4bad985f)) +- fix(desktop): more compact "compact" link previews ([#5629](https://github.com/block/buzz/pull/5629)) ([`45f4b91a36145f2ce642548c34f699f1b529bcf5`](https://github.com/block/buzz/commit/45f4b91a36145f2ce642548c34f699f1b529bcf5)) +- Harden shared agent instruction review ([#4220](https://github.com/block/buzz/pull/4220)) ([`a96af89526f7181543e7651100a944aa8e21812b`](https://github.com/block/buzz/commit/a96af89526f7181543e7651100a944aa8e21812b)) + +### Other repository changes + +- feat(mobile-messages): render compact Buzz permalink chips ([#5639](https://github.com/block/buzz/pull/5639)) ([`5ddf23d700abdd96622de2d39750c56509a7561f`](https://github.com/block/buzz/commit/5ddf23d700abdd96622de2d39750c56509a7561f)) +- Teach agents to inherit Buzz product intent ([#5875](https://github.com/block/buzz/pull/5875)) ([`1d51081b8abf4d3f9ec7fc676207f967a843e860`](https://github.com/block/buzz/commit/1d51081b8abf4d3f9ec7fc676207f967a843e860)) +- Polish mobile profiles, DMs, and sheets ([#5401](https://github.com/block/buzz/pull/5401)) ([`b30f1f61299f6f559777f797be27f193a6a4f0b3`](https://github.com/block/buzz/commit/b30f1f61299f6f559777f797be27f193a6a4f0b3)) +- Fix channel list scroll interruption ([#5815](https://github.com/block/buzz/pull/5815)) ([`0f61f24ad659abf44a7a4fcde6a0a2cbcf78f13b`](https://github.com/block/buzz/commit/0f61f24ad659abf44a7a4fcde6a0a2cbcf78f13b)) +- fix(channels): return complete member rosters ([#5765](https://github.com/block/buzz/pull/5765)) ([`e0940927ff381f6a353c637732c7a81886f9639d`](https://github.com/block/buzz/commit/e0940927ff381f6a353c637732c7a81886f9639d)) +- Fix mobile composer input regressions ([#5594](https://github.com/block/buzz/pull/5594)) ([`98d3d77b426f1107c98b7826d0224624ea774385`](https://github.com/block/buzz/commit/98d3d77b426f1107c98b7826d0224624ea774385)) +- Add mobile community invites ([#5641](https://github.com/block/buzz/pull/5641)) ([`8abc2baf0b71844fc4ff7222aab5027c862b7d1f`](https://github.com/block/buzz/commit/8abc2baf0b71844fc4ff7222aab5027c862b7d1f)) + +[Compare desktop-v0.5.11...desktop-v0.5.12](https://github.com/block/buzz/compare/desktop-v0.5.11...desktop-v0.5.12) + ## v0.5.11 ### Desktop and shared changes diff --git a/desktop/package.json b/desktop/package.json index 7abcb8f205..d13366cae3 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.11", + "version": "0.5.12", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 7fa6c4cb7e..0527d5b14b 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.11" +version = "0.5.12" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 5467645873..0710e63bd4 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.11" +version = "0.5.12" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 2986edadaf..fbf8aa546b 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.11", + "version": "0.5.12", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From 122a8b8988869f0b1a7c056a76f7d16bfb0f6fdd Mon Sep 17 00:00:00 2001 From: thomaspblock Date: Fri, 14 Aug 2026 16:48:41 -0400 Subject: [PATCH 23/33] Projects v3: unify sharing, discussions, and issue ownership (#5792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Projects v3 makes repository work shareable, discussion-aware, and easier to scan in one coherent workspace. People can copy canonical links, reopen the exact workspace tab, understand issue and pull-request context at a glance, find related channel conversations, and assign or unassign issues across Desktop and CLI. - **Unified workspace** — top-level sections sit above repository controls in one rounded workspace, with navigation positioned close to the page heading. README and Files retain branch selection; every section has a labeled icon header, and Issues and Pull Requests expose creation from a consistent right-aligned action. - **Repository management** — the repository selector is always available, including single-repository projects. Its integrated add flow lets project owners create a repository manually or select an existing repository without a separate toolbar button. - **Readable work-item lists** — issue and pull-request rows use plain-language context instead of opaque metadata. Files, commits, issues, pull requests, channels, and contributors share consistent row density and right-aligned timestamps, while deterministic fallback-avatar colors keep participants distinct on light backgrounds. Inbox pull-request metadata wraps between complete phrases and truncates long channel names instead of compressing copy into narrow columns. - **Reliable entity links** — projects, repositories, issues, pull requests, and commits have canonical `buzz://` links, preview cards, OS deep-link routing, and tab-aware navigation. Reopening the same link re-applies its destination instead of leaving the user on a locally selected tab. - **Related conversations** — repository and work-item views surface channels discussing the current entity, including participants, channel navigation, message context, and an explicit notice when discovery reaches its 500-result cap. - **Reversible issue ownership** — trusted assignment and unassignment events work across Desktop, Tauri, `buzz-sdk`, and `buzz issues`. Assignees appear in project views and the assigned inbox, while authorized users can remove assignments directly from the assignee row. Assignment state is derived chronologically from labeled Nostr notes. Issue authors and repository owners may change any assignee; other users may only assign or unassign themselves. Shared golden fixtures keep entity-link grammar and validation aligned across TypeScript and Rust. The branch also updates `webbrowser` to the patched release for RUSTSEC-2026-0257. ### Related issue N/A. ### Testing - [x] `just ci` — formatting, lint, typechecking, unit tests, and builds passed - [x] Full pre-push suite — organization, branch-skew, Desktop checks, typechecking, and tests passed on the latest push - [x] `cargo test -p buzz-cli` and focused `buzz-sdk` assignment tests passed - [x] Focused Tauri recipient-note and 500-result search-limit tests passed - [x] Desktop entity-link and issue-assignment unit tests passed - [x] Playwright smoke coverage passed for assignment, repeated entity-link navigation, repository create/select flows, section headers and actions, timestamp alignment, timeline icons, sentence-style issue/PR metadata, header spacing, avatar contrast, and Inbox metadata at stacked and side-rail breakpoints - [ ] Manual staging pass: link round-trips, Channels tab, assignment flows, and inbox routing ### Screenshots Pull requests explain who opened the request, where it lives, and which branch it comes from; fallback avatars remain visually distinct. ![Pull request list with conversational metadata](https://raw.githubusercontent.com/block/buzz/2a536de86f7e6f79b349d7bc147b2923ff2b817d/pr-5624--05-pr-list-metadata.png) Issues use the same sentence-style hierarchy while keeping status and recency easy to scan. ![Issue list with conversational metadata](https://raw.githubusercontent.com/block/buzz/2a536de86f7e6f79b349d7bc147b2923ff2b817d/pr-5624--06-issue-list-metadata.png) The wide Inbox detail keeps author, timestamp, and origin context readable beside its metadata rail. ![Pull request Inbox detail with readable metadata](https://raw.githubusercontent.com/block/buzz/e65b433e14b97c45365ed7b68ea402ec01d26615/pr-5624--02-pull-request-detail-wide.png) [View the complete six-state Projects v3 screenshot set](https://github.com/block/buzz/pull/5624#issuecomment-5268039672) and [the compact/wide Inbox comparison](https://github.com/block/buzz/pull/5624#issuecomment-5268614585). --- > Supersedes #5624, whose head commit accumulated permanently-queued required check suites (block-dco-check et al.) that GitHub never dispatched. History flattened into a single signed-off commit on latest main; tree verified byte-identical (`git merge-tree`) to merging the original branch into main. --------- Signed-off-by: Thomas Petersen Co-authored-by: Wintermute <3f1797424fd9ad6653a83665c660517777cd7f8c228c0d5907f49e01537f3ca5@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/base_prompt.md | 6 +- crates/buzz-acp/src/lib.rs | 213 +++++- crates/buzz-cli/src/commands/issues.rs | 595 ++++++++++++++++ crates/buzz-cli/src/commands/projects.rs | 42 +- crates/buzz-cli/src/lib.rs | 45 +- crates/buzz-cli/src/links.rs | 73 +- crates/buzz-sdk/src/builders.rs | 268 +++++++ desktop/playwright.config.ts | 1 + desktop/src-tauri/src/commands/messages.rs | 6 +- .../src-tauri/src/commands/messages_tests.rs | 7 + desktop/src-tauri/src/commands/mod.rs | 2 + .../commands/project_git_recipient_notes.rs | 430 ++++++++++++ .../src/commands/project_git_workflow.rs | 279 ++++---- desktop/src-tauri/src/deep_link.rs | 662 +++++------------- desktop/src-tauri/src/deep_link_tests.rs | 569 +++++++++++++++ desktop/src-tauri/src/lib.rs | 24 +- desktop/src/app/AppShell.tsx | 6 +- .../src/app/navigation/useAppNavigation.ts | 18 +- .../src/app/routes/projects.$projectId.tsx | 16 +- .../home/ui/ProjectInboxDetailPane.tsx | 10 +- .../messages/lib/composerMessageLinkNode.ts | 20 +- .../assignmentOperationFetch.test.mjs | 287 ++++++++ .../projects/assignmentOperationFetch.ts | 138 ++++ desktop/src/features/projects/hooks.ts | 63 +- .../src/features/projects/issueAssignments.ts | 167 +++++ .../projects/lib/discussionChannels.test.mjs | 132 ++++ .../projects/lib/discussionChannels.ts | 152 ++++ .../projects/lib/projectShareLinks.test.mjs | 139 ++++ .../projects/lib/projectShareLinks.ts | 139 ++++ .../projects/lib/projectsViewHelpers.ts | 19 +- .../src/features/projects/projectIssues.d.mts | 5 + .../src/features/projects/projectIssues.mjs | 127 +++- .../features/projects/projectIssues.test.mjs | 250 +++++++ .../projects/projectOwnerControl.test.mjs | 47 ++ .../features/projects/projectOwnerControl.ts | 128 ++++ .../projectRepositoryCreation.test.mjs | 101 +++ .../projects/projectRepositoryCreation.ts | 38 +- .../src/features/projects/projectWorkItems.ts | 40 +- .../projects/ui/CopyShareLinkMenuItem.tsx | 37 + .../projects/ui/DiscussionChannels.tsx | 405 +++++++++++ .../projects/ui/IssueAssigneesRow.tsx | 346 +++++++++ .../src/features/projects/ui/ProjectCards.tsx | 29 +- .../projects/ui/ProjectCommitDetailPanel.tsx | 10 + .../projects/ui/ProjectDetailChrome.tsx | 36 +- .../ui/ProjectDetailChromeActions.tsx | 30 + .../projects/ui/ProjectDetailFeedPanels.tsx | 41 +- .../projects/ui/ProjectDetailScreen.tsx | 110 ++- .../ui/ProjectIssueCommentTimeline.tsx | 94 +-- .../projects/ui/ProjectIssuesPanel.tsx | 68 +- .../projects/ui/ProjectOriginReference.tsx | 38 +- .../projects/ui/ProjectOverviewPanel.tsx | 4 + .../projects/ui/ProjectPullRequestsPanel.tsx | 85 ++- .../projects/ui/ProjectReadmePanel.tsx | 9 +- .../ui/ProjectRepositoryManagement.tsx | 30 +- .../projects/ui/ProjectRepositoryPanel.tsx | 92 +-- .../projects/ui/ProjectRepositoryPicker.tsx | 162 ++--- .../projects/ui/ProjectRepositorySource.tsx | 18 +- .../projects/ui/ProjectSectionHeader.tsx | 46 ++ .../projects/ui/ProjectWorkspaceTabList.tsx | 5 +- .../projects/ui/ProjectWorkspaceTabs.tsx | 509 ++++++++------ .../projects/ui/ProjectsIssuesList.tsx | 97 ++- .../projects/ui/ProjectsListHeaderBar.tsx | 1 + .../projects/ui/ProjectsListScopeDropdown.tsx | 4 +- .../projects/ui/ProjectsPullRequestsList.tsx | 132 +++- .../src/features/projects/ui/ProjectsView.tsx | 19 +- .../ui/ProjectsWorkItemsLoadNotice.tsx | 1 + .../features/projects/ui/RepositoryCards.tsx | 6 + .../features/projects/ui/ShareLinkButton.tsx | 68 ++ .../projects/ui/projectDetailHelpers.ts | 1 + .../projects/ui/projectListRowStyles.ts | 2 +- .../projects/ui/projectPanelStyles.ts | 7 + .../projects/useAddProjectRepository.test.mjs | 202 ++++++ .../projects/useAddProjectRepository.ts | 252 +++++-- .../projects/useAttachProjectRepository.ts | 54 +- desktop/src/shared/api/projectGit.ts | 40 ++ desktop/src/shared/deep-link.ts | 58 ++ desktop/src/shared/lib/entityLink.test.mjs | 113 ++- desktop/src/shared/lib/entityLink.ts | 120 +++- desktop/src/shared/lib/linkPreview.test.mjs | 25 + desktop/src/shared/lib/linkPreview.ts | 24 +- .../lib/useResolvedLinkPreviews.test.mjs | 54 ++ .../src/shared/lib/useResolvedLinkPreviews.ts | 46 +- .../src/shared/styles/globals/markdown.css | 9 + desktop/src/shared/ui/UserAvatar.tsx | 20 +- .../src/shared/ui/markdown/entityLinks.tsx | 16 + desktop/src/shared/ui/mentionChip.ts | 2 + desktop/src/shared/useAppDeepLinks.ts | 14 + desktop/src/shared/useEntityDeepLinks.ts | 38 + desktop/src/testing/e2eBridge.ts | 128 +++- .../e2e/entity-link-recipient-cards.spec.ts | 136 ++++ .../tests/e2e/project-commit-detail.spec.ts | 55 +- desktop/tests/e2e/project-inbox.spec.ts | 37 + .../tests/e2e/project-issue-comments.spec.ts | 27 + desktop/tests/e2e/project-pr-review.spec.ts | 8 +- .../tests/e2e/projects-v3-screenshots.spec.ts | 224 ++++++ desktop/tests/helpers/bridge.ts | 5 + docs/buzz-entity-links.md | 62 +- scripts/post-screenshots.sh | 32 +- test-fixtures/entity-links.json | 21 + 99 files changed, 8086 insertions(+), 1542 deletions(-) create mode 100644 desktop/src-tauri/src/commands/project_git_recipient_notes.rs create mode 100644 desktop/src-tauri/src/deep_link_tests.rs create mode 100644 desktop/src/features/projects/assignmentOperationFetch.test.mjs create mode 100644 desktop/src/features/projects/assignmentOperationFetch.ts create mode 100644 desktop/src/features/projects/issueAssignments.ts create mode 100644 desktop/src/features/projects/lib/discussionChannels.test.mjs create mode 100644 desktop/src/features/projects/lib/discussionChannels.ts create mode 100644 desktop/src/features/projects/lib/projectShareLinks.test.mjs create mode 100644 desktop/src/features/projects/lib/projectShareLinks.ts create mode 100644 desktop/src/features/projects/projectOwnerControl.test.mjs create mode 100644 desktop/src/features/projects/projectOwnerControl.ts create mode 100644 desktop/src/features/projects/ui/CopyShareLinkMenuItem.tsx create mode 100644 desktop/src/features/projects/ui/DiscussionChannels.tsx create mode 100644 desktop/src/features/projects/ui/IssueAssigneesRow.tsx create mode 100644 desktop/src/features/projects/ui/ProjectDetailChromeActions.tsx create mode 100644 desktop/src/features/projects/ui/ProjectSectionHeader.tsx create mode 100644 desktop/src/features/projects/ui/ShareLinkButton.tsx create mode 100644 desktop/src/features/projects/useAddProjectRepository.test.mjs create mode 100644 desktop/src/shared/useAppDeepLinks.ts create mode 100644 desktop/src/shared/useEntityDeepLinks.ts create mode 100644 desktop/tests/e2e/projects-v3-screenshots.spec.ts create mode 100644 test-fixtures/entity-links.json diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 1695b4863f..83b9357171 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -23,7 +23,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ | `buzz feed` | `get` | | `buzz social` | `publish`, `notes` | | `buzz repos` | `create`, `get`, `list` | -| `buzz issues` | `create`, `get`, `list`, `status` | +| `buzz issues` | `create`, `get`, `list`, `status`, `assign` | | `buzz pr` | `open`, `update`, `get`, `list`, `status` | | `buzz upload` | `file` | @@ -31,7 +31,9 @@ Run `buzz --help` or `buzz --help` for full usage. For multiline message When opening a pull request in response to channel work, always pass `--channel ` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation. -`buzz pr open`, `buzz issues create`, and `buzz repos create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, or repo in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. +`buzz pr open`, `buzz issues create`, `buzz repos create`, and `buzz projects create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, repo, or project in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. + +To assign an issue to someone, run `buzz issues assign --issue --repo-owner --repo-id --assignee --label ` after creating it. Remove an assignment with the matching `buzz issues unassign` arguments. Writing assignee names in the issue body or adding recipients with `issues create --to` is notification/presentation only — Buzz Desktop's Assignees rail and the "Assigned to me" filter read the signed assignment operations. Only operations signed by the issue author or repo owner are trusted for other people; anyone may assign or unassign themselves. ## Conversational Agent Creation diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index b3986fdb9b..7fd40b83db 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1071,6 +1071,7 @@ fn handle_relay_observer_control_event( pool: &mut AgentPool, observer: Option<&observer::ObserverHandle>, owner_pubkey_hex: &str, + event_publisher: RelayEventPublisher, ) { // Defense-in-depth: verify signature even though the relay already checked. if let Err(e) = buzz_core::verify_event(&event) { @@ -1116,12 +1117,162 @@ fn handle_relay_observer_control_event( Some("switch_model") => { handle_switch_model_control(&payload, pool, observer); } + Some("publish_project_owner_announcements") => { + handle_publish_project_owner_announcements_control( + &payload, + keys, + observer, + event_publisher, + ); + } _ => { tracing::debug!(payload = %payload, "ignoring unknown observer control frame"); } } } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProjectOwnerAnnouncementControl { + request_id: String, + announcements: Vec, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProjectOwnerAnnouncementTemplate { + kind: u16, + content: String, + created_at: Option, + tags: Vec>, +} + +fn handle_publish_project_owner_announcements_control( + payload: &serde_json::Value, + keys: &nostr::Keys, + observer: Option<&observer::ObserverHandle>, + publisher: RelayEventPublisher, +) { + let Ok(control) = serde_json::from_value::(payload.clone()) + else { + tracing::warn!("project announcement control frame has an invalid payload"); + return; + }; + if Uuid::parse_str(&control.request_id).is_err() + || control.announcements.is_empty() + || control.announcements.len() > 2 + { + tracing::warn!("project announcement control frame has invalid request metadata"); + return; + } + + let keys = keys.clone(); + let observer = observer.cloned(); + tokio::spawn(async move { + let events = match build_project_owner_announcement_events(control.announcements, &keys) { + Ok(events) => events, + Err(error) => { + emit_project_owner_control_result( + observer.as_ref(), + &control.request_id, + "error", + &[], + Some(error.to_string()), + ); + return; + } + }; + let mut published_events = Vec::with_capacity(events.len()); + for event in events { + if let Err(error) = publisher.publish_event(event.clone()).await { + emit_project_owner_control_result( + observer.as_ref(), + &control.request_id, + "error", + &published_events, + Some(format!("publish project announcement: {error}")), + ); + return; + } + published_events.push(event); + } + emit_project_owner_control_result( + observer.as_ref(), + &control.request_id, + "ok", + &published_events, + None, + ); + }); +} + +fn build_project_owner_announcement_events( + announcements: Vec, + keys: &nostr::Keys, +) -> Result> { + let now = nostr::Timestamp::now().as_secs(); + announcements + .into_iter() + .map(|template| { + if !matches!(template.kind, 30_617 | 30_621) { + anyhow::bail!("unsupported project announcement kind"); + } + if !template.tags.iter().any(|tag| { + tag.first().is_some_and(|value| value == "d") + && tag.get(1).is_some_and(|value| !value.trim().is_empty()) + }) { + anyhow::bail!("project announcement is missing its address"); + } + let tags = template + .tags + .into_iter() + .map(|tag| { + nostr::Tag::parse(tag) + .map_err(|error| anyhow::anyhow!("invalid project tag: {error}")) + }) + .collect::>>()?; + let created_at = template.created_at.unwrap_or(now); + if created_at > now.saturating_add(300) { + anyhow::bail!("project announcement timestamp is too far in the future"); + } + nostr::EventBuilder::new(nostr::Kind::Custom(template.kind), template.content) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(keys) + .map_err(|error| anyhow::anyhow!("sign project announcement: {error}")) + }) + .collect() +} + +fn emit_project_owner_control_result( + observer: Option<&observer::ObserverHandle>, + request_id: &str, + status: &str, + events: &[nostr::Event], + error: Option, +) { + let Some(observer) = observer else { + return; + }; + observer.emit( + "control_result", + None, + &observer::ObserverContext { + channel_id: None, + session_id: None, + turn_id: None, + started_at: None, + }, + serde_json::json!({ + "type": "publish_project_owner_announcements", + "requestId": request_id, + "status": status, + "events": events, + "error": error, + }), + ); +} + /// Handle a `cancel_turn` control frame: signal the in-flight task to cancel. fn handle_cancel_turn_control( payload: &serde_json::Value, @@ -2423,7 +2574,14 @@ async fn tokio_main() -> Result<()> { match control_event { Some(event) => { if let Some(ref owner_hex) = owner_cache.pubkey { - handle_relay_observer_control_event(&config.keys, event, &mut pool, observer.as_ref(), owner_hex); + handle_relay_observer_control_event( + &config.keys, + event, + &mut pool, + observer.as_ref(), + owner_hex, + relay.event_publisher(), + ); } else { tracing::warn!("observer control frame received but no owner resolved — dropping"); } @@ -5062,6 +5220,59 @@ mod owner_control_command_tests { ControlSignal::Rotate )); } + + #[test] + fn project_owner_control_signs_only_addressable_project_events() { + let keys = Keys::generate(); + let events = build_project_owner_announcement_events( + vec![ + ProjectOwnerAnnouncementTemplate { + kind: 30_621, + content: String::new(), + created_at: Some(1), + tags: vec![vec!["d".to_string(), "project".to_string()]], + }, + ProjectOwnerAnnouncementTemplate { + kind: 30_617, + content: String::new(), + created_at: Some(1), + tags: vec![vec!["d".to_string(), "repository".to_string()]], + }, + ], + &keys, + ) + .expect("valid project events"); + + assert_eq!(events.len(), 2); + assert!(events.iter().all(|event| event.pubkey == keys.public_key())); + assert!(events.iter().all(|event| event.verify().is_ok())); + } + + #[test] + fn project_owner_control_rejects_arbitrary_or_unaddressed_events() { + let keys = Keys::generate(); + let arbitrary = build_project_owner_announcement_events( + vec![ProjectOwnerAnnouncementTemplate { + kind: 1, + content: String::new(), + created_at: None, + tags: vec![vec!["d".to_string(), "project".to_string()]], + }], + &keys, + ); + assert!(arbitrary.is_err()); + + let unaddressed = build_project_owner_announcement_events( + vec![ProjectOwnerAnnouncementTemplate { + kind: 30_621, + content: String::new(), + created_at: None, + tags: vec![], + }], + &keys, + ); + assert!(unaddressed.is_err()); + } } #[cfg(test)] diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 91c64a3915..15284a0d7b 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -1,8 +1,232 @@ +use std::collections::{HashMap, HashSet}; + use crate::client::BuzzClient; use crate::commands::with_git_provenance; use crate::error::CliError; use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id}; use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta}; +use nostr::Timestamp; +use serde::Deserialize; + +const ISSUE_ASSIGNMENT_LABEL: &str = "assignment"; +const ISSUE_UNASSIGNMENT_LABEL: &str = "unassignment"; + +fn assignment_note_label(assignees: &[String], label: Option<&str>) -> Result { + if let Some(label) = label { + let label = label.trim(); + if label.is_empty() || label.chars().count() > 128 { + return Err(CliError::Usage( + "--label must be between 1 and 128 characters".into(), + )); + } + return Ok(label.to_string()); + } + let prefixes = assignees + .iter() + .map(|assignee| format!("{}…", assignee.chars().take(8).collect::())) + .collect::>(); + for included in (0..=prefixes.len()).rev() { + let omitted = prefixes.len() - included; + let mut generated = prefixes[..included].join(", "); + if omitted > 0 { + let suffix = format!("{omitted} other{}", if omitted == 1 { "" } else { "s" }); + if !generated.is_empty() { + generated.push_str(", and "); + } + generated.push_str(&suffix); + } + if !generated.is_empty() && generated.chars().count() <= 128 { + return Ok(generated); + } + } + Err(CliError::Usage( + "Unable to generate an assignee label between 1 and 128 characters".into(), + )) +} + +#[derive(Clone, Copy)] +enum IssueAssignmentOperation { + Assign, + Unassign, +} + +#[derive(Debug)] +struct AssignmentEvent { + id: String, + pubkey: String, + created_at: u64, + tags: Vec>, +} + +#[derive(Debug, Deserialize)] +struct AssignmentQueryEvent { + id: String, + kind: u16, + pubkey: String, + created_at: u64, + tags: Vec>, +} + +impl From<&AssignmentQueryEvent> for AssignmentEvent { + fn from(event: &AssignmentQueryEvent) -> Self { + Self { + id: event.id.clone(), + pubkey: event.pubkey.clone(), + created_at: event.created_at, + tags: event.tags.clone(), + } + } +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct AssignmentState { + assignees: HashSet, + heads: HashMap, +} + +#[derive(Debug)] +struct ParsedAssignmentOperation { + id: String, + is_assignment: bool, + pubkeys: Vec, + prior: Option, +} + +fn tag_values<'a>(event: &'a AssignmentEvent, name: &str) -> Vec<&'a str> { + event + .tags + .iter() + .filter_map(|tag| { + if tag.first().map(String::as_str) != Some(name) { + return None; + } + tag.get(1) + .map(String::as_str) + .filter(|value| !value.is_empty()) + }) + .collect() +} + +fn has_root_tag(event: &AssignmentEvent, issue_id: &str) -> bool { + event.tags.iter().any(|tag| { + matches!( + tag.as_slice(), + [name, value, ..] if name == "e" && value == issue_id + ) + }) +} + +fn is_hex64(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn apply_assignment_operation(state: &mut AssignmentState, operation: ParsedAssignmentOperation) { + if let Some(prior) = operation.prior.as_ref() { + let Some(target) = operation.pubkeys.first() else { + return; + }; + if state.heads.get(target) != Some(prior) { + return; + } + } + for pubkey in operation.pubkeys { + if operation.is_assignment { + state.assignees.insert(pubkey.clone()); + } else { + state.assignees.remove(&pubkey); + } + state.heads.insert(pubkey, operation.id.clone()); + } +} + +fn reduce_assignment_operations( + issue_id: &str, + issue_author: &str, + repo_owner: &str, + events: &[AssignmentEvent], +) -> AssignmentState { + let issue_author = issue_author.to_ascii_lowercase(); + let repo_owner = repo_owner.to_ascii_lowercase(); + let mut events = events.iter().collect::>(); + events.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.id.cmp(&right.id)) + }); + + let mut uncaused_self_operations = Vec::new(); + let mut authoritative_operations = Vec::new(); + let mut causal_self_operations = Vec::new(); + for event in events { + if !has_root_tag(event, issue_id) { + continue; + } + let labels = tag_values(event, "t"); + let is_assignment = labels.contains(&ISSUE_ASSIGNMENT_LABEL); + let is_unassignment = labels.contains(&ISSUE_UNASSIGNMENT_LABEL); + if is_assignment == is_unassignment { + continue; + } + let signer = event.pubkey.to_ascii_lowercase(); + let pubkeys = tag_values(event, "p") + .into_iter() + .map(str::to_ascii_lowercase) + .collect::>(); + let is_authoritative = signer == issue_author || signer == repo_owner; + let is_self_operation = pubkeys.len() == 1 && pubkeys[0] == signer; + if !is_authoritative && !is_self_operation { + continue; + } + + let mut operation = ParsedAssignmentOperation { + id: event.id.to_ascii_lowercase(), + is_assignment, + pubkeys, + prior: None, + }; + if is_authoritative { + authoritative_operations.push(operation); + continue; + } + + let prior_tags = event + .tags + .iter() + .filter(|tag| tag.first().map(String::as_str) == Some("prior")) + .collect::>(); + if prior_tags.is_empty() { + uncaused_self_operations.push(operation); + } else if prior_tags.len() == 1 && prior_tags[0].get(1).is_some_and(|prior| is_hex64(prior)) + { + operation.prior = prior_tags[0].get(1).map(|prior| prior.to_ascii_lowercase()); + causal_self_operations.push(operation); + } + } + + let mut state = AssignmentState::default(); + for operation in uncaused_self_operations + .into_iter() + .chain(authoritative_operations) + .chain(causal_self_operations) + { + apply_assignment_operation(&mut state, operation); + } + state +} + +struct IssueAssignmentContext { + created_at: u64, + prior: Option, +} + +impl IssueAssignmentOperation { + fn content(self, label: &str) -> String { + match self { + Self::Assign => format!("Assigned this issue to {label}"), + Self::Unassign => format!("Unassigned {label} from this issue"), + } + } +} pub async fn cmd_create_issue( client: &BuzzClient, @@ -40,6 +264,185 @@ pub async fn cmd_create_issue( Ok(()) } +/// Publish an issue assignment: a kind:1 comment on the issue whose `p` +/// tags are the assignees, labeled `t: assignment` (same event shape the +/// Desktop app writes). Clients trust it when signed by the issue author +/// or repo owner, or when it is a self-assignment. +pub async fn cmd_assign_issue( + client: &BuzzClient, + issue: &str, + repo_owner: &str, + repo_id: &str, + assignees: &[String], + label: Option<&str>, +) -> Result<(), CliError> { + publish_issue_assignment_operation( + client, + issue, + repo_owner, + repo_id, + assignees, + label, + IssueAssignmentOperation::Assign, + ) + .await +} + +/// Publish an issue unassignment with the same trust rules as assignment. +pub async fn cmd_unassign_issue( + client: &BuzzClient, + issue: &str, + repo_owner: &str, + repo_id: &str, + assignees: &[String], + label: Option<&str>, +) -> Result<(), CliError> { + publish_issue_assignment_operation( + client, + issue, + repo_owner, + repo_id, + assignees, + label, + IssueAssignmentOperation::Unassign, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn publish_issue_assignment_operation( + client: &BuzzClient, + issue: &str, + repo_owner: &str, + repo_id: &str, + assignees: &[String], + label: Option<&str>, + operation: IssueAssignmentOperation, +) -> Result<(), CliError> { + validate_hex64(issue)?; + validate_hex64(repo_owner)?; + validate_repo_id(repo_id)?; + for assignee in assignees { + validate_hex64(assignee)?; + } + + let label = assignment_note_label(assignees, label)?; + let content = operation.content(&label); + let repo = GitRepoCoord { + owner: repo_owner.to_string(), + id: repo_id.to_string(), + }; + let signer = client.keys().public_key().to_hex(); + let is_self_service = assignees.len() == 1 + && assignees[0].eq_ignore_ascii_case(&signer) + && !signer.eq_ignore_ascii_case(repo_owner); + let context = issue_assignment_context(client, issue, &repo, &signer, is_self_service).await?; + let builder = match (operation, is_self_service) { + (IssueAssignmentOperation::Assign, true) => { + buzz_sdk::build_git_issue_assignment_with_prior( + &repo, + issue, + assignees, + &content, + context.prior.as_deref(), + ) + } + (IssueAssignmentOperation::Unassign, true) => { + buzz_sdk::build_git_issue_unassignment_with_prior( + &repo, + issue, + assignees, + &content, + context.prior.as_deref(), + ) + } + (IssueAssignmentOperation::Assign, false) => { + buzz_sdk::build_git_issue_assignment(&repo, issue, assignees, &content) + } + (IssueAssignmentOperation::Unassign, false) => { + buzz_sdk::build_git_issue_unassignment(&repo, issue, assignees, &content) + } + } + .map(|builder| builder.custom_created_at(Timestamp::from_secs(context.created_at))); + let event = client.sign_event(builder.map_err(sdk_err)?)?; + let resp = client.submit_event(event).await?; + println!("{resp}"); + Ok(()) +} + +async fn issue_assignment_context( + client: &BuzzClient, + issue: &str, + repo: &GitRepoCoord, + signer: &str, + include_prior: bool, +) -> Result { + let root_filter = serde_json::json!({ + "kinds": [1621], + "ids": [issue], + "limit": 1 + }); + let assignment_filter = serde_json::json!({ + "kinds": [1], + "#e": [issue], + "#t": [ISSUE_ASSIGNMENT_LABEL, ISSUE_UNASSIGNMENT_LABEL], + "limit": 500 + }); + let signer_comment_filter = serde_json::json!({ + "kinds": [1], + "#e": [issue], + "authors": [signer], + "limit": 1 + }); + let response = client + .query_multi(&[root_filter, assignment_filter, signer_comment_filter]) + .await?; + // CLI read responses intentionally omit signatures, so deserialize only + // the event fields needed for assignment reduction. + let events = serde_json::from_str::>(&response) + .map_err(|error| CliError::Other(format!("parse issue assignment context: {error}")))?; + let root = events + .iter() + .find(|event| event.kind == 1621 && event.id == issue) + .ok_or_else(|| CliError::Other("issue root was not returned by the relay".into()))?; + let expected_repo = format!("30617:{}:{}", repo.owner.to_ascii_lowercase(), repo.id); + let root_matches_repo = root.tags.iter().any(|tag| { + tag.as_slice().first().map(String::as_str) == Some("a") + && tag.as_slice().get(1) == Some(&expected_repo) + }); + if !root_matches_repo { + return Err(CliError::Other( + "issue root does not match the requested repository".into(), + )); + } + + let comments = events + .iter() + .filter(|event| event.kind == 1) + .map(AssignmentEvent::from) + .collect::>(); + let latest = comments + .iter() + .filter(|event| event.pubkey.eq_ignore_ascii_case(signer)) + .map(|event| event.created_at) + .max() + .unwrap_or(0); + let created_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| CliError::Other(format!("read system clock: {error}")))? + .as_secs() + .max(latest.saturating_add(1)); + let prior = include_prior + .then(|| { + reduce_assignment_operations(issue, &root.pubkey, &repo.owner, &comments) + .heads + .get(&signer.to_ascii_lowercase()) + .cloned() + }) + .flatten(); + Ok(IssueAssignmentContext { created_at, prior }) +} + pub async fn cmd_get_issue(client: &BuzzClient, event: &str) -> Result<(), CliError> { validate_hex64(event)?; let filter = serde_json::json!({ @@ -202,5 +605,197 @@ pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), ) .await } + IssuesCmd::Assign { + issue, + repo_owner, + repo_id, + assignee, + label, + } => { + cmd_assign_issue( + client, + &issue, + &repo_owner, + &repo_id, + &assignee, + label.as_deref(), + ) + .await + } + IssuesCmd::Unassign { + issue, + repo_owner, + repo_id, + assignee, + label, + } => { + cmd_unassign_issue( + client, + &issue, + &repo_owner, + &repo_id, + &assignee, + label.as_deref(), + ) + .await + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + assignment_note_label, reduce_assignment_operations, AssignmentEvent, AssignmentQueryEvent, + ISSUE_ASSIGNMENT_LABEL, ISSUE_UNASSIGNMENT_LABEL, + }; + + const ISSUE: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const OWNER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const VOLUNTEER: &str = "5555555555555555555555555555555555555555555555555555555555555555"; + + fn assignment_event( + pubkey: &str, + id: &str, + assignment: bool, + created_at: u64, + prior: Option<&str>, + ) -> AssignmentEvent { + let mut tags = vec![ + vec!["e".into(), ISSUE.into(), "".into(), "root".into()], + vec!["p".into(), VOLUNTEER.into()], + vec![ + "t".into(), + if assignment { + ISSUE_ASSIGNMENT_LABEL.into() + } else { + ISSUE_UNASSIGNMENT_LABEL.into() + }, + ], + ]; + if let Some(prior) = prior { + tags.push(vec!["prior".into(), prior.into()]); + } + AssignmentEvent { + id: id.into(), + pubkey: pubkey.into(), + created_at, + tags, + } + } + + #[test] + fn assignment_note_label_enforces_desktop_length_limit() { + let assignees = vec!["a".repeat(64)]; + assert_eq!( + assignment_note_label(&assignees, Some(" Thomas ")).unwrap(), + "Thomas" + ); + assert!(assignment_note_label(&assignees, Some("")).is_err()); + assert!(assignment_note_label(&assignees, Some(&"x".repeat(129))).is_err()); + assert_eq!( + assignment_note_label(&assignees, None).unwrap(), + "aaaaaaaa…" + ); + let many_assignees = (0..50) + .map(|index| format!("{index:064x}")) + .collect::>(); + let generated = assignment_note_label(&many_assignees, None).unwrap(); + assert!(generated.chars().count() <= 128); + assert!(generated.contains("others")); + } + + #[test] + fn assignment_query_event_accepts_sig_stripped_cli_reads() { + let event = serde_json::from_value::(serde_json::json!({ + "id": "1".repeat(64), + "kind": 1, + "pubkey": VOLUNTEER, + "created_at": 200, + "tags": [["e", ISSUE, "", "root"]] + })) + .unwrap(); + + assert_eq!(event.kind, 1); + assert_eq!(event.pubkey, VOLUNTEER); + } + + #[test] + fn uncaused_future_self_operations_lose_to_authority() { + let owner_unassign = "1".repeat(64); + let state = reduce_assignment_operations( + ISSUE, + AUTHOR, + OWNER, + &[ + assignment_event(VOLUNTEER, &"2".repeat(64), true, 1_000, None), + assignment_event(OWNER, &owner_unassign, false, 200, None), + ], + ); + assert!(!state.assignees.contains(VOLUNTEER)); + assert_eq!(state.heads.get(VOLUNTEER), Some(&owner_unassign)); + + let owner_assign = "3".repeat(64); + let state = reduce_assignment_operations( + ISSUE, + AUTHOR, + OWNER, + &[ + assignment_event(VOLUNTEER, &"4".repeat(64), false, 1_000, None), + assignment_event(OWNER, &owner_assign, true, 200, None), + ], + ); + assert!(state.assignees.contains(VOLUNTEER)); + assert_eq!(state.heads.get(VOLUNTEER), Some(&owner_assign)); + } + + #[test] + fn causal_self_operations_can_follow_authority() { + let owner_assign = "5".repeat(64); + let self_unassign = "6".repeat(64); + let state = reduce_assignment_operations( + ISSUE, + AUTHOR, + OWNER, + &[ + assignment_event(OWNER, &owner_assign, true, 200, None), + assignment_event(VOLUNTEER, &self_unassign, false, 300, Some(&owner_assign)), + ], + ); + assert!(!state.assignees.contains(VOLUNTEER)); + assert_eq!(state.heads.get(VOLUNTEER), Some(&self_unassign)); + + let owner_unassign = "7".repeat(64); + let self_assign = "8".repeat(64); + let state = reduce_assignment_operations( + ISSUE, + AUTHOR, + OWNER, + &[ + assignment_event(OWNER, &owner_unassign, false, 200, None), + assignment_event(VOLUNTEER, &self_assign, true, 300, Some(&owner_unassign)), + ], + ); + assert!(state.assignees.contains(VOLUNTEER)); + assert_eq!(state.heads.get(VOLUNTEER), Some(&self_assign)); + } + + #[test] + fn stale_causal_self_operation_is_ignored() { + let initial_assign = "9".repeat(64); + let owner_unassign = "a".repeat(64); + let state = reduce_assignment_operations( + ISSUE, + AUTHOR, + OWNER, + &[ + assignment_event(OWNER, &initial_assign, true, 100, None), + assignment_event(OWNER, &owner_unassign, false, 200, None), + assignment_event(VOLUNTEER, &"c".repeat(64), true, 300, Some(&initial_assign)), + ], + ); + + assert!(!state.assignees.contains(VOLUNTEER)); + assert_eq!(state.heads.get(VOLUNTEER), Some(&owner_unassign)); } } diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index e6798dbfc4..32056bc699 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -108,13 +108,29 @@ fn make_tag(parts: &[&str]) -> Result { // ── Submit helper ───────────────────────────────────────────────────────────── -async fn submit_project(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> { +/// Submit a project event and print the relay's write response. +/// +/// `link_slug` carries the project's d-tag on creates whose slug fits the +/// `buzz://` link charset; the response then also carries a `link` field, +/// which renders as a rich preview card in Buzz Desktop when included in a +/// chat message — agents announce projects with it (see base_prompt.md). +async fn submit_project( + client: &BuzzClient, + builder: EventBuilder, + link_slug: Option<&str>, +) -> Result<(), CliError> { let event = client.sign_event(builder)?; + let owner = event.pubkey.to_hex(); let raw = client.submit_event(event).await?; - println!( - "{}", - parse_write_response(&raw, "project changed concurrently; retry")? - ); + let response = parse_write_response(&raw, "project changed concurrently; retry")?; + match link_slug { + Some(slug) => crate::client::print_create_response( + &response, + "link", + &crate::links::project_link(&owner, slug), + ), + None => println!("{response}"), + } Ok(()) } @@ -207,7 +223,15 @@ pub async fn cmd_create( // ── Build via Layer B (enforces all writer policy) ──────────────────── let builder = build_project(slug, name, description, &members, channel, visibility) .map_err(|e| CliError::Usage(e.to_string()))?; - submit_project(client, builder).await + + // Slugs wider than the link charset stay linkless rather than emitting a + // `link` no client can parse. + submit_project( + client, + builder, + crate::links::is_linkable_dtag(slug).then_some(slug), + ) + .await } /// `buzz projects get` @@ -320,7 +344,7 @@ pub async fn cmd_add_repo( } let builder = rebuild_project(&head.content, tags, next_ts)?; - submit_project(client, builder).await + submit_project(client, builder, None).await } /// `buzz projects remove-repo` @@ -383,7 +407,7 @@ pub async fn cmd_remove_repo( // Single rebuild validates the full envelope and strips any remaining auth. let builder = rebuild_project(&head.content, tags, next_ts)?; - submit_project(client, builder).await + submit_project(client, builder, None).await } /// `buzz projects update` @@ -484,7 +508,7 @@ pub async fn cmd_update( let builder = build_project_with_tags(&head.content, tags) .map_err(|e| CliError::Other(format!("envelope validation failed: {e}")))? .custom_created_at(next_ts); - submit_project(client, builder).await + submit_project(client, builder, None).await } /// `buzz projects delete` diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 3893c5b642..2b041da57b 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1695,6 +1695,47 @@ pub enum IssuesCmd { #[arg(long = "to")] to: Vec, }, + /// Assign an issue to one or more people or agents. Only assignments + /// signed by the issue author or repo owner are trusted by clients; + /// anyone may assign themselves (sole assignee = your own pubkey). + Assign { + /// Issue event id (64-char hex) + #[arg(long)] + issue: String, + /// Repo owner pubkey (64-char hex) + #[arg(long)] + repo_owner: String, + /// Repo identifier (d-tag) + #[arg(long)] + repo_id: String, + /// Assignee pubkey (64-char hex) — can be specified multiple times + #[arg(long = "assignee", required = true)] + assignee: Vec, + /// Human-readable assignee name(s) for the note body, e.g. "Thomas". + /// Defaults to the truncated assignee pubkeys. + #[arg(long)] + label: Option, + }, + /// Remove one or more assignees from an issue. Issue authors and repo + /// owners may remove anyone; other users may remove only themselves. + Unassign { + /// Issue event id (64-char hex) + #[arg(long)] + issue: String, + /// Repo owner pubkey (64-char hex) + #[arg(long)] + repo_owner: String, + /// Repo identifier (d-tag) + #[arg(long)] + repo_id: String, + /// Assignee pubkey to remove — can be specified multiple times + #[arg(long = "assignee", required = true)] + assignee: Vec, + /// Human-readable assignee name(s) for the note body. + /// Defaults to the truncated assignee pubkeys. + #[arg(long)] + label: Option, + }, } #[derive(Subcommand)] @@ -2290,7 +2331,7 @@ mod tests { ); assert_eq!( names(&cmd, "issues"), - vec!["create", "get", "list", "status"] + vec!["assign", "create", "get", "list", "status", "unassign"] ); assert_eq!(names(&cmd, "media"), vec!["get"]); assert_eq!(names(&cmd, "upload"), vec!["file"]); @@ -2319,7 +2360,7 @@ mod tests { ("dms", 4), ("emoji", 5), ("feed", 1), - ("issues", 4), + ("issues", 6), ("media", 1), ("messages", 8), ("pack", 2), diff --git a/crates/buzz-cli/src/links.rs b/crates/buzz-cli/src/links.rs index 043bdc48b0..7d512710d4 100644 --- a/crates/buzz-cli/src/links.rs +++ b/crates/buzz-cli/src/links.rs @@ -8,12 +8,41 @@ //! //! Callers are expected to validate inputs first (`validate_hex64`, //! `validate_repo_id`); the identifier charsets need no URL encoding. +//! +//! Coordinate links additionally accept an optional `&tab=` parameter +//! (`files|commits|issues|prs|contributors|channels`) selecting a workspace tab on +//! the receiving side. The CLI builders emit the canonical no-tab form +//! (overview); the parameter exists for the desktop's tab-aware copy-link +//! button. + +/// Whether a d-tag can be expressed in a `buzz://` link. +/// +/// Project slugs accept up to 1024 bytes of arbitrary UTF-8, but the link +/// format is restricted to `[a-zA-Z0-9._-]{1,64}` (no leading dot, no `..`) +/// so links need no escaping and stay safe to paste. Callers must check +/// before building a link and omit the field when it returns false, rather +/// than emitting a link no client can parse. Mirrors `isValidDtag` in +/// `desktop/src/shared/lib/entityLink.ts`. +pub fn is_linkable_dtag(dtag: &str) -> bool { + !dtag.is_empty() + && dtag.len() <= 64 + && !dtag.starts_with('.') + && !dtag.contains("..") + && dtag + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') +} /// Build a `buzz://repo` link for a repository announcement (kind 30617). pub fn repo_link(owner: &str, repo_id: &str) -> String { format!("buzz://repo?owner={owner}&d={repo_id}") } +/// Build a `buzz://project` link for a project announcement (kind 30621). +pub fn project_link(owner: &str, project_id: &str) -> String { + format!("buzz://project?owner={owner}&d={project_id}") +} + /// Build a `buzz://pr` link for a pull request event (kind 1618). pub fn pull_request_link(event_id: &str, owner: &str, repo_id: &str) -> String { format!("buzz://pr?id={event_id}&owner={owner}&d={repo_id}") @@ -27,25 +56,49 @@ pub fn issue_link(event_id: &str, owner: &str, repo_id: &str) -> String { #[cfg(test)] mod tests { use super::*; + use serde_json::Value; - const OWNER: &str = "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; - const EVENT_ID: &str = "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; + fn golden() -> Value { + serde_json::from_str(include_str!("../../../test-fixtures/entity-links.json")) + .expect("valid entity-links golden fixture") + } - // Golden strings shared with desktop/src/shared/lib/entityLink.test.mjs - // ("builders emit the canonical cross-language link format"). #[test] fn golden_format_matches_desktop() { + let golden = golden(); + let owner = golden["owner"].as_str().unwrap(); + let event_id = golden["eventId"].as_str().unwrap(); + let dtag = golden["dtag"].as_str().unwrap(); assert_eq!( - pull_request_link(EVENT_ID, OWNER, "buzz-world"), - format!("buzz://pr?id={EVENT_ID}&owner={OWNER}&d=buzz-world") + pull_request_link(event_id, owner, dtag), + golden["links"]["pullRequest"].as_str().unwrap() ); assert_eq!( - issue_link(EVENT_ID, OWNER, "buzz-world"), - format!("buzz://issue?id={EVENT_ID}&owner={OWNER}&d=buzz-world") + issue_link(event_id, owner, dtag), + golden["links"]["issue"].as_str().unwrap() ); assert_eq!( - repo_link(OWNER, "buzz-world"), - format!("buzz://repo?owner={OWNER}&d=buzz-world") + repo_link(owner, dtag), + golden["links"]["repository"].as_str().unwrap() ); + assert_eq!( + project_link(owner, dtag), + golden["links"]["project"].as_str().unwrap() + ); + } + + #[test] + fn linkable_dtag_matches_the_desktop_charset() { + let golden = golden(); + for ok in golden["validDtags"].as_array().unwrap() { + let ok = ok.as_str().unwrap(); + assert!(is_linkable_dtag(ok), "{ok:?} should be linkable"); + } + assert!(is_linkable_dtag(&"a".repeat(64))); + for bad in golden["invalidDtags"].as_array().unwrap() { + let bad = bad.as_str().unwrap(); + assert!(!is_linkable_dtag(bad), "{bad:?} should not be linkable"); + } + assert!(!is_linkable_dtag(&"a".repeat(65))); } } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 948fa775f5..30311ddcf4 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -1121,6 +1121,131 @@ pub fn build_git_issue( Ok(EventBuilder::new(Kind::Custom(KIND_GIT_ISSUE as u16), content).tags(tags)) } +/// Build an issue assignment note (kind:1) — a labeled comment whose `p` +/// tags are the assignees, mirroring the Desktop app's assignment events. +/// +/// Tag layout: `["e", , "", "root"]`, `["a", ]`, one `["p", ..]` +/// per assignee, and `["t", "assignment"]`. +/// +/// Clients only trust assignments signed by the issue author or the repo +/// owner (who may assign anyone), or a self-assignment whose sole assignee +/// is the signer. Assignments from other signers are ignored on read. +pub fn build_git_issue_assignment( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, +) -> Result { + build_git_issue_assignment_with_prior(repo, issue_id, assignees, content, None) +} + +/// Build an issue assignment note with an optional causal assignment-operation +/// event ID in a `["prior", ]` tag. +/// +/// `prior`, when present, must be a 64-character hexadecimal event ID. +pub fn build_git_issue_assignment_with_prior( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, + prior: Option<&str>, +) -> Result { + build_git_issue_assignee_operation( + repo, + issue_id, + assignees, + content, + GitIssueAssigneeOperation::Assign, + prior, + ) +} + +/// Build an issue unassignment note (kind:1) whose `p` tags name the people +/// being removed and whose operation label is `t: unassignment`. +/// +/// Clients trust unassignments signed by the issue author or repository owner, +/// or a self-unassignment whose sole `p` tag is the signer. +pub fn build_git_issue_unassignment( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, +) -> Result { + build_git_issue_unassignment_with_prior(repo, issue_id, assignees, content, None) +} + +/// Build an issue unassignment note with an optional causal +/// assignment-operation event ID in a `["prior", ]` tag. +/// +/// `prior`, when present, must be a 64-character hexadecimal event ID. +pub fn build_git_issue_unassignment_with_prior( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, + prior: Option<&str>, +) -> Result { + build_git_issue_assignee_operation( + repo, + issue_id, + assignees, + content, + GitIssueAssigneeOperation::Unassign, + prior, + ) +} + +#[derive(Clone, Copy)] +enum GitIssueAssigneeOperation { + Assign, + Unassign, +} + +impl GitIssueAssigneeOperation { + fn label(self) -> &'static str { + match self { + Self::Assign => "assignment", + Self::Unassign => "unassignment", + } + } +} + +fn build_git_issue_assignee_operation( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, + operation: GitIssueAssigneeOperation, + prior: Option<&str>, +) -> Result { + check_content(content, 64 * 1024)?; + let issue = check_hex_exact(issue_id, 64, "issue")?; + let a_value = repo.to_a_tag_value()?; + if assignees.is_empty() || assignees.len() > 50 { + return Err(SdkError::InvalidInput( + "between 1 and 50 assignees are required".into(), + )); + } + let mut normalized = assignees + .iter() + .map(|assignee| check_pubkey_hex(assignee, "assignee")) + .collect::, _>>()?; + normalized.sort(); + normalized.dedup(); + + let mut tags = vec![tag(&["e", &issue, "", "root"])?, tag(&["a", &a_value])?]; + for assignee in &normalized { + tags.push(tag(&["p", assignee])?); + } + tags.push(tag(&["t", operation.label()])?); + if let Some(prior) = prior { + let prior = check_hex_exact(prior, 64, "prior assignment operation")?; + tags.push(tag(&["prior", &prior])?); + } + + Ok(EventBuilder::new(Kind::Custom(1), content).tags(tags)) +} + /// Status to apply to a patch or issue root (kind:1630/1631/1632/1633, NIP-34). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GitStatus { @@ -3481,6 +3606,149 @@ mod tests { assert!(matches!(err, SdkError::InvalidInput(_))); } + #[test] + fn git_issue_assignment_happy_path() { + let owner = "a".repeat(64); + let repo = GitRepoCoord { + owner: owner.clone(), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + // Duplicates (case-insensitive) collapse to a single p tag. + let assignees = vec!["C".repeat(64), "c".repeat(64), "d".repeat(64)]; + let ev = sign( + build_git_issue_assignment(&repo, &issue, &assignees, "Assigned this issue to Thomas") + .unwrap(), + ); + assert_eq!(ev.kind.as_u16(), 1); + assert_eq!(ev.content, "Assigned this issue to Thomas"); + assert!(has_tag(&ev, "e", &issue)); + assert!(has_tag(&ev, "a", &format!("30617:{owner}:repo"))); + assert!(has_tag(&ev, "p", &"c".repeat(64))); + assert!(has_tag(&ev, "p", &"d".repeat(64))); + assert!(has_tag(&ev, "t", "assignment")); + let p_count = ev + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("p")) + .count(); + assert_eq!(p_count, 2); + } + + #[test] + fn git_issue_assignment_rejects_bad_input() { + let repo = GitRepoCoord { + owner: "a".repeat(64), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + // No assignees. + let err = build_git_issue_assignment(&repo, &issue, &[], "x").unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + // Malformed assignee pubkey. + let err = + build_git_issue_assignment(&repo, &issue, &["nope".to_string()], "x").unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + // Malformed issue id. + let err = build_git_issue_assignment(&repo, "short", &["c".repeat(64)], "x").unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn git_issue_assignment_with_prior_emits_valid_causal_tag() { + let repo = GitRepoCoord { + owner: "a".repeat(64), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + let assignee = "c".repeat(64); + let prior = "d".repeat(64); + let ev = sign( + build_git_issue_assignment_with_prior( + &repo, + &issue, + &[assignee], + "Assigned this issue", + Some(&prior), + ) + .unwrap(), + ); + + assert!(has_tag(&ev, "prior", &prior)); + let unassignment = sign( + build_git_issue_unassignment_with_prior( + &repo, + &issue, + &["c".repeat(64)], + "Unassigned this issue", + Some(&prior), + ) + .unwrap(), + ); + assert!(has_tag(&unassignment, "prior", &prior)); + assert!(build_git_issue_assignment_with_prior( + &repo, + &issue, + &["c".repeat(64)], + "Assigned this issue", + Some("invalid"), + ) + .is_err()); + } + + #[test] + fn git_issue_unassignment_happy_path() { + let owner = "a".repeat(64); + let repo = GitRepoCoord { + owner: owner.clone(), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + let assignee = "c".repeat(64); + let ev = sign( + build_git_issue_unassignment( + &repo, + &issue, + std::slice::from_ref(&assignee), + "Unassigned Thomas from this issue", + ) + .unwrap(), + ); + assert_eq!(ev.kind.as_u16(), 1); + assert_eq!(ev.content, "Unassigned Thomas from this issue"); + assert!(has_tag(&ev, "e", &issue)); + assert!(has_tag(&ev, "a", &format!("30617:{owner}:repo"))); + assert!(has_tag(&ev, "p", &assignee)); + assert!(has_tag(&ev, "t", "unassignment")); + assert!(!has_tag(&ev, "t", "assignment")); + } + + #[test] + fn legacy_issue_assignment_builders_omit_prior() { + let repo = GitRepoCoord { + owner: "a".repeat(64), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + let assignees = vec!["c".repeat(64)]; + let assignment = sign( + build_git_issue_assignment(&repo, &issue, &assignees, "Assigned this issue").unwrap(), + ); + let unassignment = sign( + build_git_issue_unassignment(&repo, &issue, &assignees, "Unassigned this issue") + .unwrap(), + ); + + assert!(!assignment + .tags + .iter() + .any(|tag| { tag.as_slice().first().map(String::as_str) == Some("prior") })); + assert!(!unassignment + .tags + .iter() + .any(|tag| { tag.as_slice().first().map(String::as_str) == Some("prior") })); + } + #[test] fn git_status_open_happy_path() { let root = event_id().to_hex(); diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 8b272436fd..131657cbb9 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -109,6 +109,7 @@ export default defineConfig({ "**/send-channel-binding.spec.ts", "**/project-commit-detail.spec.ts", "**/project-inbox.spec.ts", + "**/projects-v3-screenshots.spec.ts", "**/project-issue-comments.spec.ts", "**/project-pr-review.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 4f839638b9..168be1ecf6 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -215,7 +215,7 @@ pub async fn search_messages( until: Option, state: State<'_, AppState>, ) -> Result { - let cap = limit.unwrap_or(20).min(100); + let cap = search_messages_limit(limit); let filter = build_search_messages_filter( &q, cap, @@ -229,6 +229,10 @@ pub async fn search_messages( Ok(nostr_convert::search_response_from_events(&events)) } +fn search_messages_limit(limit: Option) -> u32 { + limit.unwrap_or(20).min(500) +} + /// Fetch the full reply subtree under a thread root, server-side. /// /// Unlike the channel timeline (which the desktop assembles from its local diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs index a907a3dff1..c0ad03d936 100644 --- a/desktop/src-tauri/src/commands/messages_tests.rs +++ b/desktop/src-tauri/src/commands/messages_tests.rs @@ -1,5 +1,12 @@ use super::*; +#[test] +fn search_messages_limit_allows_discussion_discovery_page() { + assert_eq!(search_messages_limit(None), 20); + assert_eq!(search_messages_limit(Some(500)), 500); + assert_eq!(search_messages_limit(Some(1_000)), 500); +} + #[test] fn marker_author_scope_validates_scope_and_required_pubkey() { assert_eq!( diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 5247371646..761bee9cd3 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -52,6 +52,7 @@ mod project_git_diff; mod project_git_exec; mod project_git_merge_error; mod project_git_push; +mod project_git_recipient_notes; mod project_git_workflow; mod project_repo_paths; mod project_terminal; @@ -106,6 +107,7 @@ pub use profile::*; pub use project_git::*; pub use project_git_branches::*; pub use project_git_diff::*; +pub use project_git_recipient_notes::*; pub use project_git_workflow::*; pub use project_terminal::*; pub use qr_download::*; diff --git a/desktop/src-tauri/src/commands/project_git_recipient_notes.rs b/desktop/src-tauri/src/commands/project_git_recipient_notes.rs new file mode 100644 index 0000000000..4695749fed --- /dev/null +++ b/desktop/src-tauri/src/commands/project_git_recipient_notes.rs @@ -0,0 +1,430 @@ +//! Labeled recipient notes for the Projects workflow: kind:1 comments whose +//! `p` tags name recipients on a root event. Pull-request review requests +//! (`t: review-request`) and issue assignments (`t: assignment`) share this +//! shape so clients can parse them with one code path. + +use super::project_git_workflow::{ + normalize_event_id, project_owner_identity, validate_repo_address, +}; +use crate::app_state::AppState; +use crate::relay::submit_signed_event_with_keys; +use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; +use serde::Deserialize; +use tauri::{AppHandle, State}; + +/// Repository-scoped metadata for an agent-signed review request. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPullRequestReviewRequestInput { + target_owner: String, + repo_address: String, + pull_request_id: String, + reviewers: Vec, + reviewer_label: String, +} + +/// Repository-scoped metadata for an agent-signed issue assignee operation. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectIssueAssigneeOperationInput { + target_owner: String, + repo_address: String, + issue_id: String, + assignees: Vec, + assignee_label: String, + created_at: u64, +} + +#[derive(Clone, Copy)] +enum IssueAssigneeOperation { + Assign, + Unassign, +} + +impl IssueAssigneeOperation { + fn label(self) -> &'static str { + match self { + Self::Assign => "assignment", + Self::Unassign => "unassignment", + } + } + + fn content(self, assignee_label: &str) -> String { + match self { + Self::Assign => format!("Assigned this issue to {assignee_label}"), + Self::Unassign => format!("Unassigned {assignee_label} from this issue"), + } + } +} + +/// Parameters for [`build_labeled_recipient_note_event`]. +struct LabeledRecipientNote<'a> { + repo_address: &'a str, + root_id: &'a str, + root_id_error: &'a str, + recipients: &'a [String], + recipient_noun: &'a str, + label: &'a str, + content: String, + created_at: Option, +} + +/// Shared builder for labeled kind:1 notes tagging recipients (`p`) on a +/// root event — the convention used by both PR review requests +/// (`t: review-request`) and issue assignments (`t: assignment`). +fn build_labeled_recipient_note_event( + keys: &Keys, + note: LabeledRecipientNote<'_>, +) -> Result { + let LabeledRecipientNote { + repo_address, + root_id, + root_id_error, + recipients, + recipient_noun, + label, + content, + created_at, + } = note; + let owner = keys.public_key().to_hex(); + validate_repo_address(repo_address, &owner)?; + let root_id = normalize_event_id(root_id).ok_or_else(|| root_id_error.to_string())?; + if recipients.is_empty() || recipients.len() > 50 { + return Err(format!("Select between 1 and 50 {recipient_noun}s.")); + } + let mut recipients = recipients + .iter() + .map(|recipient| { + normalize_event_id(recipient).ok_or_else(|| format!("Invalid {recipient_noun} pubkey.")) + }) + .collect::, _>>()?; + recipients.sort(); + recipients.dedup(); + + let mut raw_tags = vec![ + vec!["e".to_string(), root_id, String::new(), "root".to_string()], + vec!["a".to_string(), repo_address.to_string()], + ]; + raw_tags.extend( + recipients + .into_iter() + .map(|recipient| vec!["p".to_string(), recipient]), + ); + raw_tags.push(vec!["t".to_string(), label.to_string()]); + let tags = raw_tags + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|error| format!("build {label} tags: {error}"))?; + let mut builder = EventBuilder::new(Kind::TextNote, content).tags(tags); + if let Some(created_at) = created_at { + builder = builder.custom_created_at(Timestamp::from_secs(created_at)); + } + builder + .sign_with_keys(keys) + .map(|event| event.as_json()) + .map_err(|error| format!("sign {label} note: {error}")) +} + +fn build_review_request_event( + keys: &Keys, + repo_address: &str, + pull_request_id: &str, + reviewers: &[String], + reviewer_label: &str, +) -> Result { + let reviewer_label = reviewer_label.trim(); + if reviewer_label.is_empty() || reviewer_label.chars().count() > 128 { + return Err("Reviewer label must be between 1 and 128 characters.".to_string()); + } + build_labeled_recipient_note_event( + keys, + LabeledRecipientNote { + repo_address, + root_id: pull_request_id, + root_id_error: "Invalid pull request event ID.", + recipients: reviewers, + recipient_noun: "reviewer", + label: "review-request", + content: format!("Requested a review from {reviewer_label}"), + created_at: None, + }, + ) +} + +#[cfg(test)] +fn build_issue_assignment_event( + keys: &Keys, + repo_address: &str, + issue_id: &str, + assignees: &[String], + assignee_label: &str, + created_at: Option, +) -> Result { + build_issue_assignee_operation_event( + keys, + repo_address, + issue_id, + assignees, + assignee_label, + created_at, + IssueAssigneeOperation::Assign, + ) +} + +#[cfg(test)] +fn build_issue_unassignment_event( + keys: &Keys, + repo_address: &str, + issue_id: &str, + assignees: &[String], + assignee_label: &str, + created_at: Option, +) -> Result { + build_issue_assignee_operation_event( + keys, + repo_address, + issue_id, + assignees, + assignee_label, + created_at, + IssueAssigneeOperation::Unassign, + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_issue_assignee_operation_event( + keys: &Keys, + repo_address: &str, + issue_id: &str, + assignees: &[String], + assignee_label: &str, + created_at: Option, + operation: IssueAssigneeOperation, +) -> Result { + let assignee_label = assignee_label.trim(); + if assignee_label.is_empty() || assignee_label.chars().count() > 128 { + return Err("Assignee label must be between 1 and 128 characters.".to_string()); + } + build_labeled_recipient_note_event( + keys, + LabeledRecipientNote { + repo_address, + root_id: issue_id, + root_id_error: "Invalid issue event ID.", + recipients: assignees, + recipient_noun: "assignee", + label: operation.label(), + content: operation.content(assignee_label), + created_at, + }, + ) +} + +#[tauri::command] +pub async fn sign_project_pull_request_review_request( + input: ProjectPullRequestReviewRequestInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let target_owner = input.target_owner.trim().to_ascii_lowercase(); + if normalize_event_id(&target_owner).is_none() { + return Err("Invalid target repository owner.".to_string()); + } + let identity = project_owner_identity(&app, &state, &target_owner)?; + let event = Event::from_json(build_review_request_event( + &identity.keys, + &input.repo_address, + &input.pull_request_id, + &input.reviewers, + &input.reviewer_label, + )?) + .map_err(|error| format!("parse signed review request: {error}"))?; + submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref()) + .await?; + Ok(()) +} + +#[tauri::command] +pub async fn sign_project_issue_assignment( + input: ProjectIssueAssigneeOperationInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + sign_project_issue_assignee_operation(input, IssueAssigneeOperation::Assign, app, state).await +} + +#[tauri::command] +pub async fn sign_project_issue_unassignment( + input: ProjectIssueAssigneeOperationInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + sign_project_issue_assignee_operation(input, IssueAssigneeOperation::Unassign, app, state).await +} + +async fn sign_project_issue_assignee_operation( + input: ProjectIssueAssigneeOperationInput, + operation: IssueAssigneeOperation, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let target_owner = input.target_owner.trim().to_ascii_lowercase(); + if normalize_event_id(&target_owner).is_none() { + return Err("Invalid target repository owner.".to_string()); + } + let identity = project_owner_identity(&app, &state, &target_owner)?; + let event = Event::from_json(build_issue_assignee_operation_event( + &identity.keys, + &input.repo_address, + &input.issue_id, + &input.assignees, + &input.assignee_label, + Some(input.created_at), + operation, + )?) + .map_err(|error| format!("parse signed issue {}: {error}", operation.label()))?; + submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref()) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + build_issue_assignment_event, build_issue_unassignment_event, build_review_request_event, + }; + use nostr::{Event, JsonUtil, Keys}; + + #[test] + fn issue_assignment_is_signed_by_repository_owner() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let assignee = "b".repeat(64); + let repo_address = format!("30617:{owner}:buzz"); + let event = Event::from_json( + build_issue_assignment_event( + &keys, + &repo_address, + &"d".repeat(64), + std::slice::from_ref(&assignee), + "Bob", + None, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(event.pubkey, keys.public_key()); + assert_eq!(event.kind, nostr::Kind::TextNote); + assert_eq!(event.content, "Assigned this issue to Bob"); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["p", assignee.as_str()])); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["t", "assignment"])); + assert!(event.verify().is_ok()); + } + + #[test] + fn issue_assignment_rejects_invalid_metadata() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let repo_address = format!("30617:{owner}:buzz"); + + assert!(build_issue_assignment_event( + &keys, + &repo_address, + &"d".repeat(64), + &[], + "Bob", + None, + ) + .is_err()); + assert!(build_issue_assignment_event( + &keys, + &repo_address, + &"d".repeat(64), + &["b".repeat(64)], + " ", + None, + ) + .is_err()); + assert!(build_issue_assignment_event( + &keys, + &repo_address, + "not-an-event-id", + &["b".repeat(64)], + "Bob", + None, + ) + .is_err()); + } + + #[test] + fn issue_unassignment_is_signed_by_repository_owner() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let assignee = "b".repeat(64); + let repo_address = format!("30617:{owner}:buzz"); + let event = Event::from_json( + build_issue_unassignment_event( + &keys, + &repo_address, + &"d".repeat(64), + std::slice::from_ref(&assignee), + "Bob", + Some(123), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(event.content, "Unassigned Bob from this issue"); + assert_eq!(event.created_at.as_secs(), 123); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["p", assignee.as_str()])); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["t", "unassignment"])); + assert!(event.verify().is_ok()); + } + + #[test] + fn review_request_is_signed_by_repository_owner() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let reviewer = "b".repeat(64); + let repo_address = format!("30617:{owner}:buzz"); + let event = Event::from_json( + build_review_request_event( + &keys, + &repo_address, + &"d".repeat(64), + std::slice::from_ref(&reviewer), + "Bob", + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(event.pubkey, keys.public_key()); + assert_eq!(event.kind, nostr::Kind::TextNote); + assert_eq!(event.content, "Requested a review from Bob"); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["p", reviewer.as_str()])); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["t", "review-request"])); + assert!(event.verify().is_ok()); + } +} diff --git a/desktop/src-tauri/src/commands/project_git_workflow.rs b/desktop/src-tauri/src/commands/project_git_workflow.rs index 9e06852762..2784068c7c 100644 --- a/desktop/src-tauri/src/commands/project_git_workflow.rs +++ b/desktop/src-tauri/src/commands/project_git_workflow.rs @@ -59,17 +59,6 @@ pub struct ProjectPullRequestMergeInput { expected_commit: String, } -/// Repository-scoped metadata for an agent-signed review request. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProjectPullRequestReviewRequestInput { - target_owner: String, - repo_address: String, - pull_request_id: String, - reviewers: Vec, - reviewer_label: String, -} - /// Repository-scoped metadata for an agent-signed lifecycle status. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -90,21 +79,41 @@ pub struct ProjectPullRequestMergedStatusInput { status_event: String, } +/// A project or repository announcement signed by its direct or managed owner. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectOwnerAnnouncementInput { + target_owner: String, + kind: u16, + content: String, + created_at: Option, + tags: Vec>, +} + +/// Signed announcement plus any relay publication failure for recovery. +#[derive(Serialize)] +pub struct ProjectOwnerAnnouncementResult { + /// Serialized signed Nostr event. + event: String, + /// Relay error when signing succeeded but publication did not. + publication_error: Option, +} + fn normalize_commit(value: &str) -> Option { clean_commit(Some(value.trim().to_ascii_lowercase())) } -fn normalize_event_id(value: &str) -> Option { +pub(crate) fn normalize_event_id(value: &str) -> Option { let value = value.trim().to_ascii_lowercase(); (value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())).then_some(value) } -struct ProjectOwnerIdentity { - keys: Keys, - auth_tag: Option, +pub(crate) struct ProjectOwnerIdentity { + pub(crate) keys: Keys, + pub(crate) auth_tag: Option, } -fn project_owner_identity( +pub(crate) fn project_owner_identity( app: &AppHandle, state: &AppState, target_owner: &str, @@ -126,7 +135,7 @@ fn project_owner_identity( .iter() .find(|record| record.pubkey.eq_ignore_ascii_case(target_owner)) .ok_or_else(|| { - "Only the repository owner or the owner of its managed agent can merge pull requests." + "Only the owner identity or the owner of its managed agent can perform this action." .to_string() })?; if let Some(error) = spawn_key_refusal(record) { @@ -143,7 +152,7 @@ fn project_owner_identity( }) } -fn validate_repo_address(repo_address: &str, owner: &str) -> Result<(), String> { +pub(crate) fn validate_repo_address(repo_address: &str, owner: &str) -> Result<(), String> { let prefix = format!("30617:{owner}:"); if repo_address.strip_prefix(&prefix).is_none_or(str::is_empty) { return Err("Repository address does not match the repository owner.".to_string()); @@ -151,6 +160,67 @@ fn validate_repo_address(repo_address: &str, owner: &str) -> Result<(), String> Ok(()) } +fn validate_project_owner_announcement( + input: &ProjectOwnerAnnouncementInput, +) -> Result<(), String> { + if !matches!(input.kind, 30_617 | 30_621) { + return Err("Only project and repository announcements can be signed here.".to_string()); + } + let has_valid_d_tag = input.tags.iter().any(|tag| { + tag.first().is_some_and(|value| value == "d") + && tag.get(1).is_some_and(|value| !value.trim().is_empty()) + }); + if !has_valid_d_tag { + return Err("Project and repository announcements require a non-empty d tag.".to_string()); + } + if let Some(created_at) = input.created_at { + // Mirror the ACP publish path (`build_project_owner_announcement_events`): + // these are addressable events where the latest created_at wins, so a + // far-future timestamp would wedge the head until that time. Reject + // anything more than 5 minutes ahead. + if created_at > Timestamp::now().as_secs().saturating_add(300) { + return Err("Announcement timestamp is too far in the future.".to_string()); + } + } + Ok(()) +} + +/// Sign and publish an addressable project event as a direct or managed owner. +#[tauri::command] +pub async fn publish_project_owner_announcement( + input: ProjectOwnerAnnouncementInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + validate_project_owner_announcement(&input)?; + let target_owner = input.target_owner.trim().to_ascii_lowercase(); + if normalize_event_id(&target_owner).is_none() { + return Err("Invalid project owner.".to_string()); + } + let identity = project_owner_identity(&app, &state, &target_owner)?; + let nostr_tags = input + .tags + .into_iter() + .map(|tag| Tag::parse(tag).map_err(|error| format!("invalid tag: {error}"))) + .collect::, _>>()?; + let mut builder = EventBuilder::new(Kind::Custom(input.kind), input.content).tags(nostr_tags); + if let Some(created_at) = input.created_at { + builder = builder.custom_created_at(Timestamp::from(created_at)); + } + let event = builder + .sign_with_keys(&identity.keys) + .map_err(|error| format!("sign failed: {error}"))?; + let publication_error = + submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref()) + .await + .err(); + + Ok(ProjectOwnerAnnouncementResult { + event: event.as_json(), + publication_error, + }) +} + fn validate_merge_status_metadata( repo_address: &str, owner: &str, @@ -243,63 +313,6 @@ fn build_pull_request_status_event( .map_err(|error| format!("sign pull request status: {error}")) } -fn build_review_request_event( - keys: &Keys, - repo_address: &str, - pull_request_id: &str, - reviewers: &[String], - reviewer_label: &str, -) -> Result { - let owner = keys.public_key().to_hex(); - validate_repo_address(repo_address, &owner)?; - let pull_request_id = normalize_event_id(pull_request_id) - .ok_or_else(|| "Invalid pull request event ID.".to_string())?; - if reviewers.is_empty() || reviewers.len() > 50 { - return Err("Select between 1 and 50 reviewers.".to_string()); - } - let mut reviewers = reviewers - .iter() - .map(|reviewer| { - normalize_event_id(reviewer).ok_or_else(|| "Invalid reviewer pubkey.".to_string()) - }) - .collect::, _>>()?; - reviewers.sort(); - reviewers.dedup(); - let reviewer_label = reviewer_label.trim(); - if reviewer_label.is_empty() || reviewer_label.chars().count() > 128 { - return Err("Reviewer label must be between 1 and 128 characters.".to_string()); - } - - let mut raw_tags = vec![ - vec![ - "e".to_string(), - pull_request_id, - String::new(), - "root".to_string(), - ], - vec!["a".to_string(), repo_address.to_string()], - ]; - raw_tags.extend( - reviewers - .into_iter() - .map(|reviewer| vec!["p".to_string(), reviewer]), - ); - raw_tags.push(vec!["t".to_string(), "review-request".to_string()]); - let tags = raw_tags - .into_iter() - .map(Tag::parse) - .collect::, _>>() - .map_err(|error| format!("build review request tags: {error}"))?; - EventBuilder::new( - Kind::TextNote, - format!("Requested a review from {reviewer_label}"), - ) - .tags(tags) - .sign_with_keys(keys) - .map(|event| event.as_json()) - .map_err(|error| format!("sign pull request review request: {error}")) -} - fn same_repository(left: &str, right: &str) -> bool { left.trim() .trim_end_matches('/') @@ -452,30 +465,6 @@ pub async fn sign_project_pull_request_status( Ok(()) } -#[tauri::command] -pub async fn sign_project_pull_request_review_request( - input: ProjectPullRequestReviewRequestInput, - app: AppHandle, - state: State<'_, AppState>, -) -> Result<(), String> { - let target_owner = input.target_owner.trim().to_ascii_lowercase(); - if normalize_event_id(&target_owner).is_none() { - return Err("Invalid target repository owner.".to_string()); - } - let identity = project_owner_identity(&app, &state, &target_owner)?; - let event = Event::from_json(build_review_request_event( - &identity.keys, - &input.repo_address, - &input.pull_request_id, - &input.reviewers, - &input.reviewer_label, - )?) - .map_err(|error| format!("parse signed review request: {error}"))?; - submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref()) - .await?; - Ok(()) -} - #[tauri::command] pub async fn publish_project_pull_request_merged_status( input: ProjectPullRequestMergedStatusInput, @@ -683,8 +672,8 @@ pub async fn merge_project_pull_request( mod tests { use super::{ align_unborn_head_branch, build_merged_status_event, build_pull_request_status_event, - build_review_request_event, normalize_commit, same_repository, - validate_merge_status_metadata, + normalize_commit, same_repository, validate_merge_status_metadata, + validate_project_owner_announcement, ProjectOwnerAnnouncementInput, }; use crate::commands::project_git_exec::{build_test_git_auth_config, run_git}; use nostr::{Event, JsonUtil, Keys, Timestamp}; @@ -717,6 +706,62 @@ mod tests { assert_eq!(normalize_commit(&"z".repeat(40)), None); } + #[test] + fn project_owner_announcement_is_limited_to_addressable_project_kinds() { + let valid = ProjectOwnerAnnouncementInput { + target_owner: "a".repeat(64), + kind: 30_621, + content: String::new(), + created_at: Some(1), + tags: vec![vec!["d".to_string(), "project".to_string()]], + }; + assert!(validate_project_owner_announcement(&valid).is_ok()); + + let invalid_kind = ProjectOwnerAnnouncementInput { kind: 1, ..valid }; + assert_eq!( + validate_project_owner_announcement(&invalid_kind), + Err("Only project and repository announcements can be signed here.".to_string()) + ); + } + + #[test] + fn project_owner_announcement_requires_an_address() { + let input = ProjectOwnerAnnouncementInput { + target_owner: "a".repeat(64), + kind: 30_617, + content: String::new(), + created_at: None, + tags: vec![vec!["name".to_string(), "buzz".to_string()]], + }; + assert_eq!( + validate_project_owner_announcement(&input), + Err("Project and repository announcements require a non-empty d tag.".to_string()) + ); + } + + #[test] + fn project_owner_announcement_rejects_far_future_timestamps() { + // Mirrors the ACP path's +300s cap: an addressable head stamped far in + // the future could not be superseded until that time. + let base = ProjectOwnerAnnouncementInput { + target_owner: "a".repeat(64), + kind: 30_621, + content: String::new(), + created_at: Some(Timestamp::now().as_secs() + 200), + tags: vec![vec!["d".to_string(), "project".to_string()]], + }; + assert!(validate_project_owner_announcement(&base).is_ok()); + + let far_future = ProjectOwnerAnnouncementInput { + created_at: Some(Timestamp::now().as_secs() + 301), + ..base + }; + assert_eq!( + validate_project_owner_announcement(&far_future), + Err("Announcement timestamp is too far in the future.".to_string()) + ); + } + #[test] fn repository_comparison_normalizes_git_suffix_and_trailing_slash() { assert!(same_repository( @@ -850,36 +895,4 @@ mod tests { ) .is_err()); } - - #[test] - fn review_request_is_signed_by_repository_owner() { - let keys = Keys::generate(); - let owner = keys.public_key().to_hex(); - let reviewer = "b".repeat(64); - let repo_address = format!("30617:{owner}:buzz"); - let event = Event::from_json( - build_review_request_event( - &keys, - &repo_address, - &"d".repeat(64), - std::slice::from_ref(&reviewer), - "Bob", - ) - .unwrap(), - ) - .unwrap(); - - assert_eq!(event.pubkey, keys.public_key()); - assert_eq!(event.kind, nostr::Kind::TextNote); - assert_eq!(event.content, "Requested a review from Bob"); - assert!(event - .tags - .iter() - .any(|tag| tag.as_slice() == ["p", reviewer.as_str()])); - assert!(event - .tags - .iter() - .any(|tag| tag.as_slice() == ["t", "review-request"])); - assert!(event.verify().is_ok()); - } } diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 08366673e4..9b4a01b1c7 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -127,6 +127,49 @@ impl PendingCommunityDeepLinks { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PendingEntityDeepLink { + id: String, + href: String, +} + +#[derive(Default)] +pub(crate) struct PendingEntityDeepLinks(Mutex>); + +impl PendingEntityDeepLinks { + fn enqueue(&self, href: String) -> PendingEntityDeepLink { + let mut queue = self.0.lock().expect("pending deep-link queue poisoned"); + if let Some(existing) = queue.iter().find(|item| item.href == href) { + return existing.clone(); + } + let pending = PendingEntityDeepLink { + id: uuid::Uuid::new_v4().to_string(), + href, + }; + queue.push_back(pending.clone()); + pending + } + + fn first(&self) -> Option { + self.0 + .lock() + .expect("pending deep-link queue poisoned") + .front() + .cloned() + } + + fn acknowledge(&self, id: &str) -> bool { + let mut queue = self.0.lock().expect("pending deep-link queue poisoned"); + if queue.front().is_some_and(|item| item.id == id) { + queue.pop_front(); + true + } else { + false + } + } +} + #[tauri::command] pub(crate) fn take_pending_community_deep_link( pending: State<'_, PendingCommunityDeepLinks>, @@ -142,6 +185,21 @@ pub(crate) fn acknowledge_pending_community_deep_link( pending.acknowledge(&id) } +#[tauri::command] +pub(crate) fn take_pending_entity_deep_link( + pending: State<'_, PendingEntityDeepLinks>, +) -> Option { + pending.first() +} + +#[tauri::command] +pub(crate) fn acknowledge_pending_entity_deep_link( + id: String, + pending: State<'_, PendingEntityDeepLinks>, +) -> bool { + pending.acknowledge(&id) +} + fn queue_community_deep_link( app: &tauri::AppHandle, kind: &str, @@ -175,6 +233,10 @@ fn queue_navigation_deep_link(app: &tauri::AppHandle, kind: &str, payload: &serd }); } +fn queue_entity_deep_link(app: &tauri::AppHandle, href: String) -> PendingEntityDeepLink { + app.state::().enqueue(href) +} + fn activate_main_window(app: &tauri::AppHandle) { let Some(window) = app.get_webview_window("main") else { return; @@ -220,6 +282,29 @@ fn parse_channel_deep_link(url: &Url) -> Option { }) } +#[cfg(desktop)] +pub(crate) fn install_deep_link_handlers(app: &mut tauri::App) { + use tauri_plugin_deep_link::DeepLinkExt; + + let dl_handle = app.handle().clone(); + app.deep_link().on_open_url(move |event| { + for url in event.urls() { + handle_deep_link_url(&dl_handle, url.as_str()); + } + }); + + #[cfg(any(target_os = "windows", target_os = "linux"))] + match app.deep_link().get_current() { + Ok(Some(urls)) => { + for url in urls { + handle_deep_link_url(app.handle(), url.as_str()); + } + } + Ok(None) => {} + Err(error) => eprintln!("buzz-desktop: failed to read launch deep link: {error}"), + } +} + /// Parse the query string of a `buzz://message?…` URL into the JSON /// payload emitted on `deep-link-message`. Returns `None` when a required /// param (`channel`, `id`) is missing or empty — mirroring the validation @@ -279,6 +364,90 @@ fn parse_join_deep_link(url: &Url) -> Option { })) } +/// Hosts of the `buzz://` git-entity links built by +/// `desktop/src/shared/lib/entityLink.ts` and `crates/buzz-cli/src/links.rs`. +const ENTITY_LINK_HOSTS: [&str; 4] = ["repo", "project", "pr", "issue"]; + +fn is_hex64(value: &str) -> bool { + value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Mirrors `isValidDtag` in `entityLink.ts` — the link format addresses a +/// narrower d-tag charset than Nostr allows. +fn is_linkable_dtag(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + && !value.starts_with('.') + && !value.contains("..") +} + +/// Validate a `buzz://repo|project|pr|issue?…` link and return it verbatim +/// for the frontend, which re-parses it with `parseEntityLink` before +/// navigating. Validating here too keeps a malformed link from raising and +/// focusing the window for a navigation that would then be declined. +/// +/// Workspace tabs addressable by `buzz://repo|project` links — mirrors +/// `ENTITY_LINK_TABS` in `entityLink.ts`. +const ENTITY_LINK_TABS: [&str; 6] = [ + "files", + "commits", + "issues", + "prs", + "contributors", + "channels", +]; + +/// The canonical-form rules match `parseEntityLink`: no path segments, no +/// fragment, and no parameters beyond `owner`/`d` (plus `id` for event +/// links and the optional `tab` for coordinate links), so a future +/// extension of the format is declined by old builds rather than silently +/// misread. +fn parse_entity_deep_link(url: &Url) -> Option<()> { + let host = url.host_str()?; + if !ENTITY_LINK_HOSTS.contains(&host) { + return None; + } + if !matches!(url.path(), "" | "/") || url.fragment().is_some() { + return None; + } + + let needs_event_id = host == "pr" || host == "issue"; + let allows_tab = host == "repo" || host == "project"; + let (mut owner, mut dtag, mut id, mut tab) = (None, None, None, None); + for (key, value) in url.query_pairs() { + let slot = match key.as_ref() { + "owner" => &mut owner, + "d" => &mut dtag, + "id" if needs_event_id => &mut id, + "tab" if allows_tab => &mut tab, + _ => return None, + }; + if slot.is_some() { + return None; + } + *slot = Some(value.into_owned()); + } + + if !owner.is_some_and(|owner| is_hex64(&owner)) { + return None; + } + if !dtag.is_some_and(|dtag| is_linkable_dtag(&dtag)) { + return None; + } + if needs_event_id && !id.is_some_and(|id| is_hex64(&id)) { + return None; + } + if let Some(tab) = tab { + if !ENTITY_LINK_TABS.contains(&tab.as_str()) { + return None; + } + } + Some(()) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct AddCommunityDeepLinkPayload { @@ -411,6 +580,7 @@ fn parse_nostr_bind_deep_link(url: &Url) -> Result` — emits `deep-link-connect` to the frontend +/// - `buzz://repo|project|pr|issue?…` — emits `deep-link-entity` to the frontend pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { let url = match Url::parse(url_str) { Ok(u) => u, @@ -497,6 +667,20 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { queue_navigation_deep_link(app, "message", &payload); let _ = app.emit("deep-link-message", payload); } + Some("repo" | "project" | "pr" | "issue") => { + // `buzz://repo|project?owner=&d=` and + // `buzz://pr|issue?id=&owner=&d=` — the + // share links copied from the Projects UI. The frontend owns + // routing (`useEntityDeepLinks`), so the validated URL is + // forwarded unchanged. + if parse_entity_deep_link(&url).is_none() { + eprintln!("buzz-desktop: malformed entity deep link: {url_str}"); + return; + } + activate_main_window(app); + let pending = queue_entity_deep_link(app, url_str.to_owned()); + let _ = app.emit("deep-link-entity", pending); + } Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) { Ok(payload) => { activate_main_window(app); @@ -516,479 +700,5 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } #[cfg(test)] -mod tests { - use url::Url; - - use super::{ - parse_add_community_deep_link, parse_channel_deep_link, parse_join_deep_link, - parse_message_deep_link, parse_nostr_bind_deep_link, PendingCommunityDeepLink, - PendingCommunityDeepLinks, PendingNavigationDeepLink, PendingNavigationDeepLinks, - }; - - fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { - PendingCommunityDeepLink { - id: id.to_owned(), - kind: if code.is_some() { "join" } else { "connect" }.to_owned(), - relay_url: relay_url.to_owned(), - code: code.map(str::to_owned), - policy_receipt: None, - name: None, - } - } - - fn pending_navigation( - id: &str, - kind: &str, - channel_id: &str, - message_id: Option<&str>, - thread_root_id: Option<&str>, - ) -> PendingNavigationDeepLink { - PendingNavigationDeepLink { - id: id.to_owned(), - kind: kind.to_owned(), - channel_id: channel_id.to_owned(), - message_id: message_id.map(str::to_owned), - thread_root_id: thread_root_id.map(str::to_owned), - } - } - - #[test] - fn pending_navigation_links_are_fifo_acknowledged_and_deduplicated() { - let queue = PendingNavigationDeepLinks::default(); - queue.enqueue(pending_navigation( - "first", - "channel", - "channel-1", - None, - None, - )); - queue.enqueue(pending_navigation( - "duplicate", - "channel", - "channel-1", - None, - None, - )); - queue.enqueue(pending_navigation( - "second", - "message", - "channel-1", - Some("message-1"), - Some("root-1"), - )); - - assert_eq!(queue.first().unwrap().id, "first"); - assert!(!queue.acknowledge("second")); - assert!(queue.acknowledge("first")); - assert_eq!(queue.first().unwrap().id, "second"); - assert!(queue.acknowledge("second")); - assert!(queue.first().is_none()); - } - - #[test] - fn pending_navigation_links_can_be_cleared() { - let queue = PendingNavigationDeepLinks::default(); - queue.enqueue(pending_navigation( - "first", - "channel", - "channel-1", - None, - None, - )); - queue.enqueue(pending_navigation( - "second", - "message", - "channel-1", - Some("message-1"), - None, - )); - - queue.clear(); - assert!(queue.first().is_none()); - } - - #[test] - fn pending_navigation_queue_recovers_after_mutex_poisoning() { - let queue = std::sync::Arc::new(PendingNavigationDeepLinks::default()); - let poisoner = std::sync::Arc::clone(&queue); - assert!(std::thread::spawn(move || { - let _guard = poisoner.0.lock().unwrap(); - panic!("poison queue for recovery regression"); - }) - .join() - .is_err()); - - queue.enqueue(pending_navigation( - "after-poison", - "channel", - "channel-1", - None, - None, - )); - assert_eq!(queue.first().unwrap().id, "after-poison"); - assert!(queue.acknowledge("after-poison")); - assert!(queue.first().is_none()); - } - - #[test] - fn pending_join_serializes_policy_receipt_for_cold_launch_recovery() { - let mut link = pending("join", "wss://relay.example", Some("invite")); - link.policy_receipt = Some("relay-signed-receipt".to_owned()); - - let payload = serde_json::to_value(link).unwrap(); - assert_eq!(payload["policyReceipt"], "relay-signed-receipt"); - } - - #[test] - fn pending_community_links_are_fifo_and_acknowledged_in_order() { - let queue = PendingCommunityDeepLinks::default(); - queue.enqueue(pending("first", "wss://one.example", Some("one"))); - queue.enqueue(pending("second", "wss://two.example", Some("two"))); - assert_eq!(queue.first().unwrap().id, "first"); - assert!(!queue.acknowledge("second")); - assert!(queue.acknowledge("first")); - assert_eq!(queue.first().unwrap().id, "second"); - } - - #[test] - fn pending_community_links_dedupe_exact_intents() { - let queue = PendingCommunityDeepLinks::default(); - queue.enqueue(pending("first", "wss://one.example", Some("one"))); - queue.enqueue(pending("duplicate", "wss://one.example", Some("one"))); - assert!(queue.acknowledge("first")); - assert!(queue.first().is_none()); - } - - fn valid_nostr_bind_url() -> Url { - Url::parse( - "buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard", - ) - .unwrap() - } - - #[test] - fn parse_add_community_deep_link_extracts_relay_and_name() { - let url = Url::parse( - "buzz://add-community?relay=wss%3A%2F%2Facme.communities.buzz.xyz&name=Acme%20Team&ignored=value", - ) - .unwrap(); - let payload = parse_add_community_deep_link(&url).unwrap(); - assert_eq!(payload.relay_url, "wss://acme.communities.buzz.xyz"); - assert_eq!(payload.name.as_deref(), Some("Acme Team")); - } - - #[test] - fn parse_add_community_deep_link_accepts_an_omitted_or_empty_name() { - for raw in [ - "buzz://add-community?relay=wss%3A%2F%2Facme.example", - "buzz://add-community?relay=wss%3A%2F%2Facme.example&name=", - ] { - assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()) - .unwrap() - .name - .is_none()); - } - } - - #[test] - fn parse_add_community_deep_link_rejects_invalid_relays() { - for raw in [ - "buzz://add-community", - "buzz://add-community?relay=", - "buzz://add-community?relay=not-a-url", - "buzz://add-community?relay=https%3A%2F%2Facme.example", - "buzz://add-community?relay=wss%3A%2F%2F", - ] { - assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()).is_none()); - } - } - - #[test] - fn parse_channel_deep_link_accepts_one_path_segment() { - let url = Url::parse("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32").unwrap(); - let payload = parse_channel_deep_link(&url).unwrap(); - assert_eq!(payload["channelId"], "580ca78b-9dae-46f3-8854-bd671853ba32"); - } - - #[test] - fn parse_channel_deep_link_accepts_message_path() { - let message_id = "8455293f0123456789abcdef0123456789abcdef0123456789abcdef01234567"; - let url = Url::parse(&format!( - "buzz://channel/a372f080-5961-4535-b1a3-edffface377d/{message_id}" - )) - .unwrap(); - let payload = parse_channel_deep_link(&url).unwrap(); - assert_eq!(payload["channelId"], "a372f080-5961-4535-b1a3-edffface377d"); - assert_eq!(payload["messageId"], message_id); - } - - #[test] - fn parse_channel_deep_link_accepts_v7_and_normalizes_uppercase() { - for (raw, expected) in [ - ( - "buzz://channel/018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9", - "018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9", - ), - ( - "buzz://channel/580CA78B-9DAE-46F3-8854-BD671853BA32", - "580ca78b-9dae-46f3-8854-bd671853ba32", - ), - ] { - let payload = parse_channel_deep_link(&Url::parse(raw).unwrap()).unwrap(); - assert_eq!(payload["channelId"], expected); - } - } - - #[test] - fn parse_channel_deep_link_rejects_malformed_forms() { - for raw in [ - "buzz://channel", - "buzz://channel/", - "buzz://channel/one/two", - "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/not-hex", - "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/extra", - "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/", - "buzz://channel/one?extra=true", - "buzz://channel/one#fragment", - "buzz://:pass@channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "buzz://channel/not-a-uuid", - "buzz://channel/%2F", - "buzz://channel/%00", - ] { - assert!(parse_channel_deep_link(&Url::parse(raw).unwrap()).is_none()); - } - } - - #[test] - fn parse_message_deep_link_extracts_required_params() { - let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert_eq!(payload["channelId"], "abc"); - assert_eq!(payload["messageId"], "xyz"); - assert!(payload["threadRootId"].is_null()); - } - - #[test] - fn parse_message_deep_link_accepts_buzz_scheme() { - let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert_eq!(payload["channelId"], "abc"); - assert_eq!(payload["messageId"], "xyz"); - } - - #[test] - fn parse_message_deep_link_includes_thread_root() { - let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=root1").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert_eq!(payload["threadRootId"], "root1"); - } - - #[test] - fn parse_message_deep_link_rejects_missing_id() { - let url = Url::parse("buzz://message?channel=abc").unwrap(); - assert!(parse_message_deep_link(&url).is_none()); - } - - #[test] - fn parse_message_deep_link_rejects_empty_channel() { - // Regression: `channel=&id=foo` previously produced channelId: "". - let url = Url::parse("buzz://message?channel=&id=foo").unwrap(); - assert!(parse_message_deep_link(&url).is_none()); - } - - #[test] - fn parse_message_deep_link_rejects_empty_id() { - let url = Url::parse("buzz://message?channel=abc&id=").unwrap(); - assert!(parse_message_deep_link(&url).is_none()); - } - - #[test] - fn parse_message_deep_link_treats_empty_thread_as_absent() { - let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert!(payload["threadRootId"].is_null()); - } - - #[test] - fn parse_join_deep_link_extracts_relay_and_code() { - let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def").unwrap(); - let payload = parse_join_deep_link(&url).expect("required params present"); - assert_eq!(payload["relayUrl"], "wss://relay.example"); - assert_eq!(payload["code"], "abc.def"); - assert!(payload["policyReceipt"].is_null()); - } - - #[test] - fn parse_join_deep_link_extracts_policy_receipt() { - let url = Url::parse( - "buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&policy_receipt=receipt.value", - ) - .unwrap(); - let payload = parse_join_deep_link(&url).expect("required params present"); - assert_eq!(payload["policyReceipt"], "receipt.value"); - } - - #[test] - fn parse_join_deep_link_rejects_missing_code() { - let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_join_deep_link_rejects_empty_code() { - let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_join_deep_link_rejects_missing_relay() { - let url = Url::parse("buzz://join?code=abc.def").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_join_deep_link_rejects_non_websocket_relay() { - let url = Url::parse("buzz://join?relay=https%3A%2F%2Frelay.example&code=abc.def").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_valid_url() { - let payload = parse_nostr_bind_deep_link(&valid_nostr_bind_url()).unwrap(); - assert_eq!(payload.challenge_id, "550e8400-e29b-41d4-a716-446655440000"); - assert_eq!(payload.nonce, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567"); - assert_eq!(payload.verification_code, "123456"); - assert_eq!(payload.audience, "buzz:nostr-identity"); - assert_eq!(payload.action, "bind_nostr_identity"); - assert_eq!(payload.protocol, "buzz-nostr-identity"); - assert_eq!(payload.version, "1"); - assert_eq!(payload.origin, "https://example.com"); - assert_eq!(payload.expires_at, "2999-01-01T00:00:00Z"); - assert_eq!(payload.return_mode, "clipboard"); - assert_eq!(payload.callback_url, None); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_same_origin_callback_url() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fexample.com%2Fbuzz%3FmockSession%3D1").unwrap(); - let payload = parse_nostr_bind_deep_link(&url).unwrap(); - assert_eq!( - payload.callback_url.as_deref(), - Some("https://example.com/buzz?mockSession=1") - ); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_browser_fragment_return() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1&callback_url=https%3A%2F%2Fexample.com%2Fbuzz").unwrap(); - let payload = parse_nostr_bind_deep_link(&url).unwrap(); - - assert_eq!(payload.return_mode, "browser_fragment_v1"); - assert_eq!( - payload.callback_url.as_deref(), - Some("https://example.com/buzz") - ); - } - - #[test] - fn parse_nostr_bind_deep_link_requires_callback_for_browser_fragment_return() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1").unwrap(); - - assert_eq!( - parse_nostr_bind_deep_link(&url).unwrap_err(), - "browser_fragment_v1 requires callback_url" - ); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_http_callback_url() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=http%3A%2F%2Fexample.com%2Fbuzz").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_missing_challenge_id() { - let url = Url::parse("buzz://nostr-bind?nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_empty_nonce() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_missing_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_short_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_long_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=1234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_non_digit_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345a&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_wrong_action() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=wrong&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_wrong_audience() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=other&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_non_https_origin() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=http%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_origin_with_path() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com%2Fbind&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_origin_with_credentials() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fuser%40example.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_unsupported_return_mode() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=callback").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_expired_link_for_user_facing_error() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2000-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - let payload = parse_nostr_bind_deep_link(&url).unwrap(); - assert_eq!(payload.expires_at, "2000-01-01T00:00:00Z"); - } -} +#[path = "deep_link_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/deep_link_tests.rs b/desktop/src-tauri/src/deep_link_tests.rs new file mode 100644 index 0000000000..eaddbb7a4c --- /dev/null +++ b/desktop/src-tauri/src/deep_link_tests.rs @@ -0,0 +1,569 @@ +use url::Url; + +use super::{ + parse_add_community_deep_link, parse_channel_deep_link, parse_entity_deep_link, + parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link, + PendingCommunityDeepLink, PendingCommunityDeepLinks, PendingEntityDeepLinks, + PendingNavigationDeepLink, PendingNavigationDeepLinks, ENTITY_LINK_TABS, +}; + +fn entity_link_golden() -> serde_json::Value { + serde_json::from_str(include_str!("../../../test-fixtures/entity-links.json")) + .expect("valid entity-links golden fixture") +} + +#[test] +fn parse_entity_deep_link_accepts_every_share_link_shape() { + let golden = entity_link_golden(); + let owner = golden["owner"].as_str().unwrap(); + let dtag = golden["dtag"].as_str().unwrap(); + for raw in golden["links"] + .as_object() + .unwrap() + .values() + .map(|value| value.as_str().unwrap().to_owned()) + .chain(golden["tabs"].as_array().unwrap().iter().map(|tab| { + format!( + "buzz://repo?owner={owner}&d={dtag}&tab={}", + tab.as_str().unwrap() + ) + })) + { + assert!( + parse_entity_deep_link(&Url::parse(&raw).unwrap()).is_some(), + "{raw}" + ); + } + let expected_tabs = golden["tabs"] + .as_array() + .unwrap() + .iter() + .map(|tab| tab.as_str().unwrap()) + .collect::>(); + assert_eq!(ENTITY_LINK_TABS.as_slice(), expected_tabs); +} + +#[test] +fn parse_entity_deep_link_rejects_malformed_and_non_canonical_links() { + let golden = entity_link_golden(); + let owner = golden["owner"].as_str().unwrap(); + let event_id = golden["eventId"].as_str().unwrap(); + for raw in [ + // Missing or malformed identifiers. + format!("buzz://repo?owner={owner}"), + "buzz://repo?owner=nope&d=buzz-world".to_owned(), + format!("buzz://repo?owner={owner}&d=.hidden"), + format!("buzz://repo?owner={owner}&d=has%20space"), + format!("buzz://pr?owner={owner}&d=buzz-world"), + format!("buzz://pr?id=short&owner={owner}&d=buzz-world"), + // Coordinate links take no event id. + format!("buzz://repo?id={event_id}&owner={owner}&d=buzz-world"), + // Non-canonical: unknown param, duplicate param, path, fragment. + format!("buzz://repo?owner={owner}&d=buzz-world&relay=wss%3A%2F%2Fx.example"), + format!("buzz://repo?owner={owner}&owner={owner}&d=buzz-world"), + // Unknown tab value, duplicate tab, and tab on an event link. + format!("buzz://repo?owner={owner}&d=buzz-world&tab=overview"), + format!("buzz://repo?owner={owner}&d=buzz-world&tab=prs&tab=prs"), + format!("buzz://pr?id={event_id}&owner={owner}&d=buzz-world&tab=prs"), + format!("buzz://repo/extra?owner={owner}&d=buzz-world"), + format!("buzz://repo?owner={owner}&d=buzz-world#top"), + // Not an entity host. + format!("buzz://message?owner={owner}&d=buzz-world"), + ] { + assert!( + parse_entity_deep_link(&Url::parse(&raw).unwrap()).is_none(), + "{raw}" + ); + } +} + +fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { + PendingCommunityDeepLink { + id: id.to_owned(), + kind: if code.is_some() { "join" } else { "connect" }.to_owned(), + relay_url: relay_url.to_owned(), + code: code.map(str::to_owned), + policy_receipt: None, + name: None, + } +} + +fn pending_navigation( + id: &str, + kind: &str, + channel_id: &str, + message_id: Option<&str>, + thread_root_id: Option<&str>, +) -> PendingNavigationDeepLink { + PendingNavigationDeepLink { + id: id.to_owned(), + kind: kind.to_owned(), + channel_id: channel_id.to_owned(), + message_id: message_id.map(str::to_owned), + thread_root_id: thread_root_id.map(str::to_owned), + } +} + +#[test] +fn pending_navigation_links_are_fifo_acknowledged_and_deduplicated() { + let queue = PendingNavigationDeepLinks::default(); + queue.enqueue(pending_navigation( + "first", + "channel", + "channel-1", + None, + None, + )); + queue.enqueue(pending_navigation( + "duplicate", + "channel", + "channel-1", + None, + None, + )); + queue.enqueue(pending_navigation( + "second", + "message", + "channel-1", + Some("message-1"), + Some("root-1"), + )); + + assert_eq!(queue.first().unwrap().id, "first"); + assert!(!queue.acknowledge("second")); + assert!(queue.acknowledge("first")); + assert_eq!(queue.first().unwrap().id, "second"); + assert!(queue.acknowledge("second")); + assert!(queue.first().is_none()); +} + +#[test] +fn pending_navigation_links_can_be_cleared() { + let queue = PendingNavigationDeepLinks::default(); + queue.enqueue(pending_navigation( + "first", + "channel", + "channel-1", + None, + None, + )); + queue.enqueue(pending_navigation( + "second", + "message", + "channel-1", + Some("message-1"), + None, + )); + + queue.clear(); + assert!(queue.first().is_none()); +} + +#[test] +fn pending_navigation_queue_recovers_after_mutex_poisoning() { + let queue = std::sync::Arc::new(PendingNavigationDeepLinks::default()); + let poisoner = std::sync::Arc::clone(&queue); + assert!(std::thread::spawn(move || { + let _guard = poisoner.0.lock().unwrap(); + panic!("poison queue for recovery regression"); + }) + .join() + .is_err()); + + queue.enqueue(pending_navigation( + "after-poison", + "channel", + "channel-1", + None, + None, + )); + assert_eq!(queue.first().unwrap().id, "after-poison"); + assert!(queue.acknowledge("after-poison")); + assert!(queue.first().is_none()); +} + +#[test] +fn pending_join_serializes_policy_receipt_for_cold_launch_recovery() { + let mut link = pending("join", "wss://relay.example", Some("invite")); + link.policy_receipt = Some("relay-signed-receipt".to_owned()); + + let payload = serde_json::to_value(link).unwrap(); + assert_eq!(payload["policyReceipt"], "relay-signed-receipt"); +} + +#[test] +fn pending_community_links_are_fifo_and_acknowledged_in_order() { + let queue = PendingCommunityDeepLinks::default(); + queue.enqueue(pending("first", "wss://one.example", Some("one"))); + queue.enqueue(pending("second", "wss://two.example", Some("two"))); + assert_eq!(queue.first().unwrap().id, "first"); + assert!(!queue.acknowledge("second")); + assert!(queue.acknowledge("first")); + assert_eq!(queue.first().unwrap().id, "second"); +} + +#[test] +fn pending_community_links_dedupe_exact_intents() { + let queue = PendingCommunityDeepLinks::default(); + queue.enqueue(pending("first", "wss://one.example", Some("one"))); + queue.enqueue(pending("duplicate", "wss://one.example", Some("one"))); + assert!(queue.acknowledge("first")); + assert!(queue.first().is_none()); +} + +#[test] +fn pending_entity_links_survive_until_acknowledged_in_order() { + let queue = PendingEntityDeepLinks::default(); + let first = queue.enqueue("buzz://project?owner=aa&d=first".to_owned()); + let second = queue.enqueue("buzz://project?owner=aa&d=second".to_owned()); + + assert_eq!(queue.first(), Some(first.clone())); + assert!(!queue.acknowledge(&second.id)); + assert!(queue.acknowledge(&first.id)); + assert_eq!(queue.first(), Some(second)); +} + +#[test] +fn pending_entity_links_dedupe_launch_and_open_callbacks() { + let queue = PendingEntityDeepLinks::default(); + let href = "buzz://project?owner=aa&d=buzz".to_owned(); + let first = queue.enqueue(href.clone()); + let duplicate = queue.enqueue(href); + + assert_eq!(duplicate.id, first.id); + assert!(queue.acknowledge(&first.id)); + assert!(queue.first().is_none()); +} + +fn valid_nostr_bind_url() -> Url { + Url::parse( + "buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard", + ) + .unwrap() +} + +#[test] +fn parse_add_community_deep_link_extracts_relay_and_name() { + let url = Url::parse( + "buzz://add-community?relay=wss%3A%2F%2Facme.communities.buzz.xyz&name=Acme%20Team&ignored=value", + ) + .unwrap(); + let payload = parse_add_community_deep_link(&url).unwrap(); + assert_eq!(payload.relay_url, "wss://acme.communities.buzz.xyz"); + assert_eq!(payload.name.as_deref(), Some("Acme Team")); +} + +#[test] +fn parse_add_community_deep_link_accepts_an_omitted_or_empty_name() { + for raw in [ + "buzz://add-community?relay=wss%3A%2F%2Facme.example", + "buzz://add-community?relay=wss%3A%2F%2Facme.example&name=", + ] { + assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()) + .unwrap() + .name + .is_none()); + } +} + +#[test] +fn parse_add_community_deep_link_rejects_invalid_relays() { + for raw in [ + "buzz://add-community", + "buzz://add-community?relay=", + "buzz://add-community?relay=not-a-url", + "buzz://add-community?relay=https%3A%2F%2Facme.example", + "buzz://add-community?relay=wss%3A%2F%2F", + ] { + assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()).is_none()); + } +} + +#[test] +fn parse_channel_deep_link_accepts_one_path_segment() { + let url = Url::parse("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32").unwrap(); + let payload = parse_channel_deep_link(&url).unwrap(); + assert_eq!(payload["channelId"], "580ca78b-9dae-46f3-8854-bd671853ba32"); +} + +#[test] +fn parse_channel_deep_link_accepts_message_path() { + let message_id = "8455293f0123456789abcdef0123456789abcdef0123456789abcdef01234567"; + let url = Url::parse(&format!( + "buzz://channel/a372f080-5961-4535-b1a3-edffface377d/{message_id}" + )) + .unwrap(); + let payload = parse_channel_deep_link(&url).unwrap(); + assert_eq!(payload["channelId"], "a372f080-5961-4535-b1a3-edffface377d"); + assert_eq!(payload["messageId"], message_id); +} + +#[test] +fn parse_channel_deep_link_accepts_v7_and_normalizes_uppercase() { + for (raw, expected) in [ + ( + "buzz://channel/018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9", + "018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9", + ), + ( + "buzz://channel/580CA78B-9DAE-46F3-8854-BD671853BA32", + "580ca78b-9dae-46f3-8854-bd671853ba32", + ), + ] { + let payload = parse_channel_deep_link(&Url::parse(raw).unwrap()).unwrap(); + assert_eq!(payload["channelId"], expected); + } +} + +#[test] +fn parse_channel_deep_link_rejects_malformed_forms() { + for raw in [ + "buzz://channel", + "buzz://channel/", + "buzz://channel/one/two", + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/not-hex", + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/extra", + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/", + "buzz://channel/one?extra=true", + "buzz://channel/one#fragment", + "buzz://:pass@channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "buzz://channel/not-a-uuid", + "buzz://channel/%2F", + "buzz://channel/%00", + ] { + assert!(parse_channel_deep_link(&Url::parse(raw).unwrap()).is_none()); + } +} + +#[test] +fn parse_message_deep_link_extracts_required_params() { + let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["channelId"], "abc"); + assert_eq!(payload["messageId"], "xyz"); + assert!(payload["threadRootId"].is_null()); +} + +#[test] +fn parse_message_deep_link_accepts_buzz_scheme() { + let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["channelId"], "abc"); + assert_eq!(payload["messageId"], "xyz"); +} + +#[test] +fn parse_message_deep_link_includes_thread_root() { + let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=root1").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["threadRootId"], "root1"); +} + +#[test] +fn parse_message_deep_link_rejects_missing_id() { + let url = Url::parse("buzz://message?channel=abc").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); +} + +#[test] +fn parse_message_deep_link_rejects_empty_channel() { + // Regression: `channel=&id=foo` previously produced channelId: "". + let url = Url::parse("buzz://message?channel=&id=foo").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); +} + +#[test] +fn parse_message_deep_link_rejects_empty_id() { + let url = Url::parse("buzz://message?channel=abc&id=").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); +} + +#[test] +fn parse_message_deep_link_treats_empty_thread_as_absent() { + let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert!(payload["threadRootId"].is_null()); +} + +#[test] +fn parse_join_deep_link_extracts_relay_and_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def").unwrap(); + let payload = parse_join_deep_link(&url).expect("required params present"); + assert_eq!(payload["relayUrl"], "wss://relay.example"); + assert_eq!(payload["code"], "abc.def"); + assert!(payload["policyReceipt"].is_null()); +} + +#[test] +fn parse_join_deep_link_extracts_policy_receipt() { + let url = Url::parse( + "buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&policy_receipt=receipt.value", + ) + .unwrap(); + let payload = parse_join_deep_link(&url).expect("required params present"); + assert_eq!(payload["policyReceipt"], "receipt.value"); +} + +#[test] +fn parse_join_deep_link_rejects_missing_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_join_deep_link_rejects_empty_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_join_deep_link_rejects_missing_relay() { + let url = Url::parse("buzz://join?code=abc.def").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_join_deep_link_rejects_non_websocket_relay() { + let url = Url::parse("buzz://join?relay=https%3A%2F%2Frelay.example&code=abc.def").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_valid_url() { + let payload = parse_nostr_bind_deep_link(&valid_nostr_bind_url()).unwrap(); + assert_eq!(payload.challenge_id, "550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(payload.nonce, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567"); + assert_eq!(payload.verification_code, "123456"); + assert_eq!(payload.audience, "buzz:nostr-identity"); + assert_eq!(payload.action, "bind_nostr_identity"); + assert_eq!(payload.protocol, "buzz-nostr-identity"); + assert_eq!(payload.version, "1"); + assert_eq!(payload.origin, "https://example.com"); + assert_eq!(payload.expires_at, "2999-01-01T00:00:00Z"); + assert_eq!(payload.return_mode, "clipboard"); + assert_eq!(payload.callback_url, None); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_same_origin_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fexample.com%2Fbuzz%3FmockSession%3D1").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + assert_eq!( + payload.callback_url.as_deref(), + Some("https://example.com/buzz?mockSession=1") + ); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_browser_fragment_return() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1&callback_url=https%3A%2F%2Fexample.com%2Fbuzz").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + + assert_eq!(payload.return_mode, "browser_fragment_v1"); + assert_eq!( + payload.callback_url.as_deref(), + Some("https://example.com/buzz") + ); +} + +#[test] +fn parse_nostr_bind_deep_link_requires_callback_for_browser_fragment_return() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1").unwrap(); + + assert_eq!( + parse_nostr_bind_deep_link(&url).unwrap_err(), + "browser_fragment_v1 requires callback_url" + ); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_http_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=http%3A%2F%2Fexample.com%2Fbuzz").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_missing_challenge_id() { + let url = Url::parse("buzz://nostr-bind?nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_empty_nonce() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_missing_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_short_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_long_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=1234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_non_digit_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345a&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_wrong_action() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=wrong&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_wrong_audience() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=other&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_non_https_origin() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=http%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_origin_with_path() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com%2Fbind&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_origin_with_credentials() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fuser%40example.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_unsupported_return_mode() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=callback").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_expired_link_for_user_facing_error() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2000-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + assert_eq!(payload.expires_at, "2000-01-01T00:00:00Z"); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 436d9c63ba..6f3f48f3a8 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -49,9 +49,11 @@ use app_state::{build_app_state, resolve_persisted_identity, AppState}; use builderlab::*; use commands::*; use deep_link::{ - acknowledge_pending_community_deep_link, acknowledge_pending_navigation_deep_link, - clear_pending_navigation_deep_links, handle_deep_link_url, take_pending_community_deep_link, - take_pending_navigation_deep_link, PendingCommunityDeepLinks, PendingNavigationDeepLinks, + acknowledge_pending_community_deep_link, acknowledge_pending_entity_deep_link, + acknowledge_pending_navigation_deep_link, clear_pending_navigation_deep_links, + handle_deep_link_url, take_pending_community_deep_link, take_pending_entity_deep_link, + take_pending_navigation_deep_link, PendingCommunityDeepLinks, PendingEntityDeepLinks, + PendingNavigationDeepLinks, }; use huddle::audio_output::{ get_audio_output_device, list_audio_output_devices, set_audio_output_device, @@ -304,6 +306,7 @@ pub fn run() { .manage(ClipboardState::new()) .manage(PendingCommunityDeepLinks::default()) .manage(PendingNavigationDeepLinks::default()) + .manage(PendingEntityDeepLinks::default()) .manage(BuilderlabSession::default()) .manage(BuilderlabLogin::default()) .manage(commands::pairing::PairingHandle::new()) @@ -515,15 +518,7 @@ pub fn run() { // and on cold start. The single-instance plugin handles forwarding // from duplicate launches on Windows/Linux. #[cfg(desktop)] - { - use tauri_plugin_deep_link::DeepLinkExt; - let dl_handle = app.handle().clone(); - app.deep_link().on_open_url(move |event| { - for url in event.urls() { - handle_deep_link_url(&dl_handle, url.as_str()); - } - }); - } + deep_link::install_deep_link_handlers(app); // Defer launch-time agent restoration until `apply_workspace` has // installed the active workspace relay and identity. Starting here @@ -619,6 +614,8 @@ pub fn run() { take_pending_navigation_deep_link, acknowledge_pending_navigation_deep_link, clear_pending_navigation_deep_links, + take_pending_entity_deep_link, + acknowledge_pending_entity_deep_link, start_builderlab_login, cancel_builderlab_login, get_builderlab_auth, @@ -659,8 +656,11 @@ pub fn run() { delete_project_remote_branch, push_project_local_repository, pull_project_local_repository, + publish_project_owner_announcement, sign_project_pull_request_status, sign_project_pull_request_review_request, + sign_project_issue_assignment, + sign_project_issue_unassignment, publish_project_pull_request_merged_status, merge_project_pull_request, open_project_terminal, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index befb56cb2b..6257a75b72 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -90,7 +90,7 @@ import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBou import { joinChannel } from "@/shared/api/tauri"; import type { Channel, ChannelVisibility, SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; -import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; +import { useAppDeepLinks } from "@/shared/useAppDeepLinks"; import { SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; @@ -640,8 +640,8 @@ export function AppShell() { unreadChannelIds, unreadChannelNotificationCount, }); - // Dispatch `buzz://message` deep links only from the main window; the companion is dedicated to its active Huddle route. - useMessageDeepLinks(!isHuddleRoom); + // Dispatch `buzz://` deep links only from the main window; the companion is dedicated to its active Huddle route. + useAppDeepLinks(!isHuddleRoom); const handleOpenCreateChannel = React.useCallback( () => setIsCreateChannelOpen(true), [], diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 4c7382a306..2203aa03a6 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -11,6 +11,7 @@ import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDe import type { SearchHit } from "@/shared/api/types"; type NavigationBehavior = { + force?: boolean; replace?: boolean; resetScroll?: boolean; }; @@ -27,12 +28,13 @@ export function useAppNavigation() { to: string; params?: Record; search?: Record; + state?: Record; }, behavior: NavigationBehavior = {}, ) => { const nextLocation = router.buildLocation(next as never); - if (location.href === nextLocation.href) { + if (location.href === nextLocation.href && !behavior.force) { return false; } @@ -110,6 +112,11 @@ export function useAppNavigation() { pullRequestId?: string; issueId?: string; repositoryId?: string; + /** Workspace tab requested by a share link (link vocabulary). */ + tab?: string; + /** Unique per entity-link activation so repeating the same link can + * re-apply an unchanged tab selection. */ + entityNavigationId?: string; }, ) => commitNavigation( @@ -129,9 +136,16 @@ export function useAppNavigation() { ...(behavior?.repositoryId ? { repositoryId: behavior.repositoryId } : {}), + ...(behavior?.tab ? { tab: behavior.tab } : {}), }, + state: behavior?.entityNavigationId + ? { entityNavigationId: behavior.entityNavigationId } + : undefined, + }, + { + ...behavior, + force: Boolean(behavior?.entityNavigationId), }, - behavior, ), [commitNavigation], ); diff --git a/desktop/src/app/routes/projects.$projectId.tsx b/desktop/src/app/routes/projects.$projectId.tsx index 4954428748..34af176adb 100644 --- a/desktop/src/app/routes/projects.$projectId.tsx +++ b/desktop/src/app/routes/projects.$projectId.tsx @@ -1,7 +1,8 @@ import * as React from "react"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useLocation } from "@tanstack/react-router"; import { usePreviewFeatureWarning } from "@/shared/features"; +import { isEntityLinkTab } from "@/shared/lib/entityLink"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; const ProjectDetailScreen = React.lazy(async () => { @@ -21,23 +22,34 @@ export const Route = createFileRoute("/projects/$projectId")({ issueId: typeof search.issueId === "string" ? search.issueId : undefined, repositoryId: typeof search.repositoryId === "string" ? search.repositoryId : undefined, + tab: isEntityLinkTab(search.tab) ? search.tab : undefined, }), }); function ProjectDetailRouteComponent() { usePreviewFeatureWarning("projects"); const { projectId } = Route.useParams(); - const { commitHash, pullRequestId, issueId, repositoryId } = + const { commitHash, pullRequestId, issueId, repositoryId, tab } = Route.useSearch(); + const entityNavigationId = useLocation({ + select: (location) => { + const value = ( + location.state as { entityNavigationId?: unknown } | undefined + )?.entityNavigationId; + return typeof value === "string" ? value : undefined; + }, + }); return ( }> ); diff --git a/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx b/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx index d31ac95274..ccf134df40 100644 --- a/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx +++ b/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx @@ -51,6 +51,10 @@ export function ProjectInboxDetailPane({ const inboxTitle = `${authorLabel} sent you ${ workItem.type === "pull-request" ? "a pull request" : "an issue" }`; + // The action deep-links to this specific work item in the project view, + // so the label names the entity, not the project. + const openLabel = + workItem.type === "pull-request" ? "Open pull request" : "Open issue"; const handleOpenMergeRecoveryTerminal = React.useCallback( async (input: { expectedCommit: string; @@ -110,16 +114,16 @@ export function ProjectInboxDetailPane({
    diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.ts b/desktop/src/features/messages/lib/composerMessageLinkNode.ts index fb5cf3ed2b..a01109e010 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.ts +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.ts @@ -5,6 +5,7 @@ import type { EditorView } from "@tiptap/pm/view"; import { buildIssueLink, + buildProjectLink, buildPullRequestLink, buildRepoLink, parseEntityLink, @@ -30,9 +31,9 @@ export type ComposerMessageLinkAttributes = { }; const BARE_BUZZ_LINK_AT_START = - /^buzz:\/\/(?:message\?|channel\/|(?:pr|issue|repo)\?)[^\s<>"')\]}*]+/i; + /^buzz:\/\/(?:message\?|channel\/|(?:pr|issue|repo|project)\?)[^\s<>"')\]}*]+/i; const BUZZ_LINK_SUFFIX_AT_START = - /^:\/\/(?:message\?|channel\/|(?:pr|issue|repo)\?)[^\s<>"')\]}*]+/i; + /^:\/\/(?:message\?|channel\/|(?:pr|issue|repo|project)\?)[^\s<>"')\]}*]+/i; const TRAILING_PUNCTUATION = /[.,;:!?]+$/; function trimBareBuzzLink(value: string): string { @@ -83,6 +84,11 @@ export function resolveComposerMessageLinkAttributes( channelName: "", href: buildRepoLink(entity.value), }; + case "project": + return { + channelName: "", + href: buildProjectLink(entity.value), + }; case "pr": return { channelName: "", @@ -257,17 +263,21 @@ function composerLinkPresentation( } const shortId = - entity.value.type === "repo" ? "" : entity.value.id.slice(0, 8); + entity.value.type === "repo" || entity.value.type === "project" + ? "" + : entity.value.id.slice(0, 8); return { ariaLabel: entity.value.type === "repo" ? `Open repository ${entity.value.dtag}` - : `Open ${entity.value.type === "pr" ? "pull request" : "issue"} ${shortId} in repository ${entity.value.dtag}`, + : entity.value.type === "project" + ? `Open project ${entity.value.dtag}` + : `Open ${entity.value.type === "pr" ? "pull request" : "issue"} ${shortId} in repository ${entity.value.dtag}`, channelName: "", dataAttributes: { "data-buzz-link-kind": entity.value.type }, icon: entity.value.type, label: - entity.value.type === "repo" + entity.value.type === "repo" || entity.value.type === "project" ? entity.value.dtag : `${entity.value.dtag} · ${shortId}`, }; diff --git a/desktop/src/features/projects/assignmentOperationFetch.test.mjs b/desktop/src/features/projects/assignmentOperationFetch.test.mjs new file mode 100644 index 0000000000..3882d1807e --- /dev/null +++ b/desktop/src/features/projects/assignmentOperationFetch.test.mjs @@ -0,0 +1,287 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + fetchAssignmentOperationEvents, + mergeEventsById, +} from "./assignmentOperationFetch.ts"; +import { fetchProjectsWorkItems } from "./projectWorkItems.ts"; + +const REPO_OWNER = "a".repeat(64); +const REPO_ADDRESS = `30617:${REPO_OWNER}:relay`; +const ISSUE_ID = "1".repeat(64); +const ASSIGNEE = "b".repeat(64); + +function makeIssue() { + return { + id: ISSUE_ID, + kind: 1621, + pubkey: REPO_OWNER, + created_at: 100, + content: "An issue", + tags: [ + ["a", REPO_ADDRESS], + ["subject", "Fix the thing"], + ], + }; +} + +/** Owner-signed assignment operation (kind:1, `t: assignment`). */ +function makeAssignment(id, createdAt, issueId = ISSUE_ID) { + return { + id, + kind: 1, + pubkey: REPO_OWNER, + created_at: createdAt, + content: "Assigned", + tags: [ + ["e", issueId, "", "root"], + ["a", REPO_ADDRESS], + ["p", ASSIGNEE], + ["t", "assignment"], + ], + }; +} + +function makeComment(id, createdAt, issueId = ISSUE_ID) { + return { + id, + kind: 1, + pubkey: REPO_OWNER, + created_at: createdAt, + content: `Comment ${id.slice(0, 4)}`, + tags: [ + ["e", issueId, "", "root"], + ["a", REPO_ADDRESS], + ], + }; +} + +function eventId(index) { + return index.toString(16).padStart(64, "0"); +} + +/** + * Relay model matching production semantics (`filter_to_query_params` + + * `filter_fully_pushable` in `crates/buzz-relay/src/handlers/req.rs`): + * + * 1. SQL applies kinds / `#e` / `since` / `until` (inclusive), orders + * `(created_at DESC, id ASC)`, and cuts to `LIMIT` — clamped to 1,000. + * 2. `#t` / `#a` are applied in Rust AFTER the SQL LIMIT. + * + * Step 2 is the trap the round-3 review caught: a filter that carries `#t` + * gets the newest N kind-1 candidates first, then loses tag mismatches, so a + * short page does NOT mean exhaustion. Any regression back to `#t`-reliant + * fetching fails these tests. + */ +function makeRelayModel(events) { + const calls = []; + const fetchEvents = async (filter) => { + calls.push(filter); + const limit = Math.min(filter.limit ?? 1_000, 1_000); + // SQL phase: pushed constraints only. + const candidates = events + .filter((event) => { + if (filter.kinds && !filter.kinds.includes(event.kind)) return false; + if ( + filter["#e"] && + !event.tags.some( + (tag) => tag[0] === "e" && filter["#e"].includes(tag[1]), + ) + ) { + return false; + } + if (filter.until !== undefined && event.created_at > filter.until) { + return false; + } + if (filter.since !== undefined && event.created_at < filter.since) { + return false; + } + return true; + }) + .sort((left, right) => + right.created_at !== left.created_at + ? right.created_at - left.created_at + : left.id < right.id + ? -1 + : 1, + ) + .slice(0, limit); + // Rust post-filter phase: tag constraints applied AFTER the LIMIT. + return candidates.filter((event) => { + if ( + filter["#t"] && + !event.tags.some( + (tag) => tag[0] === "t" && filter["#t"].includes(tag[1]), + ) + ) { + return false; + } + if ( + filter["#a"] && + !event.tags.some( + (tag) => tag[0] === "a" && filter["#a"].includes(tag[1]), + ) + ) { + return false; + } + return true; + }); + }; + return { calls, fetchEvents }; +} + +// ── fetchAssignmentOperationEvents vs. real relay query semantics ──────────── + +test("finds an assignment buried behind 600 newer comments on the real relay model", async () => { + // The round-3 adversarial case: one old assignment, then 600 newer comments + // on the same issue. A `#t`-carrying filter sees the newest 500 candidates + // post-filtered to zero and falsely declares exhaustion (0/1). The + // `#e`-keyed walk must return 1/1. + const assignment = makeAssignment(eventId(9_999), 200); + const comments = Array.from({ length: 600 }, (_, index) => + makeComment(eventId(index + 1), 1_000 + index), + ); + const { calls, fetchEvents } = makeRelayModel([assignment, ...comments]); + + const events = await fetchAssignmentOperationEvents([ISSUE_ID], fetchEvents); + + assert.deepEqual( + events.map((event) => event.id), + [eventId(9_999)], + "the buried assignment must survive the bounded page", + ); + assert.ok(calls.length >= 2, "must page past the first bounded window"); + for (const filter of calls) { + assert.equal( + filter["#t"], + undefined, + "the filter must carry only SQL-pushed constraints — `#t` is post-filtered after LIMIT", + ); + assert.equal(filter["#a"], undefined, "`#a` is post-filtered after LIMIT"); + } +}); + +test("paginates to exhaustion across several full pages", async () => { + // 1,203 operations spread across distinct seconds — three pages at the + // 500-event window. + const operations = Array.from({ length: 1_203 }, (_, index) => + makeAssignment(eventId(index + 1), 1_000_000 + index), + ); + const { calls, fetchEvents } = makeRelayModel(operations); + + const events = await fetchAssignmentOperationEvents([ISSUE_ID], fetchEvents); + + assert.equal(events.length, 1_203, "every operation must be loaded"); + assert.ok(calls.length >= 3, "must page past the 500-event window"); +}); + +test("escapes a second denser than one page by widening to the relay clamp", async () => { + // 700 externally signed operations sharing one created_at second: an + // inclusive `until` cursor alone can never advance past the first 500. + // The loop must widen to the relay's 1,000-row clamp and load all 700. + const operations = Array.from({ length: 700 }, (_, index) => + makeAssignment(eventId(index + 1), 1_000), + ); + const { fetchEvents } = makeRelayModel(operations); + + const events = await fetchAssignmentOperationEvents([ISSUE_ID], fetchEvents); + + assert.equal(events.length, 700, "same-second density must not drop events"); +}); + +test("reports a second denser than the relay clamp instead of silently dropping", async () => { + // 1,100 events in one second exceeds the relay's 1,000-row page clamp — + // unreachable through NIP-01 filters. That must surface as an error (the + // caller shows a failed assignments section), never as silent loss. + const operations = Array.from({ length: 1_100 }, (_, index) => + makeAssignment(eventId(index + 1), 1_000), + ); + const { fetchEvents } = makeRelayModel(operations); + + await assert.rejects( + fetchAssignmentOperationEvents([ISSUE_ID], fetchEvents), + /full relay page/, + ); +}); + +test("skips the relay for zero issues", async () => { + const events = await fetchAssignmentOperationEvents([], async () => { + throw new Error("must not query the relay"); + }); + assert.deepEqual(events, []); +}); + +test("chunks large issue sets and dedupes operations across chunks", async () => { + // 150 issues → two `#e` chunks at the 100-id chunk size. + const issueIds = Array.from({ length: 150 }, (_, index) => + eventId(5_000 + index), + ); + const operations = issueIds.map((issueId, index) => + makeAssignment(eventId(index + 1), 2_000 + index, issueId), + ); + const { calls, fetchEvents } = makeRelayModel(operations); + + const events = await fetchAssignmentOperationEvents(issueIds, fetchEvents); + + assert.equal(events.length, 150); + assert.equal(calls.length, 2, "150 issues must fan out as two #e chunks"); + assert.ok(calls.every((filter) => filter["#e"].length <= 100)); +}); + +test("mergeEventsById drops duplicates and keeps both sources", () => { + const shared = makeAssignment(eventId(1), 10); + const merged = mergeEventsById( + [shared, makeComment(eventId(2), 11)], + [shared, makeAssignment(eventId(3), 12)], + ); + assert.deepEqual( + merged.map((event) => event.id), + [eventId(1), eventId(2), eventId(3)], + ); +}); + +// ── Regression: assignment predating a full comment window ───────────────── +// +// The reduction in projectIssues.mjs is only as complete as the events it is +// handed. The general comment fetch is bounded (2,000 shared across repos in +// fetchProjectsWorkItems), so an old assignment operation can be evicted by +// newer unrelated comments. The dedicated issue-keyed exhaustive query must +// restore it — against the real relay's query semantics. + +test("an assignment older than 600 newer comments still reduces to an assignee", async () => { + const issue = makeIssue(); + const assignment = makeAssignment(eventId(9_999), 200); + const comments = Array.from({ length: 600 }, (_, index) => + makeComment(eventId(index + 1), 1_000 + index), + ); + const { fetchEvents } = makeRelayModel([issue, assignment, ...comments]); + + const result = await fetchProjectsWorkItems( + [{ repositories: [{ repoAddress: REPO_ADDRESS }] }], + fetchEvents, + ); + + assert.equal(result.issues.items.length, 1); + assert.deepEqual( + result.issues.items[0].issue.assignees, + [ASSIGNEE], + "assignee evicted from the comment window must be restored by the dedicated assignment query", + ); +}); + +test("a failed assignment query surfaces as a failed section instead of silent loss", async () => { + const issue = makeIssue(); + const fetchEvents = async (filter) => { + if (filter.kinds?.includes(1621)) return [issue]; + if (filter["#e"]) throw new Error("relay hiccup"); + return []; + }; + + const result = await fetchProjectsWorkItems( + [{ repositories: [{ repoAddress: REPO_ADDRESS }] }], + fetchEvents, + ); + + assert.ok(result.issues.failedSections.includes("assignments")); +}); diff --git a/desktop/src/features/projects/assignmentOperationFetch.ts b/desktop/src/features/projects/assignmentOperationFetch.ts new file mode 100644 index 0000000000..fbdc90b218 --- /dev/null +++ b/desktop/src/features/projects/assignmentOperationFetch.ts @@ -0,0 +1,138 @@ +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_TEXT_NOTE } from "@/shared/constants/kinds"; +import { + ISSUE_ASSIGNMENT_LABEL, + ISSUE_UNASSIGNMENT_LABEL, +} from "./projectIssues.mjs"; + +type FetchEventsInput = Parameters<(typeof relayClient)["fetchEvents"]>[0]; + +const ASSIGNMENT_PAGE_LIMIT = 500; + +/** + * The relay clamps every REQ page to this many rows regardless of the + * requested `limit` (`DEFAULT_MAX_PAGE_LIMIT` in `crates/buzz-db/src/event.rs`). + * A single second denser than this is unreachable through NIP-01 pagination, + * so the loop below reports it as an error instead of silently dropping + * operations. + */ +const RELAY_MAX_PAGE_LIMIT = 1_000; + +/** Issue ids per relay query. Each id adds one JSONB containment clause to the + * relay's SQL, so batches are kept small enough to stay cheap while still + * collapsing typical projects into a single query. */ +const ISSUE_ID_CHUNK_SIZE = 100; + +function isAssignmentOperation(event: RelayEvent): boolean { + return event.tags.some( + (tag) => + tag[0] === "t" && + (tag[1] === ISSUE_ASSIGNMENT_LABEL || + tag[1] === ISSUE_UNASSIGNMENT_LABEL), + ); +} + +/** + * Loads every assignment/unassignment operation for the given issues, + * paginating to exhaustion instead of trusting a bounded comment window. + * + * Why: assignment state is reduced from kind:1 operations (`t: assignment` / + * `t: unassignment`), but the general comment fetches are bounded (500 per + * repo in `hooks.ts`, 2,000 shared in `projectWorkItems.ts`). Once newer + * comments push an older operation out of that window, its assignee silently + * vanishes from the issue — and a later self-service operation can reduce + * against the wrong `prior` head. + * + * The filter deliberately carries ONLY constraints the relay pushes into SQL + * before applying `LIMIT`: kinds, `#e`, `until`, `limit` (see + * `filter_fully_pushable` in `crates/buzz-relay/src/handlers/req.rs`). Tag + * filters like `#t`/`#a` are post-filtered in Rust AFTER the SQL `LIMIT`, so + * including them would make a short page meaningless — the newest N candidate + * rows could all be post-filtered away while older matches remain, and the + * loop would declare exhaustion having seen nothing. Instead the query walks + * the full comment stream of the given issues (`#e` is pushed via JSONB + * containment) and the assignment labels are filtered locally. + * + * Pagination uses an inclusive `until` cursor with id-level dedupe. The relay + * orders `(created_at DESC, id ASC)`, so a full page whose oldest timestamp + * equals the cursor means a single second denser than the page: the loop + * escalates `limit` to the relay's hard page clamp once, and if the second is + * denser than even that, throws — the caller surfaces a failed assignments + * section instead of silently losing operations. NIP-01 filters cannot + * express the relay's composite `(created_at, id)` keyset cursor, so this is + * the strongest client-only guarantee available. + */ +export async function fetchAssignmentOperationEvents( + issueIds: string[], + fetchEvents: ( + filter: FetchEventsInput, + ) => Promise = relayClient.fetchEvents.bind(relayClient), +): Promise { + if (issueIds.length === 0) return []; + const chunks: string[][] = []; + for (let i = 0; i < issueIds.length; i += ISSUE_ID_CHUNK_SIZE) { + chunks.push(issueIds.slice(i, i + ISSUE_ID_CHUNK_SIZE)); + } + const pages = await Promise.all( + chunks.map((chunk) => fetchIssueCommentsExhaustively(chunk, fetchEvents)), + ); + const seen = new Map(); + for (const page of pages) { + for (const event of page) { + if (isAssignmentOperation(event) && !seen.has(event.id)) { + seen.set(event.id, event); + } + } + } + return [...seen.values()]; +} + +async function fetchIssueCommentsExhaustively( + issueIds: string[], + fetchEvents: (filter: FetchEventsInput) => Promise, +): Promise { + const seen = new Map(); + let limit = ASSIGNMENT_PAGE_LIMIT; + let until: number | undefined; + for (;;) { + const page = await fetchEvents({ + kinds: [KIND_TEXT_NOTE], + "#e": issueIds, + limit, + ...(until === undefined ? {} : { until }), + }); + for (const event of page) { + if (!seen.has(event.id)) seen.set(event.id, event); + } + // Only SQL-pushed constraints are in the filter, so a short page is a + // true end-of-results signal. + if (page.length < limit) break; + const oldest = Math.min(...page.map((event) => event.created_at)); + if (until === undefined || oldest < until) { + until = oldest; + continue; + } + // Full page and the inclusive cursor cannot advance: every row shares + // the cursor second. Widen to the relay's hard clamp so the whole second + // fits in one page; beyond that, no NIP-01 filter can reach the rest. + if (limit < RELAY_MAX_PAGE_LIMIT) { + limit = RELAY_MAX_PAGE_LIMIT; + continue; + } + throw new Error( + "Could not load assignment history: more than a full relay page of " + + "issue comments share one timestamp.", + ); + } + return [...seen.values()]; +} + +/** Merge two event lists, dropping duplicates by event id. */ +export function mergeEventsById( + base: RelayEvent[], + extra: RelayEvent[], +): RelayEvent[] { + const ids = new Set(base.map((event) => event.id)); + return [...base, ...extra.filter((event) => !ids.has(event.id))]; +} diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index 8591430cc2..7f3f55ff02 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -38,6 +38,10 @@ import type { RelayEvent, } from "@/shared/api/types"; import { summarizeProjectActivityEvents } from "./projectActivity.mjs"; +import { + fetchAssignmentOperationEvents, + mergeEventsById, +} from "./assignmentOperationFetch"; import type { ProjectIssue } from "./projectIssues.mjs"; import { nextProjectIssueCommentCreatedAt, @@ -224,30 +228,43 @@ async function fetchRepoState(project: Repository): Promise { async function fetchProjectIssues( project: Repository, ): Promise { - const [issueEvents, statusEvents, commentEvents] = await Promise.all([ - relayClient.fetchEvents({ - kinds: [KIND_GIT_ISSUE], - "#a": [project.repoAddress], - limit: 200, - }), - relayClient.fetchEvents({ - kinds: [ - KIND_GIT_STATUS_OPEN, - KIND_GIT_STATUS_MERGED, - KIND_GIT_STATUS_CLOSED, - KIND_GIT_STATUS_DRAFT, - ], - "#a": [project.repoAddress], - limit: 500, - }), - relayClient.fetchEvents({ - kinds: [KIND_TEXT_NOTE], - "#a": [project.repoAddress], - limit: 500, - }), - ]); + const issuePromise = relayClient.fetchEvents({ + kinds: [KIND_GIT_ISSUE], + "#a": [project.repoAddress], + limit: 200, + }); + const [issueEvents, statusEvents, commentEvents, assignmentEvents] = + await Promise.all([ + issuePromise, + relayClient.fetchEvents({ + kinds: [ + KIND_GIT_STATUS_OPEN, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, + ], + "#a": [project.repoAddress], + limit: 500, + }), + relayClient.fetchEvents({ + kinds: [KIND_TEXT_NOTE], + "#a": [project.repoAddress], + limit: 500, + }), + // Assignment state must reduce over the complete operation history, not + // whatever survives the bounded comment window above. Keyed by issue id + // (`#e`) because that is the only tag constraint the relay applies + // before its SQL LIMIT — see fetchAssignmentOperationEvents. + issuePromise.then((events) => + fetchAssignmentOperationEvents(events.map((event) => event.id)), + ), + ]); - return projectIssueEventsToIssues(issueEvents, statusEvents, commentEvents); + return projectIssueEventsToIssues( + issueEvents, + statusEvents, + mergeEventsById(commentEvents, assignmentEvents), + ); } async function fetchProjectPullRequests( diff --git a/desktop/src/features/projects/issueAssignments.ts b/desktop/src/features/projects/issueAssignments.ts new file mode 100644 index 0000000000..aa0810dea7 --- /dev/null +++ b/desktop/src/features/projects/issueAssignments.ts @@ -0,0 +1,167 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import * as React from "react"; + +import { + signProjectIssueAssignment, + signProjectIssueUnassignment, +} from "@/shared/api/projectGit"; +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; +import { KIND_TEXT_NOTE } from "@/shared/constants/kinds"; +import type { Repository as Project } from "./hooks"; +import { + ISSUE_ASSIGNMENT_LABEL, + ISSUE_UNASSIGNMENT_LABEL, + nextProjectIssueCommentCreatedAt, + type ProjectIssue, +} from "./projectIssues.mjs"; + +function nextAssignmentOperationCreatedAt( + issue: ProjectIssue, + project: Project, + signAsManagedOwner: boolean, + signerPubkey: string, +) { + const signer = signAsManagedOwner ? project.owner : signerPubkey; + return nextProjectIssueCommentCreatedAt( + issue, + Math.floor(Date.now() / 1_000), + signer, + ); +} + +function normalizedAssigneeLabel(label: string) { + const normalized = label.trim(); + if (normalized.length === 0 || Array.from(normalized).length > 128) { + throw new Error("Assignee label must be between 1 and 128 characters."); + } + return normalized; +} + +type IssueAssignmentOperation = "assign" | "unassign"; +type IssueAssignmentMutationInput = { + assignees: string[]; + assigneeLabel: string; + issue: ProjectIssue; + signerPubkey: string; + signAsManagedOwner: boolean; +}; + +async function writeProjectIssueAssignment({ + assignees, + assigneeLabel, + issue, + operation, + project, + signerPubkey, + signAsManagedOwner, +}: IssueAssignmentMutationInput & { + operation: IssueAssignmentOperation; + project: Project; +}): Promise { + if (assignees.length === 0) { + throw new Error("Select at least one assignee."); + } + const normalizedLabel = normalizedAssigneeLabel(assigneeLabel); + const assigneePubkeys = [ + ...new Set(assignees.map((pubkey) => pubkey.toLowerCase())), + ]; + const createdAt = nextAssignmentOperationCreatedAt( + issue, + project, + signAsManagedOwner, + signerPubkey, + ); + const isAssignment = operation === "assign"; + const content = isAssignment + ? `Assigned this issue to ${normalizedLabel}` + : `Unassigned ${normalizedLabel} from this issue`; + const label = isAssignment + ? ISSUE_ASSIGNMENT_LABEL + : ISSUE_UNASSIGNMENT_LABEL; + if (signAsManagedOwner) { + const signManagedOperation = isAssignment + ? signProjectIssueAssignment + : signProjectIssueUnassignment; + await signManagedOperation({ + targetOwner: project.owner, + repoAddress: project.repoAddress, + issueId: issue.id, + assignees: assigneePubkeys, + assigneeLabel: normalizedLabel, + createdAt, + }); + return; + } + const normalizedSigner = signerPubkey.toLowerCase(); + const prior = + assigneePubkeys.length === 1 && assigneePubkeys[0] === normalizedSigner + ? issue.assigneeOperationHeads[normalizedSigner] + : undefined; + const event = await signRelayEvent({ + kind: KIND_TEXT_NOTE, + content, + createdAt, + tags: [ + ["e", issue.id, "", "root"], + ["a", project.repoAddress], + ...assigneePubkeys.map((pubkey) => ["p", pubkey]), + ["t", label], + ...(prior ? [["prior", prior]] : []), + ], + }); + + await relayClient.publishEvent( + event, + `Timed out ${operation}ing issue.`, + `Failed to ${operation} issue.`, + ); +} + +export function useProjectIssueWriteInvalidation( + project: Project | null | undefined, +) { + const queryClient = useQueryClient(); + return React.useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: ["project", project?.id ?? "none", "issues"], + }); + void queryClient.invalidateQueries({ + queryKey: ["projects", "work-items"], + }); + void queryClient.invalidateQueries({ + queryKey: ["projects", "activity-summaries"], + }); + }, [project?.id, queryClient]); +} + +function useProjectIssueAssignmentMutation( + project: Project | null | undefined, + operation: IssueAssignmentOperation, +) { + const invalidate = useProjectIssueWriteInvalidation(project); + + return useMutation({ + mutationFn: (input: IssueAssignmentMutationInput) => { + if (!project) throw new Error("No project selected."); + return writeProjectIssueAssignment({ + ...input, + operation, + project, + }); + }, + onSuccess: invalidate, + }); +} + +export function useAssignProjectIssueMutation( + project: Project | null | undefined, +) { + return useProjectIssueAssignmentMutation(project, "assign"); +} + +export function useUnassignProjectIssueMutation( + project: Project | null | undefined, +) { + return useProjectIssueAssignmentMutation(project, "unassign"); +} diff --git a/desktop/src/features/projects/lib/discussionChannels.test.mjs b/desktop/src/features/projects/lib/discussionChannels.test.mjs new file mode 100644 index 0000000000..95f73f6373 --- /dev/null +++ b/desktop/src/features/projects/lib/discussionChannels.test.mjs @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + commitDiscussionQuery, + discussionSnippet, + entityDiscussionQuery, + formatNameList, + groupDiscussionChannels, + repositoryDiscussionQuery, +} from "./discussionChannels.ts"; + +const OWNER = "a".repeat(64); +const EVENT_ID = "b".repeat(64); +const ALICE = "c".repeat(64); +const BOB = "d".repeat(64); + +test("query builders emit the tokens FTS needs, nothing else", () => { + assert.equal(entityDiscussionQuery(EVENT_ID), EVENT_ID); + assert.equal( + repositoryDiscussionQuery({ owner: OWNER, dtag: "buzz-world" }), + `${OWNER} buzz-world`, + ); +}); + +test("commit queries match full or short hash citations", () => { + const hash = "0123456789abcdef0123456789abcdef01234567"; + assert.equal( + commitDiscussionQuery({ hash, shortHash: "0123456" }), + `${hash} OR 0123456`, + ); + // Short hash derives from the full hash when the snapshot omitted it. + assert.equal(commitDiscussionQuery({ hash }), `${hash} OR 0123456`); + // Degenerate case: already-short hashes search as a single token. + assert.equal(commitDiscussionQuery({ hash: "0123456" }), "0123456"); +}); + +test("hits group into channels ordered by count then recency", () => { + const channels = groupDiscussionChannels([ + { channelId: "c1", channelName: "general", createdAt: 100, pubkey: ALICE }, + { channelId: "c2", channelName: "design", createdAt: 300, pubkey: BOB }, + { channelId: "c1", channelName: "general", createdAt: 200, pubkey: BOB }, + { channelId: "c3", channelName: "random", createdAt: 300, pubkey: ALICE }, + ]); + assert.deepEqual(channels, [ + { + id: "c1", + name: "general", + messageCount: 2, + lastActivityAt: 200, + // Most recent speaker first. + participants: [BOB, ALICE], + }, + // c2 and c3 tie on count; newer activity first (stable tie broken by time). + { + id: "c2", + name: "design", + messageCount: 1, + lastActivityAt: 300, + participants: [BOB], + }, + { + id: "c3", + name: "random", + messageCount: 1, + lastActivityAt: 300, + participants: [ALICE], + }, + ]); +}); + +test("channel-less hits are dropped, names backfill, participants dedupe", () => { + const channels = groupDiscussionChannels([ + { channelId: null, channelName: null, createdAt: 100, pubkey: ALICE }, + { channelId: "c1", channelName: null, createdAt: 100, pubkey: ALICE }, + { + channelId: "c1", + channelName: "general", + createdAt: 50, + pubkey: ALICE.toUpperCase(), + }, + ]); + assert.deepEqual(channels, [ + { + id: "c1", + name: "general", + messageCount: 2, + lastActivityAt: 100, + participants: [ALICE], + }, + ]); +}); + +test("formatNameList reads naturally at every size", () => { + assert.equal(formatNameList([]), ""); + assert.equal(formatNameList(["Alice"]), "Alice"); + assert.equal(formatNameList(["Alice", "Bob"]), "Alice and Bob"); + assert.equal( + formatNameList(["Alice", "Bob", "Carol"]), + "Alice, Bob and Carol", + ); + assert.equal( + formatNameList(["Alice", "Bob", "Carol", "Dan"]), + "Alice, Bob and 2 others", + ); +}); + +test("discussionSnippet strips entity links and coordinates", () => { + assert.equal( + discussionSnippet( + `Can someone review buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=buzz before Friday?`, + ), + "Can someone review before Friday?", + ); + assert.equal( + discussionSnippet(`Deploying 30617:${OWNER}:relay-tools tonight`), + "Deploying tonight", + ); + assert.equal( + discussionSnippet(`buzz://repo?owner=${OWNER}&d=buzz`), + "Shared a link to this.", + ); +}); + +test("discussionSnippet truncates long content on an ellipsis", () => { + // The cap only bounds DOM size — the row's CSS truncation does the + // visual cut — so it just needs to hold for arbitrarily long content. + const long = "word ".repeat(200); + const snippet = discussionSnippet(long); + assert.ok(snippet.length <= 400); + assert.ok(snippet.endsWith("…")); +}); diff --git a/desktop/src/features/projects/lib/discussionChannels.ts b/desktop/src/features/projects/lib/discussionChannels.ts new file mode 100644 index 0000000000..3776467248 --- /dev/null +++ b/desktop/src/features/projects/lib/discussionChannels.ts @@ -0,0 +1,152 @@ +/** + * "Discussed in" channel discovery for Buzz git entities. + * + * Chat messages that reference a PR, issue, or repository do so only through + * `buzz://` links in their *content* — they carry no entity tags (see + * `useMessageLinkPreviews.ts`). So discovery runs the relay's NIP-50 + * full-text search over message content and groups the hits by channel. + * + * Query construction leans on the FTS tokenizer: the relay ANDs every token + * of the search text, and `buzz://` links tokenize into their query-param + * values. A PR/issue link contains the entity's 64-hex event id (globally + * unique token), and every repo/PR/issue link contains the repository + * coordinate's `owner` pubkey and `d`-tag — so searching those tokens finds + * exactly the messages linking the entity, across all channels the viewer + * can read (the relay re-authorizes each hit). + */ + +import type { SearchHit } from "@/shared/api/searchTypes"; + +export type DiscussionChannel = { + id: string; + /** Channel display name from the search hit; null when the relay omitted it. */ + name: string | null; + messageCount: number; + /** Unix seconds of the newest matching message. */ + lastActivityAt: number; + /** Unique author pubkeys, most recent speaker first. */ + participants: string[]; +}; + +/** + * Search text matching messages that link a specific PR or issue: the event + * id is a single 64-hex token unique to the entity, present in every + * `buzz://pr|issue?id=…` link. + */ +export function entityDiscussionQuery(eventId: string): string { + return eventId; +} + +/** + * Search text matching messages that link a repository or any of its PRs + * and issues: all those links carry `owner=&d=`, so the owner + * pubkey and d-tag tokens together identify the repository coordinate. + */ +export function repositoryDiscussionQuery(repository: { + owner: string; + dtag: string; +}): string { + return `${repository.owner} ${repository.dtag}`; +} + +/** + * Chat cites commits by either the full or the abbreviated hash, so match + * both. `websearch_to_tsquery` (the relay's NIP-50 parser) treats a literal + * `OR` between words as a disjunction. + */ +export function commitDiscussionQuery(commit: { + hash: string; + shortHash?: string | null; +}): string { + const short = commit.shortHash ?? commit.hash.slice(0, 7); + if (!short || short === commit.hash) { + return commit.hash; + } + return `${commit.hash} OR ${short}`; +} + +/** + * Group search hits into unique channels, ordered by message count then + * recency. Channel-less hits (no `h` tag) are dropped. + */ +export function groupDiscussionChannels( + hits: readonly Pick< + SearchHit, + "channelId" | "channelName" | "createdAt" | "pubkey" + >[], +): DiscussionChannel[] { + const byChannel = new Map(); + // Newest first so each channel's participant list leads with the most + // recent speaker. + const ordered = [...hits].sort((a, b) => b.createdAt - a.createdAt); + for (const hit of ordered) { + if (!hit.channelId) continue; + const pubkey = hit.pubkey.toLowerCase(); + const existing = byChannel.get(hit.channelId); + if (existing) { + existing.messageCount += 1; + existing.lastActivityAt = Math.max( + existing.lastActivityAt, + hit.createdAt, + ); + if (existing.name === null && hit.channelName) { + existing.name = hit.channelName; + } + if (!existing.participants.includes(pubkey)) { + existing.participants.push(pubkey); + } + } else { + byChannel.set(hit.channelId, { + id: hit.channelId, + name: hit.channelName ?? null, + messageCount: 1, + lastActivityAt: hit.createdAt, + participants: [pubkey], + }); + } + } + return [...byChannel.values()].sort( + (a, b) => + b.messageCount - a.messageCount || b.lastActivityAt - a.lastActivityAt, + ); +} + +/** + * Human list of discussing names: "Alice", "Alice and Bob", + * "Alice, Bob and Carol", "Alice, Bob and 3 others". + */ +export function formatNameList(names: readonly string[], maxNames = 3): string { + if (names.length === 0) return ""; + if (names.length === 1) return names[0]; + if (names.length <= maxNames) { + return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; + } + const shown = names.slice(0, maxNames - 1); + const others = names.length - shown.length; + return `${shown.join(", ")} and ${others} others`; +} + +// Generous cap: the row's CSS `truncate` does the visual cut at the card +// edge, so this only bounds DOM size against very long messages. Keep it +// comfortably above what an ultrawide screen can show on one line. +const SNIPPET_MAX_CHARS = 400; + +/** + * One-line preview of a discussing message: entity links and coordinates are + * dropped (the reader is already looking at the entity), whitespace collapses, + * and long content truncates on an ellipsis. + */ +export function discussionSnippet(content: string): string { + const cleaned = content + .replace(/buzz:\/\/\S+/g, "") + .replace(/\b\d{5}:[0-9a-f]{64}:\S+/gi, "") + .replace(/\s+/g, " ") + .trim(); + if (cleaned.length === 0) { + return "Shared a link to this."; + } + if (cleaned.length <= SNIPPET_MAX_CHARS) { + return cleaned; + } + return `${cleaned.slice(0, SNIPPET_MAX_CHARS - 1).trimEnd()}…`; +} diff --git a/desktop/src/features/projects/lib/projectShareLinks.test.mjs b/desktop/src/features/projects/lib/projectShareLinks.test.mjs new file mode 100644 index 0000000000..23325c578a --- /dev/null +++ b/desktop/src/features/projects/lib/projectShareLinks.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + issueShareLink, + parseAddressableCoordinate, + projectShareLink, + pullRequestShareLink, + repositoryShareLink, + shareTabForWorkspaceTab, + workspaceTabForShareTab, +} from "./projectShareLinks.ts"; + +const OWNER = "a".repeat(64); +const EVENT_ID = "b".repeat(64); +const REPO_ADDRESS = `30617:${OWNER}:flappy-bee`; +const PROJECT_ADDRESS = `30621:${OWNER}:pollinator`; + +test("parseAddressableCoordinate splits only the two structural separators", () => { + assert.deepEqual(parseAddressableCoordinate(`30617:${OWNER}:a:b`), { + kind: 30617, + owner: OWNER, + dtag: "a:b", + }); + assert.deepEqual( + parseAddressableCoordinate(`30617:${OWNER.toUpperCase()}:repo`)?.owner, + OWNER, + ); +}); + +test("parseAddressableCoordinate rejects malformed coordinates", () => { + for (const address of [ + null, + undefined, + "", + OWNER, + `30617:${OWNER}`, + `30617:not-a-pubkey:repo`, + `30617:${OWNER}:`, + `:${OWNER}:repo`, + `notakind:${OWNER}:repo`, + ]) { + assert.equal(parseAddressableCoordinate(address), null, String(address)); + } +}); + +test("projectShareLink links explicit projects by their 30621 coordinate", () => { + assert.equal( + projectShareLink({ projectAddress: PROJECT_ADDRESS }), + `buzz://project?owner=${OWNER}&d=pollinator`, + ); +}); + +test("projectShareLink carries the active workspace tab for both link kinds", () => { + assert.equal( + projectShareLink({ projectAddress: PROJECT_ADDRESS }, "prs"), + `buzz://project?owner=${OWNER}&d=pollinator&tab=prs`, + ); + // Legacy projects share as buzz://repo and keep the tab too. + assert.equal( + projectShareLink({ projectAddress: REPO_ADDRESS }, "issues"), + `buzz://repo?owner=${OWNER}&d=flappy-bee&tab=issues`, + ); +}); + +test("workspace tab ids map onto link tabs and back", () => { + assert.equal(shareTabForWorkspaceTab("prs"), "prs"); + assert.equal(shareTabForWorkspaceTab("issues"), "issues"); + assert.equal(shareTabForWorkspaceTab("files"), "files"); + assert.equal(shareTabForWorkspaceTab("contributors"), "contributors"); + // "activity" is the workspace's name for the commit list. + assert.equal(shareTabForWorkspaceTab("activity"), "commits"); + assert.equal(workspaceTabForShareTab("commits"), "activity"); + assert.equal(workspaceTabForShareTab("prs"), "prs"); + // Overview and PR-detail sub-tabs have no link spelling. + assert.equal(shareTabForWorkspaceTab("overview"), undefined); + assert.equal(shareTabForWorkspaceTab("pr-conversation"), undefined); +}); + +test("projectShareLink links legacy projects as their backing repository", () => { + assert.equal( + projectShareLink({ projectAddress: REPO_ADDRESS }), + `buzz://repo?owner=${OWNER}&d=flappy-bee`, + ); +}); + +test("projectShareLink declines coordinates the link format cannot express", () => { + for (const dtag of [ + "has space", + "..", + ".hidden", + "x".repeat(65), + "emoji🐝", + ]) { + assert.equal( + projectShareLink({ projectAddress: `30621:${OWNER}:${dtag}` }), + null, + dtag, + ); + } + // Some other addressable kind is not a project or repository. + assert.equal(projectShareLink({ projectAddress: `30000:${OWNER}:x` }), null); +}); + +test("repositoryShareLink links the repository coordinate", () => { + assert.equal( + repositoryShareLink({ repoAddress: REPO_ADDRESS }), + `buzz://repo?owner=${OWNER}&d=flappy-bee`, + ); + assert.equal( + repositoryShareLink({ repoAddress: PROJECT_ADDRESS }), + null, + "a project coordinate is not a repository", + ); +}); + +test("issue and pull request links carry the event id and repo coordinate", () => { + assert.equal( + issueShareLink({ id: EVENT_ID, repoAddress: REPO_ADDRESS }), + `buzz://issue?id=${EVENT_ID}&owner=${OWNER}&d=flappy-bee`, + ); + assert.equal( + pullRequestShareLink({ id: EVENT_ID, repoAddress: REPO_ADDRESS }), + `buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=flappy-bee`, + ); +}); + +test("issue and pull request links require a repo coordinate and hex id", () => { + assert.equal(issueShareLink({ id: EVENT_ID, repoAddress: null }), null); + assert.equal(pullRequestShareLink({ id: EVENT_ID, repoAddress: null }), null); + assert.equal( + issueShareLink({ id: "short", repoAddress: REPO_ADDRESS }), + null, + ); + assert.equal( + pullRequestShareLink({ id: "short", repoAddress: REPO_ADDRESS }), + null, + ); +}); diff --git a/desktop/src/features/projects/lib/projectShareLinks.ts b/desktop/src/features/projects/lib/projectShareLinks.ts new file mode 100644 index 0000000000..7598539f00 --- /dev/null +++ b/desktop/src/features/projects/lib/projectShareLinks.ts @@ -0,0 +1,139 @@ +/** + * Share links for the Projects read models. + * + * Every builder returns `null` instead of throwing when the entity cannot be + * addressed by a `buzz://` link — addressable d-tags accept a wider charset + * (and 1024 bytes) than the link format's `[a-zA-Z0-9._-]{1,64}`, and issues + * and pull requests loaded outside a repository have no coordinate at all. + * Callers hide the share affordance on `null` rather than copying a link that + * would not parse on the receiving side. + */ + +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import { + buildIssueLink, + buildProjectLink, + buildPullRequestLink, + buildRepoLink, + type EntityLinkTab, + isLinkableCoordinate, +} from "@/shared/lib/entityLink"; + +import type { ProjectIssue } from "../projectIssues.mjs"; +import type { Project, Repository } from "../projectModels"; +import type { ProjectPullRequest } from "../projectPullRequests.mjs"; + +type Coordinate = { kind: number; owner: string; dtag: string }; + +const HEX64_RE = /^[a-fA-F0-9]{64}$/; + +/** + * Split an addressable coordinate (`::`). Only the first two + * separators are structural — d-tags may themselves contain colons, so the + * remainder is taken verbatim. + */ +export function parseAddressableCoordinate( + address: string | null | undefined, +): Coordinate | null { + if (!address) return null; + + const kindEnd = address.indexOf(":"); + if (kindEnd < 1) return null; + const ownerEnd = address.indexOf(":", kindEnd + 1); + if (ownerEnd < 0) return null; + + const kind = Number(address.slice(0, kindEnd)); + const owner = address.slice(kindEnd + 1, ownerEnd); + const dtag = address.slice(ownerEnd + 1); + if (!Number.isInteger(kind) || !HEX64_RE.test(owner) || dtag.length === 0) { + return null; + } + + return { kind, owner: owner.toLowerCase(), dtag }; +} + +function repositoryCoordinate( + repoAddress: string | null | undefined, +): Coordinate | null { + const coordinate = parseAddressableCoordinate(repoAddress); + return coordinate?.kind === KIND_REPO_ANNOUNCEMENT ? coordinate : null; +} + +/** + * Map a workspace tab id (`WorkspaceTabs` vocabulary) onto the link format's + * tab value. The overview tab is the link's default and PR-detail sub-tabs + * have their own `buzz://pr` links, so both map to `undefined` (no tab). + */ +export function shareTabForWorkspaceTab( + workspaceTab: string, +): EntityLinkTab | undefined { + switch (workspaceTab) { + case "files": + case "issues": + case "prs": + case "contributors": + case "channels": + return workspaceTab; + case "activity": + return "commits"; + default: + return undefined; + } +} + +/** Inverse of `shareTabForWorkspaceTab`, for the receiving side. */ +export function workspaceTabForShareTab(tab: EntityLinkTab): string { + return tab === "commits" ? "activity" : tab; +} + +/** + * Link to a project. Legacy (implicit) projects are backed by a repository + * announcement rather than a kind:30621 event, so they share as `buzz://repo` + * — which resolves to the same project route on the receiving side. + */ +export function projectShareLink( + project: Project, + tab?: EntityLinkTab, +): string | null { + const coordinate = parseAddressableCoordinate(project.projectAddress); + if (!coordinate || !isLinkableCoordinate(coordinate.owner, coordinate.dtag)) { + return null; + } + + if (coordinate.kind === KIND_PROJECT_ANNOUNCEMENT) { + return buildProjectLink({ ...coordinate, tab }); + } + return coordinate.kind === KIND_REPO_ANNOUNCEMENT + ? buildRepoLink({ ...coordinate, tab }) + : null; +} + +export function repositoryShareLink(repository: Repository): string | null { + const coordinate = repositoryCoordinate(repository.repoAddress); + return coordinate && isLinkableCoordinate(coordinate.owner, coordinate.dtag) + ? buildRepoLink(coordinate) + : null; +} + +export function issueShareLink(issue: ProjectIssue): string | null { + const coordinate = repositoryCoordinate(issue.repoAddress); + return coordinate && + HEX64_RE.test(issue.id) && + isLinkableCoordinate(coordinate.owner, coordinate.dtag) + ? buildIssueLink({ ...coordinate, id: issue.id }) + : null; +} + +export function pullRequestShareLink( + pullRequest: ProjectPullRequest, +): string | null { + const coordinate = repositoryCoordinate(pullRequest.repoAddress); + return coordinate && + HEX64_RE.test(pullRequest.id) && + isLinkableCoordinate(coordinate.owner, coordinate.dtag) + ? buildPullRequestLink({ ...coordinate, id: pullRequest.id }) + : null; +} diff --git a/desktop/src/features/projects/lib/projectsViewHelpers.ts b/desktop/src/features/projects/lib/projectsViewHelpers.ts index 45bb3e3254..87fc34e64e 100644 --- a/desktop/src/features/projects/lib/projectsViewHelpers.ts +++ b/desktop/src/features/projects/lib/projectsViewHelpers.ts @@ -17,7 +17,7 @@ export type ProjectsRepositoryScope = | "local" | "buzz" | "linked"; -export type ProjectsWorkItemScope = "all" | "mine"; +export type ProjectsWorkItemScope = "all" | "mine" | "assigned"; export type ProjectsFilter = | "all" | "mine" @@ -119,9 +119,13 @@ export function writeStoredRepositoryScope(scope: ProjectsRepositoryScope) { } } -function readStoredWorkItemScope(key: string): ProjectsWorkItemScope { +function readStoredWorkItemScope( + key: string, + allowed: ProjectsWorkItemScope[], +): ProjectsWorkItemScope { try { - return globalThis.localStorage?.getItem(key) === "mine" ? "mine" : "all"; + const value = globalThis.localStorage?.getItem(key); + return allowed.find((scope) => scope === value) ?? "all"; } catch { return "all"; } @@ -136,7 +140,9 @@ function writeStoredWorkItemScope(key: string, scope: ProjectsWorkItemScope) { } export function readStoredPullRequestScope(): ProjectsWorkItemScope { - return readStoredWorkItemScope(PROJECTS_PULL_REQUEST_SCOPE_STORAGE_KEY); + return readStoredWorkItemScope(PROJECTS_PULL_REQUEST_SCOPE_STORAGE_KEY, [ + "mine", + ]); } export function writeStoredPullRequestScope(scope: ProjectsWorkItemScope) { @@ -144,7 +150,10 @@ export function writeStoredPullRequestScope(scope: ProjectsWorkItemScope) { } export function readStoredIssueScope(): ProjectsWorkItemScope { - return readStoredWorkItemScope(PROJECTS_ISSUE_SCOPE_STORAGE_KEY); + return readStoredWorkItemScope(PROJECTS_ISSUE_SCOPE_STORAGE_KEY, [ + "mine", + "assigned", + ]); } export function writeStoredIssueScope(scope: ProjectsWorkItemScope) { diff --git a/desktop/src/features/projects/projectIssues.d.mts b/desktop/src/features/projects/projectIssues.d.mts index 4b0420602c..8abf67b4fa 100644 --- a/desktop/src/features/projects/projectIssues.d.mts +++ b/desktop/src/features/projects/projectIssues.d.mts @@ -28,12 +28,17 @@ export type ProjectIssue = { originAgentName: string | null; labels: string[]; recipients: string[]; + assignees: string[]; + assigneeOperationHeads: Record; status: ProjectIssueStatus; statusEventId: string | null; updatedAt: number; comments: ProjectIssueComment[]; }; +export const ISSUE_ASSIGNMENT_LABEL: "assignment"; +export const ISSUE_UNASSIGNMENT_LABEL: "unassignment"; + export const PROJECT_ISSUE_STATUS: { TRIAGE: "Triage"; BACKLOG: "Backlog"; diff --git a/desktop/src/features/projects/projectIssues.mjs b/desktop/src/features/projects/projectIssues.mjs index 331837ac5b..49363d6551 100644 --- a/desktop/src/features/projects/projectIssues.mjs +++ b/desktop/src/features/projects/projectIssues.mjs @@ -1,3 +1,13 @@ +import { sortEvents } from "../../shared/api/relayClientShared.ts"; + +// Issue assignment mirrors PR review requests (projectPullRequests.mjs): +// a kind:1 comment labeled with this `t` tag whose `p` tags are the +// assignees. Labeled text notes stay readable for any client that treats +// them as plain comments, and the `p` tags route the assignment into the +// assignee's mention feed (inbox) for free. +export const ISSUE_ASSIGNMENT_LABEL = "assignment"; +export const ISSUE_UNASSIGNMENT_LABEL = "unassignment"; + export const PROJECT_ISSUE_STATUS = { TRIAGE: "Triage", BACKLOG: "Backlog", @@ -73,21 +83,98 @@ function statusFromEvent(issue, statusEvent) { return PROJECT_ISSUE_STATUS.BACKLOG; } -function commentsForIssue(issueId, commentEvents) { - return commentEvents - .filter((event) => - event.tags.some( - (tag) => (tag[0] === "e" || tag[0] === "E") && tag[1] === issueId, - ), - ) - .sort((left, right) => left.created_at - right.created_at) - .map((event) => ({ - id: event.id, - content: event.content, - tags: getImetaTags(event), - author: event.pubkey, - createdAt: event.created_at, - })); +/** + * Assignment state is reduced from trusted kind:1 operations. `t: assignment` + * adds each `p` tag and `t: unassignment` removes it. The issue root's `p` + * tags are notification routing only. + * + * Trusted signers are the issue author and repo owner (who may change anyone), + * plus any community member whose operation names only themselves. Uncaused + * self-service operations are applied first, authoritative operations second, + * and self-service operations that causally reference the current per-assignee + * operation head last. This prevents signer-controlled timestamps from + * overriding authority while allowing a later observed owner/author decision + * to be superseded by the affected assignee. + */ +function assignmentStateForIssue(issue, issueCommentEvents) { + const allowedActors = allowedActorsForRoot(issue); + const assignees = new Set(); + const operationHeads = new Map(); + const uncausedSelfServiceOperations = []; + const authoritativeOperations = []; + const causalSelfServiceOperations = []; + const events = sortEvents( + issueCommentEvents.filter( + (event) => + event.kind === 1 && + event.tags.some((tag) => tag[0] === "e" && tag[1] === issue.id), + ), + ); + for (const event of events) { + const labels = getAllTags(event, "t"); + const isAssignment = labels.includes(ISSUE_ASSIGNMENT_LABEL); + const isUnassignment = labels.includes(ISSUE_UNASSIGNMENT_LABEL); + if (isAssignment === isUnassignment) continue; + const signer = event.pubkey.toLowerCase(); + const pubkeys = getAllTags(event, "p").map((pubkey) => + pubkey.toLowerCase(), + ); + const isSelfOperation = pubkeys.length === 1 && pubkeys[0] === signer; + if (!allowedActors.has(signer) && !isSelfOperation) continue; + const operation = { + id: event.id.toLowerCase(), + isAssignment, + pubkeys, + }; + if (allowedActors.has(signer)) { + authoritativeOperations.push(operation); + } else { + const priorTags = event.tags.filter((tag) => tag[0] === "prior"); + if (priorTags.length === 0) { + uncausedSelfServiceOperations.push(operation); + continue; + } + if ( + priorTags.length !== 1 || + !/^[a-fA-F0-9]{64}$/.test(priorTags[0]?.[1] ?? "") + ) { + continue; + } + causalSelfServiceOperations.push({ + ...operation, + prior: priorTags[0][1].toLowerCase(), + }); + } + } + for (const { id, isAssignment, pubkeys, prior } of [ + ...uncausedSelfServiceOperations, + ...authoritativeOperations, + ...causalSelfServiceOperations, + ]) { + if (prior && operationHeads.get(pubkeys[0]) !== prior) continue; + for (const pubkey of pubkeys) { + if (isAssignment) { + assignees.add(pubkey); + } else { + assignees.delete(pubkey); + } + operationHeads.set(pubkey, id); + } + } + return { + assignees: [...assignees], + heads: Object.fromEntries(operationHeads), + }; +} + +function commentsForIssue(issueCommentEvents) { + return sortEvents(issueCommentEvents).map((event) => ({ + id: event.id, + content: event.content, + tags: getImetaTags(event), + author: event.pubkey, + createdAt: event.created_at, + })); } export function eventToProjectIssue( @@ -96,7 +183,13 @@ export function eventToProjectIssue( commentEvents = [], ) { const latestStatus = latestStatusForIssue(issue, statusEvents); - const comments = commentsForIssue(issue.id, commentEvents); + const issueCommentEvents = commentEvents.filter((event) => + event.tags.some( + (tag) => (tag[0] === "e" || tag[0] === "E") && tag[1] === issue.id, + ), + ); + const comments = commentsForIssue(issueCommentEvents); + const assignmentState = assignmentStateForIssue(issue, issueCommentEvents); const title = getTag(issue, "subject") || issue.content.split("\n")[0] || @@ -114,6 +207,8 @@ export function eventToProjectIssue( originAgentName: getTag(issue, "buzz-origin-agent") ?? null, labels: getAllTags(issue, "t"), recipients: getAllTags(issue, "p"), + assignees: assignmentState.assignees, + assigneeOperationHeads: assignmentState.heads, status: statusFromEvent(issue, latestStatus), statusEventId: latestStatus?.id ?? null, updatedAt: diff --git a/desktop/src/features/projects/projectIssues.test.mjs b/desktop/src/features/projects/projectIssues.test.mjs index 3275412149..4c670213b5 100644 --- a/desktop/src/features/projects/projectIssues.test.mjs +++ b/desktop/src/features/projects/projectIssues.test.mjs @@ -6,6 +6,8 @@ import { eventToProjectIssue, getAllTags, getTag, + ISSUE_ASSIGNMENT_LABEL, + ISSUE_UNASSIGNMENT_LABEL, nextProjectIssueCommentCreatedAt, PROJECT_ISSUE_STATUS, } from "./projectIssues.mjs"; @@ -44,6 +46,33 @@ function statusEvent({ kind, pubkey, createdAt }) { }; } +function assignmentComment( + pubkey, + assignees, + id, + label = ISSUE_ASSIGNMENT_LABEL, + createdAt = 200, + prior, +) { + return { + id, + kind: 1, + pubkey, + created_at: createdAt, + content: + label === ISSUE_ASSIGNMENT_LABEL + ? "Assigned this issue" + : "Unassigned this issue", + tags: [ + ["e", "e".repeat(64), "", "root"], + ["a", REPO_ADDRESS], + ...assignees.map((value) => ["p", value]), + ["t", label], + ...(prior ? [["prior", prior]] : []), + ], + }; +} + test("ignores status events from a different pubkey", () => { const attackerClosed = statusEvent({ kind: 1632, @@ -151,6 +180,227 @@ test("parses public and private-safe issue provenance", () => { assert.equal(privateIssue.originAgentName, "Builder"); }); +test("assignees follow trusted assignment operations in deterministic order", () => { + const assignee = "d".repeat(64); + const otherAssignee = "f".repeat(64); + const volunteer = "5".repeat(64); + + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + // Author assigns (self-assignment included) — trusted. + assignmentComment(AUTHOR, [assignee.toUpperCase(), AUTHOR], "assign-1"), + // Repo owner assigns — trusted; duplicate assignee dedupes. + assignmentComment(OWNER, [assignee, otherAssignee], "assign-2"), + // Any member self-assigning (sole p tag is the signer) — trusted. + assignmentComment(volunteer, [volunteer], "assign-3"), + // Untrusted signer assigning someone else — ignored. + assignmentComment(ATTACKER, ["a".repeat(64)], "assign-4"), + // Untrusted signer sneaking themselves in alongside others — ignored. + assignmentComment(ATTACKER, [ATTACKER, "b".repeat(64)], "assign-5"), + // A volunteer may remove only themselves. + assignmentComment( + volunteer, + [volunteer], + "unassign-1", + ISSUE_UNASSIGNMENT_LABEL, + 201, + ), + // An untrusted signer cannot remove somebody else. + assignmentComment( + ATTACKER, + [otherAssignee], + "unassign-2", + ISSUE_UNASSIGNMENT_LABEL, + 202, + ), + // Repo owner may remove any assignee. + assignmentComment( + OWNER, + [otherAssignee], + "unassign-3", + ISSUE_UNASSIGNMENT_LABEL, + 203, + ), + // Same-second operations use event id as a stable tie-breaker: + // assign sorts before unassign here, leaving the assignee removed. + assignmentComment(OWNER, [otherAssignee], "a-assign", undefined, 204), + assignmentComment( + OWNER, + [otherAssignee], + "z-unassign", + ISSUE_UNASSIGNMENT_LABEL, + 204, + ), + // Trusted plain comment without the label adds nothing. + { + id: "plain-comment", + kind: 1, + pubkey: AUTHOR, + created_at: 201, + content: "Just a comment", + tags: [ + ["e", "e".repeat(64), "", "root"], + ["p", ATTACKER], + ], + }, + ], + ); + + assert.deepEqual(issue.assignees.sort(), [AUTHOR, assignee].sort()); +}); + +test("owner unassignment overrides a future-dated self-assignment", () => { + const volunteer = "5".repeat(64); + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + assignmentComment( + volunteer, + [volunteer], + "future-self-assign", + undefined, + 1_000, + ), + assignmentComment( + OWNER, + [volunteer], + "owner-unassign", + ISSUE_UNASSIGNMENT_LABEL, + 200, + ), + ], + ); + + assert.deepEqual(issue.assignees, []); +}); + +test("owner assignment overrides a future-dated self-unassignment", () => { + const volunteer = "5".repeat(64); + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + assignmentComment( + volunteer, + [volunteer], + "future-self-unassign", + ISSUE_UNASSIGNMENT_LABEL, + 1_000, + ), + assignmentComment(OWNER, [volunteer], "owner-assign", undefined, 200), + ], + ); + + assert.deepEqual(issue.assignees, [volunteer]); +}); + +test("causal self-unassignment can follow an owner assignment", () => { + const volunteer = "5".repeat(64); + const ownerAssignmentId = "1".repeat(64); + const selfUnassignmentId = "2".repeat(64); + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + assignmentComment(OWNER, [volunteer], ownerAssignmentId), + assignmentComment( + volunteer, + [volunteer], + selfUnassignmentId, + ISSUE_UNASSIGNMENT_LABEL, + 300, + ownerAssignmentId, + ), + ], + ); + + assert.deepEqual(issue.assignees, []); + assert.equal(issue.assigneeOperationHeads[volunteer], selfUnassignmentId); +}); + +test("causal self-assignment can follow an owner unassignment", () => { + const volunteer = "5".repeat(64); + const ownerUnassignmentId = "3".repeat(64); + const selfAssignmentId = "4".repeat(64); + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + assignmentComment( + OWNER, + [volunteer], + ownerUnassignmentId, + ISSUE_UNASSIGNMENT_LABEL, + ), + assignmentComment( + volunteer, + [volunteer], + selfAssignmentId, + ISSUE_ASSIGNMENT_LABEL, + 300, + ownerUnassignmentId, + ), + ], + ); + + assert.deepEqual(issue.assignees, [volunteer]); + assert.equal(issue.assigneeOperationHeads[volunteer], selfAssignmentId); +}); + +test("ignores a causal self-operation with a stale prior", () => { + const volunteer = "5".repeat(64); + const initialAssignmentId = "6".repeat(64); + const ownerUnassignmentId = "7".repeat(64); + const staleSelfAssignmentId = "8".repeat(64); + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + assignmentComment(OWNER, [volunteer], initialAssignmentId), + assignmentComment( + OWNER, + [volunteer], + ownerUnassignmentId, + ISSUE_UNASSIGNMENT_LABEL, + 250, + ), + assignmentComment( + volunteer, + [volunteer], + staleSelfAssignmentId, + ISSUE_ASSIGNMENT_LABEL, + 300, + initialAssignmentId, + ), + ], + ); + + assert.deepEqual(issue.assignees, []); + assert.equal(issue.assigneeOperationHeads[volunteer], ownerUnassignmentId); +}); + +test("issue recipients remain notification routing, not assignments", () => { + const recipient = "d".repeat(64); + const otherRecipient = "f".repeat(64); + const issue = eventToProjectIssue( + issueEvent({ + tags: [ + ["a", REPO_ADDRESS], + ["subject", "Something is broken"], + // Routing tag every issue carries — not an assignment. + ["p", OWNER], + ["p", recipient.toUpperCase()], + ["p", otherRecipient], + ], + }), + ); + + assert.deepEqual(issue.assignees, []); +}); + test("builds repository-scoped issue creation tags", () => { assert.deepEqual( buildGitIssueTags({ diff --git a/desktop/src/features/projects/projectOwnerControl.test.mjs b/desktop/src/features/projects/projectOwnerControl.test.mjs new file mode 100644 index 0000000000..51eeaf4e75 --- /dev/null +++ b/desktop/src/features/projects/projectOwnerControl.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isDanglingProjectMemberPublish, + PartialAnnouncementPublishError, +} from "./projectOwnerControl.ts"; + +const OWNER = "a".repeat(64); + +function event(kind, id) { + return { + id, + kind, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [["d", "platform"]], + }; +} + +// ── isDanglingProjectMemberPublish ────────────────────────────────────────── +// +// The ACP side publishes [project, repository] announcements sequentially and +// reports already-live events alongside a failure. addRepo may resume only +// from the exact "project landed, repository did not" state. + +test("project-landed/repository-missing partial publish is resumable", () => { + const error = new PartialAnnouncementPublishError("publish failed", [ + event(30621, "1".repeat(64)), + ]); + assert.equal(isDanglingProjectMemberPublish(error), true); +}); + +test("a clean failure (nothing published) is not resumable", () => { + const error = new PartialAnnouncementPublishError("publish failed", []); + assert.equal(isDanglingProjectMemberPublish(error), false); + assert.equal(isDanglingProjectMemberPublish(new Error("boom")), false); +}); + +test("a failure after both events landed is not resumable", () => { + const error = new PartialAnnouncementPublishError("publish failed", [ + event(30621, "1".repeat(64)), + event(30617, "2".repeat(64)), + ]); + assert.equal(isDanglingProjectMemberPublish(error), false); +}); diff --git a/desktop/src/features/projects/projectOwnerControl.ts b/desktop/src/features/projects/projectOwnerControl.ts new file mode 100644 index 0000000000..1d0f0c7fb4 --- /dev/null +++ b/desktop/src/features/projects/projectOwnerControl.ts @@ -0,0 +1,128 @@ +import { subscribeControlResults } from "@/features/agents/observerRelayStore"; +import { sendAgentObserverControl } from "@/shared/api/observerRelay"; +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; + +const OWNER_CONTROL_TIMEOUT_MS = 20_000; + +export type ProjectOwnerAnnouncementTemplate = { + kind: number; + content: string; + createdAt?: number; + tags: string[][]; +}; + +type ProjectOwnerControlResult = { + type: "publish_project_owner_announcements"; + status: string; + requestId: string; + events?: RelayEvent[]; + error?: string | null; +}; + +/** + * A remote-agent publish that failed after some announcements already landed. + * `publishedEvents` holds the events the ACP side reported as live before the + * failure, so callers can tell a clean failure (retry republishes everything) + * from a partial one (retry must resume from where publication stopped). + */ +export class PartialAnnouncementPublishError extends Error { + readonly publishedEvents: RelayEvent[]; + + constructor(message: string, publishedEvents: RelayEvent[]) { + super(message); + this.name = "PartialAnnouncementPublishError"; + this.publishedEvents = publishedEvents; + } +} + +/** + * True when a failed [project, repository] announcement publish stopped + * exactly between its two events — the project head landed, the repository + * event did not. That is the only partial state addRepo can resume from by + * republishing just the repository event; anything else must surface. + */ +export function isDanglingProjectMemberPublish( + error: unknown, +): error is PartialAnnouncementPublishError { + return ( + error instanceof PartialAnnouncementPublishError && + error.publishedEvents.some( + (event) => event.kind === KIND_PROJECT_ANNOUNCEMENT, + ) && + !error.publishedEvents.some( + (event) => event.kind === KIND_REPO_ANNOUNCEMENT, + ) + ); +} + +/** Ask a remotely managed agent to publish project events under its own key. */ +export function publishOwnedAgentProjectAnnouncements( + agentPubkey: string, + announcements: ProjectOwnerAnnouncementTemplate[], +): Promise { + const requestId = crypto.randomUUID(); + + return new Promise((resolve, reject) => { + let settled = false; + const finish = ( + result: { events: RelayEvent[] } | { error: Error }, + ): void => { + if (settled) return; + settled = true; + window.clearTimeout(timeout); + unsubscribe(); + if ("error" in result) reject(result.error); + else resolve(result.events); + }; + const unsubscribe = subscribeControlResults(agentPubkey, (frame) => { + const projectFrame = frame as unknown as ProjectOwnerControlResult; + if ( + projectFrame.type !== "publish_project_owner_announcements" || + projectFrame.requestId !== requestId + ) { + return; + } + if (projectFrame.status === "ok" && projectFrame.events) { + finish({ events: projectFrame.events }); + } else { + const message = + projectFrame.error || "The agent could not update this project."; + // The ACP side publishes announcements sequentially and reports the + // ones that were already live when a later one failed. Preserve that + // partial-success metadata instead of discarding it, so callers can + // resume publication rather than treating the state as unrecoverable. + const published = projectFrame.events ?? []; + finish({ + error: + published.length > 0 + ? new PartialAnnouncementPublishError(message, published) + : new Error(message), + }); + } + }); + const timeout = window.setTimeout(() => { + finish({ + error: new Error( + "The project owner agent did not respond. Make sure it is running and try again.", + ), + }); + }, OWNER_CONTROL_TIMEOUT_MS); + + void sendAgentObserverControl(agentPubkey, { + type: "publish_project_owner_announcements", + requestId, + announcements, + }).catch((error: unknown) => { + finish({ + error: + error instanceof Error + ? error + : new Error("Failed to contact the project owner agent."), + }); + }); + }); +} diff --git a/desktop/src/features/projects/projectRepositoryCreation.test.mjs b/desktop/src/features/projects/projectRepositoryCreation.test.mjs index 0a80e382c9..a7bcc5bccc 100644 --- a/desktop/src/features/projects/projectRepositoryCreation.test.mjs +++ b/desktop/src/features/projects/projectRepositoryCreation.test.mjs @@ -345,3 +345,104 @@ test("buildProjectPatchTemplate catches duplicate d in live head via full-envelo /NIP-MP.*'d'/, ); }); + +// ── buildAddedRepositoryEventTemplatesFromHead: partial-publish recovery ──── +// +// addRepo publishes two events sequentially (project head, then repository). +// If the repository publish fails after the project head lands, the head +// references a coordinate with no repository event — a dangling member. +// Retry must heal it: when the live head already lists the coordinate but no +// kind-30617 head exists there, the builder returns resume templates instead +// of throwing the "already contains" race error. + +test("retry after event 2 fails (event 1 succeeded) returns resume templates that heal the dangling member", () => { + const OWNER = "a".repeat(64); + const existingAddress = `30617:${OWNER}:desktop`; + const newAddress = `30617:${OWNER}:mobile`; + const accessChannelId = "11111111-1111-4111-8111-111111111111"; + const preAddHead = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [ + ["d", "platform"], + ["buzz-channel", accessChannelId], + ["a", existingAddress], + ], + }; + + // Attempt 1: fresh add. Project template gains the address; the repository + // head at the coordinate does not exist yet. + const attempt1 = buildAddedRepositoryEventTemplatesFromHead({ + accessChannelId, + existingRepositoryAddresses: [existingAddress], + liveHead: preAddHead, + name: "Mobile", + ownerPubkey: OWNER, + repositoryHeadExists: false, + }); + assert.equal(attempt1.resume, false); + assert.deepEqual( + attempt1.project.tags.filter((tag) => tag[0] === "a").map((tag) => tag[1]), + [existingAddress, newAddress], + ); + + // Event 1 (project head) lands; event 2 (repository) fails. The live head + // now references the coordinate, but no repository head exists. + const danglingHead = { + ...preAddHead, + id: "f".repeat(64), + created_at: 101, + tags: [...preAddHead.tags, ["a", newAddress]], + }; + + // Attempt 2 (retry): must not throw "already contains" — it must resume. + const attempt2 = buildAddedRepositoryEventTemplatesFromHead({ + accessChannelId, + existingRepositoryAddresses: [existingAddress, newAddress], + liveHead: danglingHead, + name: "Mobile", + ownerPubkey: OWNER, + repositoryHeadExists: false, + }); + assert.equal(attempt2.resume, true); + // The project template must not double-add the coordinate. + assert.deepEqual( + attempt2.project.tags.filter((tag) => tag[0] === "a").map((tag) => tag[1]), + [existingAddress, newAddress], + ); + // The repository template is the same missing event the first attempt + // failed to publish. + assert.deepEqual(attempt2.repository, attempt1.repository); + assert.equal(attempt2.repositoryAddress, newAddress); +}); + +test("a coordinate in the live head WITH a live repository head is still a concurrent-add conflict", () => { + const OWNER = "a".repeat(64); + const newAddress = `30617:${OWNER}:mobile`; + const liveHead = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [ + ["d", "platform"], + ["a", newAddress], + ], + }; + assert.throws( + () => + buildAddedRepositoryEventTemplatesFromHead({ + accessChannelId: "11111111-1111-4111-8111-111111111111", + existingRepositoryAddresses: [], + liveHead, + name: "Mobile", + ownerPubkey: OWNER, + repositoryHeadExists: true, + }), + /already contains.*mobile.*another session/, + ); +}); diff --git a/desktop/src/features/projects/projectRepositoryCreation.ts b/desktop/src/features/projects/projectRepositoryCreation.ts index 832350f901..08da3a4118 100644 --- a/desktop/src/features/projects/projectRepositoryCreation.ts +++ b/desktop/src/features/projects/projectRepositoryCreation.ts @@ -18,6 +18,8 @@ function repositoryDtagFromName(name: string): string { .replace(/^-+|-+$/g, ""); } +export { repositoryDtagFromName }; + /** * Creates a project-replacement event template from a live, signed raw head * (fetched immediately before the mutation). Only the `a` membership tags are @@ -132,6 +134,13 @@ export type AddedRepositoryEventTemplatesFromHead = { repository: ProjectEventTemplate; repositoryAddress: string; repositoryDtag: string; + /** + * True when the live head already references the coordinate but the caller + * indicated no repository head exists there (a dangling member from an + * earlier partial publish). The project head is already correct — publish + * only the repository event to heal. + */ + resume: boolean; }; /** @@ -152,6 +161,7 @@ export function buildAddedRepositoryEventTemplatesFromHead({ liveHead, name, ownerPubkey, + repositoryHeadExists = true, webUrl, }: { accessChannelId?: string; @@ -161,6 +171,14 @@ export function buildAddedRepositoryEventTemplatesFromHead({ liveHead: RelayEvent; name: string; ownerPubkey: string; + /** + * Whether a kind-30617 head already exists at the new coordinate. When the + * live project head references the coordinate but no repository head exists + * there, an earlier add-repository publish failed between its two events — + * return resume templates instead of throwing so retry can heal the + * dangling member. + */ + repositoryHeadExists?: boolean; webUrl?: string; }): AddedRepositoryEventTemplatesFromHead { const normalizedOwner = ownerPubkey.trim().toLowerCase(); @@ -179,9 +197,13 @@ export function buildAddedRepositoryEventTemplatesFromHead({ .filter((tag) => tag[0] === "a" && tag[1]) .map((tag) => tag[1] as string); - // If the repo is already in the live head (race: another session added it), - // surface that to the caller. - if (liveAddresses.includes(repositoryAddress)) { + // If the repo is already in the live head with a live repository head at + // the coordinate (race: another session added it), surface that to the + // caller. Without a repository head the membership is a dangling member + // from a partial publish — resume by publishing only the repository event. + const resume = + liveAddresses.includes(repositoryAddress) && !repositoryHeadExists; + if (liveAddresses.includes(repositoryAddress) && repositoryHeadExists) { throw new Error( `This project already contains "${repositoryDtag}" (it was added by another session).`, ); @@ -216,9 +238,12 @@ export function buildAddedRepositoryEventTemplatesFromHead({ const normalizedWebUrl = webUrl?.trim(); if (normalizedWebUrl) repositoryTags.push(["web", normalizedWebUrl]); - const newAddresses = isUnavailableMember - ? [...liveAddresses] - : [...liveAddresses, repositoryAddress]; + // In resume mode the live head already lists the coordinate; the project + // template is a no-op republish guard and must not double-add the address. + const newAddresses = + isUnavailableMember || resume + ? [...liveAddresses] + : [...liveAddresses, repositoryAddress]; const projectTemplate = buildProjectPatchTemplate({ liveHead, @@ -235,5 +260,6 @@ export function buildAddedRepositoryEventTemplatesFromHead({ }, repositoryAddress, repositoryDtag, + resume, }; } diff --git a/desktop/src/features/projects/projectWorkItems.ts b/desktop/src/features/projects/projectWorkItems.ts index c2170e687a..75ba678908 100644 --- a/desktop/src/features/projects/projectWorkItems.ts +++ b/desktop/src/features/projects/projectWorkItems.ts @@ -1,5 +1,9 @@ import { relayClient } from "@/shared/api/relayClient"; import type { RelayEvent } from "@/shared/api/types"; +import { + fetchAssignmentOperationEvents, + mergeEventsById, +} from "./assignmentOperationFetch"; import { KIND_GIT_ISSUE, KIND_GIT_PR_UPDATE, @@ -33,6 +37,7 @@ type ProjectRepository = /** Optional event groups that can fail without discarding root work items. */ export type ProjectWorkItemSection = + | "assignments" | "comments" | "pull-request-updates" | "statuses"; @@ -85,13 +90,14 @@ export async function fetchProjectsWorkItems( ), ), ]; - const [rootResult, updateResult, commentResult, statusResult] = + const rootPromise = fetchEvents({ + kinds: [KIND_GIT_ISSUE, KIND_GIT_PULL_REQUEST], + "#a": repoAddresses, + limit: 2_000, + }); + const [rootResult, updateResult, commentResult, statusResult, assignResult] = await Promise.allSettled([ - fetchEvents({ - kinds: [KIND_GIT_ISSUE, KIND_GIT_PULL_REQUEST], - "#a": repoAddresses, - limit: 2_000, - }), + rootPromise, fetchEvents({ kinds: [KIND_GIT_PR_UPDATE], "#a": repoAddresses, @@ -112,6 +118,19 @@ export async function fetchProjectsWorkItems( "#a": repoAddresses, limit: 2_000, }), + // Assignment state must reduce over the complete operation history — + // the 2,000-comment window above is shared across every loaded repo + // and can evict older assignment operations. Keyed by issue id (`#e`) + // because that is the only tag constraint the relay applies before its + // SQL LIMIT; see fetchAssignmentOperationEvents. + rootPromise.then((rootEvents) => + fetchAssignmentOperationEvents( + rootEvents + .filter((event) => event.kind === KIND_GIT_ISSUE) + .map((event) => event.id), + fetchEvents, + ), + ), ]); if (rootResult.status === "rejected") { @@ -122,8 +141,10 @@ export async function fetchProjectsWorkItems( const updateEvents = updateResult.status === "fulfilled" ? updateResult.value : []; - const commentEvents = - commentResult.status === "fulfilled" ? commentResult.value : []; + const commentEvents = mergeEventsById( + commentResult.status === "fulfilled" ? commentResult.value : [], + assignResult.status === "fulfilled" ? assignResult.value : [], + ); const statusEvents = statusResult.status === "fulfilled" ? statusResult.value : []; const rootsByRepo = groupByRepoAddress(rootResult.value); @@ -194,6 +215,9 @@ export async function fetchProjectsWorkItems( ) .sort((left, right) => right.issue.updatedAt - left.issue.updatedAt); const sharedFailedSections: ProjectWorkItemSection[] = []; + if (assignResult.status === "rejected") { + sharedFailedSections.push("assignments"); + } if (commentResult.status === "rejected") { sharedFailedSections.push("comments"); } diff --git a/desktop/src/features/projects/ui/CopyShareLinkMenuItem.tsx b/desktop/src/features/projects/ui/CopyShareLinkMenuItem.tsx new file mode 100644 index 0000000000..53d564dba8 --- /dev/null +++ b/desktop/src/features/projects/ui/CopyShareLinkMenuItem.tsx @@ -0,0 +1,37 @@ +import { Link2 } from "lucide-react"; + +import { copyTextToClipboard } from "@/shared/lib/clipboard"; +import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; + +/** + * "Copy link" row for the Projects action menus. Renders nothing when the + * entity has no shareable coordinate (see `lib/projectShareLinks`) so we never + * offer a link that would fail to parse for the recipient. + */ +export function CopyShareLinkMenuItem({ + label = "Copy link", + link, + successMessage = "Link copied to clipboard", + testId, +}: { + label?: string; + link: string | null; + successMessage?: string; + testId?: string; +}) { + if (!link) return null; + + return ( + { + event.preventDefault(); + event.stopPropagation(); + copyTextToClipboard(link, successMessage); + }} + > + + {label} + + ); +} diff --git a/desktop/src/features/projects/ui/DiscussionChannels.tsx b/desktop/src/features/projects/ui/DiscussionChannels.tsx new file mode 100644 index 0000000000..008b85f858 --- /dev/null +++ b/desktop/src/features/projects/ui/DiscussionChannels.tsx @@ -0,0 +1,405 @@ +import { Hash } from "lucide-react"; +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { + type DiscussionChannel, + discussionSnippet, + groupDiscussionChannels, +} from "@/features/projects/lib/discussionChannels"; +import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; +import { useSearchMessagesQuery } from "@/features/search/hooks"; +import type { SearchHit } from "@/shared/api/searchTypes"; +import { cn } from "@/shared/lib/cn"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +// Relay search caps a page at 500. Use the full page and surface a lower-bound +// marker when it fills rather than silently presenting partial totals as exact. +const DISCUSSION_SEARCH_LIMIT = 500; +const COLLAPSED_MENTION_ROWS = 3; + +/** + * Messages (and the channels containing them) that link the entity matched + * by `query` (see `discussionChannels.ts` for how queries are built). + * Results cover only channels the viewer can read — the relay authorizes + * every search hit. Profiles for the discussing authors resolve in the same + * hook so rows can show names and avatars. + */ +export function useDiscussionChannels(query: string): { + channels: DiscussionChannel[]; + hits: SearchHit[]; + isLoading: boolean; + isTruncated: boolean; +} { + const search = useSearchMessagesQuery(query, { + limit: DISCUSSION_SEARCH_LIMIT, + }); + const hits = React.useMemo( + () => + [...(search.data?.hits ?? [])].sort((a, b) => b.createdAt - a.createdAt), + [search.data], + ); + const channels = React.useMemo(() => groupDiscussionChannels(hits), [hits]); + return { + channels, + hits, + isLoading: search.isLoading, + isTruncated: hits.length >= DISCUSSION_SEARCH_LIMIT, + }; +} + +/** Channel display name, preferring the hit's name, then the channel list, + * then a short id so private/renamed channels still render something. */ +function useChannelNameLookup(enabled: boolean) { + const channelsQuery = useChannelsQuery({ enabled }); + return React.useCallback( + (id: string, nameFromHit: string | null) => + nameFromHit ?? + channelsQuery.data?.find((channel) => channel.id === id)?.name ?? + id.slice(0, 8), + [channelsQuery.data], + ); +} + +/** + * "Channels" card for PR, issue, and commit detail views: a bordered, + * softly tinted block with a small header that separates it from the + * surrounding text. Each channel gets a single truncating line — + * "Alice, Bob and Carol discussed this in #channel · 2h ago — snippet…" — + * cut at the card edge regardless of screen width. Clicking the snippet + * jumps to that message (thread-aware), the same way inbox items do. + * Renders nothing until at least one channel references the entity, so + * the detail layout stays unchanged for undiscussed items. + */ +export function DiscussedInChannels({ + className, + entityLabel = "this", + query, + testId, +}: { + /** Extra spacing/alignment classes from the call site. */ + className?: string; + /** How the sentence names the entity, e.g. "this issue". */ + entityLabel?: string; + query: string; + testId?: string; +}) { + const { channels, hits, isTruncated } = useDiscussionChannels(query); + const { goChannel, openSearchHit } = useAppNavigation(); + const [expanded, setExpanded] = React.useState(false); + const channelName = useChannelNameLookup(channels.length > 0); + const visible = expanded + ? channels + : channels.slice(0, COLLAPSED_MENTION_ROWS); + const profilesQuery = useUsersBatchQuery( + visible.flatMap((channel) => channel.participants), + { enabled: visible.length > 0 }, + ); + const profiles = profilesQuery.data?.profiles; + // Hits are sorted newest first, so the first hit per channel is the one a + // click should land on (and the one worth quoting). + const latestHitByChannel = React.useMemo(() => { + const byChannel = new Map(); + for (const hit of hits) { + if (hit.channelId && !byChannel.has(hit.channelId)) { + byChannel.set(hit.channelId, hit); + } + } + return byChannel; + }, [hits]); + if (channels.length === 0) return null; + + const hiddenCount = channels.length - visible.length; + + return ( +
    +

    + Channels +

    +
    + {visible.map((channel) => { + const latestHit = latestHitByChannel.get(channel.id); + if (!latestHit) return null; + const name = channelName(channel.id, channel.name); + return ( +
    + + + + + {" "} + discussed {entityLabel} in{" "} + + + + +
    + ); + })} +
    + {hiddenCount > 0 ? ( + + ) : null} + {isTruncated ? ( +

    + Showing mentions from the 500 most recent search results. +

    + ) : null} +
    + ); +} + +const NAME_LIST_MAX = 3; + +/** + * The "Alice, Bob and Carol" (or "Alice, Bob and 2 others") part of the + * sentence, with each name opening that person's profile popover. Mirrors + * the wording of `formatNameList` in `discussionChannels.ts`. + */ +function DiscussionNameList({ + participants, + profiles, +}: { + participants: string[]; + profiles: UserProfileLookup | undefined; +}) { + const showAll = participants.length <= NAME_LIST_MAX; + const shown = showAll + ? participants + : participants.slice(0, NAME_LIST_MAX - 1); + const others = participants.length - shown.length; + return ( + <> + {shown.map((pubkey, index) => { + const isLast = index === shown.length - 1; + const separator = + index === 0 ? null : isLast && others === 0 ? " and " : ", "; + return ( + + {separator ? ( + {separator} + ) : null} + + + + + ); + })} + {others > 0 ? ( + + {" "} + and {others} others + + ) : null} + + ); +} + +function ParticipantFacepile({ + interactive = false, + participants, + profiles, +}: { + /** Wrap each avatar in a profile popover. Leave off when the facepile is + * nested inside another button (nested interactive elements are invalid). */ + interactive?: boolean; + participants: string[]; + profiles: UserProfileLookup | undefined; +}) { + const shown = participants.slice(0, 4); + const overflow = participants.length - shown.length; + return ( + + {shown.map((pubkey, index) => { + const label = resolveUserLabel({ profiles, pubkey }); + if (!interactive) { + return ( + 0 && "-ml-1.5", + )} + displayName={label} + key={pubkey} + size="xs" + /> + ); + } + return ( + + + + ); + })} + {overflow > 0 ? ( + + +{overflow} + + ) : null} + + ); +} + +/** + * Full-width channel list for the workspace "Channels" tab: every channel + * where the repository (or its PRs/issues) is linked in chat, with the + * people who discussed it there. + */ +export function DiscussionChannelsPanel({ query }: { query: string }) { + const { channels, isLoading, isTruncated } = useDiscussionChannels(query); + const { goChannel } = useAppNavigation(); + const channelName = useChannelNameLookup(channels.length > 0); + const profilesQuery = useUsersBatchQuery( + channels.flatMap((channel) => channel.participants), + { enabled: channels.length > 0 }, + ); + const profiles = profilesQuery.data?.profiles; + + if (isLoading) { + return ( +

    + Searching channel discussions… +

    + ); + } + if (channels.length === 0) { + return ( +

    + No channels reference this repository yet. Paste its link (or a PR or + issue link) in a channel and it will show up here. +

    + ); + } + + return ( +
    +
      + {channels.map((channel) => { + const name = channelName(channel.id, channel.name); + const speakers = channel.participants + .slice(0, 2) + .map((pubkey) => resolveUserLabel({ profiles, pubkey })); + const others = channel.participants.length - speakers.length; + return ( +
    • + +
    • + ); + })} +
    + {isTruncated ? ( +

    + Showing the latest {DISCUSSION_SEARCH_LIMIT} mentions; totals may be + higher. +

    + ) : null} +
    + ); +} diff --git a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx new file mode 100644 index 0000000000..b41145b01a --- /dev/null +++ b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx @@ -0,0 +1,346 @@ +import { Search, X } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; +import type { Repository as Project } from "@/features/projects/hooks"; +import { + useAssignProjectIssueMutation, + useUnassignProjectIssueMutation, +} from "@/features/projects/issueAssignments"; +import type { ProjectIssue } from "@/features/projects/projectIssues.mjs"; +import { useUserSearchQuery } from "@/features/profile/hooks"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { UserSearchResult } from "@/shared/api/types"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +function profileForPubkey(pubkey: string, profiles?: UserProfileLookup) { + return profiles?.[normalizePubkey(pubkey)] ?? null; +} + +function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) { + const profile = profileForPubkey(pubkey, profiles); + return ( + profile?.displayName?.trim() || + profile?.nip05Handle?.trim() || + truncatePubkey(pubkey) + ); +} + +function assigneeSearchLabel(user: UserSearchResult) { + return ( + user.displayName?.trim() || + user.nip05Handle?.trim() || + truncatePubkey(user.pubkey) + ); +} + +/** Compact overlapping assignee avatars for issue list rows. */ +export function IssueAssigneeFacepile({ + assignees, + profiles, +}: { + assignees: string[]; + profiles?: UserProfileLookup; +}) { + if (assignees.length === 0) return null; + return ( + + {assignees.slice(0, 3).map((pubkey) => { + const profile = profileForPubkey(pubkey, profiles); + const label = labelForPubkey(pubkey, profiles); + return ( + + + + ); + })} + + ); +} + +/** Assignee avatars and the assignment picker for an issue. + * + * The issue author, repo owner, or managed-agent owner + * (`canAssignOthers`) get the full people/agent picker. Everyone else + * who is signed in gets a self-assign button — readers trust an + * assignment whose only assignee is its signer (see `projectIssues.mjs`). + */ +export function IssueAssigneesRow({ + canAssignOthers, + issue, + profiles, + project, + signAsManagedOwner, + viewerPubkey, +}: { + canAssignOthers: boolean; + issue: ProjectIssue; + profiles?: UserProfileLookup; + project: Project; + signAsManagedOwner: boolean; + viewerPubkey: string | null; +}) { + const [pickerOpen, setPickerOpen] = React.useState(false); + const [assigneeQuery, setAssigneeQuery] = React.useState(""); + const assignmentOperationInFlightRef = React.useRef(false); + const assignMutation = useAssignProjectIssueMutation(project); + const unassignMutation = useUnassignProjectIssueMutation(project); + const deferredAssigneeQuery = React.useDeferredValue(assigneeQuery.trim()); + const currentAssignees = React.useMemo( + () => new Set(issue.assignees.map(normalizePubkey)), + [issue.assignees], + ); + const userSearchQuery = useUserSearchQuery(deferredAssigneeQuery, { + allowEmpty: true, + enabled: canAssignOthers && pickerOpen, + limit: 50, + }); + const isArchivedDiscovery = useIsArchivedPredicate(); + // Unlike PR reviewers, the issue author stays in the candidate list — + // self-assignment is normal issue-tracker behavior. + const candidates = React.useMemo( + () => + (userSearchQuery.data ?? []).filter((user) => { + const pubkey = normalizePubkey(user.pubkey); + return !currentAssignees.has(pubkey) && !isArchivedDiscovery(pubkey); + }), + [currentAssignees, isArchivedDiscovery, userSearchQuery.data], + ); + const viewer = viewerPubkey ? normalizePubkey(viewerPubkey) : null; + const operationSigner = viewer ?? project.owner; + + const handleAssign = React.useCallback( + async ( + pubkey: string, + assigneeLabel: string, + options?: { asManagedOwner?: boolean }, + ) => { + if (assignMutation.isPending || assignmentOperationInFlightRef.current) + return; + assignmentOperationInFlightRef.current = true; + try { + await assignMutation.mutateAsync({ + assignees: [pubkey], + assigneeLabel, + issue, + signerPubkey: operationSigner, + signAsManagedOwner: options?.asManagedOwner ?? false, + }); + setPickerOpen(false); + setAssigneeQuery(""); + toast.success("Issue assigned."); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to assign issue.", + ); + } finally { + assignmentOperationInFlightRef.current = false; + } + }, + [assignMutation, issue, operationSigner], + ); + + const handleUnassign = React.useCallback( + async (pubkey: string, assigneeLabel: string) => { + if (unassignMutation.isPending || assignmentOperationInFlightRef.current) + return; + assignmentOperationInFlightRef.current = true; + try { + await unassignMutation.mutateAsync({ + assignees: [pubkey], + assigneeLabel, + issue, + signerPubkey: operationSigner, + signAsManagedOwner, + }); + toast.success("Issue unassigned."); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to unassign issue.", + ); + } finally { + assignmentOperationInFlightRef.current = false; + } + }, + [issue, operationSigner, signAsManagedOwner, unassignMutation], + ); + + React.useEffect(() => { + if (!pickerOpen) setAssigneeQuery(""); + }, [pickerOpen]); + + const canSelfAssign = + viewer !== null && !canAssignOthers && !currentAssignees.has(viewer); + + if (issue.assignees.length === 0 && !canAssignOthers && !canSelfAssign) { + return null; + } + + return ( +
    + {issue.assignees.map((pubkey) => { + const profile = profileForPubkey(pubkey, profiles); + const label = labelForPubkey(pubkey, profiles); + const canUnassign = + canAssignOthers || + (viewer !== null && normalizePubkey(pubkey) === viewer); + const avatar = ( + + ); + return ( + + + {canUnassign ? ( + + ) : ( + {avatar} + )} + + + {canUnassign ? `Unassign ${label}` : `${label} — assigned`} + + + ); + })} + {canSelfAssign && viewer ? ( + + ) : null} + {canAssignOthers ? ( + + + + + + + Assign issue + + Choose a person or agent to work on this issue. + + +
    + + setAssigneeQuery(event.target.value)} + placeholder="Search people and agents" + value={assigneeQuery} + /> +
    +
    + {userSearchQuery.isLoading ? ( +

    + Searching… +

    + ) : candidates.length > 0 ? ( + candidates.map((candidate) => { + const label = assigneeSearchLabel(candidate); + return ( + + ); + }) + ) : ( +

    + No matching people or agents. +

    + )} +
    +
    +
    + ) : null} +
    + ); +} diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index 09535fdfc7..4c387b60c3 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -24,6 +24,7 @@ import { relativeTime, } from "@/features/projects/lib/projectsViewHelpers"; import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability"; +import { projectShareLink } from "@/features/projects/lib/projectShareLinks"; import { projectTerminalLabel } from "@/features/projects/ui/useOpenProjectTerminal"; import { PROJECT_LIST_ROW_CLASS, @@ -50,6 +51,7 @@ import { Card } from "@/shared/ui/card"; import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; function ProjectUpdatedLabel({ @@ -169,6 +171,10 @@ const PROJECT_STAT_ITEMS = [ }, ] as const; +/** + * Textual commit/PR/issue counts. Repository lists show these next to the + * activity bar; project lists show the bar alone (counts via its tooltips). + */ export function ProjectStatsRow({ summary, fixedColumns = false, @@ -224,7 +230,10 @@ export function ProjectActivityBar({ // z-10 lifts the bar above the card's full-surface open button so it // can receive hover events. Fixed h-2 wrapper keeps layout stable // while the inner bar grows on hover. -
    +
    {total > 0 ? items @@ -395,6 +404,10 @@ function ProjectActionsMenu({ return ( + { event.preventDefault(); @@ -541,13 +554,8 @@ export function ProjectGridCard({ />
    -
    -
    - -
    -
    - -
    +
    +
    @@ -612,11 +620,10 @@ export function ProjectListRow({ />
    - -
    +
    diff --git a/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx b/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx index 0f7d95a01c..7937c3aba4 100644 --- a/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx @@ -8,7 +8,9 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { commitDiscussionQuery } from "@/features/projects/lib/discussionChannels"; import type { ProjectRepoCommit, ProjectRepoDiff } from "@/shared/api/types"; +import { DiscussedInChannels } from "./DiscussionChannels"; import { CopyCommitHashButton } from "./ProjectCommitCopyButton"; import { PROJECT_DETAIL_PANEL_CLASS } from "./projectPanelStyles"; import { ProfileIdentityButton } from "./ProjectProfileIdentity"; @@ -115,6 +117,14 @@ export function ProjectCommitDetailPanel({ {diff?.commitBody ? ( ) : null} + ; - onGoChannel: (channelId: string) => void; onGoProjectHome: () => void; onGoProjects: () => void; project: Project; + /** + * Workspace tab the copied link should open (`undefined` = overview), so + * sharing from the PR or issue list lands recipients on that same list. + */ + shareTab?: EntityLinkTab; }) { return (
    )} - {project.projectChannelId ? ( - - ) : null} +
    + {actions} + +
    ); diff --git a/desktop/src/features/projects/ui/ProjectDetailChromeActions.tsx b/desktop/src/features/projects/ui/ProjectDetailChromeActions.tsx new file mode 100644 index 0000000000..552745824f --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectDetailChromeActions.tsx @@ -0,0 +1,30 @@ +import type { Project, Repository } from "@/features/projects/hooks"; +import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; + +/** + * Repository-scoped controls for the project detail chrome. Extracted from + * `ProjectDetailScreen` to keep the screen under the file-size ratchet. + */ +export function ProjectDetailChromeActions({ + identityPubkey, + onRepositoryChange, + project, + projects, + repository, +}: { + identityPubkey?: string; + onRepositoryChange: (repositoryId: string) => void; + project: Project; + projects: Project[]; + repository: Repository; +}) { + return ( + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx index 6676044c86..e5cb68431f 100644 --- a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx @@ -16,7 +16,7 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; -import { GitBranch, GitCommitHorizontal } from "lucide-react"; +import { GitBranch } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { CopyCommitHashButton } from "./ProjectCommitCopyButton"; @@ -84,13 +84,14 @@ export function ContributorsPanel({ {rows.map((row, index) => (
    - {row.lastCommitAt ? ( - <> - · - updated {relativeTime(row.lastCommitAt)} - - ) : null}
    + {row.lastCommitAt ? ( + + {relativeTime(row.lastCommitAt)} + + ) : null}
    ))}
    @@ -175,12 +179,6 @@ export function ActivityPanel({ return (
    -
    - -

    - Commits -

    -
    {commits.map((commit) => { const matchedProfile = profileForCommit( @@ -241,12 +239,6 @@ export function ActivityPanel({ title={commit.subject} trailing={ <> - - {relativeTime(commit.timestamp)} - + + {relativeTime(commit.timestamp)} + } /> diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index 1b2adf316a..9285d25687 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, ExternalLink, FolderGit2 } from "lucide-react"; +import { ArrowLeft, FolderGit2 } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; @@ -60,8 +60,13 @@ import { resolveProjectDefaultBranch, } from "@/features/projects/lib/projectBranches"; import { normalizeRepositoryUrl } from "@/features/projects/lib/projectsViewHelpers"; +import { + shareTabForWorkspaceTab, + workspaceTabForShareTab, +} from "@/features/projects/lib/projectShareLinks"; import { selectProjectRepository } from "@/features/projects/projectModels"; import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds"; +import type { EntityLinkTab } from "@/shared/lib/entityLink"; import { useProjectRepoPresentation } from "@/features/projects/useProjectRepoHost"; import { WorkspaceTabs } from "./ProjectWorkspaceTabs"; import type { RepoSourceHeaderControls } from "./ProjectRepositorySource"; @@ -73,7 +78,7 @@ import { import type { CreateIssueDialogInput } from "./CreateIssueDialog"; import { ProjectBranchActionDialogs } from "./ProjectBranchActionDialogs"; import { ProjectDetailChrome } from "./ProjectDetailChrome"; -import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; +import { ProjectDetailChromeActions } from "./ProjectDetailChromeActions"; import { UnavailableProjectRepositories } from "./UnavailableProjectRepositories"; import { PROJECT_TAB_CRUMB_LABELS, @@ -84,10 +89,13 @@ import { type ProjectDetailScreenProps = { commitHash?: string; + entityNavigationId?: string; projectId: string; pullRequestId?: string; issueId?: string; repositoryId?: string; + /** Workspace tab requested by a share link (link vocabulary). */ + tab?: EntityLinkTab; }; const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [ @@ -103,7 +111,15 @@ const PROJECT_REPOSITORY_SEARCH_KEYS = [ ] as const; export function ProjectDetailScreen(props: ProjectDetailScreenProps) { - const { commitHash, projectId, pullRequestId, issueId, repositoryId } = props; + const { + commitHash, + entityNavigationId, + projectId, + pullRequestId, + issueId, + repositoryId, + tab, + } = props; const { goChannel, goProject, goProjects } = useAppNavigation(); const { activeCommunity } = useCommunities(); const mainInsetRef = useMainInsetRef(); @@ -161,14 +177,14 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { const [selectedPullRequestId, setSelectedPullRequestId] = React.useState< string | null >(pullRequestId ?? null); - React.useEffect( - () => setSelectedPullRequestId(pullRequestId ?? null), - [pullRequestId], - ); const [selectedIssueId, setSelectedIssueId] = React.useState( issueId ?? null, ); - React.useEffect(() => setSelectedIssueId(issueId ?? null), [issueId]); + // biome-ignore lint/correctness/useExhaustiveDependencies: the transient request ID deliberately reapplies an unchanged entity selection. + React.useEffect(() => { + setSelectedPullRequestId(pullRequestId ?? null); + setSelectedIssueId(issueId ?? null); + }, [entityNavigationId, issueId, pullRequestId]); const [selectedCommitHash, setSelectedCommitHash] = React.useState< string | null >(commitHash ?? null); @@ -176,9 +192,14 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { () => setSelectedCommitHash(commitHash ?? null), [commitHash], ); - // Bumped when breadcrumb navigation should land on the project Overview - // tab; remounts WorkspaceTabs, which owns the selected-tab state. + // Remounts WorkspaceTabs when breadcrumb navigation should open Overview. const [tabsResetKey, setTabsResetKey] = React.useState(0); + // Local state lets breadcrumb and repository resets drop a share-link tab. + const [requestedTab, setRequestedTab] = React.useState< + EntityLinkTab | undefined + >(tab); + // biome-ignore lint/correctness/useExhaustiveDependencies: the transient request ID deliberately reapplies an unchanged share-link tab. + React.useEffect(() => setRequestedTab(tab), [entityNavigationId, tab]); // Mirror of the WorkspaceTabs selection so the breadcrumb can name the // active sub-tab. The Overview (readme) tab is "home" and gets no crumb. const [activeTab, setActiveTab] = React.useState("overview"); @@ -484,6 +505,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { const issuePubkeys = (issuesQuery.data ?? []).flatMap((issue) => [ issue.author, ...issue.recipients, + ...issue.assignees, ...issue.comments.map((comment) => comment.author), ]); return [ @@ -814,6 +836,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { setSelectedPullRequestId(null); setSelectedIssueId(null); setSelectedCommitHash(null); + setRequestedTab(undefined); // Remount the workspace tabs so the project page opens on Overview // instead of whatever tab the work item left behind. setTabsResetKey((key) => key + 1); @@ -828,6 +851,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { setSelectedPullRequestId(null); setSelectedIssueId(null); setSelectedCommitHash(null); + setRequestedTab(undefined); setRepoSource("remote"); setTabsResetKey((key) => key + 1); }; @@ -846,63 +870,28 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { activeTabCrumb={activeTabCrumb} activeWorkItemCrumb={activeWorkItemCrumb} chromeRef={projectDetailHeaderChromeRef} - onGoChannel={(channelId) => { - void goChannel(channelId); - }} onGoProjectHome={handleGoToProjectHome} onGoProjects={() => { void goProjects(); }} project={project} + shareTab={ + activeWorkItemCrumb + ? undefined + : shareTabForWorkspaceTab(activeTab) + } />
    -
    -
    -
    -
    -

    - {project.name} -

    - {repoRemote.webUrl && - (repoRemote.host.kind !== "external" || - repoSource === "local") ? ( - - ) : null} -
    -
    -
    - - Repository - - -
    -
    -
    - + } projectId={project.id} repoDiff={displayedRepoDiff} repoDiffError={displayedRepoDiffError} diff --git a/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx b/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx index 72c76c5473..7556653075 100644 --- a/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx +++ b/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, ChevronUp, History, MessageSquare } from "lucide-react"; +import { ChevronDown, ChevronUp, History } from "lucide-react"; import * as React from "react"; import type { ProjectIssue } from "@/features/projects/hooks"; @@ -11,6 +11,8 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; import { ProfileAuthorName } from "./ProjectProfileIdentity"; import { ProjectRichContent } from "./ProjectRichContent"; @@ -44,14 +46,14 @@ export function ProjectIssueCommentTimeline({ const displayedComments = isCollapsed ? [] : visibleComments; if (orderedComments.length === 0) { - return

    No comments yet.

    ; + return null; } return ( -
    +
    ) : null} - {displayedComments.map((comment, index) => ( -
    -
    - {index < displayedComments.length - 1 ? ( - - ) : null} - - - -
    -
    -
    - - - {resolveUserLabel({ profiles, pubkey: comment.author })} - - - - {relativeTime(comment.createdAt)} - + {displayedComments.map((comment, index) => { + const authorLabel = resolveUserLabel({ + profiles, + pubkey: comment.author, + }); + return ( +
    +
    + {index < displayedComments.length - 1 ? ( + + ) : null} + {/* bg-background keeps the connector line from showing through + while the avatar image (or delayed fallback) loads. */} + +
    +
    + {/* h-5 matches the avatar so the header line centers on it. */} +
    + + + {authorLabel} + + + + {relativeTime(comment.createdAt)} + +
    +
    -
    -
    - ))} + ); + })}
    ); } diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx index 6f34248dd1..7b7d196ec4 100644 --- a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx @@ -2,6 +2,7 @@ import { CircleCheck, CircleDot, CircleX, MessageSquare } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; +import { useIsManagedAgent } from "@/features/agent-memory/hooks"; import { ForumComposer } from "@/features/forum/ui/ForumComposer"; import { type ProjectIssue, @@ -13,20 +14,26 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { entityDiscussionQuery } from "@/features/projects/lib/discussionChannels"; +import { issueShareLink } from "@/features/projects/lib/projectShareLinks"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; +import { useIdentityQuery } from "@/shared/api/hooks"; import type { ChannelMember } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { IssueAssigneeFacepile, IssueAssigneesRow } from "./IssueAssigneesRow"; import { ProjectFeedRow, ProjectFeedRowCluster, ProjectFeedRowMonoCell, } from "./ProjectFeedRow"; +import { DiscussedInChannels } from "./DiscussionChannels"; import { ProjectIssueCommentTimeline } from "./ProjectIssueCommentTimeline"; import { ProjectOriginReference } from "./ProjectOriginReference"; import { OverviewRailSection } from "./ProjectOverviewPanel"; import { ProfileIdentityButton } from "./ProjectProfileIdentity"; import { ProjectRichContent } from "./ProjectRichContent"; +import { ShareLinkButton } from "./ShareLinkButton"; export function issueStatusClassName(status: ProjectIssue["status"]) { if (status === "Done") return "text-purple-400"; @@ -97,7 +104,7 @@ function IssueRow({ /> {authorLabel} created this - issue {relativeTime(issue.createdAt)} + issue · {issue.status} @@ -111,6 +118,7 @@ function IssueRow({ ))} } + eventId={issue.id} onOpen={onOpen} statusIcon={ @@ -119,6 +127,10 @@ function IssueRow({ title={issue.title} trailing={ <> + {issue.comments.length > 0 ? ( ) : ( - a public channel + a private channel )} - (author-claimed) + + (author-claimed) + ); } @@ -41,10 +58,15 @@ export function ProjectOriginReference({ if (agentName) { return ( - started privately with + + started privately with + {agentName} diff --git a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx index 0cb06b3e99..9f56f97524 100644 --- a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx @@ -36,6 +36,8 @@ type ProjectOverviewPanelProps = { externalUrl?: string | null; files: ProjectRepoFile[]; gitDataState: GitDataState; + /** Hide the readme header rows when the workspace renders them itself. */ + hideReadmeHeader?: boolean; project: Project; onViewContributors: () => void; profiles?: UserProfileLookup; @@ -149,6 +151,7 @@ export function ProjectOverviewPanel({ externalUrl, files, gitDataState, + hideReadmeHeader, onViewContributors, project, profiles, @@ -182,6 +185,7 @@ export function ProjectOverviewPanel({ externalUrl={externalUrl} file={readmeFile} gitDataState={gitDataState} + hideHeader={hideReadmeHeader} sourceControls={sourceControls} unavailableReason={unavailableReason} /> diff --git a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx index 54e3689e40..3544c42c49 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx @@ -17,6 +17,7 @@ import * as React from "react"; import { toast } from "sonner"; import { useIsManagedAgent } from "@/features/agent-memory/hooks"; +import { DiscussedInChannels } from "./DiscussionChannels"; import { ProjectOriginReference } from "./ProjectOriginReference"; import { ForumComposer } from "@/features/forum/ui/ForumComposer"; import { @@ -26,6 +27,8 @@ import { useCreateProjectPullRequestCommentMutation, } from "@/features/projects/hooks"; import { projectPullRequestCommentTimelineKind } from "@/features/projects/projectPullRequests.mjs"; +import { entityDiscussionQuery } from "@/features/projects/lib/discussionChannels"; +import { pullRequestShareLink } from "@/features/projects/lib/projectShareLinks"; import { formatExactTimestamp, relativeTime, @@ -51,6 +54,7 @@ import { import { ProjectRichContent } from "./ProjectRichContent"; import { PullRequestReviewersRow } from "./PullRequestReviewersRow"; import { PullRequestReviewCard } from "./PullRequestReviewCard"; +import { ShareLinkButton } from "./ShareLinkButton"; function profileForPubkey(pubkey: string, profiles?: UserProfileLookup) { return profiles?.[normalizePubkey(pubkey)] ?? null; @@ -201,10 +205,7 @@ function PullRequestCommitRow({ /> {authorLabel}{" "} - authored{" "} - - {relativeTime(createdAt)} - + authored {branch ? ( @@ -218,16 +219,25 @@ function PullRequestCommitRow({ testId="project-pull-request-commit-row" title={message} trailing={ - hash ? ( - - - - - ) : undefined + <> + {hash ? ( + + + + + ) : null} + + {relativeTime(createdAt)} + + } /> ); @@ -268,10 +278,7 @@ function PullRequestRow({ {authorLabel} {" "} - created this pull request{" "} - - {relativeTime(pullRequest.createdAt)} - + created this pull request {pullRequest.branchName ? ( @@ -312,6 +319,13 @@ function PullRequestRow({ title="View pull request" /> + + {relativeTime(pullRequest.createdAt)} + } /> @@ -338,10 +352,22 @@ export function PullRequestDetailHeader({ #{pullRequest.id.slice(0, 8)} + -

    - - +

    + + - + created {relativeTime(pullRequest.createdAt)} -

    + +
    {reviewHistory.length > 0 ? (
    -

    - - Add Your Comment -

    {sourceControls ? (
    @@ -163,7 +169,6 @@ export function ReadmePanel({ (channelsQuery.data ?? []).filter( @@ -84,7 +104,10 @@ export function ProjectRepositoryManagement({ channels={accessChannels} isCreating={createMutation.isPending} onAdd={async (input) => { - const result = await createMutation.mutateAsync(input); + const result = await createMutation.mutateAsync({ + ...input, + ownerControlAgentPubkey, + }); onChange(result.repository.id); toast.success(`Repository "${result.repository.name}" created.`); }} @@ -96,6 +119,7 @@ export function ProjectRepositoryManagement({ isAttaching={attachMutation.isPending} onAttach={async (candidate) => { const result = await attachMutation.mutateAsync({ + ownerControlAgentPubkey, project, repository: candidate, }); @@ -118,7 +142,7 @@ export function ProjectRepositoryManagement({