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()