Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions crates/buzz-db/src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
51 changes: 44 additions & 7 deletions crates/buzz-relay/src/handlers/side_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 =
Expand Down
154 changes: 154 additions & 0 deletions crates/buzz-test-client/tests/e2e_relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<serde_json::Value>(&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() {
Expand Down
15 changes: 15 additions & 0 deletions desktop/src/features/messages/lib/systemEventCopy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
10 changes: 10 additions & 0 deletions desktop/src/features/messages/lib/systemEventCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions desktop/src/features/messages/ui/SystemMessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
addedByActionPrefix,
describeChannelTextFieldChange,
describeChannelNameChange,
toInlineName,
} from "../lib/systemEventCopy";
import { MessageAgentOwner } from "./MessageAgentOwner";
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
Loading