Skip to main content

xmtp_mls/groups/
metadata.rs

1//! Mutable metadata, permissions, and group settings.
2
3use super::*;
4
5impl<Context> MlsGroup<Context>
6where
7    Context: XmtpSharedContext,
8{
9    /// Updates the name of the group. Will error if the user does not have the appropriate permissions
10    /// to perform these updates.
11    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
12    #[cfg_attr(
13        not(any(test, feature = "test-utils")),
14        tracing::instrument(level = "trace", skip(self))
15    )]
16    pub async fn update_group_name(&self, group_name: String) -> Result<(), GroupError> {
17        self.ensure_not_paused().await?;
18
19        if group_name.len() > MAX_GROUP_NAME_LENGTH {
20            return Err(GroupError::TooManyCharacters {
21                length: MAX_GROUP_NAME_LENGTH,
22            });
23        }
24        if self.metadata().await?.conversation_type == ConversationType::Dm {
25            return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into());
26        }
27        let intent_data: Vec<u8> =
28            UpdateMetadataIntentData::new_update_group_name(group_name).into();
29        let intent = QueueIntent::metadata_update()
30            .data(intent_data)
31            .queue(self)?;
32
33        let _ = self.sync_until_intent_resolved(intent.id).await?;
34        Ok(())
35    }
36
37    /// Set the group's opaque `app_data` slot.
38    ///
39    /// `expected_app_data` is an optional compare-and-swap guard. When
40    /// `Some`, the update is abandoned with [`GroupError::AppDataSuperseded`]
41    /// unless the committed value still equals it — including when another
42    /// member's commit wins the epoch race *after* this intent was published.
43    /// Callers reconciling structured state should pass the value they merged
44    /// against, so a concurrent write is reported rather than overwritten.
45    ///
46    /// `None` keeps the historical last-writer-wins behavior: whatever landed
47    /// in the meantime is overwritten.
48    // implements: META-065
49    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
50    #[cfg_attr(
51        not(any(test, feature = "test-utils")),
52        tracing::instrument(level = "trace", skip(self))
53    )]
54    pub async fn update_app_data(
55        &self,
56        app_data: String,
57        expected_app_data: Option<String>,
58    ) -> Result<(), GroupError> {
59        self.ensure_not_paused().await?;
60
61        if app_data.len() > MAX_APP_DATA_LENGTH {
62            return Err(GroupError::TooManyCharacters {
63                length: MAX_APP_DATA_LENGTH,
64            });
65        }
66        if self.metadata().await?.conversation_type == ConversationType::Dm {
67            return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into());
68        }
69
70        // Fail the already-stale case before touching the network. The
71        // authoritative check runs again at publish time, which is what
72        // catches a change that lands between here and the commit.
73        if let Some(expected) = &expected_app_data {
74            // Read the slot itself rather than going through `app_data()`: a
75            // group whose `app_data` has never been set is a legitimate "not
76            // what you expected", and reporting it as `MissingExtension` would
77            // hand the caller an error where it asked a question. A genuine
78            // read failure still propagates — an unreadable group is not
79            // evidence that someone else wrote the field.
80            let actual = self.read_single_component::<AppDataComponent>()?;
81            if actual.as_deref() != Some(expected.as_str()) {
82                return Err(GroupError::AppDataSuperseded {
83                    expected: expected.clone(),
84                    // An unset slot reports as empty. The guard cannot yet
85                    // *express* "I expect this to be unset" — the intent's
86                    // `expected_field_value` is an optional string, where
87                    // absent already means "no guard" — so an unset slot can
88                    // only ever be a mismatch here, never an expectation.
89                    actual: actual.unwrap_or_default(),
90                });
91            }
92        }
93
94        let intent_data: Vec<u8> =
95            UpdateMetadataIntentData::new_update_app_data(app_data, expected_app_data.clone())
96                .into();
97        let intent = QueueIntent::metadata_update()
98            .data(intent_data)
99            .queue(self)?;
100
101        match self.sync_until_intent_resolved(intent.id).await {
102            Ok(_) => Ok(()),
103            Err(err) => {
104                // A guarded intent that lost the race is marked `Superseded`
105                // rather than `Error`; translate it into the typed error so a
106                // stale write is distinguishable from a genuine sync failure.
107                if let Some(expected) = expected_app_data
108                    && matches!(
109                        self.context.db().fetch(&intent.id),
110                        Ok(Some(StoredGroupIntent {
111                            state: IntentState::Superseded,
112                            ..
113                        }))
114                    )
115                {
116                    // Superseded is only set after the publish path read the
117                    // committed value successfully, so this read should too;
118                    // if it somehow fails, that error is the honest one.
119                    return Err(GroupError::AppDataSuperseded {
120                        expected,
121                        actual: self.app_data()?,
122                    });
123                }
124                Err(err)
125            }
126        }
127    }
128
129    /// Updates min version of the group to match this client's version.
130    /// Not publicly exposed because:
131    /// - Setting the min version to pre-release versions may not behave as expected
132    /// - When the version is not explicitly specified, unexpected behavior may arise,
133    ///   for example if the code is left in across multiple version bumps.
134    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
135    #[cfg_attr(
136        not(any(test, feature = "test-utils")),
137        tracing::instrument(level = "trace", skip(self))
138    )]
139    #[allow(dead_code)]
140    pub(crate) async fn update_group_min_version_to_match_self(&self) -> Result<(), GroupError> {
141        let version = self.context.version_info().pkg_version();
142        self.update_group_min_version(version).await
143    }
144
145    /// Updates min version of the group to match the given version.
146    ///
147    /// # Arguments
148    /// * `version` - The libxmtp version to update the group min version to.
149    ///   This is a semver-formatted string matching the Cargo.toml in the
150    ///   libxmtp dependency, and does not match mobile or web release versions.
151    ///   Comparison is done via the [`semver`] crate's `Ord` impl, so
152    ///   pre-release identifiers (e.g. `"1.0.0-rc.1"`) sort BEFORE the
153    ///   corresponding release (`"1.0.0"`) per semver 2.0 §11. Build
154    ///   metadata (`+...`) parses but is included in ordering by the
155    ///   semver crate — avoid passing it unless you understand the
156    ///   total-ordering implication.
157    ///
158    /// # Returns
159    /// A `Result` indicating success or failure of the operation.
160    pub async fn update_group_min_version(&self, version: &str) -> Result<(), GroupError> {
161        self.ensure_not_paused().await?;
162
163        // Footgun guards (apply on send side; receive side enforces
164        // the same monotonicity invariant as the source of truth):
165        //
166        // 1. `version > own pkg_version` would pause this client (and
167        //    every peer at or below this version) the moment the bump
168        //    lands. Refuse.
169        // 2. `version < current floor` would silently unpause peers
170        //    between the new and old floors, defeating the gate. Refuse.
171        //    Lenient on an unparseable current floor — mirror the
172        //    receive-side behavior in `enforce_min_version_monotonicity`
173        //    rather than have the send-side refuse where the receive-
174        //    side accepts. (Brick recovery: a group with malformed
175        //    legacy GMM bytes shouldn't be permanently un-bumpable.)
176        let target_v =
177            LibXMTPVersion::parse(version).map_err(|e| GroupError::InvalidMinVersion {
178                value: version.to_string(),
179                reason: e.to_string(),
180            })?;
181        let own_version_str = self.context.version_info().pkg_version().to_string();
182        let own_v =
183            LibXMTPVersion::parse(&own_version_str).map_err(|e| GroupError::InvalidMinVersion {
184                value: own_version_str.clone(),
185                reason: format!("own pkg_version: {e}"),
186            })?;
187        if target_v > own_v {
188            return Err(GroupError::MinVersionExceedsOwnVersion {
189                requested: version.to_string(),
190                own: own_version_str,
191            });
192        }
193        let current_str = self
194            .live_mutable_metadata()?
195            .attributes
196            .get(MetadataField::MinimumSupportedProtocolVersion.as_str())
197            .cloned();
198        if let Some(current_str) = current_str.as_deref()
199            && !current_str.is_empty()
200        {
201            match LibXMTPVersion::parse(current_str) {
202                Ok(current_v) => {
203                    if target_v < current_v {
204                        return Err(GroupError::MinVersionDowngrade {
205                            requested: version.to_string(),
206                            current: current_str.to_string(),
207                        });
208                    }
209                }
210                Err(e) => {
211                    // Observability: malformed prior floor is an operator-
212                    // visible signal that the legacy GMM bytes are corrupt.
213                    // Leniency below preserves brick-recovery; the warning
214                    // surfaces the corruption.
215                    tracing::warn!(
216                        current = %current_str,
217                        error = %e,
218                        "update_group_min_version: existing min_version is unparseable; \
219                         proceeding without downgrade check"
220                    );
221                }
222            }
223        }
224
225        tracing::info!("update_group_min_version: queuing bump to {}", version);
226        let intent_data: Vec<u8> =
227            UpdateMetadataIntentData::new_update_group_min_version_to_match_self(
228                version.to_string(),
229            )
230            .into();
231        let intent = QueueIntent::metadata_update()
232            .data(intent_data)
233            .queue(self)?;
234
235        let _ = self.sync_until_intent_resolved(intent.id).await?;
236        Ok(())
237    }
238
239    /// Updates the commit log signer of the group. Will error if the user does not have the appropriate permissions
240    /// to perform these updates.
241    pub async fn update_commit_log_signer(
242        &self,
243        commit_log_signer: xmtp_cryptography::Secret,
244    ) -> Result<(), GroupError> {
245        self.ensure_not_paused().await?;
246
247        if self.metadata().await?.conversation_type == ConversationType::Dm {
248            return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into());
249        }
250        let intent_data: Vec<u8> =
251            UpdateMetadataIntentData::new_update_commit_log_signer(commit_log_signer).into();
252        let intent = QueueIntent::metadata_update()
253            .data(intent_data)
254            .queue(self)?;
255
256        let _ = self.sync_until_intent_resolved(intent.id).await?;
257        Ok(())
258    }
259
260    pub(in crate::groups) fn min_protocol_version_from_extensions(
261        mutable_metadata: &GroupMutableMetadata,
262    ) -> Option<String> {
263        mutable_metadata
264            .attributes
265            .get(&MetadataField::MinimumSupportedProtocolVersion.to_string())
266            .map(|v| v.to_string())
267    }
268
269    /// Updates the permission policy of the group. This requires super admin permissions.
270    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
271    #[cfg_attr(
272        not(any(test, feature = "test-utils")),
273        tracing::instrument(level = "trace", skip(self))
274    )]
275    pub async fn update_permission_policy(
276        &self,
277        permission_update_type: PermissionUpdateType,
278        permission_policy: PermissionPolicyOption,
279        metadata_field: Option<MetadataField>,
280    ) -> Result<(), GroupError> {
281        self.ensure_not_paused().await?;
282
283        if self.metadata().await?.conversation_type == ConversationType::Dm {
284            return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into());
285        }
286        if permission_update_type == PermissionUpdateType::UpdateMetadata
287            && metadata_field.is_none()
288        {
289            return Err(MetadataPermissionsError::InvalidPermissionUpdate.into());
290        }
291
292        if matches!(
293            permission_update_type,
294            PermissionUpdateType::AddAdmin | PermissionUpdateType::RemoveAdmin
295        ) && permission_policy == PermissionPolicyOption::Allow
296        {
297            return Err(MetadataPermissionsError::InvalidPermissionUpdate.into());
298        }
299
300        let intent_data: Vec<u8> = UpdatePermissionIntentData::new(
301            permission_update_type,
302            permission_policy,
303            metadata_field.as_ref().map(|field| field.to_string()),
304        )
305        .into();
306
307        let intent = QueueIntent::update_permission()
308            .data(intent_data)
309            .queue(self)?;
310
311        let _ = self.sync_until_intent_resolved(intent.id).await?;
312        Ok(())
313    }
314
315    /// Retrieves the group name from the group's mutable metadata extension.
316    pub fn group_name(&self) -> Result<String, GroupError> {
317        Ok(self
318            .read_single_component::<GroupNameComponent>()?
319            .unwrap_or_default())
320    }
321
322    /// Retrieves the app_data field from the group's mutable metadata extension
323    pub fn app_data(&self) -> Result<String, GroupError> {
324        Ok(self
325            .read_single_component::<AppDataComponent>()?
326            .unwrap_or_default())
327    }
328
329    /// Updates the description of the group.
330    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
331    #[cfg_attr(
332        not(any(test, feature = "test-utils")),
333        tracing::instrument(level = "trace", skip(self))
334    )]
335    pub async fn update_group_description(
336        &self,
337        group_description: String,
338    ) -> Result<(), GroupError> {
339        self.ensure_not_paused().await?;
340
341        if group_description.len() > MAX_GROUP_DESCRIPTION_LENGTH {
342            return Err(GroupError::TooManyCharacters {
343                length: MAX_GROUP_DESCRIPTION_LENGTH,
344            });
345        }
346
347        if self.metadata().await?.conversation_type == ConversationType::Dm {
348            return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into());
349        }
350        let intent_data: Vec<u8> =
351            UpdateMetadataIntentData::new_update_group_description(group_description).into();
352        let intent = QueueIntent::metadata_update()
353            .data(intent_data)
354            .queue(self)?;
355
356        let _ = self.sync_until_intent_resolved(intent.id).await?;
357        Ok(())
358    }
359
360    pub fn group_description(&self) -> Result<String, GroupError> {
361        Ok(self
362            .read_single_component::<GroupDescriptionComponent>()?
363            .unwrap_or_default())
364    }
365
366    /// Updates the image URL (square) of the group.
367    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
368    #[cfg_attr(
369        not(any(test, feature = "test-utils")),
370        tracing::instrument(level = "trace", skip(self))
371    )]
372    pub async fn update_group_image_url_square(
373        &self,
374        group_image_url_square: String,
375    ) -> Result<(), GroupError> {
376        self.ensure_not_paused().await?;
377
378        if group_image_url_square.len() > MAX_GROUP_IMAGE_URL_LENGTH {
379            return Err(GroupError::TooManyCharacters {
380                length: MAX_GROUP_IMAGE_URL_LENGTH,
381            });
382        }
383
384        if self.metadata().await?.conversation_type == ConversationType::Dm {
385            return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into());
386        }
387        let intent_data: Vec<u8> =
388            UpdateMetadataIntentData::new_update_group_image_url_square(group_image_url_square)
389                .into();
390        let intent = QueueIntent::metadata_update()
391            .data(intent_data)
392            .queue(self)?;
393
394        let _ = self.sync_until_intent_resolved(intent.id).await?;
395        Ok(())
396    }
397
398    /// Retrieves the image URL (square) of the group from the group's mutable metadata extension.
399    pub fn group_image_url_square(&self) -> Result<String, GroupError> {
400        Ok(self
401            .read_single_component::<GroupImageUrlComponent>()?
402            .unwrap_or_default())
403    }
404
405    pub async fn update_conversation_message_disappearing_settings(
406        &self,
407        settings: MessageDisappearingSettings,
408    ) -> Result<(), GroupError> {
409        self.ensure_not_paused().await?;
410
411        self.update_conversation_message_disappear_from_ns(settings.from_ns)
412            .await?;
413        self.update_conversation_message_disappear_in_ns(settings.in_ns)
414            .await
415    }
416
417    pub async fn remove_conversation_message_disappearing_settings(
418        &self,
419    ) -> Result<(), GroupError> {
420        self.ensure_not_paused().await?;
421
422        self.update_conversation_message_disappearing_settings(
423            MessageDisappearingSettings::default(),
424        )
425        .await
426    }
427
428    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
429    #[cfg_attr(
430        not(any(test, feature = "test-utils")),
431        tracing::instrument(level = "trace", skip(self))
432    )]
433    pub(in crate::groups) async fn update_conversation_message_disappear_from_ns(
434        &self,
435        expire_from_ms: i64,
436    ) -> Result<(), GroupError> {
437        self.ensure_not_paused().await?;
438
439        let intent_data: Vec<u8> =
440            UpdateMetadataIntentData::new_update_conversation_message_disappear_from_ns(
441                expire_from_ms,
442            )
443            .into();
444        let intent = QueueIntent::metadata_update()
445            .data(intent_data)
446            .queue(self)?;
447        let _ = self.sync_until_intent_resolved(intent.id).await?;
448        Ok(())
449    }
450
451    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
452    #[cfg_attr(
453        not(any(test, feature = "test-utils")),
454        tracing::instrument(level = "trace", skip(self))
455    )]
456    pub(in crate::groups) async fn update_conversation_message_disappear_in_ns(
457        &self,
458        expire_in_ms: i64,
459    ) -> Result<(), GroupError> {
460        self.ensure_not_paused().await?;
461
462        let intent_data: Vec<u8> =
463            UpdateMetadataIntentData::new_update_conversation_message_disappear_in_ns(expire_in_ms)
464                .into();
465        let intent = QueueIntent::metadata_update()
466            .data(intent_data)
467            .queue(self)?;
468        let _ = self.sync_until_intent_resolved(intent.id).await?;
469        Ok(())
470    }
471
472    /// If group is not paused, will return None, otherwise will return the version that the group is paused for
473    pub fn paused_for_version(&self) -> Result<Option<String>, GroupError> {
474        let paused_for_version = self.context.db().get_group_paused_version(&self.group_id)?;
475        Ok(paused_for_version)
476    }
477
478    #[tracing::instrument(skip_all, level = "trace")]
479    pub(in crate::groups) async fn ensure_not_paused(&self) -> Result<(), GroupError> {
480        if let Some(min_version) = self.context.db().get_group_paused_version(&self.group_id)? {
481            Err(GroupError::GroupPausedUntilUpdate(min_version))
482        } else {
483            Ok(())
484        }
485    }
486
487    pub fn conversation_message_disappearing_settings(
488        &self,
489    ) -> Result<MessageDisappearingSettings, GroupError> {
490        let stored = self
491            .context
492            .db()
493            .find_group(&self.group_id)?
494            .ok_or(NotFound::GroupById(self.group_id))?;
495        if stored.membership_state == GroupMembershipState::Restored {
496            return match (
497                stored.message_disappear_from_ns,
498                stored.message_disappear_in_ns,
499            ) {
500                (Some(from_ns), Some(in_ns)) => {
501                    Ok(MessageDisappearingSettings::new(from_ns, in_ns))
502                }
503                _ => Err(GroupError::MetadataPermissionsError(
504                    GroupMetadataError::MissingExtension.into(),
505                )),
506            };
507        }
508        let metadata = self.live_mutable_metadata()?;
509        Self::conversation_message_disappearing_settings_from_extensions(&metadata)
510    }
511
512    pub fn conversation_message_disappearing_settings_from_extensions(
513        mutable_metadata: &GroupMutableMetadata,
514    ) -> Result<MessageDisappearingSettings, GroupError> {
515        let disappear_from_ns = mutable_metadata
516            .attributes
517            .get(&MetadataField::MessageDisappearFromNS.to_string());
518        let disappear_in_ns = mutable_metadata
519            .attributes
520            .get(&MetadataField::MessageDisappearInNS.to_string());
521
522        if let (Some(Ok(message_disappear_from_ns)), Some(Ok(message_disappear_in_ns))) = (
523            disappear_from_ns.map(|s| s.parse::<i64>()),
524            disappear_in_ns.map(|s| s.parse::<i64>()),
525        ) {
526            Ok(MessageDisappearingSettings::new(
527                message_disappear_from_ns,
528                message_disappear_in_ns,
529            ))
530        } else {
531            Err(GroupError::MetadataPermissionsError(
532                GroupMetadataError::MissingExtension.into(),
533            ))
534        }
535    }
536
537    pub fn pending_remove_list(&self) -> Result<Vec<String>, GroupError> {
538        self.context
539            .db()
540            .get_pending_remove_users(&self.group_id)
541            .map_err(Into::into)
542    }
543
544    /// Checks if the given inbox ID is the pending-remove list of the group at the most recently synced epoch.
545    pub fn is_in_pending_remove(&self, inbox_id: &str) -> Result<bool, GroupError> {
546        self.context
547            .db()
548            .get_user_pending_remove_status(&self.group_id, inbox_id)
549            .map_err(Into::into)
550    }
551
552    /// Retrieves the admin list of the group from the group's mutable metadata extension.
553    ///
554    /// The dictionary-backed `TlsSet<InboxId>` is iterated in sorted raw-byte
555    /// order.
556    pub fn admin_list(&self) -> Result<Vec<String>, GroupError> {
557        self.read_admin_set(AdminListKind::Admin)
558    }
559
560    /// Retrieves the super admin list of the group from the group's mutable metadata extension.
561    ///
562    /// Same ordering contract as [`Self::admin_list`].
563    pub fn super_admin_list(&self) -> Result<Vec<String>, GroupError> {
564        self.read_admin_set(AdminListKind::SuperAdmin)
565    }
566
567    fn read_admin_set(&self, kind: AdminListKind) -> Result<Vec<String>, GroupError> {
568        if self.membership_state()? == GroupMembershipState::Restored {
569            return Ok(Vec::new());
570        }
571        let ctx = self.load_group_context()?;
572        let extensions = ctx.extensions();
573        let facade = xmtp_mls_common::app_data::typed_facade::MlsGroupAppData::new(extensions);
574        let set = match kind {
575            AdminListKind::Admin => facade.get::<AdminListComponent>(),
576            AdminListKind::SuperAdmin => facade.get::<SuperAdminListComponent>(),
577        }
578        .map_err(|e| {
579            GroupError::MetadataPermissionsError(MetadataPermissionsError::ComponentSource(e))
580        })?;
581        Ok(set
582            .map(|s| s.iter().map(|id| id.to_hex()).collect())
583            .unwrap_or_default())
584    }
585
586    /// Checks if the given inbox ID is an admin of the group at the most recently synced epoch.
587    pub fn is_admin(&self, inbox_id: String) -> Result<bool, GroupError> {
588        let mutable_metadata = self.mutable_metadata()?;
589        Ok(mutable_metadata.admin_list.contains(&inbox_id))
590    }
591
592    /// Checks if the given inbox ID is a super admin of the group at the most recently synced epoch.
593    pub fn is_super_admin(&self, inbox_id: String) -> Result<bool, GroupError> {
594        let mutable_metadata = self.mutable_metadata()?;
595        Ok(mutable_metadata.super_admin_list.contains(&inbox_id))
596    }
597
598    /// Super-admin status in the current MLS dictionary, for authorization.
599    ///
600    /// An archived super-admin list is display history and grants nothing.
601    /// A Restored placeholder's stub dictionary names the importer, so it
602    /// grants nothing either until a validated Welcome replaces it.
603    // implements: ARCH-015
604    pub(crate) fn live_is_super_admin(&self, inbox_id: &str) -> Result<bool, GroupError> {
605        if self.membership_state()? == GroupMembershipState::Restored {
606            return Ok(false);
607        }
608        Ok(self
609            .live_mutable_metadata()?
610            .super_admin_list
611            .iter()
612            .any(|id| id == inbox_id))
613    }
614
615    /// Checks if the given inbox ID is a super admin of the group at the most recently synced epoch
616    pub fn is_super_admin_without_lock(
617        &self,
618        mls_group: &OpenMlsGroup,
619        inbox_id: String,
620    ) -> Result<bool, GroupMutableMetadataError> {
621        let list =
622            xmtp_mls_common::app_data::component_source::read_super_admin_list_from_dict(mls_group)
623                .map_err(GroupMutableMetadataError::from)?
624                .unwrap_or_default();
625        Ok(list.contains(&inbox_id))
626    }
627
628    /// Retrieves the conversation type of the group from the group's metadata extension.
629    pub async fn conversation_type(&self) -> Result<ConversationType, GroupError> {
630        let conversation_type = self.context.db().get_conversation_type(&self.group_id)?;
631        Ok(conversation_type)
632    }
633}