mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 04:58:45 +00:00
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:
committed by
Michelle Tang
parent
bc2d4a0415
commit
9d3f4ffa08
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user