Add groups in common screen.

Adds a new screen to show which groups the user has in common with another user.
This commit is contained in:
Jeffrey Starke
2025-04-02 17:11:53 -04:00
committed by Michelle Tang
parent bc2d4a0415
commit 9d3f4ffa08
7 changed files with 287 additions and 4 deletions

View File

@@ -1148,6 +1148,10 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".groups.ui.incommon.GroupsInCommonActivity"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar" />
<service
android:enabled="true"
android:exported="false"

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.groups
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.rx3.asFlow
import kotlinx.coroutines.withContext
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Centralizes operations for retrieving groups that a given recipient has in common with another user.
*/
class GroupsInCommonRepository(private val context: Context) {
fun getGroupsInCommon(recipientId: RecipientId): Flow<List<Recipient>> {
return Recipient.observable(recipientId)
.asFlow()
.map { recipient ->
if (recipient.hasGroupsInCommon) {
getGroupsContainingRecipient(recipientId)
} else {
emptyList()
}
}
}
private suspend fun getGroupsContainingRecipient(recipientId: RecipientId): List<Recipient> = 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()
}
}

View File

@@ -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<Recipient>,
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")
)
)
}
}

View File

@@ -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<List<Recipient>> = groupsInCommonRepo.getGroupsInCommon(recipientId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
}

View File

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

View File

@@ -2575,6 +2575,12 @@
<!-- Displays the name of a contact. The first placeholder is the name the user has assigned to that contact, the second name is the name the contact assigned to themselves -->
<string name="AboutSheet__user_set_display_name_and_profile_name">%1$s (%2$s)</string>
<!-- Title of the screen showing which groups they have in common with another user. -->
<plurals name="GroupsInCommon__n_groups_in_common_title">
<item quantity="one">%1$s group in common</item>
<item quantity="other">%1$s groups in common</item>
</plurals>
<!-- CallParticipantsListDialog -->
<plurals name="CallParticipantsListDialog_in_this_call">
<item quantity="one">In this call (%1$d)</item>

View File

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