mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Allow Huddle between 2 humans in DM (#1347)
This commit is contained in:
@@ -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
|
||||
})],
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)),
|
||||
|
||||
73
desktop/src-tauri/src/huddle/transcription.rs
Normal file
73
desktop/src-tauri/src/huddle/transcription.rs
Normal 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(())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
136
desktop/src/features/channels/lib/huddleAvailability.test.mjs
Normal file
136
desktop/src/features/channels/lib/huddleAvailability.test.mjs
Normal 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,
|
||||
);
|
||||
});
|
||||
35
desktop/src/features/channels/lib/huddleAvailability.ts
Normal file
35
desktop/src/features/channels/lib/huddleAvailability.ts
Normal 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;
|
||||
}
|
||||
@@ -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"] });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
303
desktop/src/features/huddle/components/HuddleAttachment.tsx
Normal file
303
desktop/src/features/huddle/components/HuddleAttachment.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
29
desktop/src/features/huddle/lib/huddleCardState.test.mjs
Normal file
29
desktop/src/features/huddle/lib/huddleCardState.test.mjs
Normal 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,
|
||||
);
|
||||
});
|
||||
9
desktop/src/features/huddle/lib/huddleCardState.ts
Normal file
9
desktop/src/features/huddle/lib/huddleCardState.ts
Normal 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;
|
||||
}
|
||||
97
desktop/src/features/huddle/lib/huddleChannelName.test.mjs
Normal file
97
desktop/src/features/huddle/lib/huddleChannelName.test.mjs
Normal 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",
|
||||
);
|
||||
});
|
||||
81
desktop/src/features/huddle/lib/huddleChannelName.ts
Normal file
81
desktop/src/features/huddle/lib/huddleChannelName.ts
Normal 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`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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'}),
|
||||
|
||||
Reference in New Issue
Block a user