diff --git a/core/conversations/src/lib.rs b/core/conversations/src/lib.rs index a4b10e6..4dadf8d 100644 --- a/core/conversations/src/lib.rs +++ b/core/conversations/src/lib.rs @@ -31,5 +31,5 @@ pub use service_context::ExternalServices; pub use service_traits::{DeliveryService, RegistrationService, WakeupService}; pub use shared_traits::{IdentId, IdentIdRef, IdentityProvider}; pub use storage::{ChatStore, ConversationKind}; -pub use types::AddressedEnvelope; +pub use types::{AddressedEnvelope, ConvoMetadata}; pub use utils::{hex_trunc, trunc}; diff --git a/crates/generic-chat/src/client.rs b/crates/generic-chat/src/client.rs index 21fdeac..a684ecd 100644 --- a/crates/generic-chat/src/client.rs +++ b/crates/generic-chat/src/client.rs @@ -6,8 +6,8 @@ use components::{ThreadedWakeupService, WakeupEvent}; use crossbeam_channel::{Receiver, Sender, select}; use crypto::Ed25519VerifyingKey; use libchat::{ - ConversationId, ConvoOutcome, Core, DeliveryService, GroupV2Config, IdentId, IdentIdRef, - InboxOutcome, PayloadOutcome, RegistrationService, + ConversationId, ConvoMetadata, ConvoOutcome, Core, DeliveryService, GroupV2Config, IdentId, + IdentIdRef, InboxOutcome, PayloadOutcome, RegistrationService, }; use logos_account::{AccountDirectory, resolve_device_ids}; use parking_lot::Mutex; @@ -34,6 +34,25 @@ pub struct GroupMember { pub local_identity: IdentId, } +/// Metadata a caller supplies when creating a group: its shared name and +/// description. Distinct from [`ConvoMetadata`], the type a conversation +/// reports back — the two carry different concerns and evolve independently +/// (the reported metadata may grow fields a caller cannot set). +#[derive(Debug, Clone)] +pub struct GroupMetadata { + pub name: String, + pub desc: String, +} + +impl GroupMetadata { + pub fn new(name: impl Into, desc: impl Into) -> Self { + Self { + name: name.into(), + desc: desc.into(), + } + } +} + /// The transport as the client sees it: a [`DeliveryService`] for outbound /// publishing plus the inbound payload stream the worker drains. One object owns /// both directions of the boundary. @@ -168,16 +187,20 @@ where /// account resolves to the signer ids its directory bundle endorses; the /// group invite goes to every one of them. An empty slice creates a group /// with only this client, to grow via [`Self::add_group_members`]. + /// `metadata` becomes the group's shared name and description, carried to + /// every joiner in the welcome and readable via [`Self::group_metadata`]; + /// both fields may be empty. pub fn create_group_conversation( &mut self, accounts: &[AccountAddressRef], + metadata: GroupMetadata, ) -> Result { let signers = self.signers_from_accounts(accounts)?; let signer_refs: Vec = signers.iter().collect(); self.core .lock() - .create_group_convo(&signer_refs) + .create_group_convo_v2(&signer_refs, &metadata.name, &metadata.desc) .map_err(Into::into) } @@ -213,6 +236,16 @@ where Ok(dedup_members(members)) } + /// The group's shared metadata (name and description), set at creation and + /// carried to every joiner in the welcome. Both fields may be empty. Fails + /// for a direct conversation and for a legacy group that carries no metadata. + pub fn group_metadata(&self, convo_id: &str) -> Result { + self.core + .lock() + .convo_metadata(convo_id) + .map_err(Into::into) + } + /// List all conversation IDs known to this client. pub fn list_conversations(&self) -> Result, ClientError> { self.core.lock().list_conversations().map_err(Into::into) diff --git a/crates/generic-chat/src/lib.rs b/crates/generic-chat/src/lib.rs index 6d7d14f..f3c42d3 100644 --- a/crates/generic-chat/src/lib.rs +++ b/crates/generic-chat/src/lib.rs @@ -6,7 +6,7 @@ mod errors; mod event; pub use builder::{ChatClientBuilder, Unset}; -pub use client::{ChatClient, GroupMember, Transport}; +pub use client::{ChatClient, GroupMember, GroupMetadata, Transport}; pub use delegate::DelegateSigner; pub use delivery_in_process::{InProcessDelivery, MessageBus}; pub use errors::ClientError; @@ -14,8 +14,8 @@ pub use event::{Event, MessageSender}; // Re-export types callers need to interact with ChatClient. pub use libchat::{ - AddressedEnvelope, ChatStore, ConversationClass, ConversationId, DeliveryService, - GroupV2Config, IdentityProvider, RegistrationService, StorageConfig, + AddressedEnvelope, ChatStore, ConversationClass, ConversationId, ConvoMetadata, + DeliveryService, GroupV2Config, IdentityProvider, RegistrationService, StorageConfig, }; // The directory trait bounds ChatClient's registry parameter, so callers // writing code generic over ChatClient need it too. diff --git a/crates/generic-chat/tests/group_v2.rs b/crates/generic-chat/tests/group_v2.rs index 42e2d3d..b664917 100644 --- a/crates/generic-chat/tests/group_v2.rs +++ b/crates/generic-chat/tests/group_v2.rs @@ -11,10 +11,15 @@ use crossbeam_channel::Receiver; use libchat::ChatStorage; use logos_account::TestLogosAccount; use logos_generic_chat::{ - ChatClient, ChatClientBuilder, ConversationClass, DelegateSigner, Event, GroupV2Config, - InProcessDelivery, MessageBus, + ChatClient, ChatClientBuilder, ConversationClass, DelegateSigner, Event, GroupMetadata, + GroupV2Config, InProcessDelivery, MessageBus, }; +/// Metadata for a group these tests create without a name or description. +fn unnamed_group() -> GroupMetadata { + GroupMetadata::new("", "") +} + /// Millisecond GroupV2 timers so the de-mls commit/consensus dance completes /// in test time; the library defaults wait 60s before committing an add. fn fast_group_v2_config() -> GroupV2Config { @@ -137,7 +142,7 @@ fn group_v2_three_members() { let (mut pax, pax_events, pax_addr) = create_test_client(bus.clone(), reg.clone()); let convo_id = saro - .create_group_conversation(&[&raya_addr]) + .create_group_conversation(&[&raya_addr], unnamed_group()) .expect("saro create group"); // The invite lands once saro's steward commit finalizes (wakeup-driven); @@ -221,7 +226,7 @@ fn peers_invited_to_many_groups() { let mut convo_ids = Vec::new(); for _ in 0..GROUPS { convo_ids.push( - saro.create_group_conversation(&[&raya_addr, &pax_addr]) + saro.create_group_conversation(&[&raya_addr, &pax_addr], unnamed_group()) .expect("saro create group"), ); } @@ -259,7 +264,9 @@ fn group_creator_is_in_own_roster() { let (mut saro, _saro_events, saro_addr) = create_test_client(bus.clone(), reg.clone()); - let convo_id = saro.create_group_conversation(&[]).expect("empty group"); + let convo_id = saro + .create_group_conversation(&[], unnamed_group()) + .expect("empty group"); let roster = saro.group_members(&convo_id).expect("group_members"); let accounts: Vec> = roster .iter() @@ -290,7 +297,7 @@ fn add_batch_with_missing_key_package_invites_no_one() { .unwrap(); let convo_id = saro - .create_group_conversation(&[&raya_addr]) + .create_group_conversation(&[&raya_addr], unnamed_group()) .expect("saro create group"); wait_for_group_started(&raya_events, "raya ConversationStarted"); @@ -320,14 +327,16 @@ fn group_invite_of_unpublished_account_is_an_error() { let unpublished = TestLogosAccount::new(); let err = saro - .create_group_conversation(&[&unpublished.address()]) + .create_group_conversation(&[&unpublished.address()], unnamed_group()) .expect_err("no bundle published for the account"); assert!(matches!( err, logos_generic_chat::ClientError::AccountResolution(_) )); - let convo_id = saro.create_group_conversation(&[]).expect("empty group"); + let convo_id = saro + .create_group_conversation(&[], unnamed_group()) + .expect("empty group"); let err = saro .add_group_members(&convo_id, &[&unpublished.address()]) .expect_err("no bundle published for the account"); @@ -336,3 +345,50 @@ fn group_invite_of_unpublished_account_is_an_error() { logos_generic_chat::ClientError::AccountResolution(_) )); } + +/// A group's name and description are set at creation and reach every joiner in +/// the welcome: the creator reads them back, and a joiner reads the same values +/// once its conversation starts. +#[test] +fn group_metadata_reaches_joiners() { + let bus = MessageBus::default(); + let reg = EphemeralRegistry::new(); + + let (mut saro, _saro_events, _saro_addr) = create_test_client(bus.clone(), reg.clone()); + let (raya, raya_events, raya_addr) = create_test_client(bus.clone(), reg.clone()); + + let convo_id = saro + .create_group_conversation( + &[&raya_addr], + GroupMetadata::new("Book Club", "Weekly reads"), + ) + .expect("saro create group"); + + let meta = saro.group_metadata(&convo_id).expect("creator metadata"); + assert_eq!(meta.name, "Book Club"); + assert_eq!(meta.desc, "Weekly reads"); + + let raya_convo_id = wait_for_group_started(&raya_events, "raya ConversationStarted"); + let meta = raya + .group_metadata(&raya_convo_id) + .expect("joiner metadata"); + assert_eq!(meta.name, "Book Club"); + assert_eq!(meta.desc, "Weekly reads"); +} + +/// A group created without a name or description reports empty metadata rather +/// than failing: both fields are optional. +#[test] +fn group_metadata_defaults_to_empty() { + let bus = MessageBus::default(); + let reg = EphemeralRegistry::new(); + + let (mut saro, _saro_events, _saro_addr) = create_test_client(bus.clone(), reg.clone()); + + let convo_id = saro + .create_group_conversation(&[], unnamed_group()) + .expect("empty group"); + let meta = saro.group_metadata(&convo_id).expect("creator metadata"); + assert_eq!(meta.name, ""); + assert_eq!(meta.desc, ""); +} diff --git a/crates/generic-chat/tests/saro_and_raya.rs b/crates/generic-chat/tests/saro_and_raya.rs index eca1b0e..01b954f 100644 --- a/crates/generic-chat/tests/saro_and_raya.rs +++ b/crates/generic-chat/tests/saro_and_raya.rs @@ -286,6 +286,24 @@ fn saro_raya_message_exchange() { assert_eq!(raya.list_conversations().unwrap().len(), 1); } +/// Group metadata is a group concept: a direct conversation has none, so the +/// fetch is an error callers must handle. +#[test] +fn group_metadata_on_direct_conversation_errors() { + let bus = MessageBus::default(); + let reg = EphemeralRegistry::new(); + + let (mut saro, _saro_events) = + create_test_client(bus.clone(), reg.clone()).expect("client create"); + let (raya, _raya_events) = create_test_client(bus.clone(), reg.clone()).expect("client create"); + + let convo_id = saro + .create_direct_conversation(raya.addr()) + .expect("convo create"); + saro.group_metadata(&convo_id) + .expect_err("direct conversation has no group metadata"); +} + #[derive(Debug)] struct FailingDelivery { inbound_tx: Sender>,