diff --git a/.env.example b/.env.example index 051ece03b..7a9c0970a 100644 --- a/.env.example +++ b/.env.example @@ -81,6 +81,17 @@ OKTA_AUDIENCE=sprout-desktop # OKTA_AUDIENCE=sprout-api # OKTA_PUBKEY_CLAIM=nostr_pubkey +# ----------------------------------------------------------------------------- +# Ephemeral Channels (TTL testing) +# ----------------------------------------------------------------------------- +# Override the TTL for all ephemeral channels (in seconds). When set, any +# channel created with a TTL tag will use this value instead of the +# client-provided one. Unset to use the client-provided TTL. +# SPROUT_EPHEMERAL_TTL_OVERRIDE=60 + +# How often the reaper checks for expired ephemeral channels (default: 60s). +# SPROUT_REAPER_INTERVAL_SECS=5 + # ----------------------------------------------------------------------------- # Logging / Tracing # ----------------------------------------------------------------------------- diff --git a/crates/sprout-db/src/channel.rs b/crates/sprout-db/src/channel.rs index 940b92f8c..d8c532f23 100644 --- a/crates/sprout-db/src/channel.rs +++ b/crates/sprout-db/src/channel.rs @@ -58,6 +58,10 @@ pub struct ChannelRecord { pub purpose_set_by: Option>, /// When the purpose was last set. pub purpose_set_at: Option>, + /// TTL in seconds for ephemeral channels. `None` means permanent. + pub ttl_seconds: Option, + /// Deadline by which a new message must arrive or the channel is auto-archived. + pub ttl_deadline: Option>, } /// A channel membership row as returned from the database. @@ -85,6 +89,7 @@ pub async fn create_channel( visibility: ChannelVisibility, description: Option<&str>, created_by: &[u8], + ttl_seconds: Option, ) -> Result { if created_by.len() != 32 { return Err(DbError::InvalidData(format!( @@ -99,8 +104,9 @@ pub async fn create_channel( sqlx::query( r#" - INSERT INTO channels (id, name, channel_type, visibility, description, created_by) - VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6) + INSERT INTO channels (id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline) + VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6, $7, + CASE WHEN $7 IS NOT NULL THEN NOW() + ($7 || ' seconds')::interval ELSE NULL END) "#, ) .bind(id) @@ -109,6 +115,7 @@ pub async fn create_channel( .bind(visibility.as_str()) .bind(description) .bind(created_by) + .bind(ttl_seconds) .execute(&mut *tx) .await?; @@ -135,7 +142,8 @@ pub async fn create_channel( created_by, created_at, updated_at, archived_at, deleted_at, nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, - purpose, purpose_set_by, purpose_set_at + purpose, purpose_set_by, purpose_set_at, + ttl_seconds, ttl_deadline FROM channels WHERE id = $1 "#, ) @@ -152,6 +160,7 @@ pub async fn create_channel( /// /// Returns `(record, true)` if the channel was newly created, or `(record, false)` if a /// channel with `channel_id` already exists (duplicate — caller should reject the event). +#[allow(clippy::too_many_arguments)] pub async fn create_channel_with_id( pool: &PgPool, channel_id: Uuid, @@ -160,6 +169,7 @@ pub async fn create_channel_with_id( visibility: ChannelVisibility, description: Option<&str>, created_by: &[u8], + ttl_seconds: Option, ) -> Result<(ChannelRecord, bool)> { if created_by.len() != 32 { return Err(DbError::InvalidData(format!( @@ -172,8 +182,9 @@ pub async fn create_channel_with_id( let rows_affected = sqlx::query( r#" - INSERT INTO channels (id, name, channel_type, visibility, description, created_by) - VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6) + INSERT INTO channels (id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline) + VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6, $7, + CASE WHEN $7 IS NOT NULL THEN NOW() + ($7 || ' seconds')::interval ELSE NULL END) ON CONFLICT (id) DO NOTHING "#, ) @@ -183,6 +194,7 @@ pub async fn create_channel_with_id( .bind(visibility.as_str()) .bind(description) .bind(created_by) + .bind(ttl_seconds) .execute(&mut *tx) .await? .rows_affected(); @@ -215,7 +227,8 @@ pub async fn create_channel_with_id( created_by, created_at, updated_at, archived_at, deleted_at, nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, - purpose, purpose_set_by, purpose_set_at + purpose, purpose_set_by, purpose_set_at, + ttl_seconds, ttl_deadline FROM channels WHERE id = $1 "#, ) @@ -237,7 +250,8 @@ pub async fn get_channel(pool: &PgPool, channel_id: Uuid) -> Result) -> Result) -> Result Result { let purpose: Option = row.try_get("purpose").unwrap_or(None); let purpose_set_by: Option> = row.try_get("purpose_set_by").unwrap_or(None); let purpose_set_at: Option> = row.try_get("purpose_set_at").unwrap_or(None); + let ttl_seconds: Option = row.try_get("ttl_seconds").unwrap_or(None); + let ttl_deadline: Option> = row.try_get("ttl_deadline").unwrap_or(None); Ok(ChannelRecord { id, @@ -829,6 +849,8 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result { purpose, purpose_set_by, purpose_set_at, + ttl_seconds, + ttl_deadline, }) } @@ -1086,3 +1108,42 @@ pub async fn get_member_role( .await?; Ok(row.map(|r| r.try_get("role")).transpose()?) } + +/// Bump the TTL deadline for an ephemeral channel after a new message. +/// +/// No-op for permanent channels or channels that are already archived/deleted. +pub async fn bump_ttl_deadline(pool: &PgPool, channel_id: Uuid) -> Result<()> { + sqlx::query( + "UPDATE channels SET ttl_deadline = NOW() + (ttl_seconds || ' seconds')::interval \ + WHERE id = $1 AND ttl_seconds IS NOT NULL AND archived_at IS NULL AND deleted_at IS NULL", + ) + .bind(channel_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Archive ephemeral channels whose TTL deadline has passed. +/// +/// Returns the list of channel IDs that were archived. Idempotent — the +/// `archived_at IS NULL` guard prevents double-archiving even if called +/// concurrently from multiple relay pods. +pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result> { + let rows = sqlx::query( + "UPDATE channels SET archived_at = NOW() \ + WHERE ttl_seconds IS NOT NULL \ + AND ttl_deadline < NOW() \ + AND archived_at IS NULL \ + AND deleted_at IS NULL \ + RETURNING id", + ) + .fetch_all(pool) + .await?; + + rows.into_iter() + .map(|row| { + let id: Uuid = row.try_get("id")?; + Ok(id) + }) + .collect() +} diff --git a/crates/sprout-db/src/dm.rs b/crates/sprout-db/src/dm.rs index 70c1054d5..14fea3dc0 100644 --- a/crates/sprout-db/src/dm.rs +++ b/crates/sprout-db/src/dm.rs @@ -439,6 +439,8 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result { purpose: row.try_get("purpose").unwrap_or(None), purpose_set_by: row.try_get("purpose_set_by").unwrap_or(None), purpose_set_at: row.try_get("purpose_set_at").unwrap_or(None), + ttl_seconds: row.try_get("ttl_seconds").unwrap_or(None), + ttl_deadline: row.try_get("ttl_deadline").unwrap_or(None), }) } diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index 359a2bdb4..d481a1adb 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -298,6 +298,7 @@ impl Db { visibility: channel::ChannelVisibility, description: Option<&str>, created_by: &[u8], + ttl_seconds: Option, ) -> Result { channel::create_channel( &self.pool, @@ -306,6 +307,7 @@ impl Db { visibility, description, created_by, + ttl_seconds, ) .await } @@ -313,6 +315,7 @@ impl Db { /// Creates a channel with a client-supplied UUID. /// /// Returns `(record, true)` if newly created, `(record, false)` if already exists. + #[allow(clippy::too_many_arguments)] pub async fn create_channel_with_id( &self, channel_id: Uuid, @@ -321,6 +324,7 @@ impl Db { visibility: channel::ChannelVisibility, description: Option<&str>, created_by: &[u8], + ttl_seconds: Option, ) -> Result<(channel::ChannelRecord, bool)> { channel::create_channel_with_id( &self.pool, @@ -330,6 +334,7 @@ impl Db { visibility, description, created_by, + ttl_seconds, ) .await } @@ -465,6 +470,16 @@ impl Db { channel::get_member_role(&self.pool, channel_id, pubkey).await } + /// Bump the TTL deadline for an ephemeral channel after a new message. + pub async fn bump_ttl_deadline(&self, channel_id: Uuid) -> Result<()> { + channel::bump_ttl_deadline(&self.pool, channel_id).await + } + + /// Archive ephemeral channels whose TTL deadline has passed. + pub async fn reap_expired_ephemeral_channels(&self) -> Result> { + channel::reap_expired_ephemeral_channels(&self.pool).await + } + // ── Users ──────────────────────────────────────────────────────────────── /// Ensure a user record exists (upsert). diff --git a/crates/sprout-relay/src/api/channels.rs b/crates/sprout-relay/src/api/channels.rs index 94118206d..f61ab4486 100644 --- a/crates/sprout-relay/src/api/channels.rs +++ b/crates/sprout-relay/src/api/channels.rs @@ -112,6 +112,8 @@ fn channel_record_to_json( "participants": participants, "participant_pubkeys": participant_pubkeys, "is_member": is_member, + "ttl_seconds": channel.ttl_seconds, + "ttl_deadline": channel.ttl_deadline.map(|t| t.to_rfc3339()), }) } diff --git a/crates/sprout-relay/src/api/channels_metadata.rs b/crates/sprout-relay/src/api/channels_metadata.rs index 73652f9d5..eac375372 100644 --- a/crates/sprout-relay/src/api/channels_metadata.rs +++ b/crates/sprout-relay/src/api/channels_metadata.rs @@ -50,6 +50,8 @@ fn channel_detail_to_json(record: &ChannelRecord, member_count: i64) -> serde_js "topic_required": record.topic_required, "max_members": record.max_members, "nip29_group_id": record.nip29_group_id, + "ttl_seconds": record.ttl_seconds, + "ttl_deadline": record.ttl_deadline.map(|t| t.to_rfc3339()), }) } diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index 9a9179b78..c290f4042 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -61,6 +61,13 @@ pub struct Config { pub pubkey_allowlist_enabled: bool, /// Media storage configuration (S3/MinIO). pub media: sprout_media::MediaConfig, + + /// Optional override for ephemeral channel TTL (in seconds). + /// When set, any channel created with a TTL tag will use this value instead + /// of the client-provided one. Useful for testing ephemeral expiry quickly. + /// Example: `SPROUT_EPHEMERAL_TTL_OVERRIDE=60` → all ephemeral channels expire + /// 60 seconds after the last message. + pub ephemeral_ttl_override: Option, } impl Config { @@ -194,6 +201,18 @@ impl Config { }), }; + let ephemeral_ttl_override = std::env::var("SPROUT_EPHEMERAL_TTL_OVERRIDE") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&v| v > 0); + + if let Some(ttl) = ephemeral_ttl_override { + warn!( + "SPROUT_EPHEMERAL_TTL_OVERRIDE={ttl}s — all ephemeral channels will use \ + this TTL instead of the client-provided value." + ); + } + Ok(Self { bind_addr, database_url, @@ -213,6 +232,7 @@ impl Config { metrics_port, pubkey_allowlist_enabled, media, + ephemeral_ttl_override, }) } } diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index 6b00a7982..c06f03b37 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -1029,6 +1029,8 @@ pub async fn ingest_event( } }); + let ttl_seconds = super::resolve_ttl(&event, state.config.ephemeral_ttl_override); + let actor_bytes = event.pubkey.serialize().to_vec(); let (_, was_created) = state .db @@ -1039,6 +1041,7 @@ pub async fn ingest_event( visibility, description.as_deref(), &actor_bytes, + ttl_seconds, ) .await .map_err(|e| IngestError::Internal(format!("error: {e}")))?; @@ -1304,6 +1307,17 @@ pub async fn ingest_event( }); } + // ── 20b. Bump ephemeral channel TTL deadline ────────────────────── + // Any successfully stored channel-scoped event keeps the channel alive. + // Skip kind:9007 (create) — the deadline was just set during creation. + if let Some(ch_id) = channel_id { + if kind_u32 != KIND_NIP29_CREATE_GROUP { + if let Err(e) = state.db.bump_ttl_deadline(ch_id).await { + warn!(channel = %ch_id, "TTL deadline bump failed: {e}"); + } + } + } + // ── 21. Side effects ───────────────────────────────────────────────── if crate::handlers::side_effects::is_side_effect_kind(kind_u32) { if let Err(e) = diff --git a/crates/sprout-relay/src/handlers/mod.rs b/crates/sprout-relay/src/handlers/mod.rs index 72af16951..1cb732586 100644 --- a/crates/sprout-relay/src/handlers/mod.rs +++ b/crates/sprout-relay/src/handlers/mod.rs @@ -8,3 +8,29 @@ pub mod ingest; pub mod req; /// NIP-29 and NIP-25 side-effect handlers. pub mod side_effects; + +/// Extract an optional TTL (in seconds) from a Nostr event's `ttl` tag, +/// applying the server-side override when configured. +/// +/// Returns `None` when the event carries no `ttl` tag — the channel is permanent. +pub fn resolve_ttl(event: &nostr::Event, ephemeral_ttl_override: Option) -> Option { + let from_tag: Option = event.tags.iter().find_map(|t| { + if t.kind().to_string() == "ttl" { + t.content().and_then(|s| s.parse::().ok()) + } else { + None + } + }); + + match (from_tag, ephemeral_ttl_override) { + (Some(original), Some(ovr)) => { + tracing::debug!( + original, + override_val = ovr, + "Applying SPROUT_EPHEMERAL_TTL_OVERRIDE" + ); + Some(ovr) + } + (ttl, _) => ttl, + } +} diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs index f84907852..5eb02f8f6 100644 --- a/crates/sprout-relay/src/handlers/side_effects.rs +++ b/crates/sprout-relay/src/handlers/side_effects.rs @@ -948,6 +948,7 @@ async fn handle_create_group(event: &Event, state: &Arc) -> anyhow::Re let actor_bytes = event.pubkey.serialize().to_vec(); let description = extract_tag_value(event, "about"); + let ttl_seconds = super::resolve_ttl(event, state.config.ephemeral_ttl_override); // If the event has an h-tag UUID, ingest_event() already created the channel // via create_channel_with_id(). Fetch it rather than creating a duplicate. @@ -966,6 +967,7 @@ async fn handle_create_group(event: &Event, state: &Arc) -> anyhow::Re visibility, description.as_deref(), &actor_bytes, + ttl_seconds, ) .await? } @@ -979,6 +981,7 @@ async fn handle_create_group(event: &Event, state: &Arc) -> anyhow::Re visibility, description.as_deref(), &actor_bytes, + ttl_seconds, ) .await? }; diff --git a/crates/sprout-relay/src/main.rs b/crates/sprout-relay/src/main.rs index 4f9c8489b..3ced6651b 100644 --- a/crates/sprout-relay/src/main.rs +++ b/crates/sprout-relay/src/main.rs @@ -149,6 +149,67 @@ async fn main() -> anyhow::Result<()> { let wf_cron = Arc::clone(&workflow_engine); tokio::spawn(async move { wf_cron.run().await }); + // Ephemeral channel reaper — archives channels whose TTL deadline has passed. + // Runs every 60s, matching the workflow cron loop pattern. The SQL UPDATE + // uses `archived_at IS NULL` as a guard, so concurrent runs from multiple + // pods are harmless (at worst, duplicate system messages — same trade-off + // as the workflow cron loop). Will be upgraded to use pg_advisory_lock + // together with the workflow engine in a future multi-pod coordination pass. + { + let reaper_state = Arc::clone(&state); + let reaper_interval_secs: u64 = std::env::var("SPROUT_REAPER_INTERVAL_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(60); + tokio::spawn(async move { + info!( + interval_secs = reaper_interval_secs, + "Ephemeral channel reaper started" + ); + loop { + tokio::time::sleep(std::time::Duration::from_secs(reaper_interval_secs)).await; + + let expired = match reaper_state.db.reap_expired_ephemeral_channels().await { + Ok(ids) => ids, + Err(e) => { + error!("Ephemeral reaper tick failed: {e}"); + continue; + } + }; + + if expired.is_empty() { + continue; + } + + info!(count = expired.len(), "Ephemeral reaper archived channels"); + + for channel_id in &expired { + // Emit a system message so members see why the channel was archived. + if let Err(e) = sprout_relay::handlers::side_effects::emit_system_message( + &reaper_state, + *channel_id, + serde_json::json!({ "type": "channel_auto_archived" }), + ) + .await + { + error!(channel = %channel_id, "reaper system message failed: {e}"); + } + + // Update NIP-29 discovery events so clients see the archived state. + if let Err(e) = + sprout_relay::handlers::side_effects::emit_group_discovery_events( + &reaper_state, + *channel_id, + ) + .await + { + error!(channel = %channel_id, "reaper discovery update failed: {e}"); + } + } + } + }); + } + // Multi-node fan-out consumer: receive events from Redis pub/sub // (published by other relay instances) and fan out to local WS subscribers. { diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index f54e43c76..6ec4113dc 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -48,7 +48,7 @@ const overrides = new Map([ ["src/features/agents/ui/AgentsView.tsx", 790], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona/team import + provider/model fields ["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes ["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display - ["src/shared/api/types.ts", 525], // persona provider/model fields + forum types + workflow type re-exports + ["src/shared/api/types.ts", 530], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields ]); async function walkFiles(directory) { diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 67e6b2b59..cece9ad73 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -48,6 +48,7 @@ pub async fn create_channel( channel_type: String, visibility: String, description: Option, + ttl_seconds: Option, state: State<'_, AppState>, ) -> Result { let channel_uuid = uuid::Uuid::new_v4(); @@ -61,8 +62,14 @@ pub async fn create_channel( other => return Err(format!("invalid channel_type: {other}")), }; - let builder = - events::build_create_channel(channel_uuid, &name, vis, ct, description.as_deref())?; + let builder = events::build_create_channel( + channel_uuid, + &name, + vis, + ct, + description.as_deref(), + ttl_seconds, + )?; submit_event(builder, &state).await?; // Follow-up GET to return the full ChannelInfo the frontend expects. diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 27d15bee8..b3f8430ed 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -110,6 +110,7 @@ pub fn build_create_channel( visibility: &str, channel_type: &str, about: Option<&str>, + ttl_seconds: Option, ) -> Result { let mut tags = vec![ tag(vec!["h", &channel_id.to_string()])?, @@ -120,6 +121,9 @@ pub fn build_create_channel( if let Some(a) = about { tags.push(tag(vec!["about", a])?); } + if let Some(ttl) = ttl_seconds { + tags.push(tag(vec!["ttl", &ttl.to_string()])?); + } Ok(EventBuilder::new(Kind::Custom(9007), "").tags(tags)) } diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index aaf027d15..7eb511c17 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -70,6 +70,8 @@ pub struct ChannelInfo { pub participant_pubkeys: Vec, #[serde(default = "default_true")] pub is_member: bool, + pub ttl_seconds: Option, + pub ttl_deadline: Option, } #[derive(Serialize, Deserialize)] @@ -94,6 +96,8 @@ pub struct ChannelDetailInfo { pub topic_required: bool, pub max_members: Option, pub nip29_group_id: Option, + pub ttl_seconds: Option, + pub ttl_deadline: Option, } #[derive(Serialize, Deserialize)] diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index f6401af23..e7c78c1c3 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -449,22 +449,34 @@ export function AppShell() { isOpeningDm={openDmMutation.isPending} isPresencePending={presenceSession.isPending} selfPresenceStatus={presenceSession.currentStatus} - onCreateChannel={async ({ description, name, visibility }) => { + onCreateChannel={async ({ + description, + name, + visibility, + ttlSeconds, + }) => { const createdChannel = await createChannelMutation.mutateAsync({ name, description, channelType: "stream", visibility, + ttlSeconds, }); openChannelView(createdChannel.id); }} - onCreateForum={async ({ description, name, visibility }) => { + onCreateForum={async ({ + description, + name, + visibility, + ttlSeconds, + }) => { const createdForum = await createForumMutation.mutateAsync({ name, description, channelType: "forum", visibility, + ttlSeconds, }); openChannelView(createdForum.id); diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index f850bd77f..41484880c 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -1,6 +1,9 @@ import { Bot, Home, Lock, PenSquare, Plus, Search, Zap } from "lucide-react"; import * as React from "react"; +/** Default TTL for ephemeral channels: 1 day of inactivity. */ +const EPHEMERAL_TTL_SECONDS = 86400; + import { useManagedAgentsQuery } from "@/features/agents/hooks"; import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; @@ -68,11 +71,13 @@ type AppSidebarProps = { name: string; description?: string; visibility: ChannelVisibility; + ttlSeconds?: number; }) => Promise; onCreateForum: (input: { name: string; description?: string; visibility: ChannelVisibility; + ttlSeconds?: number; }) => Promise; onOpenBrowseChannels: () => void; onOpenBrowseForums: () => void; @@ -97,6 +102,7 @@ function useCreateForm( name: string; description?: string; visibility: ChannelVisibility; + ttlSeconds?: number; }) => Promise, entityLabel: string, ) { @@ -105,6 +111,7 @@ function useCreateForm( const [draftDescription, setDraftDescription] = React.useState(""); const [draftVisibility, setDraftVisibility] = React.useState("open"); + const [draftEphemeral, setDraftEphemeral] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(); const inputRef = React.useRef(null); @@ -124,6 +131,7 @@ function useCreateForm( setDraftName(""); setDraftDescription(""); setDraftVisibility("open"); + setDraftEphemeral(false); setIsOpen(false); } @@ -142,6 +150,11 @@ function useCreateForm( setDraftVisibility(value); } + function changeEphemeral(value: boolean) { + setErrorMessage(undefined); + setDraftEphemeral(value); + } + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); @@ -158,11 +171,13 @@ function useCreateForm( name, description: description || undefined, visibility: draftVisibility, + ttlSeconds: draftEphemeral ? EPHEMERAL_TTL_SECONDS : undefined, }); setDraftName(""); setDraftDescription(""); setDraftVisibility("open"); + setDraftEphemeral(false); setIsOpen(false); } catch (error) { setErrorMessage( @@ -178,6 +193,7 @@ function useCreateForm( draftName, draftDescription, draftVisibility, + draftEphemeral, errorMessage, inputRef, toggle, @@ -185,6 +201,7 @@ function useCreateForm( changeName, changeDescription, changeVisibility, + changeEphemeral, handleSubmit, }; } @@ -311,6 +328,40 @@ function PrivateCheckbox({ ); } +// --------------------------------------------------------------------------- +// EphemeralCheckbox — checkbox toggle for auto-archiving channels +// --------------------------------------------------------------------------- + +function EphemeralCheckbox({ + disabled, + isEphemeral, + onChange, +}: { + disabled: boolean; + isEphemeral: boolean; + onChange: (isEphemeral: boolean) => void; +}) { + const id = React.useId(); + + return ( +
+ onChange(checked === true)} + /> + +
+ ); +} + // --------------------------------------------------------------------------- // ChannelGroupSection — unified Channels / Forums section // --------------------------------------------------------------------------- @@ -411,6 +462,11 @@ function ChannelGroupSection({ } testId={createVisibilityTestId} /> +