diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 54a6037c239..9caf42e51af 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -41,6 +41,12 @@ const STARTER_CHANNELS: &[StarterChannelSpec] = &[ }, ]; +/// Starter channels a new person joins only when the community already has +/// them. They are never created: whether a community has one, and how it is +/// configured (for example as an announce channel with `join_role = guest`), +/// is its owners' call. A join takes the channel's `join_role`. +const JOIN_ONLY_STARTER_CHANNEL_NAMES: &[&str] = &["announcements"]; + // ── Tauri commands ──────────────────────────────────────────────────────────── /// Return the channels the active identity belongs to (plus its own @@ -230,8 +236,8 @@ fn is_duplicate_channel_rejection(error: &str) -> bool { error.contains("relay rejected event:") && error.contains("duplicate: channel already exists") } -fn is_matching_starter_channel(channel: &ChannelInfo, spec: &StarterChannelSpec) -> bool { - normalize_channel_name(&channel.name) == normalize_channel_name(spec.name) +fn is_matching_starter_channel(channel: &ChannelInfo, name: &str) -> bool { + normalize_channel_name(&channel.name) == normalize_channel_name(name) && channel.channel_type == "stream" && channel.visibility == "open" && channel.archived_at.is_none() @@ -241,7 +247,7 @@ fn has_all_starter_channels(channels: &[ChannelInfo]) -> bool { STARTER_CHANNELS.iter().all(|spec| { channels .iter() - .any(|channel| is_matching_starter_channel(channel, spec)) + .any(|channel| is_matching_starter_channel(channel, spec.name)) }) } @@ -251,10 +257,14 @@ async fn ensure_starter_channel_memberships( channels: &mut [ChannelInfo], changed_channel_ids: &mut Vec, ) -> Result<(), String> { - for spec in STARTER_CHANNELS { + let names = STARTER_CHANNELS + .iter() + .map(|spec| spec.name) + .chain(JOIN_ONLY_STARTER_CHANNEL_NAMES.iter().copied()); + for name in names { let Some(channel) = channels .iter_mut() - .find(|channel| is_matching_starter_channel(channel, spec)) + .find(|channel| is_matching_starter_channel(channel, name)) else { continue; }; @@ -417,7 +427,7 @@ async fn ensure_starter_channels_inner( for spec in STARTER_CHANNELS { if existing_channels .iter() - .any(|channel| is_matching_starter_channel(channel, spec)) + .any(|channel| is_matching_starter_channel(channel, spec.name)) { continue; } diff --git a/desktop/src-tauri/src/commands/channels/starter_tests.rs b/desktop/src-tauri/src/commands/channels/starter_tests.rs index 71bbbd0e894..65ada0383d4 100644 --- a/desktop/src-tauri/src/commands/channels/starter_tests.rs +++ b/desktop/src-tauri/src/commands/channels/starter_tests.rs @@ -6,19 +6,36 @@ use axum::{routing::post, Json, Router}; use nostr::Keys; use serde_json::json; -#[tokio::test] -async fn created_starter_channels_are_reported_when_metadata_is_unavailable() { - let _serial = crate::relay_admission::TEST_SERIAL.lock().await; - // The relay accepts every create, but its replica never serves metadata. +type Submitted = std::sync::Arc>>; + +/// A relay that accepts and records every submitted event but never serves +/// any from `/query`. +async fn accepting_relay() -> (String, Submitted, tokio::task::JoinHandle<()>) { + let submitted = Submitted::default(); + let captured = submitted.clone(); let router = Router::new() .route("/query", post(|| async { Json(json!([])) })) .route( "/events", - post(|| async { Json(json!({"event_id": "0", "accepted": true, "message": ""})) }), + post(move |Json(event): Json| { + let captured = captured.clone(); + async move { + captured.lock().unwrap().push(event); + Json(json!({"event_id": "0", "accepted": true, "message": ""})) + } + }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + (url, submitted, server) +} + +#[tokio::test] +async fn created_starter_channels_are_reported_when_metadata_is_unavailable() { + let _serial = crate::relay_admission::TEST_SERIAL.lock().await; + // The relay accepts every create, but its replica never serves metadata. + let (url, _submitted, server) = accepting_relay().await; let state = crate::app_state::build_app_state(); *state.keys.lock().unwrap() = Keys::generate(); *state.relay_url_override.lock().unwrap() = Some(url); @@ -38,3 +55,59 @@ async fn created_starter_channels_are_reported_when_metadata_is_unavailable() { .collect(); assert_eq!(changed, expected); } + +fn starter_test_channel(id: &str, name: &str, is_member: bool) -> ChannelInfo { + ChannelInfo { + id: id.to_string(), + name: name.to_string(), + channel_type: "stream".to_string(), + visibility: "open".to_string(), + description: String::new(), + topic: None, + purpose: None, + member_count: 0, + member_pubkeys: Vec::new(), + last_message_at: None, + archived_at: None, + participants: Vec::new(), + participant_pubkeys: Vec::new(), + is_member, + ttl_seconds: None, + ttl_deadline: None, + } +} + +#[tokio::test] +async fn existing_join_only_starter_channel_is_joined_never_created() { + let _serial = crate::relay_admission::TEST_SERIAL.lock().await; + let (url, submitted, server) = accepting_relay().await; + let state = crate::app_state::build_app_state(); + let keys = Keys::generate(); + *state.keys.lock().unwrap() = keys.clone(); + *state.relay_url_override.lock().unwrap() = Some(url); + + let announcements_id = "6d5d7b9e-0277-44e5-9448-dbc967a3cea9"; + let mut channels = vec![ + starter_test_channel("029f8049-8716-4407-94df-be8d1622b7af", "general", true), + starter_test_channel( + "fee88e92-da9f-5958-a910-48db3264ad38", + "welcome-everyone", + true, + ), + starter_test_channel(announcements_id, "Announcements", false), + starter_test_channel("e62570dd-33ad-42c5-b92b-75f2689f9694", "random", false), + ]; + let mut changed = Vec::new(); + let result = + ensure_starter_channel_memberships(&state, &keys, &mut channels, &mut changed).await; + server.abort(); + + assert_eq!(result, Ok(())); + assert_eq!(changed, vec![announcements_id.to_string()]); + assert!(channels[2].is_member); + assert!(!channels[3].is_member, "non-starter channels stay unjoined"); + let submitted = submitted.lock().unwrap(); + assert_eq!(submitted.len(), 1, "only the join is sent: {submitted:?}"); + assert_eq!(submitted[0]["kind"], json!(9021)); + assert_eq!(submitted[0]["tags"][0], json!(["h", announcements_id])); +} diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs index d31acb08f8f..dd0e6990813 100644 --- a/desktop/src-tauri/src/commands/channels_tests.rs +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -448,18 +448,18 @@ fn starter_match_requires_open_unarchived_stream_by_normalized_name() { ttl_deadline: None, }; - assert!(is_matching_starter_channel(&channel, spec)); + assert!(is_matching_starter_channel(&channel, spec.name)); channel.visibility = "private".to_string(); - assert!(!is_matching_starter_channel(&channel, spec)); + assert!(!is_matching_starter_channel(&channel, spec.name)); channel.visibility = "open".to_string(); channel.channel_type = "forum".to_string(); - assert!(!is_matching_starter_channel(&channel, spec)); + assert!(!is_matching_starter_channel(&channel, spec.name)); channel.channel_type = "stream".to_string(); channel.archived_at = Some("2026-07-16T00:00:00Z".to_string()); - assert!(!is_matching_starter_channel(&channel, spec)); + assert!(!is_matching_starter_channel(&channel, spec.name)); } #[test] diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 00544ac3f63..bc514b4ca59 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -56,6 +56,12 @@ const _starterChannels = [ ), ]; +/// Starter channels a new person joins only when the community already has +/// them. They are never created: whether a community has one, and how it is +/// configured (for example as an announce channel), is its owners' call. A +/// join takes the channel's `join_role`. Matches desktop. +const _joinOnlyStarterChannelNames = ['announcements']; + final _inviteRelayConnectedProvider = FutureProvider.family(( ref, expectedRelayUrl, @@ -170,6 +176,15 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { } } + for (final name in _joinOnlyStarterChannelNames) { + _ensureScopeCurrent(); + final channel = _findStarterChannel(channels, name); + if (channel != null && !channel.isMember) { + await _joinChannel(channel.id); + _ensureScopeCurrent(); + } + } + return welcomeEveryoneId; } diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index c92e13240ec..b4d1814fa77 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -278,6 +278,37 @@ void main() { }, ); + test( + 'invite recovery joins an existing announcements channel, never creates it', + () async { + final joined = []; + final recovery = MobileInviteJoinRecovery( + loadChannels: () async => [ + _channel(id: 'general-id', name: 'general', isMember: true), + _channel(id: 'welcome-id', name: 'welcome-everyone', isMember: true), + _channel(id: 'announcements-id', name: 'Announcements'), + _channel(id: 'random-id', name: 'random'), + ], + createChannel: + ({ + required channelId, + required name, + required channelType, + required visibility, + description, + ttlSeconds, + }) async => throw StateError('nothing should be created'), + joinChannel: (channelId) async => joined.add(channelId), + relayHttpOrigin: 'https://relay.example.com', + ); + + final focusChannelId = await recovery.ensureStarterChannels(); + + expect(joined, ['announcements-id']); + expect(focusChannelId, 'welcome-id'); + }, + ); + test( 'invite recovery creates missing public starters with desktop ids', () async {