mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-25 03:11:10 +01: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:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||||
android:exported="false"/>
|
android:exported="false"/>
|
||||||
|
|
||||||
|
<activity android:name=".groups.ui.incommon.GroupsInCommonActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
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.components.emoji.EmojiTextView
|
||||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||||
import org.thoughtcrime.securesms.conversation.v2.UnverifiedProfileNameBottomSheet
|
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.nicknames.ViewNoteSheet
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||||
@@ -115,7 +116,8 @@ class AboutSheet : ComposeBottomSheetDialogFragment() {
|
|||||||
onClickSignalConnections = this::openSignalConnectionsSheet,
|
onClickSignalConnections = this::openSignalConnectionsSheet,
|
||||||
onAvatarClicked = this::openProfilePhotoViewer,
|
onAvatarClicked = this::openProfilePhotoViewer,
|
||||||
onNoteClicked = this::openNoteSheet,
|
onNoteClicked = this::openNoteSheet,
|
||||||
onUnverifiedProfileClicked = this::openUnverifiedProfileSheet
|
onUnverifiedProfileClicked = this::openUnverifiedProfileSheet,
|
||||||
|
onGroupsInCommonClicked = this::openGroupsInCommon
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,6 +140,10 @@ class AboutSheet : ComposeBottomSheetDialogFragment() {
|
|||||||
dismiss()
|
dismiss()
|
||||||
UnverifiedProfileNameBottomSheet.show(fragmentManager = parentFragmentManager, forGroup = false)
|
UnverifiedProfileNameBottomSheet.show(fragmentManager = parentFragmentManager, forGroup = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun openGroupsInCommon() {
|
||||||
|
startActivity(GroupsInCommonActivity.createIntent(requireContext(), recipientId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class AboutModel(
|
private data class AboutModel(
|
||||||
@@ -162,7 +168,8 @@ private fun Content(
|
|||||||
onClickSignalConnections: () -> Unit,
|
onClickSignalConnections: () -> Unit,
|
||||||
onAvatarClicked: () -> Unit,
|
onAvatarClicked: () -> Unit,
|
||||||
onNoteClicked: () -> Unit,
|
onNoteClicked: () -> Unit,
|
||||||
onUnverifiedProfileClicked: () -> Unit = {}
|
onUnverifiedProfileClicked: () -> Unit = {},
|
||||||
|
onGroupsInCommonClicked: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -303,6 +310,8 @@ private fun Content(
|
|||||||
AboutRow(
|
AboutRow(
|
||||||
startIcon = groupsInCommonIcon,
|
startIcon = groupsInCommonIcon,
|
||||||
text = groupsInCommonText,
|
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()
|
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 -->
|
<!-- 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>
|
<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 -->
|
<!-- CallParticipantsListDialog -->
|
||||||
<plurals name="CallParticipantsListDialog_in_this_call">
|
<plurals name="CallParticipantsListDialog_in_this_call">
|
||||||
<item quantity="one">In this call (%1$d)</item>
|
<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
|
* Our very own preview that will generate light and dark previews for
|
||||||
* composables
|
* composables
|
||||||
*/
|
*/
|
||||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
@Preview(name = "Light Theme", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||||
annotation class SignalPreview()
|
annotation class SignalPreview()
|
||||||
|
|||||||
Reference in New Issue
Block a user