diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 26f23341c1..390368df70 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1148,6 +1148,10 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
+
+
> {
+ return Recipient.observable(recipientId)
+ .asFlow()
+ .map { recipient ->
+ if (recipient.hasGroupsInCommon) {
+ getGroupsContainingRecipient(recipientId)
+ } else {
+ emptyList()
+ }
+ }
+ }
+
+ private suspend fun getGroupsContainingRecipient(recipientId: RecipientId): List = withContext(Dispatchers.IO) {
+ SignalDatabase.groups.getPushGroupsContainingMember(recipientId)
+ .asSequence()
+ .filter { it.members.contains(Recipient.self().id) }
+ .map { groupRecord -> Recipient.resolved(groupRecord.recipientId) }
+ .sortedBy { group -> group.getDisplayName(context) }
+ .toList()
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/incommon/GroupsInCommonActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/incommon/GroupsInCommonActivity.kt
new file mode 100644
index 0000000000..56aa4efa2f
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/incommon/GroupsInCommonActivity.kt
@@ -0,0 +1,193 @@
+/*
+ * Copyright 2025 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.groups.ui.incommon
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.signal.core.ui.compose.Previews
+import org.signal.core.ui.compose.Scaffolds
+import org.signal.core.ui.compose.SignalPreview
+import org.signal.core.ui.compose.theme.SignalTheme
+import org.signal.core.util.getParcelableExtraCompat
+import org.thoughtcrime.securesms.PassphraseRequiredActivity
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.avatar.AvatarImage
+import org.thoughtcrime.securesms.compose.StatusBarColorNestedScrollConnection
+import org.thoughtcrime.securesms.groups.GroupsInCommonRepository
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.recipients.RecipientId
+import org.thoughtcrime.securesms.util.viewModel
+import java.text.NumberFormat
+
+/**
+ * Displays a list of groups that the user has in common with the specified [RecipientId].
+ */
+class GroupsInCommonActivity : PassphraseRequiredActivity() {
+ companion object {
+ private const val EXTRA_RECIPIENT_ID = "recipient_id"
+
+ fun createIntent(
+ context: Context,
+ recipientId: RecipientId
+ ): Intent {
+ return Intent(context, GroupsInCommonActivity::class.java).apply {
+ putExtra(EXTRA_RECIPIENT_ID, recipientId)
+ }
+ }
+ }
+
+ private val viewModel by viewModel {
+ GroupsInCommonViewModel(
+ recipientId = intent.getParcelableExtraCompat(EXTRA_RECIPIENT_ID, RecipientId::class.java)!!,
+ groupsInCommonRepo = GroupsInCommonRepository(context = this)
+ )
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
+ setContent {
+ SignalTheme {
+ GroupsInCommonScreen(
+ viewModel = viewModel,
+ onBackPress = { supportFinishAfterTransition() },
+ activity = this
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun GroupsInCommonScreen(
+ viewModel: GroupsInCommonViewModel,
+ activity: Activity,
+ onBackPress: () -> Unit = {}
+) {
+ val groups by viewModel.groups.collectAsStateWithLifecycle()
+ val nestedScrollConnection = remember { StatusBarColorNestedScrollConnection(activity) }
+
+ GroupsInCommonContent(
+ groups = groups,
+ onBackPress = onBackPress,
+ modifier = Modifier.nestedScroll(nestedScrollConnection)
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun GroupsInCommonContent(
+ groups: List,
+ onBackPress: () -> Unit = {},
+ modifier: Modifier = Modifier
+) {
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+
+ Scaffold(
+ topBar = { TopAppBar(groupCount = groups.size, scrollBehavior = scrollBehavior, onBackPress = onBackPress) },
+ modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
+ ) { padding ->
+ LazyColumn(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.padding(padding)
+ ) {
+ items(groups) {
+ GroupRow(it)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun TopAppBar(
+ groupCount: Int,
+ scrollBehavior: TopAppBarScrollBehavior,
+ onBackPress: () -> Unit
+) {
+ Scaffolds.DefaultTopAppBar(
+ title = pluralStringResource(R.plurals.GroupsInCommon__n_groups_in_common_title, groupCount, NumberFormat.getInstance().format(groupCount)),
+ titleContent = { _, title -> Text(text = title, style = MaterialTheme.typography.titleLarge) },
+ navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
+ onNavigationClick = onBackPress,
+ scrollBehavior = scrollBehavior
+ )
+}
+
+@Composable
+private fun GroupRow(
+ group: Recipient
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(start = 24.dp, top = 12.dp, end = 24.dp, bottom = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ AvatarImage(
+ recipient = group,
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Text(
+ text = group.getGroupName(LocalContext.current)!!,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+}
+
+@SignalPreview
+@Composable
+fun GroupsInCommonContentPreview() {
+ Previews.Preview {
+ GroupsInCommonContent(
+ groups = listOf(
+ Recipient(groupName = "Family"),
+ Recipient(groupName = "happy birthday"),
+ Recipient(groupName = "The Cheesecake is a Lie"),
+ Recipient(groupName = "JEFFPARDY"),
+ Recipient(groupName = "Roommates"),
+ Recipient(groupName = "NYC Rock Climbers"),
+ Recipient(groupName = "Parkdale Run Club")
+ )
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/incommon/GroupsInCommonViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/incommon/GroupsInCommonViewModel.kt
new file mode 100644
index 0000000000..4b5a470273
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/incommon/GroupsInCommonViewModel.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2025 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.groups.ui.incommon
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import org.thoughtcrime.securesms.groups.GroupsInCommonRepository
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.recipients.RecipientId
+
+class GroupsInCommonViewModel(
+ recipientId: RecipientId,
+ groupsInCommonRepo: GroupsInCommonRepository
+) : ViewModel() {
+
+ val groups: StateFlow> = groupsInCommonRepo.getGroupsInCommon(recipientId)
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = emptyList()
+ )
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt
index 9a2e7623a8..8414fc7c19 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt
@@ -50,6 +50,7 @@ import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.conversation.v2.UnverifiedProfileNameBottomSheet
+import org.thoughtcrime.securesms.groups.ui.incommon.GroupsInCommonActivity
import org.thoughtcrime.securesms.nicknames.ViewNoteSheet
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -115,7 +116,8 @@ class AboutSheet : ComposeBottomSheetDialogFragment() {
onClickSignalConnections = this::openSignalConnectionsSheet,
onAvatarClicked = this::openProfilePhotoViewer,
onNoteClicked = this::openNoteSheet,
- onUnverifiedProfileClicked = this::openUnverifiedProfileSheet
+ onUnverifiedProfileClicked = this::openUnverifiedProfileSheet,
+ onGroupsInCommonClicked = this::openGroupsInCommon
)
}
}
@@ -138,6 +140,10 @@ class AboutSheet : ComposeBottomSheetDialogFragment() {
dismiss()
UnverifiedProfileNameBottomSheet.show(fragmentManager = parentFragmentManager, forGroup = false)
}
+
+ private fun openGroupsInCommon() {
+ startActivity(GroupsInCommonActivity.createIntent(requireContext(), recipientId))
+ }
}
private data class AboutModel(
@@ -162,7 +168,8 @@ private fun Content(
onClickSignalConnections: () -> Unit,
onAvatarClicked: () -> Unit,
onNoteClicked: () -> Unit,
- onUnverifiedProfileClicked: () -> Unit = {}
+ onUnverifiedProfileClicked: () -> Unit = {},
+ onGroupsInCommonClicked: () -> Unit = {}
) {
Box(
contentAlignment = Alignment.Center,
@@ -303,6 +310,8 @@ private fun Content(
AboutRow(
startIcon = groupsInCommonIcon,
text = groupsInCommonText,
+ endIcon = if (model.groupsInCommon > 0) ImageVector.vectorResource(id = R.drawable.symbol_chevron_right_compact_bold_16) else null,
+ onClick = if (model.groupsInCommon > 0) onGroupsInCommonClicked else null,
modifier = Modifier.fillMaxWidth()
)
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2669eea9a1..96c95fb69b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2575,6 +2575,12 @@
%1$s (%2$s)
+
+
+ - %1$s group in common
+ - %1$s groups in common
+
+
- In this call (%1$d)
diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/SignalPreview.kt b/core-ui/src/main/java/org/signal/core/ui/compose/SignalPreview.kt
index 18d0081695..e3c432b1ee 100644
--- a/core-ui/src/main/java/org/signal/core/ui/compose/SignalPreview.kt
+++ b/core-ui/src/main/java/org/signal/core/ui/compose/SignalPreview.kt
@@ -12,6 +12,6 @@ import androidx.compose.ui.tooling.preview.Preview
* Our very own preview that will generate light and dark previews for
* composables
*/
-@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
-@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(name = "Light Theme", uiMode = Configuration.UI_MODE_NIGHT_NO)
+@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
annotation class SignalPreview()