mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-08-03 13:43:18 +00:00
feat: expose group name and description through the threaded client (#183)
A GroupV2 conversation carries a shared name and description in its MLS group context, set at creation and delivered to every joiner in the welcome. The threaded client could create groups but neither set that metadata nor read it back, leaving both fields unreachable through the client API. create_group_conversation now takes a GroupMetadata and creates the group with its name and description via create_group_convo_v2; both fields may be empty for an unnamed group. A new group_metadata getter reads a group's metadata back, erroring for a direct conversation or a legacy group that carries none. GroupMetadata is a distinct input type, kept separate from the ConvoMetadata a conversation reports back so the two can evolve independently; both are re-exported from the crate root and from logos-generic-chat.
This commit is contained in:
parent
37ae88bfa7
commit
0c51d4d0b8
@ -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};
|
||||
|
||||
@ -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<String>, desc: impl Into<String>) -> 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<ConversationId, ClientError> {
|
||||
let signers = self.signers_from_accounts(accounts)?;
|
||||
let signer_refs: Vec<IdentIdRef> = 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<ConvoMetadata, ClientError> {
|
||||
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<Vec<ConversationId>, ClientError> {
|
||||
self.core.lock().list_conversations().map_err(Into::into)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<Option<&str>> = 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, "");
|
||||
}
|
||||
|
||||
@ -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<Vec<u8>>,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user