Allow Huddle between 2 humans in DM (#1347)

This commit is contained in:
klopez4212
2026-06-29 16:49:16 +01:00
committed by GitHub
parent 72dee87c8a
commit b58f671b4e
37 changed files with 1410 additions and 106 deletions

View File

@@ -202,7 +202,7 @@ pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result<S
&state,
&[serde_json::json!({
"ids": [event_id],
"kinds": [0, 1, 3, 5, 7, 9, 30078, 40002, 40003, 40008, 40099, 40100, 45001, 45003],
"kinds": [0, 1, 3, 5, 7, 9, 30078, 40002, 40003, 40008, 40099, 40100, 45001, 45003, buzz_core_pkg::kind::KIND_HUDDLE_STARTED],
"limit": 1
})],
)
@@ -228,7 +228,7 @@ async fn resolve_thread_ref(
state,
&[serde_json::json!({
"ids": [parent_event_id],
"kinds": [9, 40002, 45001, 45003],
"kinds": [9, 40002, 45001, 45003, buzz_core_pkg::kind::KIND_HUDDLE_STARTED],
"limit": 1
})],
)

View File

@@ -34,6 +34,7 @@ pub mod preprocessing;
pub mod relay_api;
pub mod state;
pub mod stt;
pub mod transcription;
pub mod tts;
pub mod wire;
@@ -60,6 +61,7 @@ pub(super) fn drain_until_shutdown<T>(
// ── 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<String>, fallback: &str) -> String {
let normalized = candidate
.unwrap_or_default()
.split_whitespace()
.collect::<Vec<_>>()
.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<VoiceInputMode
pub async fn start_huddle(
parent_channel_id: String,
member_pubkeys: Vec<String>,
channel_name: Option<String>,
state: State<'_, AppState>,
) -> Result<HuddleJoinInfo, String> {
// 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.

View File

@@ -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<bool, String> {
{
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));

View File

@@ -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)),

View File

@@ -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(())
}

View File

@@ -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,
@@ -570,6 +570,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,

View File

@@ -840,6 +840,12 @@ export function AppShell() {
<div className="absolute inset-x-0 bottom-0 z-0 h-(--buzz-huddle-drawer-height)">
<HuddleBar
className="h-full"
onOpenThread={(channelId, messageId) => {
void goChannel(channelId, {
messageId,
threadRootId: messageId,
});
}}
onVisibilityChange={setIsHuddleDrawerOpen}
/>
</div>

View File

@@ -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",
);
});

View File

@@ -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<number>(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<number>(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);
}

View File

@@ -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,
);
});

View File

@@ -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;
}

View File

@@ -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"] });

View File

@@ -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.

View File

@@ -82,6 +82,12 @@ const UNREAD_TRIGGER_KINDS = new Set<number>(CHANNEL_MESSAGE_EVENT_KINDS);
export const EMPTY_SET: ReadonlySet<string> = 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);

View File

@@ -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<CatchUpResult> => {
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 &&

View File

@@ -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<void>;
/** 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<HuddleJoinInfo>("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,

View File

@@ -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<string>;
};
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<RelayEvent>,
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<string>();
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<HuddleLifecycleState>(() => ({
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<string, RelayEvent>([
[
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 (
<Attachment
className={cn("w-96 max-w-full shadow-none", className)}
data-testid="huddle-attachment"
state="error"
>
<AttachmentMedia>
<Headphones />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>Huddle unavailable</AttachmentTitle>
<AttachmentDescription>
This huddle card is missing session details.
</AttachmentDescription>
</AttachmentContent>
</Attachment>
);
}
return (
<Attachment
className={cn("w-96 max-w-full shadow-none", className)}
data-testid="huddle-attachment"
data-huddle-state={displayEnded ? "ended" : "active"}
>
<AttachmentMedia
className={cn(
!displayEnded &&
"bg-primary/10 text-primary ring-1 ring-primary/20 dark:bg-primary/15",
)}
>
<Headphones />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>
Huddle
<span aria-hidden="true"> · </span>
{displayEnded ? "Ended" : "In progress"}
</AttachmentTitle>
<AttachmentDescription>
{participantLabel(participantCount)}
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
{canJoin ? (
<AttachmentAction
disabled={isJoining || isStarting}
onClick={() => void handleJoin()}
size="sm"
type="button"
variant="secondary"
>
<Headphones className="h-4 w-4" />
{isJoining || isStarting ? "Joining" : "Join"}
</AttachmentAction>
) : onOpenThread ? (
<AttachmentAction
aria-label="View huddle thread"
onClick={() => onOpenThread(message)}
size="sm"
type="button"
variant="ghost"
>
<MessageSquareText className="h-4 w-4" />
View thread
</AttachmentAction>
) : null}
</AttachmentActions>
</Attachment>
);
}

View File

@@ -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<string | null>(null);
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
const [reactionError, setReactionError] = React.useState<string | null>(null);
const [transcriptError, setTranscriptError] = React.useState<string | null>(
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<HuddleState>("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 (
<div
aria-hidden={isDrawerClosing}
@@ -467,12 +555,15 @@ export function HuddleBar({ className, onVisibilityChange }: HuddleBarProps) {
{/* Model download progress */}
{modelStatus &&
(modelStatus.stt !== "ready" || modelStatus.tts !== "ready") && (
((transcriptionEnabled && modelStatus.stt !== "ready") ||
modelStatus.tts !== "ready") && (
<output className="flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
<span className="truncate animate-pulse">
{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}`}
</span>
@@ -491,6 +582,12 @@ export function HuddleBar({ className, onVisibilityChange }: HuddleBarProps) {
</span>
)}
{transcriptError && (
<span className="max-w-[180px] truncate rounded bg-destructive/10 px-2 py-1 text-xs text-destructive">
{transcriptError}
</span>
)}
{showAddAgent && (
<AddAgentDialog
currentAgentPubkeys={barState.agent_pubkeys}
@@ -552,6 +649,25 @@ export function HuddleBar({ className, onVisibilityChange }: HuddleBarProps) {
selectedOutputDevice={selectedOutputDevice}
onSelectOutputDevice={setSelectedOutputDevice}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="View huddle thread"
className="buzz-huddle-control-button h-12 w-12 shrink-0 rounded-md"
disabled={!parentChannelId || !huddleThreadEventId}
onClick={handleOpenThread}
size="icon"
type="button"
variant="secondary"
>
<MessageSquareText className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="buzz-huddle-tooltip" side="top">
View thread
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
@@ -624,6 +740,30 @@ export function HuddleBar({ className, onVisibilityChange }: HuddleBarProps) {
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={
transcriptionEnabled ? "Stop transcript" : "Start transcript"
}
aria-pressed={transcriptionEnabled}
className={cn(
"buzz-huddle-control-button h-12 w-12 shrink-0 rounded-md",
transcriptionEnabled && "text-foreground",
)}
onClick={() => void handleToggleTranscript()}
size="icon"
type="button"
variant={transcriptionEnabled ? "secondary" : "ghost"}
>
<Captions className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="buzz-huddle-tooltip" side="top">
{transcriptionEnabled ? "Stop transcript" : "Start transcript"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -673,6 +813,7 @@ export function HuddleBar({ className, onVisibilityChange }: HuddleBarProps) {
: "In huddle, no microphone"}
{`, voice input: ${isPttMode ? "push to talk, press Ctrl+Space to transmit" : "voice activity detection"}`}
{modelStatus &&
transcriptionEnabled &&
modelStatus.stt !== "ready" &&
`, STT model ${modelStatus.stt}`}
{modelStatus &&

View File

@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
HUDDLE_JOINABLE_WINDOW_SECONDS,
isHuddleStartStale,
} from "./huddleCardState.ts";
test("isHuddleStartStale keeps recent huddle cards joinable", () => {
const nowSeconds = 2_000_000;
assert.equal(
isHuddleStartStale(
nowSeconds - HUDDLE_JOINABLE_WINDOW_SECONDS + 1,
nowSeconds * 1000,
),
false,
);
});
test("isHuddleStartStale marks huddle cards stale after the joinable window", () => {
const nowSeconds = 2_000_000;
assert.equal(
isHuddleStartStale(
nowSeconds - HUDDLE_JOINABLE_WINDOW_SECONDS - 1,
nowSeconds * 1000,
),
true,
);
});

View File

@@ -0,0 +1,9 @@
// Matches the ephemeral channel TTL used when a huddle is created.
export const HUDDLE_JOINABLE_WINDOW_SECONDS = 60 * 60;
export function isHuddleStartStale(
createdAtSeconds: number,
nowMs = Date.now(),
) {
return nowMs / 1000 - createdAtSeconds > HUDDLE_JOINABLE_WINDOW_SECONDS;
}

View File

@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildHuddleChannelName } from "./huddleChannelName.ts";
const SELF = "a".repeat(64);
const OTHER = "b".repeat(64);
const THIRD = "c".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("buildHuddleChannelName names one-on-one DMs with current user first", () => {
assert.equal(
buildHuddleChannelName({
channel: channel({
channelType: "dm",
participants: ["Tyler", "Kenny"],
participantPubkeys: [OTHER, SELF],
}),
currentPubkey: SELF,
members: [
member({ pubkey: SELF, displayName: "Kenny Lopez" }),
member({ pubkey: OTHER, displayName: "Tyler Durden" }),
],
}),
"Kenny <> Tyler huddle",
);
});
test("buildHuddleChannelName falls back to channel participant names", () => {
assert.equal(
buildHuddleChannelName({
channel: channel({
channelType: "dm",
participants: ["Other Person", "Self User"],
participantPubkeys: [OTHER, SELF],
}),
currentPubkey: SELF,
}),
"Self <> Other huddle",
);
});
test("buildHuddleChannelName keeps group DM participants readable", () => {
assert.equal(
buildHuddleChannelName({
channel: channel({
channelType: "dm",
participants: ["Other", "Self", "Third"],
participantPubkeys: [OTHER, SELF, THIRD],
}),
currentPubkey: SELF,
}),
"Self <> Other <> Third huddle",
);
});
test("buildHuddleChannelName names stream huddles after the channel", () => {
assert.equal(
buildHuddleChannelName({
channel: channel({ name: "engineering" }),
currentPubkey: SELF,
}),
"engineering huddle",
);
});

View File

@@ -0,0 +1,81 @@
import type { Channel, ChannelMember } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
type BuildHuddleChannelNameInput = {
channel: Channel;
currentPubkey?: string;
members?: readonly ChannelMember[];
};
function fallbackPubkeyLabel(pubkey: string): string {
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
}
function firstName(label: string): string {
return label.trim().split(/\s+/)[0] ?? "";
}
function channelParticipantLabel(
channel: Channel,
pubkey: string,
membersByPubkey: Map<string, ChannelMember>,
): string {
const normalized = normalizePubkey(pubkey);
const memberName = membersByPubkey.get(normalized)?.displayName?.trim();
if (memberName) {
return firstName(memberName);
}
const participantIndex = channel.participantPubkeys.findIndex(
(participantPubkey) => normalizePubkey(participantPubkey) === normalized,
);
const fallbackName =
participantIndex >= 0
? channel.participants[participantIndex]?.trim()
: null;
if (fallbackName) {
return firstName(fallbackName);
}
return fallbackPubkeyLabel(pubkey);
}
export function buildHuddleChannelName({
channel,
currentPubkey,
members = [],
}: BuildHuddleChannelNameInput): string {
if (channel.channelType !== "dm") {
const channelName = channel.name.trim();
return channelName ? `${channelName} huddle` : "huddle";
}
const membersByPubkey = new Map(
members.map((member) => [normalizePubkey(member.pubkey), member]),
);
const normalizedCurrentPubkey = currentPubkey
? normalizePubkey(currentPubkey)
: null;
const participantPubkeys = channel.participantPubkeys;
const orderedPubkeys =
normalizedCurrentPubkey &&
participantPubkeys.some(
(pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey,
)
? [
currentPubkey ?? "",
...participantPubkeys.filter(
(pubkey) => normalizePubkey(pubkey) !== normalizedCurrentPubkey,
),
]
: participantPubkeys;
const names = orderedPubkeys
.map((pubkey) => channelParticipantLabel(channel, pubkey, membersByPubkey))
.filter(Boolean);
if (names.length === 0) {
return "huddle";
}
return `${names.join(" <> ")} huddle`;
}

View File

@@ -9,6 +9,8 @@ import {
import {
CHANNEL_AUX_EVENT_KINDS,
CHANNEL_TIMELINE_CONTENT_KINDS,
KIND_HUDDLE_ENDED,
KIND_HUDDLE_STARTED,
} from "@/shared/constants/kinds";
const HEX64_A =
@@ -66,6 +68,21 @@ function streamEdit(targetId, content, overrides = {}) {
};
}
function huddleStarted(overrides = {}) {
return {
id: HEX64_B,
pubkey: PUBKEY_A,
kind: KIND_HUDDLE_STARTED,
created_at: 1_700_000_001,
content: JSON.stringify({
ephemeral_channel_id: "8d764100-fd8f-44cf-9c98-6d8fbd739b8c",
}),
tags: [["h", CHANNEL_ID]],
sig: "sig",
...overrides,
};
}
// ---------------------------------------------------------------------------
// Keystone regression: aux events (edits/deletions) apply by `#e` reference,
// NOT by time-window overlap. This is the invariant the split-query +
@@ -151,6 +168,12 @@ test("non-deletion event kinds do NOT hide the target message", () => {
assert.equal(out.length, 1, "the kind:9 message should still be visible");
});
test("huddle start renders as a timeline row", () => {
const out = formatTimelineMessages([huddleStarted()], null, undefined, null);
assert.equal(out.length, 1);
assert.equal(out[0].kind, KIND_HUDDLE_STARTED);
});
test("deletion target with non-hex `e` tag value is ignored", () => {
const bogusDeletion = deletionEvent(9005, HEX64_A, {
tags: [
@@ -258,6 +281,14 @@ test("countTopLevelTimelineRows ignores non-content kinds (reactions)", () => {
assert.equal(countTopLevelTimelineRows([message(hex64("1")), reaction]), 1);
});
test("countTopLevelTimelineRows counts huddle start rows", () => {
assert.equal(countTopLevelTimelineRows([huddleStarted()]), 1);
});
test("huddle ended stays lifecycle-only, not a timeline row", () => {
assert.equal(isTimelineContentEvent({ kind: KIND_HUDDLE_ENDED }), false);
});
// Guardrail: the history fetch requests exactly CHANNEL_TIMELINE_CONTENT_KINDS,
// so that set must stay in lockstep with isTimelineContentEvent. Drift would
// silently drop a content kind from history (fetched but never rendered) or

View File

@@ -25,6 +25,7 @@ import {
KIND_JOB_PROGRESS,
KIND_JOB_REQUEST,
KIND_JOB_RESULT,
KIND_HUDDLE_STARTED,
KIND_DELETION,
KIND_NIP29_DELETE_EVENT,
KIND_REACTION,
@@ -53,7 +54,8 @@ export function isTimelineContentEvent(event: RelayEvent) {
event.kind === KIND_JOB_PROGRESS ||
event.kind === KIND_JOB_RESULT ||
event.kind === KIND_JOB_CANCEL ||
event.kind === KIND_JOB_ERROR
event.kind === KIND_JOB_ERROR ||
event.kind === KIND_HUDDLE_STARTED
);
}

View File

@@ -5,6 +5,7 @@ import {
mergeTimelineHistoryMessages,
normalizeTimelineMessages,
} from "./messageQueryKeys.ts";
import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds";
const CHANNEL_ID = "timeline-window-test";
const PUBKEY = "a".repeat(64);
@@ -139,6 +140,37 @@ test("normalizeTimelineMessages still caps old visible content", () => {
);
});
test("normalizeTimelineMessages counts huddle starts as visible content", () => {
const huddleId = `${"d".repeat(63)}1`;
const messages = [];
for (let index = 0; index < 2_000; index += 1) {
messages.push(event({ id: id("old", index), createdAt: 1_000 + index }));
}
messages.push(
event({
id: huddleId,
kind: KIND_HUDDLE_STARTED,
createdAt: 4_000,
content: JSON.stringify({
ephemeral_channel_id: "8d764100-fd8f-44cf-9c98-6d8fbd739b8c",
}),
}),
);
const normalized = normalizeTimelineMessages(messages);
assert.equal(
normalized.some((item) => item.id === id("old", 0)),
false,
);
assert.equal(
normalized.some((item) => item.id === huddleId),
true,
);
assert.equal(normalized.filter((item) => item.kind === 9).length, 1_999);
});
test("timeline history merge preserves freshly fetched older content roots", () => {
const current = [];
const olderPage = [];

View File

@@ -6,6 +6,7 @@ import {
KIND_JOB_PROGRESS,
KIND_JOB_REQUEST,
KIND_JOB_RESULT,
KIND_HUDDLE_STARTED,
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_DIFF,
KIND_STREAM_MESSAGE_V2,
@@ -60,7 +61,8 @@ function isTimelineWindowContentEvent(event: RelayEvent) {
event.kind === KIND_JOB_PROGRESS ||
event.kind === KIND_JOB_RESULT ||
event.kind === KIND_JOB_CANCEL ||
event.kind === KIND_JOB_ERROR
event.kind === KIND_JOB_ERROR ||
event.kind === KIND_HUDDLE_STARTED
);
}

View File

@@ -11,6 +11,7 @@ import {
hasNestedThreadBranches,
shouldRenderUnreadDivider,
} from "./threadPanel.ts";
import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds";
function message(overrides) {
return {
@@ -66,6 +67,46 @@ test("buildMainTimelineEntries includes broadcast replies", () => {
);
});
test("buildMainTimelineEntries keeps huddle thread replies out of the parent timeline summary", () => {
const huddleRoot = message({
id: "huddle-root",
kind: KIND_HUDDLE_STARTED,
createdAt: 1,
body: JSON.stringify({
ephemeral_channel_id: "8d764100-fd8f-44cf-9c98-6d8fbd739b8c",
}),
});
const reply = message({
id: "huddle-thread-reply",
createdAt: 2,
parentId: "huddle-root",
rootId: "huddle-root",
depth: 1,
tags: [["e", "huddle-root", "", "reply"]],
});
const entries = buildMainTimelineEntries([huddleRoot, reply]);
assert.deepEqual(
entries.map((entry) => ({
id: entry.message.id,
replyCount: entry.summary?.replyCount ?? 0,
})),
[{ id: "huddle-root", replyCount: 0 }],
);
const panelData = buildThreadPanelData(
[huddleRoot, reply],
"huddle-root",
"huddle-root",
new Set(),
);
assert.deepEqual(
panelData.visibleReplies.map((entry) => entry.message.id),
["huddle-thread-reply"],
);
});
test("buildThreadPanelData connects direct comments to the thread head", () => {
const root = message({ id: "root", createdAt: 1 });
const directComment = message({

View File

@@ -1,5 +1,6 @@
import type { TimelineMessage } from "@/features/messages/types";
import { isBroadcastReply } from "@/features/messages/lib/threading";
import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds";
type ThreadPanelData = {
threadHead: TimelineMessage | null;
@@ -397,10 +398,13 @@ export function buildMainTimelineEntries(
.map((message) => {
return {
message,
summary: buildSummaryForDirectReplies(
message.id,
descendantStatsByMessageId,
),
summary:
message.kind === KIND_HUDDLE_STARTED
? null
: buildSummaryForDirectReplies(
message.id,
descendantStatsByMessageId,
),
};
});
}

View File

@@ -31,6 +31,7 @@ import { reactionEmojiUrl } from "@/shared/api/customEmoji";
import { cn } from "@/shared/lib/cn";
import { emojiDisplayName } from "@/shared/lib/emojiName";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds";
import {
AlertDialog,
AlertDialogAction,
@@ -109,7 +110,8 @@ function MoreActionsMenu({
// get default trigger-restoration (a11y intact for keyboard users).
const editJustSelectedRef = React.useRef(false);
const hasCopyActions = !message.pending;
const hasCopyActions =
!message.pending && message.kind !== KIND_HUDDLE_STARTED;
return (
<>

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment";
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
@@ -15,7 +16,10 @@ import {
threadReplyLength,
THREAD_REPLY_LINE_WIDTH_REM,
} from "@/features/messages/lib/threadTreeLayout";
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
import {
KIND_HUDDLE_STARTED,
KIND_STREAM_MESSAGE_DIFF,
} from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -286,6 +290,14 @@ export const MessageRow = React.memo(
/>
</React.Suspense>
);
case KIND_HUDDLE_STARTED:
return (
<HuddleAttachment
channelId={channelId}
message={message}
onOpenThread={onReply}
/>
);
default:
{
const waveMessage = parseWaveMessageContent(message.body);

View File

@@ -16,6 +16,7 @@ import {
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { DayDivider } from "./DayDivider";
import { MessageRow } from "./MessageRow";
@@ -334,12 +335,19 @@ function MessageRowItem({
videoReviewContext,
}: MessageRowItemProps) {
const { message, summary } = entry;
const isMutableMessage = message.kind !== KIND_HUDDLE_STARTED;
const canDelete =
onDelete && currentPubkey && message.pubkey === currentPubkey
isMutableMessage &&
onDelete &&
currentPubkey &&
message.pubkey === currentPubkey
? onDelete
: undefined;
const canEdit =
onEdit && currentPubkey && message.pubkey === currentPubkey
isMutableMessage &&
onEdit &&
currentPubkey &&
message.pubkey === currentPubkey
? onEdit
: undefined;

View File

@@ -13,6 +13,10 @@ import {
KIND_JOB_RESULT,
KIND_JOB_CANCEL,
KIND_JOB_ERROR,
KIND_HUDDLE_STARTED,
KIND_HUDDLE_PARTICIPANT_JOINED,
KIND_HUDDLE_PARTICIPANT_LEFT,
KIND_HUDDLE_ENDED,
} from "./kinds.ts";
test("isConversationalUnreadKind_streamMessage_counts", () => {
@@ -47,6 +51,17 @@ test("isConversationalUnreadKind_allJobKinds_excluded", () => {
}
});
test("isConversationalUnreadKind_huddleLifecycle_excluded", () => {
for (const kind of [
KIND_HUDDLE_STARTED,
KIND_HUDDLE_PARTICIPANT_JOINED,
KIND_HUDDLE_PARTICIPANT_LEFT,
KIND_HUDDLE_ENDED,
]) {
assert.equal(isConversationalUnreadKind(kind), false, `kind ${kind}`);
}
});
test("isConversationalUnreadKind_undefinedKind_countsAsConversational", () => {
// Optimistic/pending rows whose kind has not populated must not be dropped.
assert.equal(isConversationalUnreadKind(undefined), true);

View File

@@ -22,6 +22,10 @@ export const KIND_MEMBER_ADDED_NOTIFICATION = 44100;
export const KIND_MEMBER_REMOVED_NOTIFICATION = 44101;
export const KIND_TYPING_INDICATOR = 20002;
export const KIND_HUDDLE_REACTION = 24810;
export const KIND_HUDDLE_STARTED = 48100;
export const KIND_HUDDLE_PARTICIPANT_JOINED = 48101;
export const KIND_HUDDLE_PARTICIPANT_LEFT = 48102;
export const KIND_HUDDLE_ENDED = 48103;
// NIP-78 application-specific data. All use kind 30078; the relay
// differentiates them by d-tag ("read-state:<slotId>", "channel-sections", "channel-mutes", "channel-stars").
export const KIND_READ_STATE = 30078;
@@ -69,6 +73,10 @@ export const CHANNEL_EVENT_KINDS = [
KIND_STREAM_MESSAGE_EDIT, // 40003 — message edits
KIND_STREAM_MESSAGE_DIFF, // 40008 — message diffs
KIND_SYSTEM_MESSAGE, // 40099 — system messages (join, leave, etc.)
KIND_HUDDLE_STARTED, // 48100 — visible huddle session card
KIND_HUDDLE_PARTICIPANT_JOINED, // 48101 — huddle lifecycle overlay
KIND_HUDDLE_PARTICIPANT_LEFT, // 48102 — huddle lifecycle overlay
KIND_HUDDLE_ENDED, // 48103 — huddle lifecycle overlay
] as const;
// Auxiliary (non-row) timeline kinds: events that overlay onto or hide an
@@ -104,6 +112,7 @@ export const CHANNEL_TIMELINE_CONTENT_KINDS = [
KIND_JOB_RESULT, // 43004
KIND_JOB_CANCEL, // 43005
KIND_JOB_ERROR, // 43006
KIND_HUDDLE_STARTED, // 48100 — huddle session card
] as const;
// Timeline kinds that are NOT conversational: relay-signed system rows
@@ -119,6 +128,10 @@ const NON_CONVERSATIONAL_UNREAD_KINDS: ReadonlySet<number> = new Set([
KIND_JOB_RESULT, // 43004
KIND_JOB_CANCEL, // 43005
KIND_JOB_ERROR, // 43006
KIND_HUDDLE_STARTED, // 48100 — huddle cards are visible but non-conversational
KIND_HUDDLE_PARTICIPANT_JOINED, // 48101
KIND_HUDDLE_PARTICIPANT_LEFT, // 48102
KIND_HUDDLE_ENDED, // 48103
]);
// Whether a timeline message kind should count toward unread tallies. An

View File

@@ -154,7 +154,14 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
/// Kinds that are metadata rather than displayable content (deletions,
/// reactions, edits, legacy pre-migration messages).
static const _metadataKinds = {5, 7, 40001, 40003};
static const _metadataKinds = {
EventKind.deletion,
EventKind.reaction,
40001,
EventKind.streamMessageEdit,
EventKind.huddleParticipantJoined,
EventKind.huddleParticipantLeft,
};
/// Minimum displayable messages we want after the initial history load.
static const _minDisplayable = 15;

View File

@@ -14,6 +14,8 @@ enum SystemEventType {
channelCreated,
channelArchived,
channelUnarchived,
huddleStarted,
huddleEnded,
}
@immutable
@@ -69,6 +71,17 @@ class SystemEvent {
);
}
static SystemEvent? fromHuddleEvent(NostrEvent event) {
final type = switch (event.kind) {
EventKind.huddleStarted => SystemEventType.huddleStarted,
EventKind.huddleEnded => SystemEventType.huddleEnded,
_ => null,
};
if (type == null) return null;
return SystemEvent(type: type, actorPubkey: event.pubkey);
}
/// Human-readable description. [resolveLabel] maps a pubkey to a display
/// name — the caller provides it so this class stays free of provider deps.
String describe(String Function(String? pubkey) resolveLabel) {
@@ -93,6 +106,8 @@ class SystemEvent {
SystemEventType.channelCreated => '$actor created this channel',
SystemEventType.channelArchived => '$actor archived this channel',
SystemEventType.channelUnarchived => '$actor unarchived this channel',
SystemEventType.huddleStarted => '$actor started a huddle',
SystemEventType.huddleEnded => '$actor ended the huddle',
};
}
}
@@ -257,6 +272,27 @@ List<TimelineMessage> formatTimeline(
final normalizedCurrentPubkey = currentPubkey?.toLowerCase();
List<TimelineReaction> reactionsFor(String eventId) {
final emojiMap = reactionMap[eventId];
if (emojiMap == null) return const [];
return [
for (final entry in emojiMap.entries)
TimelineReaction(
emoji: entry.key,
count: entry.value.length,
reactedByCurrentUser:
normalizedCurrentPubkey != null &&
entry.value.containsKey(normalizedCurrentPubkey),
userPubkeys: entry.value.keys.toList(),
emojiUrl: reactionEmojiUrls[eventId]?[entry.key],
currentUserReactionId: normalizedCurrentPubkey != null
? entry.value[normalizedCurrentPubkey]
: null,
),
];
}
// 4. Filter to visible content events and build TimelineMessages.
final result = <TimelineMessage>[];
for (final event in events) {
@@ -265,24 +301,6 @@ List<TimelineMessage> formatTimeline(
if (event.kind == EventKind.systemMessage) {
final systemEvent = SystemEvent.fromContent(event.content);
if (systemEvent != null) {
final emojiMap = reactionMap[event.id];
final reactions = <TimelineReaction>[
if (emojiMap != null)
for (final entry in emojiMap.entries)
TimelineReaction(
emoji: entry.key,
count: entry.value.length,
reactedByCurrentUser:
normalizedCurrentPubkey != null &&
entry.value.containsKey(normalizedCurrentPubkey),
userPubkeys: entry.value.keys.toList(),
emojiUrl: reactionEmojiUrls[event.id]?[entry.key],
currentUserReactionId: normalizedCurrentPubkey != null
? entry.value[normalizedCurrentPubkey]
: null,
),
];
result.add(
TimelineMessage(
id: event.id,
@@ -292,7 +310,27 @@ List<TimelineMessage> formatTimeline(
tags: event.tags,
isSystem: true,
systemEvent: systemEvent,
reactions: reactions,
reactions: reactionsFor(event.id),
),
);
}
continue;
}
if (event.kind == EventKind.huddleStarted ||
event.kind == EventKind.huddleEnded) {
final systemEvent = SystemEvent.fromHuddleEvent(event);
if (systemEvent != null) {
result.add(
TimelineMessage(
id: event.id,
pubkey: event.pubkey,
createdAt: event.createdAt,
content: event.content,
tags: event.tags,
isSystem: true,
systemEvent: systemEvent,
reactions: reactionsFor(event.id),
),
);
}
@@ -309,24 +347,6 @@ List<TimelineMessage> formatTimeline(
if (tag.length >= 2 && tag[0] == 'p') tag[1],
];
final emojiMap = reactionMap[event.id];
final reactions = <TimelineReaction>[
if (emojiMap != null)
for (final entry in emojiMap.entries)
TimelineReaction(
emoji: entry.key,
count: entry.value.length,
reactedByCurrentUser:
normalizedCurrentPubkey != null &&
entry.value.containsKey(normalizedCurrentPubkey),
userPubkeys: entry.value.keys.toList(),
emojiUrl: reactionEmojiUrls[event.id]?[entry.key],
currentUserReactionId: normalizedCurrentPubkey != null
? entry.value[normalizedCurrentPubkey]
: null,
),
];
final threadRef = event.threadReference;
result.add(
@@ -338,7 +358,7 @@ List<TimelineMessage> formatTimeline(
tags: effectiveTags,
edited: edit != null,
mentionPubkeys: mentions,
reactions: reactions,
reactions: reactionsFor(event.id),
parentId: threadRef.parentId,
rootId: threadRef.rootId,
),

View File

@@ -16,6 +16,7 @@ abstract final class EventKind {
static const typingIndicator = 20002;
static const auth = 22242;
static const agentObserverFrame = 24200;
static const huddleReaction = 24810;
static const readState = 30078;
static const userStatus = 30315;
static const dmVisibility = 30622;
@@ -25,6 +26,10 @@ abstract final class EventKind {
static const systemMessage = 40099;
static const forumPost = 45001;
static const forumComment = 45003;
static const huddleStarted = 48100;
static const huddleParticipantJoined = 48101;
static const huddleParticipantLeft = 48102;
static const huddleEnded = 48103;
/// Event kinds that represent user-visible channel messages.
static const channelMessageEventKinds = [
@@ -45,6 +50,10 @@ abstract final class EventKind {
streamMessageEdit, // 40003
streamMessageDiff, // 40008
systemMessage, // 40099
huddleStarted, // 48100 — visible huddle session row
huddleParticipantJoined, // 48101 — huddle lifecycle metadata
huddleParticipantLeft, // 48102 — huddle lifecycle metadata
huddleEnded, // 48103 — visible huddle ended row
];
}

View File

@@ -141,6 +141,25 @@ NostrEvent _reaction({
sig: '',
);
NostrEvent _huddleEvent({
required String id,
required int kind,
String pubkey = 'pk1',
int createdAt = 1000,
}) => NostrEvent(
id: id,
pubkey: pubkey,
createdAt: createdAt,
kind: kind,
tags: [
['h', 'ch1'],
],
content: jsonEncode({
'ephemeral_channel_id': '8d764100-fd8f-44cf-9c98-6d8fbd739b8c',
}),
sig: '',
);
void main() {
group('SystemEvent.fromContent', () {
test('parses all known event types', () {
@@ -291,9 +310,37 @@ void main() {
);
expect(event.describe(resolve), 'Alice unarchived this channel');
});
test('huddle_started', () {
final event = SystemEvent(
type: SystemEventType.huddleStarted,
actorPubkey: 'pk1',
);
expect(event.describe(resolve), 'Alice started a huddle');
});
test('huddle_ended', () {
final event = SystemEvent(
type: SystemEventType.huddleEnded,
actorPubkey: 'pk1',
);
expect(event.describe(resolve), 'Alice ended the huddle');
});
});
group('formatTimeline', () {
test('channel filters include desktop huddle lifecycle event kinds', () {
expect(
EventKind.channelEventKinds,
containsAll([
EventKind.huddleStarted,
EventKind.huddleParticipantJoined,
EventKind.huddleParticipantLeft,
EventKind.huddleEnded,
]),
);
});
test('passes through text messages', () {
final events = [
_textMsg(id: 'a', content: 'hello'),
@@ -458,6 +505,28 @@ void main() {
expect(result[0].systemEvent!.type, SystemEventType.channelCreated);
});
test('huddle start and end events render as system rows', () {
final result = formatTimeline([
_huddleEvent(id: 'h1', kind: EventKind.huddleStarted),
_huddleEvent(id: 'h2', kind: EventKind.huddleEnded, createdAt: 1100),
]);
expect(result, hasLength(2));
expect(result[0].isSystem, isTrue);
expect(result[0].systemEvent!.type, SystemEventType.huddleStarted);
expect(result[1].isSystem, isTrue);
expect(result[1].systemEvent!.type, SystemEventType.huddleEnded);
});
test('huddle participant events are lifecycle metadata only', () {
final result = formatTimeline([
_huddleEvent(id: 'h1', kind: EventKind.huddleParticipantJoined),
_huddleEvent(id: 'h2', kind: EventKind.huddleParticipantLeft),
]);
expect(result, isEmpty);
});
test('unknown system messages are dropped', () {
final events = [
_systemMsg(id: 's1', payload: {'type': 'unknown'}),