From 668c5575a459208e87bdea726813ca416d68b1a4 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 29 Jun 2026 11:37:35 +0100 Subject: [PATCH] Improve DM huddle threads --- desktop/src-tauri/src/commands/messages.rs | 4 +- desktop/src-tauri/src/huddle/mod.rs | 54 ++-- desktop/src-tauri/src/huddle/pipeline.rs | 21 +- desktop/src-tauri/src/huddle/state.rs | 4 + desktop/src-tauri/src/huddle/transcription.rs | 73 +++++ desktop/src-tauri/src/lib.rs | 5 +- desktop/src/app/AppShell.tsx | 6 + .../channels/isDmNotifiableKind.test.mjs | 26 ++ .../features/channels/isDmNotifiableKind.ts | 15 +- .../channels/lib/huddleAvailability.test.mjs | 136 ++++++++ .../channels/lib/huddleAvailability.ts | 35 ++ .../channels/ui/ChannelMembersBar.tsx | 21 +- .../channels/unreadReadMarker.test.mjs | 35 ++ .../channels/useLiveChannelUpdates.ts | 16 +- .../features/channels/useUnreadChannels.ts | 17 +- desktop/src/features/huddle/HuddleContext.tsx | 11 +- .../huddle/components/HuddleAttachment.tsx | 303 ++++++++++++++++++ .../features/huddle/components/HuddleBar.tsx | 153 ++++++++- .../huddle/lib/huddleCardState.test.mjs | 29 ++ .../features/huddle/lib/huddleCardState.ts | 9 + .../huddle/lib/huddleChannelName.test.mjs | 97 ++++++ .../features/huddle/lib/huddleChannelName.ts | 81 +++++ .../lib/formatTimelineMessages.test.mjs | 31 ++ .../messages/lib/formatTimelineMessages.ts | 4 +- .../messages/lib/messageQueryKeys.test.mjs | 32 ++ .../features/messages/lib/messageQueryKeys.ts | 4 +- .../messages/lib/threadPanel.test.mjs | 41 +++ .../src/features/messages/lib/threadPanel.ts | 12 +- .../features/messages/ui/MessageActionBar.tsx | 4 +- .../src/features/messages/ui/MessageRow.tsx | 14 +- .../messages/ui/TimelineMessageList.tsx | 12 +- desktop/src/shared/constants/kinds.test.mjs | 15 + desktop/src/shared/constants/kinds.ts | 13 + desktop/src/shared/ui/attachment.tsx | 196 +++++++++++ .../channels/channel_messages_provider.dart | 9 +- .../features/channels/timeline_message.dart | 94 +++--- mobile/lib/shared/relay/nostr_models.dart | 9 + .../channels/timeline_message_test.dart | 69 ++++ 38 files changed, 1605 insertions(+), 105 deletions(-) create mode 100644 desktop/src-tauri/src/huddle/transcription.rs create mode 100644 desktop/src/features/channels/lib/huddleAvailability.test.mjs create mode 100644 desktop/src/features/channels/lib/huddleAvailability.ts create mode 100644 desktop/src/features/huddle/components/HuddleAttachment.tsx create mode 100644 desktop/src/features/huddle/lib/huddleCardState.test.mjs create mode 100644 desktop/src/features/huddle/lib/huddleCardState.ts create mode 100644 desktop/src/features/huddle/lib/huddleChannelName.test.mjs create mode 100644 desktop/src/features/huddle/lib/huddleChannelName.ts create mode 100644 desktop/src/shared/ui/attachment.tsx diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 2a04666fc49..f77b31cca8c 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -202,7 +202,7 @@ pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result( // ── Re-exports ──────────────────────────────────────────────────────────────── pub use state::{HuddleJoinInfo, HuddlePhase, HuddleState, VoiceInputMode}; +pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline}; // ── Imports ─────────────────────────────────────────────────────────────────── @@ -75,6 +77,22 @@ use relay_api::{ MAX_HUDDLE_AGENTS, }; +fn normalize_huddle_channel_name(candidate: Option, fallback: &str) -> String { + let normalized = candidate + .unwrap_or_default() + .split_whitespace() + .collect::>() + .join(" "); + + let name = if normalized.is_empty() { + fallback + } else { + normalized.as_str() + }; + + name.chars().take(80).collect() +} + // ── Tauri commands ──────────────────────────────────────────────────────────── /// Set the voice input mode (push-to-talk or voice-activity detection). @@ -95,6 +113,7 @@ pub async fn set_voice_input_mode( old_mode != mode && matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) && hs.stt_pipeline.is_some() + && hs.transcription_enabled }; if needs_restart { @@ -141,6 +160,7 @@ pub fn get_voice_input_mode(state: State<'_, AppState>) -> Result, + channel_name: Option, state: State<'_, AppState>, ) -> Result { // Validate inputs at the Tauri boundary. @@ -180,7 +200,8 @@ pub async fn start_huddle( let ephemeral_uuid = Uuid::new_v4(); let ephemeral_channel_id = ephemeral_uuid.to_string(); let short_id = &ephemeral_channel_id[..8]; - let channel_name = format!("huddle-{short_id}"); + let fallback_channel_name = format!("huddle-{short_id}"); + let channel_name = normalize_huddle_channel_name(channel_name, &fallback_channel_name); // All steps wrapped so we can roll back on ANY failure, including step 1. // channel_was_created tracks whether we need to archive on rollback. @@ -693,9 +714,13 @@ pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), S } } // Re-read after potential cleanup. - let (has_stt, has_tts) = { + let (has_stt, has_tts, transcription_enabled) = { let hs = state.huddle()?; - (hs.stt_pipeline.is_some(), hs.tts_pipeline.is_some()) + ( + hs.stt_pipeline.is_some(), + hs.tts_pipeline.is_some(), + hs.transcription_enabled, + ) }; // Check if models just became ready (one-shot flags). @@ -713,7 +738,7 @@ pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), S } } - if !has_stt && (stt_ready || models::is_stt_ready()) { + if transcription_enabled && !has_stt && (stt_ready || models::is_stt_ready()) { if let Some(eph_id) = &ephemeral_channel_id { if let Err(e) = maybe_start_stt_pipeline(&state, eph_id).await { eprintln!("buzz-desktop: STT hotstart failed: {e}"); @@ -771,27 +796,6 @@ pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), S Ok(()) } -/// Start the STT pipeline for the active huddle. -/// -/// Delegates to `maybe_start_stt_pipeline` — returns `Err` if models are not -/// ready or no huddle is active. Safe to call multiple times: replaces the -/// existing pipeline if already running. -#[tauri::command] -pub async fn start_stt_pipeline(state: State<'_, AppState>) -> Result<(), String> { - let ephemeral_channel_id = { - let hs = state.huddle()?; - hs.ephemeral_channel_id - .clone() - .ok_or("no active huddle — start or join a huddle first")? - }; - - match maybe_start_stt_pipeline(&state, &ephemeral_channel_id).await { - Ok(true) => Ok(()), - Ok(false) => Err("STT model not ready".to_string()), - Err(e) => Err(e), - } -} - /// Trigger a background download of voice models (Parakeet STT + Pocket TTS). /// /// Returns immediately — downloads run in tokio background tasks. diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index bcb6e5ea557..ebae8e33974 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -38,9 +38,9 @@ pub(crate) async fn post_connect_setup( } } - // Ensure voice models are downloading (idempotent). + // Prepare TTS for agent voice. STT is transcript-specific and starts only + // when transcription is explicitly enabled. if let Some(mgr) = models::global_model_manager() { - mgr.start_stt_download(state.http_client.clone()); mgr.start_tts_download(state.http_client.clone()); } @@ -58,13 +58,11 @@ pub(crate) async fn post_connect_setup( hs.audio_relay_pcm_tx = Some(pcm_tx); } - // Start pipelines: TTS first (so STT can capture tts_cancel for barge-in). + // Start TTS immediately. STT/transcript posting is opt-in and starts only + // after the user explicitly enables transcription. if let Err(e) = maybe_start_tts_pipeline(state).await { eprintln!("buzz-desktop: TTS pipeline failed to start: {e}"); } - if let Err(e) = maybe_start_stt_pipeline(state, ephemeral_channel_id).await { - eprintln!("buzz-desktop: STT pipeline failed to start: {e}"); - } Ok(()) } @@ -81,6 +79,13 @@ pub(crate) async fn maybe_start_stt_pipeline( state: &AppState, ephemeral_channel_id: &str, ) -> Result { + { + let hs = state.huddle()?; + if !hs.transcription_enabled { + return Ok(false); + } + } + if !models::is_stt_ready() { return Ok(false); // Models not downloaded yet — voice-only mode. } @@ -143,7 +148,9 @@ pub(crate) async fn maybe_start_stt_pipeline( let mut hs = state.huddle()?; hs.stt_starting.store(false, Ordering::Release); // Phase check: huddle may have been torn down during construction. - if !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) { + if !hs.transcription_enabled + || !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) + { return Ok(false); } hs.stt_pipeline = Some(Arc::clone(&pipeline)); diff --git a/desktop/src-tauri/src/huddle/state.rs b/desktop/src-tauri/src/huddle/state.rs index 4b21f81a39b..876c2d688b6 100644 --- a/desktop/src-tauri/src/huddle/state.rs +++ b/desktop/src-tauri/src/huddle/state.rs @@ -78,6 +78,8 @@ pub struct HuddleState { pub is_creator: bool, /// Whether TTS output is enabled (user-toggled). pub tts_enabled: bool, + /// Whether STT transcript posting is enabled for this huddle. + pub transcription_enabled: bool, /// Shared flag: true while TTS is playing audio. /// Shared with the STT pipeline for barge-in / echo gating. #[serde(skip)] @@ -154,6 +156,7 @@ impl Clone for HuddleState { tts_pipeline: None, // Never clone the pipeline handle. is_creator: self.is_creator, tts_enabled: self.tts_enabled, + transcription_enabled: self.transcription_enabled, tts_active: Arc::clone(&self.tts_active), tts_cancel: Arc::clone(&self.tts_cancel), tts_starting: Arc::clone(&self.tts_starting), @@ -180,6 +183,7 @@ impl Default for HuddleState { tts_pipeline: None, is_creator: false, tts_enabled: true, + transcription_enabled: false, tts_active: Arc::new(AtomicBool::new(false)), tts_cancel: Arc::new(AtomicBool::new(false)), tts_starting: Arc::new(AtomicBool::new(false)), diff --git a/desktop/src-tauri/src/huddle/transcription.rs b/desktop/src-tauri/src/huddle/transcription.rs new file mode 100644 index 00000000000..0d752c1de79 --- /dev/null +++ b/desktop/src-tauri/src/huddle/transcription.rs @@ -0,0 +1,73 @@ +use std::sync::atomic::Ordering; + +use tauri::State; + +use crate::app_state::AppState; + +use super::{models, pipeline::maybe_start_stt_pipeline}; + +/// Start the STT pipeline for the active huddle. +/// +/// Delegates to `maybe_start_stt_pipeline` — returns `Err` if models are not +/// ready or no huddle is active. Safe to call multiple times: replaces the +/// existing pipeline if already running. +#[tauri::command] +pub async fn start_stt_pipeline(state: State<'_, AppState>) -> Result<(), String> { + let ephemeral_channel_id = { + let mut hs = state.huddle()?; + hs.transcription_enabled = true; + hs.ephemeral_channel_id + .clone() + .ok_or("no active huddle — start or join a huddle first")? + }; + + match maybe_start_stt_pipeline(&state, &ephemeral_channel_id).await { + Ok(true) => Ok(()), + Ok(false) => Err("STT model not ready".to_string()), + Err(e) => Err(e), + } +} + +/// Enable or disable huddle transcript posting. +/// +/// Disabling tears down STT immediately and invalidates any in-flight transcript +/// task before it can post another segment. Enabling starts STT if models are +/// ready; otherwise the hot-start loop will begin transcribing once the model +/// download finishes. +#[tauri::command] +pub async fn set_huddle_transcription_enabled( + enabled: bool, + state: State<'_, AppState>, +) -> Result<(), String> { + let (ephemeral_channel_id, old_stt) = { + let mut hs = state.huddle()?; + hs.transcription_enabled = enabled; + + if enabled { + (hs.ephemeral_channel_id.clone(), None) + } else { + hs.session_generation.fetch_add(1, Ordering::Release); + hs.stt_starting.store(false, Ordering::Release); + (hs.ephemeral_channel_id.clone(), hs.stt_pipeline.take()) + } + }; + + if let Some(ref pipeline) = old_stt { + pipeline.shutdown(); + } + drop(old_stt); + + if enabled { + let eph_id = + ephemeral_channel_id.ok_or("no active huddle — start or join a huddle first")?; + if let Some(manager) = models::global_model_manager() { + manager.start_stt_download(state.http_client.clone()); + } + if let Err(e) = maybe_start_stt_pipeline(&state, &eph_id).await { + eprintln!("buzz-desktop: STT transcript start failed: {e}"); + } + } + + state.emit_huddle_state_changed(); + Ok(()) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 82b5caa0c62..5b007abe53d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -34,8 +34,8 @@ use huddle::audio_output::{ use huddle::{ add_agent_to_huddle, check_pipeline_hotstart, confirm_huddle_active, download_voice_models, end_huddle, get_huddle_agent_pubkeys, get_huddle_state, get_model_status, get_voice_input_mode, - join_huddle, leave_huddle, push_audio_pcm, set_tts_enabled, set_voice_input_mode, - speak_agent_message, start_huddle, start_stt_pipeline, + join_huddle, leave_huddle, push_audio_pcm, set_huddle_transcription_enabled, set_tts_enabled, + set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline, }; use managed_agents::{ backfill_persona_snapshots, ensure_nest, restore_managed_agents_on_launch, try_regenerate_nest, @@ -568,6 +568,7 @@ pub fn run() { get_huddle_state, push_audio_pcm, start_stt_pipeline, + set_huddle_transcription_enabled, download_voice_models, get_model_status, set_tts_enabled, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 79fb0899d9b..8c1a21a324c 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -840,6 +840,12 @@ export function AppShell() {
{ + void goChannel(channelId, { + messageId, + threadRootId: messageId, + }); + }} onVisibilityChange={setIsHuddleDrawerOpen} />
diff --git a/desktop/src/features/channels/isDmNotifiableKind.test.mjs b/desktop/src/features/channels/isDmNotifiableKind.test.mjs index ebea80b13e7..ea7ed809faf 100644 --- a/desktop/src/features/channels/isDmNotifiableKind.test.mjs +++ b/desktop/src/features/channels/isDmNotifiableKind.test.mjs @@ -2,6 +2,12 @@ import assert from "node:assert/strict"; import test from "node:test"; import { isDmNotifiableKind } from "./isDmNotifiableKind.ts"; +import { + KIND_HUDDLE_ENDED, + KIND_HUDDLE_PARTICIPANT_JOINED, + KIND_HUDDLE_PARTICIPANT_LEFT, + KIND_HUDDLE_STARTED, +} from "@/shared/constants/kinds"; // Regression guard for the phantom-DM-notification bug: when kind:5 deletes // gained an `h` tag, they started matching the live DM subscription. Without @@ -15,6 +21,11 @@ test("human-visible message kinds fire DM notifications", () => { assert.equal(isDmNotifiableKind(40002), true, "kind:40002 stream message v2"); assert.equal(isDmNotifiableKind(45001), true, "kind:45001 forum post"); assert.equal(isDmNotifiableKind(45003), true, "kind:45003 forum comment"); + assert.equal( + isDmNotifiableKind(KIND_HUDDLE_STARTED), + true, + "kind:48100 huddle start invite", + ); }); test("non-message kinds do NOT fire DM notifications", () => { @@ -24,4 +35,19 @@ test("non-message kinds do NOT fire DM notifications", () => { assert.equal(isDmNotifiableKind(40003), false, "kind:40003 message edit"); assert.equal(isDmNotifiableKind(40008), false, "kind:40008 message diff"); assert.equal(isDmNotifiableKind(40099), false, "kind:40099 system message"); + assert.equal( + isDmNotifiableKind(KIND_HUDDLE_PARTICIPANT_JOINED), + false, + "kind:48101 huddle participant joined", + ); + assert.equal( + isDmNotifiableKind(KIND_HUDDLE_PARTICIPANT_LEFT), + false, + "kind:48102 huddle participant left", + ); + assert.equal( + isDmNotifiableKind(KIND_HUDDLE_ENDED), + false, + "kind:48103 huddle ended", + ); }); diff --git a/desktop/src/features/channels/isDmNotifiableKind.ts b/desktop/src/features/channels/isDmNotifiableKind.ts index 3bb90f62c4f..0b74c68409a 100644 --- a/desktop/src/features/channels/isDmNotifiableKind.ts +++ b/desktop/src/features/channels/isDmNotifiableKind.ts @@ -1,10 +1,19 @@ -import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds"; +import { + CHANNEL_MESSAGE_EVENT_KINDS, + KIND_HUDDLE_STARTED, +} from "@/shared/constants/kinds"; -const DM_NOTIFIABLE_KINDS = new Set(CHANNEL_MESSAGE_EVENT_KINDS); +export const DM_NOTIFIABLE_EVENT_KINDS = [ + ...CHANNEL_MESSAGE_EVENT_KINDS, + KIND_HUDDLE_STARTED, +] as const; + +const DM_NOTIFIABLE_KINDS = new Set(DM_NOTIFIABLE_EVENT_KINDS); // DM OS-notifications gate. The DM subscription matches every `h`-tagged // event in the channel (kind:5/7/9005/edits/etc.), so we must filter to -// human-visible message kinds before firing a toast. +// human-visible message kinds before firing a toast. Huddle starts are included +// only for DMs because the start card is the invite. export function isDmNotifiableKind(kind: number): boolean { return DM_NOTIFIABLE_KINDS.has(kind); } diff --git a/desktop/src/features/channels/lib/huddleAvailability.test.mjs b/desktop/src/features/channels/lib/huddleAvailability.test.mjs new file mode 100644 index 00000000000..061d2cdc424 --- /dev/null +++ b/desktop/src/features/channels/lib/huddleAvailability.test.mjs @@ -0,0 +1,136 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; + +import { canStartHuddleInChannel } from "./huddleAvailability.ts"; + +const SELF = "a".repeat(64); +const OTHER = "b".repeat(64); + +function channel(overrides = {}) { + return { + id: "channel-id", + name: "general", + channelType: "stream", + visibility: "private", + description: "", + topic: null, + purpose: null, + memberCount: 2, + memberPubkeys: [], + lastMessageAt: null, + archivedAt: null, + participants: [], + participantPubkeys: [], + isMember: true, + ttlSeconds: null, + ttlDeadline: null, + ...overrides, + }; +} + +function member(overrides = {}) { + return { + pubkey: SELF, + role: "member", + isAgent: false, + joinedAt: "2026-01-01T00:00:00Z", + displayName: null, + ...overrides, + }; +} + +test("canStartHuddleInChannel allows DM participants", () => { + assert.equal( + canStartHuddleInChannel({ + channel: channel({ + channelType: "dm", + visibility: "private", + participantPubkeys: [OTHER, SELF], + isMember: false, + }), + currentPubkey: SELF.toUpperCase(), + selfMember: null, + }), + true, + ); +}); + +test("canStartHuddleInChannel allows DM membership when identity is not loaded yet", () => { + assert.equal( + canStartHuddleInChannel({ + channel: channel({ + channelType: "dm", + visibility: "private", + participantPubkeys: [OTHER, SELF], + isMember: true, + }), + selfMember: null, + }), + true, + ); +}); + +test("canStartHuddleInChannel blocks non-participant DMs", () => { + assert.equal( + canStartHuddleInChannel({ + channel: channel({ + channelType: "dm", + visibility: "private", + participantPubkeys: [OTHER], + isMember: false, + }), + currentPubkey: SELF, + selfMember: null, + }), + false, + ); +}); + +test("canStartHuddleInChannel keeps private channels member-gated", () => { + const privateChannel = channel({ visibility: "private" }); + + assert.equal( + canStartHuddleInChannel({ + channel: privateChannel, + currentPubkey: SELF, + selfMember: null, + }), + false, + ); + + assert.equal( + canStartHuddleInChannel({ + channel: privateChannel, + currentPubkey: SELF, + selfMember: member(), + }), + true, + ); +}); + +test("canStartHuddleInChannel blocks archived channels and DMs", () => { + assert.equal( + canStartHuddleInChannel({ + channel: channel({ + archivedAt: "2026-01-01T00:00:00Z", + visibility: "open", + }), + currentPubkey: SELF, + selfMember: member(), + }), + false, + ); + + assert.equal( + canStartHuddleInChannel({ + channel: channel({ + archivedAt: "2026-01-01T00:00:00Z", + channelType: "dm", + participantPubkeys: [SELF, OTHER], + }), + currentPubkey: SELF, + selfMember: null, + }), + false, + ); +}); diff --git a/desktop/src/features/channels/lib/huddleAvailability.ts b/desktop/src/features/channels/lib/huddleAvailability.ts new file mode 100644 index 00000000000..828712d9dd8 --- /dev/null +++ b/desktop/src/features/channels/lib/huddleAvailability.ts @@ -0,0 +1,35 @@ +import type { Channel, ChannelMember } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +type CanStartHuddleInput = { + channel: Channel; + currentPubkey?: string; + selfMember: ChannelMember | null; +}; + +export function canStartHuddleInChannel({ + channel, + currentPubkey, + selfMember, +}: CanStartHuddleInput): boolean { + if (channel.archivedAt !== null) { + return false; + } + + if (channel.channelType === "dm") { + if (channel.isMember) { + return true; + } + + if (!currentPubkey) { + return false; + } + + const normalizedCurrentPubkey = normalizePubkey(currentPubkey); + return channel.participantPubkeys.some( + (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, + ); + } + + return channel.visibility === "open" || selfMember !== null; +} diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index 9bc186db36a..add8253fc91 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useHuddle } from "@/features/huddle"; import { HuddleIndicator } from "@/features/huddle/components/HuddleIndicator"; +import { buildHuddleChannelName } from "@/features/huddle/lib/huddleChannelName"; import { useAvailableAcpRuntimes, useBackendProvidersQuery, @@ -10,6 +11,7 @@ import { useRelayAgentsQuery, } from "@/features/agents/hooks"; import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { canStartHuddleInChannel } from "@/features/channels/lib/huddleAvailability"; import type { Channel } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; @@ -81,10 +83,11 @@ export function ChannelMembersBar({ members.find( (member) => normalizePubkey(member.pubkey) === normalizedCurrentPubkey, ) ?? null; - const canStartHuddle = - channel.channelType !== "dm" && - channel.archivedAt === null && - (channel.visibility === "open" || selfMember !== null); + const canStartHuddle = canStartHuddleInChannel({ + channel, + currentPubkey, + selfMember, + }); const previousChannelIdRef = React.useRef(channel.id); React.useEffect(() => { @@ -110,7 +113,15 @@ export function ChannelMembersBar({ channelId={channel.id} onStart={async () => { try { - await startHuddle(channel.id, []); + await startHuddle( + channel.id, + [], + buildHuddleChannelName({ + channel, + currentPubkey, + members, + }), + ); // Refetch channels so the new ephemeral channel appears in the sidebar immediately // (default poll interval is 60s — too slow for huddle UX). void queryClient.invalidateQueries({ queryKey: ["channels"] }); diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs index 266bca2dfb8..5034a72c859 100644 --- a/desktop/src/features/channels/unreadReadMarker.test.mjs +++ b/desktop/src/features/channels/unreadReadMarker.test.mjs @@ -12,9 +12,16 @@ import { } from "./unreadChannelCounts.ts"; import { addThreadActivityItems, + channelCatchUpEventKinds, resolveChannelReadMarker, resolveObservedUnreadRootId, } from "./useUnreadChannels.ts"; +import { isChannelUnreadTriggerKind } from "./useLiveChannelUpdates.ts"; +import { + KIND_HUDDLE_ENDED, + KIND_HUDDLE_STARTED, + KIND_STREAM_MESSAGE, +} from "@/shared/constants/kinds"; function topLevel(id, createdAt) { return { id, createdAt, author: "a", time: "", body: "", depth: 0 }; @@ -51,6 +58,34 @@ test("receiveThenReopen_frontierAtLatestArrival_clobbersDivider", () => { assert.equal(marker.unreadCount, 0); }); +test("dmHuddleStart_isDmOnlyUnreadTrigger", () => { + assert.equal( + isChannelUnreadTriggerKind(KIND_HUDDLE_STARTED, true), + true, + "inactive DM huddle start should bump unread", + ); + assert.equal( + isChannelUnreadTriggerKind(KIND_HUDDLE_STARTED, false), + false, + "stream/forum huddle start should not become a generic unread trigger", + ); + assert.equal( + isChannelUnreadTriggerKind(KIND_HUDDLE_ENDED, true), + false, + "huddle end lifecycle should stay quiet", + ); +}); + +test("dmCatchUpFetch_includesOnlyHuddleStartInvite", () => { + const dmKinds = channelCatchUpEventKinds("dm"); + const streamKinds = channelCatchUpEventKinds("stream"); + + assert.equal(dmKinds.includes(KIND_STREAM_MESSAGE), true); + assert.equal(dmKinds.includes(KIND_HUDDLE_STARTED), true); + assert.equal(dmKinds.includes(KIND_HUDDLE_ENDED), false); + assert.equal(streamKinds.includes(KIND_HUDDLE_STARTED), false); +}); + // An explicit caller timeline position must still advance the read marker. This // is the consumer (ChannelScreen) that marks the active channel read with a // real position; the fix must not regress it. diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 69a3677217c..71b4b51e155 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -82,6 +82,12 @@ const UNREAD_TRIGGER_KINDS = new Set(CHANNEL_MESSAGE_EVENT_KINDS); export const EMPTY_SET: ReadonlySet = new Set(); +export function isChannelUnreadTriggerKind(kind: number, isDmChannel: boolean) { + return isDmChannel + ? isDmNotifiableKind(kind) + : UNREAD_TRIGGER_KINDS.has(kind); +} + function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) { return ( currentPubkey.length > 0 && event.pubkey.toLowerCase() !== currentPubkey @@ -217,10 +223,16 @@ export function useLiveChannelUpdates( return; } + const isDmChannel = dmChannelMap.has(channelId); + const isUnreadTriggerKind = isChannelUnreadTriggerKind( + event.kind, + isDmChannel, + ); + // Let the caller observe self-authored trigger events (e.g. to track // thread participation) before the author-exclusion guard filters them. if ( - UNREAD_TRIGGER_KINDS.has(event.kind) && + isUnreadTriggerKind && normalizedCurrentPubkey.length > 0 && event.pubkey.toLowerCase() === normalizedCurrentPubkey ) { @@ -232,7 +244,7 @@ export function useLiveChannelUpdates( // own outgoing messages should never make a channel unread, and // reactions / edits / system messages aren't "new content". const isExternalTriggerEvent = - UNREAD_TRIGGER_KINDS.has(event.kind) && + isUnreadTriggerKind && (normalizedCurrentPubkey.length === 0 || event.pubkey.toLowerCase() !== normalizedCurrentPubkey); const isThreadedReply = isThreadReply(event.tags); diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index f65f4bd8229..a739db3d783 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -29,6 +29,7 @@ import { import type { RelayClient } from "@/shared/api/relayClientSession"; import type { Channel, RelayEvent } from "@/shared/api/types"; import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds"; +import { DM_NOTIFIABLE_EVENT_KINDS } from "./isDmNotifiableKind"; type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & { pubkey?: string; @@ -43,6 +44,14 @@ type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & { // per-channel limit elsewhere in the app. const CATCH_UP_LIMIT = 1000; +export function channelCatchUpEventKinds( + channelType: Channel["channelType"] | undefined, +) { + return channelType === "dm" + ? DM_NOTIFIABLE_EVENT_KINDS + : CHANNEL_MESSAGE_EVENT_KINDS; +} + const participationStore = makeRootIdStore("buzz-thread-participation.v1"); const authoredStore = makeRootIdStore("buzz-thread-authored.v1"); // Thread roots where an external message @-mentioned the current user. The @@ -598,13 +607,14 @@ export function useUnreadChannels( toFetch.map(async (channelId): Promise => { try { const readAt = getEffectiveTimestamp(channelId); + const channel = channels.find((c) => c.id === channelId); // NIP-01 `since` is inclusive of `created_at >= since`. The +1 // makes the relay-side filter strict-newer; the client-side // `> readAt` check below is the belt to the suspenders. const sinceParam = readAt === null ? 0 : readAt + 1; const events = await relayClient.fetchEvents({ - kinds: [...CHANNEL_MESSAGE_EVENT_KINDS], + kinds: [...channelCatchUpEventKinds(channel?.channelType)], "#h": [channelId], since: sinceParam, limit: CATCH_UP_LIMIT, @@ -642,9 +652,8 @@ export function useUnreadChannels( let maxExternal = 0; const unreadEvents: ObservedUnreadEvent[] = []; const threadReplies: ThreadActivityItem[] = []; - const ch = channels.find((c) => c.id === channelId); - const chType = ch?.channelType; - const chName = ch?.name ?? ""; + const chType = channel?.channelType; + const chName = channel?.name ?? ""; for (const event of events) { if ( normalizedPubkey !== null && diff --git a/desktop/src/features/huddle/HuddleContext.tsx b/desktop/src/features/huddle/HuddleContext.tsx index fd5c37123d7..42fcf4b4a46 100644 --- a/desktop/src/features/huddle/HuddleContext.tsx +++ b/desktop/src/features/huddle/HuddleContext.tsx @@ -76,10 +76,13 @@ interface HuddleContextValue { selectedOutputDevice: string; /** Select a different speaker — takes effect on next huddle start/join */ setSelectedOutputDevice: (name: string) => void; + /** Active ephemeral huddle channel ID, if this client is connected to one. */ + activeEphemeralChannelId: string | null; /** Start a new huddle — calls Rust start_huddle, then connects mic + AudioWorklet */ startHuddle: ( parentChannelId: string, memberPubkeys: string[], + channelName?: string, ) => Promise; /** Join an existing huddle — calls Rust join_huddle, then connects mic + AudioWorklet */ joinHuddle: ( @@ -414,7 +417,11 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) { ); const startHuddle = React.useCallback( - async (parentChannelId: string, memberPubkeys: string[]) => { + async ( + parentChannelId: string, + memberPubkeys: string[], + channelName?: string, + ) => { if (busyRef.current) return; busyRef.current = true; @@ -427,6 +434,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) { const joinInfo = await invoke("start_huddle", { parentChannelId, memberPubkeys, + channelName, }); rustActiveRef.current = true; try { @@ -640,6 +648,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) { outputDevices, selectedOutputDevice, setSelectedOutputDevice, + activeEphemeralChannelId: ephemeralChannelId, startHuddle, joinHuddle, leaveHuddle, diff --git a/desktop/src/features/huddle/components/HuddleAttachment.tsx b/desktop/src/features/huddle/components/HuddleAttachment.tsx new file mode 100644 index 00000000000..80e446f7984 --- /dev/null +++ b/desktop/src/features/huddle/components/HuddleAttachment.tsx @@ -0,0 +1,303 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { Headphones, MessageSquareText } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import type { TimelineMessage } from "@/features/messages/types"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_HUDDLE_ENDED, + KIND_HUDDLE_PARTICIPANT_JOINED, + KIND_HUDDLE_PARTICIPANT_LEFT, + KIND_HUDDLE_STARTED, +} from "@/shared/constants/kinds"; +import { cn } from "@/shared/lib/cn"; +import { + Attachment, + AttachmentAction, + AttachmentActions, + AttachmentContent, + AttachmentDescription, + AttachmentMedia, + AttachmentTitle, +} from "@/shared/ui/attachment"; +import { useHuddle } from "../HuddleContext"; +import { isHuddleStartStale } from "../lib/huddleCardState"; + +type HuddleAttachmentProps = { + channelId: string | null; + className?: string; + message: TimelineMessage; + onOpenThread?: (message: TimelineMessage) => void; +}; + +type HuddleLifecycleState = { + ended: boolean; + participants: Set; +}; + +function parseEphemeralChannelId(content: string): string | null { + try { + const parsed = JSON.parse(content) as { ephemeral_channel_id?: unknown }; + return typeof parsed.ephemeral_channel_id === "string" + ? parsed.ephemeral_channel_id + : null; + } catch { + return null; + } +} + +function lifecycleEventChannelId(event: RelayEvent): string | null { + return parseEphemeralChannelId(event.content); +} + +function lifecycleParticipant(event: RelayEvent): string | null { + return ( + event.tags.find( + (tag) => tag[0] === "p" && typeof tag[1] === "string", + )?.[1] ?? + event.pubkey ?? + null + ); +} + +function reconstructHuddleLifecycle( + events: Iterable, + fallbackCreatorPubkey: string | undefined, + ephemeralChannelId: string, +): HuddleLifecycleState { + const sorted = [...events] + .filter((event) => lifecycleEventChannelId(event) === ephemeralChannelId) + .sort( + (left, right) => + left.created_at - right.created_at || + left.kind - right.kind || + left.id.localeCompare(right.id), + ); + const participants = new Set(); + let ended = false; + + if (fallbackCreatorPubkey) { + participants.add(fallbackCreatorPubkey); + } + + for (const event of sorted) { + switch (event.kind) { + case KIND_HUDDLE_STARTED: + ended = false; + if (event.pubkey) participants.add(event.pubkey); + break; + case KIND_HUDDLE_PARTICIPANT_JOINED: { + if (ended) break; + const pubkey = lifecycleParticipant(event); + if (pubkey) participants.add(pubkey); + break; + } + case KIND_HUDDLE_PARTICIPANT_LEFT: { + if (ended) break; + const pubkey = lifecycleParticipant(event); + if (pubkey) participants.delete(pubkey); + break; + } + case KIND_HUDDLE_ENDED: + ended = true; + break; + } + } + + return { ended, participants }; +} + +function participantLabel(count: number) { + return `${count} participant${count === 1 ? "" : "s"}`; +} + +export function HuddleAttachment({ + channelId, + className, + message, + onOpenThread, +}: HuddleAttachmentProps) { + const ephemeralChannelId = React.useMemo( + () => parseEphemeralChannelId(message.body), + [message.body], + ); + const { activeEphemeralChannelId, isStarting, joinHuddle } = useHuddle(); + const queryClient = useQueryClient(); + const [isJoining, setIsJoining] = React.useState(false); + const [lifecycleState, setLifecycleState] = + React.useState(() => ({ + ended: false, + participants: new Set(message.pubkey ? [message.pubkey] : []), + })); + + React.useEffect(() => { + if (!channelId || !ephemeralChannelId) return; + + const huddleChannelId = ephemeralChannelId; + let disposed = false; + let cleanup: (() => void) | null = null; + const seenEvents = new Map([ + [ + message.id, + { + id: message.id, + pubkey: message.pubkey ?? "", + kind: message.kind ?? KIND_HUDDLE_STARTED, + created_at: message.createdAt, + content: message.body, + tags: message.tags ?? [], + sig: "", + }, + ], + ]); + + function updateState() { + if (disposed) return; + setLifecycleState( + reconstructHuddleLifecycle( + seenEvents.values(), + message.pubkey, + huddleChannelId, + ), + ); + } + + updateState(); + relayClient + .subscribeToHuddleEvents(channelId, (event) => { + if (disposed || seenEvents.has(event.id)) return; + if (lifecycleEventChannelId(event) !== huddleChannelId) return; + seenEvents.set(event.id, event); + updateState(); + }) + .then((dispose) => { + if (disposed) { + void dispose(); + return; + } + cleanup = () => void dispose(); + }) + .catch((error) => { + console.error("[HuddleAttachment] subscription failed:", error); + }); + + return () => { + disposed = true; + cleanup?.(); + }; + }, [ + channelId, + ephemeralChannelId, + message.body, + message.createdAt, + message.id, + message.kind, + message.pubkey, + message.tags, + ]); + + const participantCount = Math.max(1, lifecycleState.participants.size); + const isEnded = lifecycleState.ended; + const isCurrentHuddle = + Boolean(ephemeralChannelId) && + activeEphemeralChannelId === ephemeralChannelId; + const isStaleUnconfirmedHuddle = + !isCurrentHuddle && isHuddleStartStale(message.createdAt); + const canJoin = Boolean( + channelId && + ephemeralChannelId && + !isEnded && + !isCurrentHuddle && + !isStaleUnconfirmedHuddle, + ); + const displayEnded = isEnded || isStaleUnconfirmedHuddle; + + async function handleJoin() { + if (!channelId || !ephemeralChannelId || isJoining || isStarting) return; + setIsJoining(true); + try { + await joinHuddle(channelId, ephemeralChannelId); + void queryClient.invalidateQueries({ queryKey: ["channels"] }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to join huddle"; + toast.error(message); + } finally { + setIsJoining(false); + } + } + + if (!ephemeralChannelId) { + return ( + + + + + + Huddle unavailable + + This huddle card is missing session details. + + + + ); + } + + return ( + + + + + + + Huddle + + {displayEnded ? "Ended" : "In progress"} + + + {participantLabel(participantCount)} + + + + {canJoin ? ( + void handleJoin()} + size="sm" + type="button" + variant="secondary" + > + + {isJoining || isStarting ? "Joining" : "Join"} + + ) : onOpenThread ? ( + onOpenThread(message)} + size="sm" + type="button" + variant="ghost" + > + + View thread + + ) : null} + + + ); +} diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index d0ebb4ea2a3..f5fd84907b1 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -1,6 +1,12 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { Bot, PhoneOff, SmilePlus } from "lucide-react"; +import { + Bot, + Captions, + MessageSquareText, + PhoneOff, + SmilePlus, +} from "lucide-react"; import * as React from "react"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; @@ -11,7 +17,10 @@ import { useIdentityQuery } from "@/shared/api/hooks"; import { relayClient } from "@/shared/api/relayClient"; import { signRelayEvent } from "@/shared/api/tauri"; import type { RelayEvent } from "@/shared/api/types"; -import { KIND_HUDDLE_REACTION } from "@/shared/constants/kinds"; +import { + KIND_HUDDLE_REACTION, + KIND_HUDDLE_STARTED, +} from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { Button } from "@/shared/ui/button"; @@ -37,12 +46,14 @@ type HuddleState = { participants: string[]; // pubkey hex strings agent_pubkeys: string[]; tts_enabled: boolean; + transcription_enabled: boolean; is_creator: boolean; voice_input_mode: "push_to_talk" | "voice_activity"; }; type HuddleBarProps = { className?: string; + onOpenThread?: (channelId: string, messageId: string) => void; onVisibilityChange?: (visible: boolean) => void; }; @@ -57,6 +68,17 @@ function firstTagValue(event: RelayEvent, name: string): string | null { return event.tags.find((tag) => tag[0] === name)?.[1] ?? null; } +function parseEphemeralChannelId(content: string): string | null { + try { + const parsed = JSON.parse(content) as { ephemeral_channel_id?: unknown }; + return typeof parsed.ephemeral_channel_id === "string" + ? parsed.ephemeral_channel_id + : null; + } catch { + return null; + } +} + function customEmojiShortcode(emoji: string): string | null { const trimmed = emoji.trim(); if (!trimmed.startsWith(":") || !trimmed.endsWith(":")) return null; @@ -119,7 +141,11 @@ function huddleReactionTags( return tags; } -export function HuddleBar({ className, onVisibilityChange }: HuddleBarProps) { +export function HuddleBar({ + className, + onOpenThread, + onVisibilityChange, +}: HuddleBarProps) { const { localAudioTrack, leaveHuddle, @@ -160,6 +186,12 @@ export function HuddleBar({ className, onVisibilityChange }: HuddleBarProps) { const [agentAddError, setAgentAddError] = React.useState(null); const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false); const [reactionError, setReactionError] = React.useState(null); + const [transcriptError, setTranscriptError] = React.useState( + null, + ); + const [huddleThreadEventId, setHuddleThreadEventId] = React.useState< + string | null + >(null); const [modelStatus, setModelStatus] = React.useState<{ stt: string; tts: string; @@ -296,6 +328,7 @@ export function HuddleBar({ className, onVisibilityChange }: HuddleBarProps) { const barState = isHuddleVisible && state ? state : renderedState; const reactionChannelId = barState?.ephemeral_channel_id ?? null; + const parentChannelId = barState?.parent_channel_id ?? null; const currentPubkey = identityQuery.data?.pubkey ?? null; const reactionSenderName = React.useMemo( () => @@ -358,6 +391,40 @@ export function HuddleBar({ className, onVisibilityChange }: HuddleBarProps) { }; }, [burstHuddleReaction, currentPubkey, reactionChannelId]); + React.useEffect(() => { + if (!parentChannelId || !reactionChannelId) { + setHuddleThreadEventId(null); + return; + } + + let disposed = false; + let cleanup: (() => void) | null = null; + setHuddleThreadEventId(null); + + void relayClient + .subscribeToHuddleEvents(parentChannelId, (event) => { + if (disposed || event.kind !== KIND_HUDDLE_STARTED) return; + if (parseEphemeralChannelId(event.content) !== reactionChannelId) + return; + setHuddleThreadEventId(event.id); + }) + .then((dispose) => { + if (disposed) { + void dispose(); + return; + } + cleanup = () => void dispose(); + }) + .catch((error) => { + console.error("[huddle] Failed to find huddle thread:", error); + }); + + return () => { + disposed = true; + cleanup?.(); + }; + }, [parentChannelId, reactionChannelId]); + const handleHuddleReactionSelect = React.useCallback( (emoji: string) => { const trimmedEmoji = emoji.trim(); @@ -405,6 +472,7 @@ export function HuddleBar({ className, onVisibilityChange }: HuddleBarProps) { const isDrawerClosing = !isHuddleVisible; const ttsEnabled = barState.tts_enabled; + const transcriptionEnabled = barState.transcription_enabled; // Self-removing detection: remote-peer audio plays through native rodio // today (outside the WebView render graph), so the browser's AEC has no @@ -436,6 +504,26 @@ export function HuddleBar({ className, onVisibilityChange }: HuddleBarProps) { } } + async function handleToggleTranscript() { + setTranscriptError(null); + try { + await invoke("set_huddle_transcription_enabled", { + enabled: !transcriptionEnabled, + }); + const s = await invoke("get_huddle_state"); + setState(s); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + setTranscriptError(`Transcript failed: ${message}`); + console.error("Failed to toggle huddle transcript:", e); + } + } + + function handleOpenThread() { + if (!parentChannelId || !huddleThreadEventId) return; + onOpenThread?.(parentChannelId, huddleThreadEventId); + } + return (
- {modelStatus.stt !== "ready" && modelStatus.tts !== "ready" + {transcriptionEnabled && + modelStatus.stt !== "ready" && + modelStatus.tts !== "ready" ? `Voice models: STT ${modelStatus.stt}, TTS ${modelStatus.tts}` - : modelStatus.stt !== "ready" + : transcriptionEnabled && modelStatus.stt !== "ready" ? `STT model: ${modelStatus.stt}` : `TTS model: ${modelStatus.tts}`} @@ -491,6 +582,12 @@ export function HuddleBar({ className, onVisibilityChange }: HuddleBarProps) { )} + {transcriptError && ( + + {transcriptError} + + )} + {showAddAgent && ( + + + + + + + View thread + +
@@ -624,6 +740,30 @@ export function HuddleBar({ className, onVisibilityChange }: HuddleBarProps) { + + + + + + {transcriptionEnabled ? "Stop transcript" : "Start transcript"} + + +