diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 8035ab58adb..fe4b89a6d8e 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -1267,6 +1267,51 @@ pub async fn update_channel( get_channel(pool, community_id, channel_id).await } +/// Atomically updates a channel name and returns `(previous_name, name)`. +/// +/// The row lock makes the pair suitable for an audit/system event: concurrent +/// renames observe each other's committed name rather than both reporting the +/// same stale previous value. +pub async fn update_channel_name( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + name: &str, +) -> Result<(String, String)> { + let name = buzz_core::channel::canonical_channel_name(name); + if name.is_empty() { + return Err(DbError::InvalidData("channel name is required".into())); + } + + let mut tx = pool.begin().await?; + let previous_name = sqlx::query_scalar::<_, String>( + "SELECT name FROM channels \ + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL \ + FOR UPDATE", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_optional(&mut *tx) + .await? + .ok_or(DbError::ChannelNotFound(channel_id))?; + + let result = sqlx::query( + "UPDATE channels SET name = $1, updated_at = NOW() \ + WHERE community_id = $2 AND id = $3 AND deleted_at IS NULL", + ) + .bind(name) + .bind(community_id.as_uuid()) + .bind(channel_id) + .execute(&mut *tx) + .await?; + if result.rows_affected() == 0 { + return Err(DbError::ChannelNotFound(channel_id)); + } + + tx.commit().await?; + Ok((previous_name, name.to_owned())) +} + /// Sets the topic for a channel, recording who set it and when. pub async fn set_topic( pool: &PgPool, diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 330525d310d..e3bd3e98cf0 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -2535,6 +2535,17 @@ impl Db { channel::update_channel(&self.pool, community_id, channel_id, updates).await } + /// Atomically updates a channel name and returns the previous and new names. + #[datastore_span(name = "update_channel_name", system = "postgresql")] + pub async fn update_channel_name( + &self, + community_id: CommunityId, + channel_id: Uuid, + name: &str, + ) -> Result<(String, String)> { + channel::update_channel_name(&self.pool, community_id, channel_id, name).await + } + /// Sets the topic for a channel. #[datastore_span(name = "set_topic", system = "postgresql")] pub async fn set_topic( diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 0dc6cbd5039..2a440a29f9d 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1433,6 +1433,19 @@ async fn handle_remove_user( Ok(()) } +fn should_emit_channel_name_change(previous_name: &str, name: &str) -> bool { + previous_name != name +} + +fn channel_name_change_content(actor: &str, previous_name: &str, name: &str) -> serde_json::Value { + serde_json::json!({ + "type": "name_changed", + "actor": actor, + "name": name, + "previous_name": previous_name, + }) +} + async fn handle_edit_metadata( tenant: &TenantContext, event: &Event, @@ -1448,17 +1461,26 @@ async fn handle_edit_metadata( if let Some(val) = tag.content() { match key.as_str() { "name" => { - state + let (previous_channel_name, updated_channel_name) = state .db - .update_channel( - tenant.community(), + .update_channel_name(tenant.community(), channel_id, val) + .await?; + if should_emit_channel_name_change( + &previous_channel_name, + &updated_channel_name, + ) { + emit_system_message( + tenant, + state, channel_id, - buzz_db::channel::ChannelUpdate { - name: Some(val.to_string()), - ..Default::default() - }, + channel_name_change_content( + &actor_hex, + &previous_channel_name, + &updated_channel_name, + ), ) .await?; + } } "about" => { state @@ -3405,6 +3427,21 @@ mod tests { })); } + #[test] + fn channel_name_change_carries_actor_and_both_names() { + let content = channel_name_change_content("actor", "old-name", "new-name"); + + assert_eq!(content["type"], "name_changed"); + assert_eq!(content["actor"], "actor"); + assert_eq!(content["previous_name"], "old-name"); + assert_eq!(content["name"], "new-name"); + } + + #[test] + fn channel_name_change_is_not_emitted_when_stored_names_match() { + assert!(!should_emit_channel_name_change("same-name", "same-name")); + } + #[test] fn delete_tombstone_omits_absent_moderation_metadata() { let content = diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 5d5ad8916c3..4759094c3e4 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -206,6 +206,160 @@ async fn create_test_channel(keys: &Keys) -> String { channel_uuid.to_string() } +/// A successful kind:9002 name edit emits one relay-signed kind:40099 event, +/// delivers it to live subscribers, and persists it for later subscriptions. +#[tokio::test] +#[ignore] +async fn test_channel_rename_emits_persistent_system_message() { + let url = relay_url(); + let owner_keys = Keys::generate(); + let channel = create_test_channel(&owner_keys).await; + let mut client = BuzzTestClient::connect(&url, &owner_keys) + .await + .expect("connect as channel owner"); + + let live_sid = sub_id("channel-rename-live"); + let filter = Filter::new() + .kind(Kind::Custom(40099)) + .custom_tags(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]); + client + .subscribe(&live_sid, vec![filter.clone()]) + .await + .expect("subscribe to system messages"); + client + .collect_until_eose(&live_sid, Duration::from_secs(5)) + .await + .expect("system message EOSE"); + + let new_name = format!("renamed-{}", Uuid::new_v4()); + let rename = EventBuilder::new(Kind::Custom(9002), "") + .tags([ + Tag::parse(["h", &channel]).expect("h tag"), + Tag::parse(["name", &new_name]).expect("name tag"), + ]) + .sign_with_keys(&owner_keys) + .expect("sign rename event"); + let ok = client.send_event(rename).await.expect("send rename event"); + assert!(ok.accepted, "rename rejected: {}", ok.message); + + let live_event = loop { + match client + .recv_event(Duration::from_secs(5)) + .await + .expect("receive live system message") + { + RelayMessage::Event { event, .. } if event.kind == Kind::Custom(40099) => { + break event; + } + _ => {} + } + }; + buzz_core::verify_event(&live_event).expect("system message signature"); + let live_content: serde_json::Value = + serde_json::from_str(&live_event.content).expect("system message JSON"); + assert_eq!(live_content["type"], "name_changed"); + assert_eq!(live_content["actor"], owner_keys.public_key().to_hex()); + assert_eq!( + live_content["previous_name"], + format!("relay-e2e-{channel}") + ); + assert_eq!(live_content["name"], new_name); + + let persisted_sid = sub_id("channel-rename-persisted"); + client + .subscribe(&persisted_sid, vec![filter.clone()]) + .await + .expect("subscribe for persisted system message"); + let persisted = client + .collect_until_eose(&persisted_sid, Duration::from_secs(5)) + .await + .expect("persisted system message EOSE"); + assert_eq!( + persisted + .iter() + .filter(|event| event.id == live_event.id) + .count(), + 1, + "rename system message should be persisted exactly once" + ); + + // A display-equivalent rename is accepted and stored canonically, but it + // must not emit another system message. + let equivalent_name = format!(" ###{new_name} "); + let no_op_rename = EventBuilder::new(Kind::Custom(9002), "") + .tags([ + Tag::parse(["h", &channel]).expect("h tag"), + Tag::parse(["name", &equivalent_name]).expect("name tag"), + ]) + .sign_with_keys(&owner_keys) + .expect("sign canonical no-op rename event"); + let no_op = client + .send_event(no_op_rename) + .await + .expect("send canonical no-op rename event"); + assert!( + no_op.accepted, + "canonical no-op rename rejected: {}", + no_op.message + ); + + match client.recv_event(Duration::from_millis(500)).await { + Err(TestClientError::Timeout) => {} + Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(40099) => { + panic!("canonical no-op rename emitted system message {}", event.id) + } + Ok(other) => panic!("unexpected relay message after canonical no-op rename: {other:?}"), + Err(error) => panic!("unexpected receive error after canonical no-op rename: {error}"), + } + + let no_op_persisted_sid = sub_id("channel-rename-no-op-persisted"); + client + .subscribe(&no_op_persisted_sid, vec![filter]) + .await + .expect("subscribe after canonical no-op rename"); + let persisted_after_no_op = client + .collect_until_eose(&no_op_persisted_sid, Duration::from_secs(5)) + .await + .expect("canonical no-op persistence EOSE"); + let name_change_count = persisted_after_no_op + .iter() + .filter(|event| { + serde_json::from_str::(&event.content) + .is_ok_and(|content| content["type"] == "name_changed") + }) + .count(); + assert_eq!( + name_change_count, 1, + "canonical no-op rename should not persist another system message" + ); + + // Invalid names are rejected before the metadata side effect runs, so a + // failed update must not produce a second system message. + let invalid_rename = EventBuilder::new(Kind::Custom(9002), "") + .tags([ + Tag::parse(["h", &channel]).expect("h tag"), + Tag::parse(["name", "### "]).expect("name tag"), + ]) + .sign_with_keys(&owner_keys) + .expect("sign invalid rename event"); + let rejected = client + .send_event(invalid_rename) + .await + .expect("send invalid rename event"); + assert!(!rejected.accepted, "invalid rename should be rejected"); + + match client.recv_event(Duration::from_millis(500)).await { + Err(TestClientError::Timeout) => {} + Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(40099) => { + panic!("failed rename emitted system message {}", event.id) + } + Ok(other) => panic!("unexpected relay message after failed rename: {other:?}"), + Err(error) => panic!("unexpected receive error after failed rename: {error}"), + } + + client.disconnect().await.expect("disconnect"); +} + #[tokio::test] #[ignore] async fn test_connect_and_authenticate() { diff --git a/desktop/src/features/messages/lib/systemEventCopy.test.mjs b/desktop/src/features/messages/lib/systemEventCopy.test.mjs index 417685d47e0..3a7663a192b 100644 --- a/desktop/src/features/messages/lib/systemEventCopy.test.mjs +++ b/desktop/src/features/messages/lib/systemEventCopy.test.mjs @@ -4,9 +4,24 @@ import test from "node:test"; import { addedByActionPrefix, describeChannelTextFieldChange, + describeChannelNameChange, toInlineName, } from "./systemEventCopy.ts"; +test("a channel rename names both the old and new names", () => { + assert.equal( + describeChannelNameChange("old-name", "new-name"), + "renamed the channel from “old-name” to “new-name”", + ); +}); + +test("a channel rename falls back to the new name when the old name is unavailable", () => { + assert.equal( + describeChannelNameChange(undefined, "new-name"), + "renamed the channel to “new-name”", + ); +}); + test("an add to the reader uses passive wording", () => { assert.equal(addedByActionPrefix(true), "were added by"); assert.equal(addedByActionPrefix(false), "added by"); diff --git a/desktop/src/features/messages/lib/systemEventCopy.ts b/desktop/src/features/messages/lib/systemEventCopy.ts index 99f7316165f..94dbf8df9b8 100644 --- a/desktop/src/features/messages/lib/systemEventCopy.ts +++ b/desktop/src/features/messages/lib/systemEventCopy.ts @@ -14,6 +14,16 @@ const CLOSE_QUOTE = "”"; export type ChannelTextField = "topic" | "purpose"; +export function describeChannelNameChange( + previousName: string | undefined, + name: string, +): string { + if (previousName === undefined) { + return `renamed the channel to ${OPEN_QUOTE}${name}${CLOSE_QUOTE}`; + } + return `renamed the channel from ${OPEN_QUOTE}${previousName}${CLOSE_QUOTE} to ${OPEN_QUOTE}${name}${CLOSE_QUOTE}`; +} + /** * The reader is the recipient of an add, while every other member is the * subject of one. Keep that distinction in the caption: "You were added by" diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index 1da16e437fc..26929dd8be6 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -31,6 +31,7 @@ import { UserAvatar } from "@/shared/ui/UserAvatar"; import { addedByActionPrefix, describeChannelTextFieldChange, + describeChannelNameChange, toInlineName, } from "../lib/systemEventCopy"; import { MessageAgentOwner } from "./MessageAgentOwner"; @@ -51,6 +52,8 @@ type SystemMessagePayload = { targets?: string[]; topic?: string; purpose?: string; + previous_name?: string; + name?: string; // Moderation tombstone fields (kind:40099 "message_deleted"). All optional and // moderator-authored — present when a moderator removed the message, absent for // a plain member self-delete. Reporter identity/evidence never appears here. @@ -580,6 +583,18 @@ function describeSystemEvent( title: actorName, action: describeChannelTextFieldChange("purpose", payload.purpose), }; + case "name_changed": + if ( + typeof payload.name !== "string" || + (payload.previous_name !== undefined && + typeof payload.previous_name !== "string") + ) { + return null; + } + return { + title: actorName, + action: describeChannelNameChange(payload.previous_name, payload.name), + }; case "channel_created": return { title: actorName, diff --git a/mobile/lib/features/channels/timeline_message.dart b/mobile/lib/features/channels/timeline_message.dart index 411902ea863..e703ad6f82b 100644 --- a/mobile/lib/features/channels/timeline_message.dart +++ b/mobile/lib/features/channels/timeline_message.dart @@ -12,6 +12,7 @@ enum SystemEventType { memberRemoved, topicChanged, purposeChanged, + nameChanged, channelCreated, channelArchived, channelUnarchived, @@ -26,6 +27,8 @@ class SystemEvent { final String? targetPubkey; final String? topic; final String? purpose; + final String? previousName; + final String? name; const SystemEvent({ required this.type, @@ -33,6 +36,8 @@ class SystemEvent { this.targetPubkey, this.topic, this.purpose, + this.previousName, + this.name, }); /// Parse a system event from the JSON content of a kind-40099 event. @@ -55,6 +60,7 @@ class SystemEvent { 'member_removed' => SystemEventType.memberRemoved, 'topic_changed' => SystemEventType.topicChanged, 'purpose_changed' => SystemEventType.purposeChanged, + 'name_changed' => SystemEventType.nameChanged, 'channel_created' => SystemEventType.channelCreated, 'channel_archived' => SystemEventType.channelArchived, 'channel_unarchived' => SystemEventType.channelUnarchived, @@ -63,12 +69,22 @@ class SystemEvent { if (type == null) return null; + final previousName = _readString(json, 'previous_name'); + final name = _readString(json, 'name'); + if (type == SystemEventType.nameChanged && + (name == null || + (json.containsKey('previous_name') && previousName == null))) { + return null; + } + return SystemEvent( type: type, actorPubkey: _readString(json, 'actor'), targetPubkey: _readString(json, 'target'), topic: _readString(json, 'topic'), purpose: _readString(json, 'purpose'), + previousName: previousName, + name: name, ); } @@ -105,6 +121,10 @@ class SystemEvent { '$actor ${_describeTextFieldChange('topic', topic)}', SystemEventType.purposeChanged => '$actor ${_describeTextFieldChange('purpose', purpose)}', + SystemEventType.nameChanged => + previousName == null + ? '$actor renamed the channel to "$name"' + : '$actor renamed the channel from "$previousName" to "$name"', SystemEventType.channelCreated => '$actor created this channel', SystemEventType.channelArchived => '$actor archived this channel', SystemEventType.channelUnarchived => '$actor unarchived this channel', diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index fbb12f71771..4f9e29a4cf0 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -3270,6 +3270,63 @@ void main() { ); }); + testWidgets('renders name_changed system event', (tester) async { + final messages = [ + _systemMsg( + id: 'sys1', + payload: { + 'type': 'name_changed', + 'actor': 'alice', + 'previous_name': 'old-name', + 'name': 'new-name', + }, + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: messages, + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + expect( + find.text('Alice renamed the channel from "old-name" to "new-name"'), + findsOneWidget, + ); + }); + + testWidgets('renders name_changed without a previous name', (tester) async { + final messages = [ + _systemMsg( + id: 'sys1', + payload: { + 'type': 'name_changed', + 'actor': 'alice', + 'name': 'new-name', + }, + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: messages, + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + expect( + find.text('Alice renamed the channel to "new-name"'), + findsOneWidget, + ); + }); + testWidgets('system message breaks author grouping', (tester) async { final messages = [ _textMsg( diff --git a/mobile/test/features/channels/timeline_message_test.dart b/mobile/test/features/channels/timeline_message_test.dart index 3dfaf184c1d..df95c2a788b 100644 --- a/mobile/test/features/channels/timeline_message_test.dart +++ b/mobile/test/features/channels/timeline_message_test.dart @@ -185,6 +185,57 @@ void main() { } }); + test('parses channel name changes', () { + final event = SystemEvent.fromContent( + jsonEncode({ + 'type': 'name_changed', + 'actor': 'pk1', + 'previous_name': 'old-name', + 'name': 'new-name', + }), + ); + + expect(event, isNotNull); + expect(event!.type, SystemEventType.nameChanged); + expect(event.actorPubkey, 'pk1'); + expect(event.previousName, 'old-name'); + expect(event.name, 'new-name'); + }); + + test('parses a channel name change without a previous name', () { + final event = SystemEvent.fromContent( + jsonEncode({ + 'type': 'name_changed', + 'actor': 'pk1', + 'name': 'new-name', + }), + ); + + expect(event, isNotNull); + expect(event!.previousName, isNull); + expect(event.name, 'new-name'); + }); + + test('rejects malformed channel name changes', () { + expect( + SystemEvent.fromContent( + jsonEncode({'type': 'name_changed', 'actor': 'pk1'}), + ), + isNull, + ); + expect( + SystemEvent.fromContent( + jsonEncode({ + 'type': 'name_changed', + 'actor': 'pk1', + 'previous_name': 123, + 'name': 'new-name', + }), + ), + isNull, + ); + }); + test('returns null for unknown type', () { final event = SystemEvent.fromContent( jsonEncode({'type': 'unknown_type'}), @@ -335,6 +386,31 @@ void main() { ); }); + test('name_changed', () { + final event = SystemEvent( + type: SystemEventType.nameChanged, + actorPubkey: 'pk1', + previousName: 'old-name', + name: 'new-name', + ); + expect( + event.describe(resolve), + 'Alice renamed the channel from "old-name" to "new-name"', + ); + }); + + test('name_changed without previous name', () { + final event = SystemEvent( + type: SystemEventType.nameChanged, + actorPubkey: 'pk1', + name: 'new-name', + ); + expect( + event.describe(resolve), + 'Alice renamed the channel to "new-name"', + ); + }); + test('channel_created', () { final event = SystemEvent( type: SystemEventType.channelCreated,