Show groups that have the same member list during group creation.

This commit is contained in:
Greyson Parrelli
2026-03-18 22:54:29 -04:00
committed by Cody Henthorne
parent f09bf5b14c
commit 9de75b3e1f
11 changed files with 140 additions and 4 deletions

View File

@@ -9,4 +9,5 @@ sealed interface LabsSettingsEvents {
data class ToggleIndividualChatPlaintextExport(val enabled: Boolean) : LabsSettingsEvents
data class ToggleStoryArchive(val enabled: Boolean) : LabsSettingsEvents
data class ToggleIncognito(val enabled: Boolean) : LabsSettingsEvents
data class ToggleGroupSuggestionsForMembers(val enabled: Boolean) : LabsSettingsEvents
}

View File

@@ -115,6 +115,15 @@ private fun LabsSettingsContent(
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleIncognito(it)) }
)
}
item {
Rows.ToggleRow(
checked = state.groupSuggestionsForMembers,
text = "Group Suggestions for Members",
label = "When creating a group, show existing groups that have the exact same members.",
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleGroupSuggestionsForMembers(it)) }
)
}
}
}
}

View File

@@ -11,5 +11,6 @@ import androidx.compose.runtime.Immutable
data class LabsSettingsState(
val individualChatPlaintextExport: Boolean = false,
val storyArchive: Boolean = false,
val incognito: Boolean = false
val incognito: Boolean = false,
val groupSuggestionsForMembers: Boolean = false
)

View File

@@ -29,6 +29,10 @@ class LabsSettingsViewModel : ViewModel() {
SignalStore.labs.incognito = event.enabled
_state.value = _state.value.copy(incognito = event.enabled)
}
is LabsSettingsEvents.ToggleGroupSuggestionsForMembers -> {
SignalStore.labs.groupSuggestionsForMembers = event.enabled
_state.value = _state.value.copy(groupSuggestionsForMembers = event.enabled)
}
}
}
@@ -36,7 +40,8 @@ class LabsSettingsViewModel : ViewModel() {
return LabsSettingsState(
individualChatPlaintextExport = SignalStore.labs.individualChatPlaintextExport,
storyArchive = SignalStore.labs.storyArchive,
incognito = SignalStore.labs.incognito
incognito = SignalStore.labs.incognito,
groupSuggestionsForMembers = SignalStore.labs.groupSuggestionsForMembers
)
}
}

View File

@@ -506,6 +506,17 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
}
}
@WorkerThread
fun getGroupsWithExactMembers(memberIds: Set<RecipientId>): List<GroupRecord> {
if (memberIds.isEmpty()) return emptyList()
val selfId = Recipient.self().id
val expectedMembers = if (selfId in memberIds) memberIds else memberIds + selfId
return getGroupsContainingMember(selfId, pushOnly = true, includeInactive = false)
.filter { it.members.toSet() == expectedMembers }
}
fun getGroups(): Reader {
val cursor = readableDatabase.query(joinedGroupSelect())
return Reader(cursor)

View File

@@ -29,12 +29,16 @@ import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.request.target.CustomTarget;
import com.bumptech.glide.request.transition.Transition;
import com.bumptech.glide.RequestManager;
import com.google.android.material.chip.ChipGroup;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.EditTextUtil;
import org.signal.core.ui.logging.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.picker.AvatarPickerFragment;
import org.thoughtcrime.securesms.contacts.ContactChip;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.components.settings.app.privacy.expire.ExpireTimerSettingsFragment;
import org.thoughtcrime.securesms.groups.ui.GroupMemberListView;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -150,6 +154,29 @@ public class AddGroupDetailsFragment extends LoggingFragment {
startActivityForResult(RecipientDisappearingMessagesActivity.forCreateGroup(requireContext(), viewModel.getDisappearingMessagesTimer().getValue()), REQUEST_DISAPPEARING_TIMER);
});
View sameGroupsSection = view.findViewById(R.id.same_groups_section);
ChipGroup sameGroupsChipGroup = view.findViewById(R.id.same_groups_chip_group);
if (SignalStore.labs().getGroupSuggestionsForMembers()) {
viewModel.getSameGroups().observe(getViewLifecycleOwner(), groups -> {
sameGroupsChipGroup.removeAllViews();
if (groups.isEmpty()) {
sameGroupsSection.setVisibility(View.GONE);
} else {
sameGroupsSection.setVisibility(View.VISIBLE);
RequestManager requestManager = Glide.with(this);
for (Recipient group : groups) {
ContactChip chip = new ContactChip(requireContext());
chip.setText(group.getDisplayName(requireContext()));
chip.setAvatar(requestManager, group, null);
chip.setCloseIconVisible(false);
chip.setOnClickListener(v -> navigateToConversation(group.getId()));
sameGroupsChipGroup.addView(chip);
}
}
});
}
name.requestFocus();
getParentFragmentManager().setFragmentResultListener(AvatarPickerFragment.REQUEST_KEY_SELECT_AVATAR,
@@ -266,6 +293,14 @@ public class AddGroupDetailsFragment extends LoggingFragment {
create.setEnabled(isEnabled);
}
private void navigateToConversation(@NonNull RecipientId groupRecipientId) {
ConversationIntents.createBuilder(requireContext(), groupRecipientId, -1L)
.subscribe(builder -> {
startActivity(builder.build());
requireActivity().finish();
});
}
private void showAvatarPicker() {
Media media = viewModel.getAvatarMedia();

View File

@@ -8,6 +8,8 @@ import androidx.core.util.Consumer;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.GroupRecord;
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
import org.thoughtcrime.securesms.groups.GroupChangeException;
import org.thoughtcrime.securesms.groups.GroupManager;
@@ -44,6 +46,19 @@ final class AddGroupDetailsRepository {
});
}
void getGroupsWithSameMembers(@NonNull Set<RecipientId> memberIds, Consumer<List<Recipient>> consumer) {
SignalExecutors.BOUNDED.execute(() -> {
List<GroupRecord> groups = SignalDatabase.groups().getGroupsWithExactMembers(memberIds);
List<Recipient> recipients = new ArrayList<>(groups.size());
for (GroupRecord group : groups) {
recipients.add(Recipient.resolved(group.getRecipientId()));
}
consumer.accept(recipients);
});
}
void createGroup(@NonNull Set<RecipientId> members,
@Nullable byte[] avatar,
@Nullable String name,

View File

@@ -16,6 +16,7 @@ import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.signal.core.models.media.Media;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
@@ -38,6 +39,7 @@ public final class AddGroupDetailsViewModel extends ViewModel {
private final MutableLiveData<Integer> disappearingMessagesTimer = new MutableLiveData<>(SignalStore.settings().getUniversalExpireTimer());
private final LiveData<Boolean> isMms;
private final LiveData<Boolean> canSubmitForm;
private final LiveData<List<Recipient>> sameGroups;
private final AddGroupDetailsRepository repository;
private Media avatarMedia;
@@ -53,6 +55,16 @@ public final class AddGroupDetailsViewModel extends ViewModel {
members = LiveDataUtil.combineLatest(initialMembers, deleted, AddGroupDetailsViewModel::filterDeletedMembers);
isMms = Transformations.map(members, AddGroupDetailsViewModel::isAnyForcedSms);
canSubmitForm = LiveDataUtil.combineLatest(isMms, isValidName, (mms, validName) -> mms || validName);
sameGroups = Transformations.switchMap(members, memberList -> {
MutableLiveData<List<Recipient>> result = new MutableLiveData<>(Collections.emptyList());
if (!memberList.isEmpty()) {
Set<RecipientId> memberIds = Stream.of(memberList)
.map(member -> member.getMember().getId())
.collect(Collectors.toSet());
repository.getGroupsWithSameMembers(memberIds, result::postValue);
}
return result;
});
repository.resolveMembers(recipientIds, initialMembers::postValue);
}
@@ -77,6 +89,10 @@ public final class AddGroupDetailsViewModel extends ViewModel {
return isMms;
}
@NonNull LiveData<List<Recipient>> getSameGroups() {
return sameGroups;
}
@NonNull LiveData<Integer> getDisappearingMessagesTimer() {
return disappearingMessagesTimer;
}

View File

@@ -7,6 +7,7 @@ class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues(
const val INDIVIDUAL_CHAT_PLAINTEXT_EXPORT: String = "labs.individual_chat_plaintext_export"
const val STORY_ARCHIVE: String = "labs.story_archive"
const val INCOGNITO: String = "labs.incognito"
const val GROUP_SUGGESTIONS_FOR_MEMBERS: String = "labs.group_suggestions_for_members"
}
public override fun onFirstEverAppLaunch() = Unit
@@ -19,6 +20,8 @@ class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues(
var incognito by booleanValue(INCOGNITO, true).falseForExternalUsers()
var groupSuggestionsForMembers by booleanValue(GROUP_SUGGESTIONS_FOR_MEMBERS, true).falseForExternalUsers()
private fun SignalStoreValueDelegate<Boolean>.falseForExternalUsers(): SignalStoreValueDelegate<Boolean> {
return this.map { actualValue -> RemoteConfig.internalUser && actualValue }
}

View File

@@ -98,7 +98,7 @@
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:orientation="vertical"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/member_list_header"
app:layout_constraintBottom_toTopOf="@id/same_groups_section"
app:layout_constraintTop_toBottomOf="@id/divider"
app:layout_goneMarginTop="23dp"
tools:visibility="visible">
@@ -118,6 +118,43 @@
</LinearLayout>
<LinearLayout
android:id="@+id/same_groups_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/mms_warning"
tools:visibility="visible">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="40dp"
android:paddingHorizontal="@dimen/dsl_settings_gutter"
android:text="@string/AddGroupDetailsFragment__groups_with_same_members"
android:textAppearance="@style/Signal.Text.TitleSmall" />
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:paddingHorizontal="@dimen/dsl_settings_gutter"
android:paddingBottom="8dp"
android:scrollbars="none">
<com.google.android.material.chip.ChipGroup
android:id="@+id/same_groups_chip_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:singleLine="true"
app:chipSpacingHorizontal="8dp" />
</HorizontalScrollView>
</LinearLayout>
<TextView
android:id="@+id/member_list_header"
android:layout_width="match_parent"
@@ -128,7 +165,7 @@
android:minHeight="52sp"
android:text="@string/AddGroupDetailsFragment__members"
android:textAppearance="@style/Signal.Text.TitleSmall"
app:layout_constraintTop_toBottomOf="@id/mms_warning" />
app:layout_constraintTop_toBottomOf="@id/same_groups_section" />
<TextView
android:id="@+id/add_later"

View File

@@ -9615,5 +9615,8 @@
<!-- Body for screen shown to let the user know that displaying their member label will take priority over their about text. -->
<string name="MemberLabelsAboutOverride__body">In this group, your Member Label will be displayed beside your photo in place of your About.</string>
<!-- Label for internal-only section showing groups with same members -->
<string name="AddGroupDetailsFragment__groups_with_same_members" translatable="false">Groups with same members (Internal Only)</string>
<!-- EOF -->
</resources>