Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
f3b8de3
New message description icon change
jbsession Oct 29, 2025
ac4d7c2
Ons lookup failed error
jbsession Oct 29, 2025
65ec34e
unregistered ONS error string
jbsession Oct 29, 2025
edee85b
Modal when clicking help
jbsession Oct 29, 2025
d3b7224
Invalid account id check
jbsession Oct 30, 2025
49d20bb
Updated url dialog commands
jbsession Oct 30, 2025
97ed879
manage admin menu
jbsession Oct 30, 2025
32d2d7f
Removed groupv2 banners
jbsession Oct 30, 2025
91830a2
Leave group option
jbsession Oct 30, 2025
417642a
options
jbsession Nov 3, 2025
06458f3
Initial search with cancel
jbsession Nov 3, 2025
6d81de6
fix search state
jbsession Nov 3, 2025
d93ad02
Animation for focused state:
jbsession Nov 3, 2025
2927aa2
cleanup, Added non-admin members list
jbsession Nov 3, 2025
a6edb1a
Selected items
jbsession Nov 3, 2025
73554cc
Initial bottom footer action
jbsession Nov 3, 2025
fd5d21b
Initial resend invite
jbsession Nov 4, 2025
3b6fb51
Update state when clicking resend
jbsession Nov 4, 2025
73f23ea
Fix search state after resend
jbsession Nov 4, 2025
23ccf9c
New error and toast strings, code cleanup
jbsession Nov 5, 2025
b7386e6
Remove members dialog
jbsession Nov 5, 2025
5351084
cleanup, screen and viewmodel renamed to ManageGroupMembers
jbsession Nov 5, 2025
5873753
clear search state after removing
jbsession Nov 6, 2025
5aebd8d
Fixed some button logic
jbsession Nov 6, 2025
70df9fb
Fixed resend invite quantity screen
jbsession Nov 6, 2025
df3f53e
Empty state
jbsession Nov 6, 2025
59d87e9
Handle chevron search state
jbsession Nov 6, 2025
f8f4fb2
Updated list sorting order
jbsession Nov 6, 2025
1db4656
Cleanup
jbsession Nov 6, 2025
8ed326e
Cleanups
jbsession Nov 6, 2025
fd4391a
Merge branch 'dev' into ses-4753/manage-members
jbsession Nov 6, 2025
360682b
Revert heap size
jbsession Nov 6, 2025
676ad55
Cleanups
jbsession Nov 6, 2025
d315cb8
Updated icon dimens
jbsession Nov 6, 2025
398af1c
Updated nonAdminMember flow
jbsession Nov 7, 2025
65728c9
Fixed unsafe member config
jbsession Nov 7, 2025
682b84c
test
jbsession Nov 7, 2025
850ff03
Revert "test"
jbsession Nov 7, 2025
8a96d6a
test
jbsession Nov 7, 2025
e14871d
Revert "test"
jbsession Nov 7, 2025
a445363
Updated search with x and close button
jbsession Nov 7, 2025
f0e66ae
Added hasMembers flag
jbsession Nov 7, 2025
db69eca
Removed unused composables, flow and function
jbsession Nov 7, 2025
47256e1
Code cleanup for commands, and unused code, renamed item
jbsession Nov 7, 2025
8bdb2d9
Moved options to state, initial uistate
jbsession Nov 7, 2025
f90d1db
Added flows to UIState
jbsession Nov 10, 2025
97ec585
Added footer to the UiState
jbsession Nov 10, 2025
6731b93
updated icon
jbsession Nov 10, 2025
043e79d
persist job
jbsession Nov 10, 2025
d869eb7
Add InviteContactsJob Factory
jbsession Nov 10, 2025
84721d6
Fixed merge conflicts
jbsession Nov 11, 2025
4a71bbe
Merge branch 'dev' into ses-4753/manage-members
jbsession Nov 14, 2025
315656c
String ref fix
jbsession Nov 14, 2025
e8c6c5e
Merge branch 'dev' into ses-4753/manage-members
jbsession Dec 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class GroupInviteException(
val isPromotion: Boolean,
val inviteeAccountIds: List<String>,
val groupName: String,
val isReinvite: Boolean,
underlying: Throwable
) : RuntimeException(underlying) {
init {
Expand All @@ -41,19 +42,26 @@ class GroupInviteException(
val third = inviteeAccountIds.getOrNull(2)?.let(getInviteeName)

if (second != null && third != null) {
return Phrase.from(context, if (isPromotion) R.string.adminPromotionFailedDescriptionMultiple else R.string.groupInviteFailedMultiple)
val errorString =
if (isPromotion) R.string.adminPromotionFailedDescriptionMultiple else
if (isReinvite) R.string.failedResendInviteMultiple else R.string.groupInviteFailedMultiple
return Phrase.from(context, errorString)
.put(NAME_KEY, first)
.put(COUNT_KEY, inviteeAccountIds.size - 1)
.put(GROUP_NAME_KEY, groupName)
.format()
} else if (second != null) {
return Phrase.from(context, if (isPromotion) R.string.adminPromotionFailedDescriptionTwo else R.string.groupInviteFailedTwo)
val errorString = if (isPromotion) R.string.adminPromotionFailedDescriptionTwo else
if (isReinvite) R.string.failedResendInviteTwo else R.string.groupInviteFailedTwo
return Phrase.from(context, errorString)
.put(NAME_KEY, first)
.put(OTHER_NAME_KEY, second)
.put(GROUP_NAME_KEY, groupName)
.format()
} else {
return Phrase.from(context, if (isPromotion) R.string.adminPromotionFailedDescription else R.string.groupInviteFailedUser)
val errorString = if (isPromotion) R.string.adminPromotionFailedDescription else
if (isReinvite) R.string.failedResendInvite else R.string.groupInviteFailedUser
return Phrase.from(context, errorString)
.put(NAME_KEY, first)
.put(GROUP_NAME_KEY, groupName)
.format()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import org.session.libsession.messaging.messages.control.GroupUpdated
import org.session.libsession.utilities.recipients.Recipient
import org.session.protos.SessionProtos.GroupUpdateDeleteMemberContentMessage
import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.groups.MemberInvite

/**
* Business logic handling group v2 operations like inviting members,
Expand All @@ -25,6 +26,11 @@ interface GroupManagerV2 {
isReinvite: Boolean, // Whether this comes from a re-invite
)

suspend fun reinviteMembers(
group: AccountId,
invites: List<MemberInvite>
)

suspend fun removeMembers(
groupAccountId: AccountId,
removedMembers: List<AccountId>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,17 @@ import org.session.libsignal.utilities.Log
class InviteContactsJob @AssistedInject constructor(
@Assisted val groupSessionId: String,
@Assisted val memberSessionIds: Array<String>,
@Assisted val isReinvite: Boolean,
private val configFactory: ConfigFactoryProtocol,
private val messageSender: MessageSender,

) : Job {

companion object {
const val KEY = "InviteContactJob"
private const val GROUP = "group"
private const val MEMBER = "member"
private const val REINVITE = "reinvite"

}

Expand Down Expand Up @@ -130,10 +133,17 @@ class InviteContactsJob @AssistedInject constructor(
inviteeAccountIds = failures.map { it.first },
groupName = groupName.orEmpty(),
underlying = firstError,
).format(MessagingModuleConfiguration.shared.context,
MessagingModuleConfiguration.shared.recipientRepository).let {
isReinvite = isReinvite
).format(
MessagingModuleConfiguration.shared.context,
MessagingModuleConfiguration.shared.recipientRepository
).let {
withContext(Dispatchers.Main) {
Toast.makeText(MessagingModuleConfiguration.shared.context, it, Toast.LENGTH_LONG).show()
Toast.makeText(
MessagingModuleConfiguration.shared.context,
it,
Toast.LENGTH_LONG
).show()
}
}
}
Expand All @@ -144,6 +154,7 @@ class InviteContactsJob @AssistedInject constructor(
Data.Builder()
.putString(GROUP, groupSessionId)
.putStringArray(MEMBER, memberSessionIds)
.putBoolean(REINVITE, isReinvite)
.build()

override fun getFactoryKey(): String = KEY
Expand All @@ -153,14 +164,17 @@ class InviteContactsJob @AssistedInject constructor(
abstract fun create(
groupSessionId: String,
memberSessionIds: Array<String>,
isReinvite: Boolean
): InviteContactsJob

override fun create(data: Data): InviteContactsJob? {
val groupSessionId = data.getString(GROUP) ?: return null
val memberSessionIds = data.getStringArray(MEMBER) ?: return null
val reinvite = data.getBooleanOrDefault(REINVITE, false)
return create(
groupSessionId = groupSessionId,
memberSessionIds = memberSessionIds,
isReinvite = reinvite
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,11 @@ import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.Disappear
import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.*
import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsScreen
import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsViewModel
import org.thoughtcrime.securesms.groups.EditGroupViewModel
import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel
import org.thoughtcrime.securesms.groups.GroupMembersViewModel
import org.thoughtcrime.securesms.groups.SelectContactsViewModel
import org.thoughtcrime.securesms.groups.compose.EditGroupScreen
import org.thoughtcrime.securesms.groups.compose.ManageGroupMembersScreen
import org.thoughtcrime.securesms.groups.compose.GroupMembersScreen
import org.thoughtcrime.securesms.groups.compose.GroupMinimumVersionBanner
import org.thoughtcrime.securesms.groups.compose.InviteContactsScreen
import org.thoughtcrime.securesms.media.MediaOverviewScreen
import org.thoughtcrime.securesms.media.MediaOverviewViewModel
Expand Down Expand Up @@ -184,20 +183,12 @@ fun ConversationSettingsNavHost(
val data: RouteManageMembers = backStackEntry.toRoute()

val viewModel =
hiltViewModel<EditGroupViewModel, EditGroupViewModel.Factory> { factory ->
factory.create(data.groupAddress)
hiltViewModel<ManageGroupMembersViewModel, ManageGroupMembersViewModel.Factory> { factory ->
factory.create(data.groupAddress, navigator)
}

EditGroupScreen(
ManageGroupMembersScreen(
viewModel = viewModel,
navigateToInviteContact = {
navController.navigate(
RouteInviteToGroup(
groupAddress = data.groupAddress,
excludingAccountIDs = viewModel.excludingAccountIDsFromContactSelection.toList()
)
)
},
onBack = dropUnlessResumed {
handleBack()
},
Expand All @@ -221,22 +212,20 @@ fun ConversationSettingsNavHost(
RouteManageMembers(data.groupAddress)
)
}
val editGroupViewModel: EditGroupViewModel = hiltViewModel(parentEntry)
val manageGroupMembersViewModel: ManageGroupMembersViewModel = hiltViewModel(parentEntry)

InviteContactsScreen(
viewModel = viewModel,
onDoneClicked = dropUnlessResumed {
//send invites from the manage group screen
editGroupViewModel.onContactSelected(viewModel.currentSelected)
manageGroupMembersViewModel.onContactSelected(viewModel.currentSelected)

handleBack()
},
onBack = dropUnlessResumed {
handleBack()
},
banner = {
GroupMinimumVersionBanner()
}
banner = {}
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,19 @@ class ConversationSettingsViewModel @AssistedInject constructor(
)
}

private val optionManageAdmins: OptionsItem by lazy{
OptionsItem(
name = context.getString(R.string.manageAdmins),
icon = R.drawable.ic_add_admin_custom,
qaTag = R.string.qa_conversation_settings_manage_members,
onClick = {
(address as? Address.Group)?.let {
navigateTo(ConversationSettingsDestination.RouteManageMembers(it))
}
}
)
}

private val optionLeaveGroup: OptionsItem by lazy{
OptionsItem(
name = context.getString(R.string.groupLeave),
Expand Down Expand Up @@ -567,6 +580,7 @@ class ConversationSettingsViewModel @AssistedInject constructor(
dangerOptions.addAll(
listOf(
optionClearMessages,
optionLeaveGroup,
optionDeleteGroup
)
)
Expand All @@ -575,6 +589,7 @@ class ConversationSettingsViewModel @AssistedInject constructor(
adminOptions.addAll(
listOf(
optionManageMembers,
optionManageAdmins,
optionDisappearingMessage(disappearingSubtitle)
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ abstract class BaseGroupMembersViewModel(
::filterContacts
).stateIn(viewModelScope, SharingStarted.Lazily, emptyList())

// Output: List of only NON-ADMINS
val nonAdminMembers: StateFlow<List<GroupMemberState>> = members
.map { list -> list.filter { !it.showAsAdmin } }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())

val hasNonAdminMembers: StateFlow<Boolean> =
groupInfo
.map { pair -> pair?.second.orEmpty().any { !it.showAsAdmin } }
.stateIn(viewModelScope, SharingStarted.Lazily, false)

fun onSearchQueryChanged(query: String) {
mutableSearchQuery.value = query
}
Expand Down Expand Up @@ -138,7 +148,7 @@ abstract class BaseGroupMembersViewModel(
showProBadge = shouldShowProBadge,
avatarUIData = avatarUtils.getUIDataFromAccountId(memberAccountId.hexString),
clickable = !isMyself,
statusLabel = getMemberLabel(status, context, amIAdmin),
statusLabel = getMemberLabel(status, context, amIAdmin)
)
}

Expand Down Expand Up @@ -170,16 +180,35 @@ abstract class BaseGroupMembersViewModel(
}
}

// Refer to notion doc for the sorting logic
// Refer to manage members/admin PRD for the sorting logic
private fun sortMembers(members: List<GroupMemberState>, currentUserId: AccountId) =
members.sortedWith(
compareBy<GroupMemberState>{ it.accountId != currentUserId } // Current user comes first
.thenBy { !it.showAsAdmin } // Admins come first
.thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) // Sort by name (case insensitive)
.thenBy { it.accountId } // Last resort: sort by account ID
compareBy<GroupMemberState> { stateOrder(it.status) }
.thenBy { it.accountId != currentUserId }
.thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
.thenBy { it.accountId }
)
}

private fun stateOrder(status: GroupMember.Status?): Int = when (status) {
// 1. Invite failed
GroupMember.Status.INVITE_FAILED -> 0
// 2. Invite not sent
GroupMember.Status.INVITE_NOT_SENT -> 1
// 3. Sending invite
GroupMember.Status.INVITE_SENDING -> 2
// 4. Invite sent
GroupMember.Status.INVITE_SENT -> 3
// 5. Invite status unknown
GroupMember.Status.INVITE_UNKNOWN -> 4
// 6. Pending removal
GroupMember.Status.REMOVED,
GroupMember.Status.REMOVED_UNKNOWN,
GroupMember.Status.REMOVED_INCLUDING_MESSAGES -> 5
// 7. Member (everything else)
else -> 6
}

data class GroupMemberState(
val accountId: AccountId,
val avatarUIData: AvatarUIData,
Expand All @@ -193,7 +222,7 @@ data class GroupMemberState(
val canRemove: Boolean,
val canPromote: Boolean,
val clickable: Boolean,
val statusLabel: String,
val statusLabel: String
) {
val canEdit: Boolean get() = canRemove || canPromote || canResendInvite || canResendPromotion
}
Loading