feat: Add ability to add members to groups from Channel Info

This commit introduces a new bottom sheet that allows users to add or remove members from a group directly from the channel info screen.

- A new "Add Members" button is now available in the `ChannelInfoSheet` for group chats.
- Tapping this button opens the `AddMemberToGroupSheet`, which lists all friends.
- Users can search for friends and add or remove them from the group.
- The "Manage Notifications" button is now hidden for group chats.

Additionally, this commit includes the following changes:

- **Changelog**: The changelog fetching and display functionality has been temporarily disabled.
- **Splash Screen**: The splash screen icon has been updated and now uses an inset drawable (`splash_screen_icon_square.xml`) to adjust its padding, which also resolves an icon display issue on Android 13.
- **Build**: All build variants are now signed with the debug key to simplify development builds.
This commit is contained in:
teamabron 2025-10-15 15:54:15 +03:30
parent b728789fb1
commit 8bdb6d0d9a
15 changed files with 282 additions and 34 deletions

View File

@ -100,6 +100,7 @@ android {
"FLAVOUR_ID",
"\"${buildproperty("build.flavour_id", "RVX_BUILD_FLAVOUR_ID")}\""
)
signingConfig = signingConfigs.getByName("debug")
}
debug {
@ -110,7 +111,7 @@ android {
resValue(
"string",
"app_name",
buildproperty("build.debug.app_name", "RVX_DEBUG_APP_NAME")!!
buildproperty("build.debug.app_name", "RVX_DEBUG_APP_NAME") ?: "ZekoChat Debug"
)
// buildConfigField(

View File

@ -59,7 +59,9 @@ class Changelogs(val context: Context, val kvStorage: KVStorage? = null) {
cachedIndex =
PeptideJson.decodeFromString(ChangelogIndex.serializer(), response.bodyAsText())
return cachedIndex as ChangelogIndex
} catch (e: Error) {
} catch (_: Error) {
return ChangelogIndex()
}catch (_: Exception) {
return ChangelogIndex()
}
}
@ -95,7 +97,8 @@ class Changelogs(val context: Context, val kvStorage: KVStorage? = null) {
}
suspend fun getLatestChangelogCode(): String {
return getLatestChangelog().version.code.toString()
// return getLatestChangelog().version.code.toString()
return ""
}
suspend fun hasSeenCurrent(): Boolean {
@ -105,7 +108,7 @@ class Changelogs(val context: Context, val kvStorage: KVStorage? = null) {
)
}
val latest = getLatestChangelog().version.code
// val latest = getLatestChangelog().version.code
val lastRead = kvStorage.get("latestChangelogRead")
if (lastRead == null) {
@ -113,7 +116,8 @@ class Changelogs(val context: Context, val kvStorage: KVStorage? = null) {
}
// If the last read changelog is >= the latest, it has been read
return lastRead.toLong() >= latest
// return lastRead.toLong() >= latest
return true
}
suspend fun markAsSeen() {
@ -124,7 +128,7 @@ class Changelogs(val context: Context, val kvStorage: KVStorage? = null) {
}
val index = fetchChangelogIndex()
val latest = index.changelogs.maxByOrNull { it.version.code }!!.version.code.toString()
kvStorage.set("latestChangelogRead", latest)
// val latest = index.changelogs.maxByOrNull { it.version.code }!!.version.code.toString()
// kvStorage.set("latestChangelogRead", latest)
}
}

View File

@ -187,8 +187,8 @@ class ChatRouterViewModel @Inject constructor(
latestChangelogRead = changelogs.hasSeenCurrent()
latestChangelog = changelogs.getLatestChangelogCode()
latestChangelogBody =
changelogs.fetchChangelogByVersionCode(latestChangelog.toLong()).rendered
// latestChangelogBody =
// changelogs.fetchChangelogByVersionCode(latestChangelog.toLong()).rendered
if (!latestChangelogRead) {
changelogs.markAsSeen()
}

View File

@ -60,12 +60,12 @@ class ChangelogsSettingsScreenViewModel @Inject constructor(
var renderedChangelog by mutableStateOf("")
suspend fun requestChangelog(version: String) {
viewModelScope.launch {
renderedChangelog = Changelogs(
context,
kvStorage
).fetchChangelogByVersionCode(version.toLong()).rendered
}
// viewModelScope.launch {
// renderedChangelog = Changelogs(
// context,
// kvStorage
// ).fetchChangelogByVersionCode(version.toLong()).rendered
// }
}
suspend fun populate() {

View File

@ -0,0 +1,214 @@
package chat.zekochat.sheets
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import chat.zekochat.api.PeptideAPI
import chat.zekochat.api.PeptideHttp
import chat.zekochat.api.PeptideJson
import chat.zekochat.api.api
import chat.zekochat.api.internals.FriendRequests
import chat.zekochat.api.routes.channel.addMember
import chat.zekochat.api.routes.channel.removeMember
import chat.zekochat.api.schemas.User
import chat.zekochat.composables.chat.MemberListItem
import chat.zekochat.screens.create.MAX_ADDABLE_PEOPLE_IN_GROUP
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.builtins.ListSerializer
class AddMemberToGroupSheetViewModel : ViewModel() {
var groupMembers = mutableStateListOf<String>()
var friendSearchQuery by mutableStateOf("")
var loadingUser by mutableStateOf("")
var friendsFilteredBySearch = mutableStateListOf<String>()
var error by mutableStateOf<String?>(null)
suspend fun fetchGroupParticipants(channelId: String) {
val response = PeptideHttp.get("/channels/$channelId/members".api())
.bodyAsText()
val users = PeptideJson.decodeFromString(
ListSerializer(User.serializer()),
response
).mapNotNull { it.id }
groupMembers.clear()
groupMembers.addAll(users)
}
fun filterFriends() {
friendsFilteredBySearch.clear()
friendsFilteredBySearch.addAll(FriendRequests.getFriends().filter {
if (friendSearchQuery.isBlank()) {
return@filter true
}
if (it.displayName == null || it.username == null) {
return@filter false
}
it.displayName.contains(friendSearchQuery, ignoreCase = true) ||
it.username.contains(friendSearchQuery, ignoreCase = true)
}.map { it.id!! })
}
fun addMemberToGroup(channelId: String, popBackStack: () -> Unit) {
if (groupMembers.size > MAX_ADDABLE_PEOPLE_IN_GROUP) {
error = "Too many members, maximum is $MAX_ADDABLE_PEOPLE_IN_GROUP"
return
}
try {
error = null
viewModelScope.launch {
groupMembers.map {
CoroutineScope(Dispatchers.IO).launch {
addMember(channelId, it)
}
}
popBackStack()
}
} catch (e: Exception) {
error = e.message
}
}
fun addMemberToGroup(channelId: String, userId: String) {
if(loadingUser.isNotEmpty()) return
viewModelScope.launch {
try {
loadingUser = userId
error = null
addMember(channelId, userId)
groupMembers.add(userId)
loadingUser = ""
} catch (e: Exception) {
loadingUser = ""
error = e.message
} catch (e: Error) {
loadingUser = ""
groupMembers.add(userId)
}
}
}
fun removeMemberFromGroup(channelId: String, userId: String) {
if(loadingUser.isNotEmpty()) return
viewModelScope.launch {
try {
loadingUser = userId
error = null
removeMember(channelId, userId)
groupMembers.remove(userId)
loadingUser = ""
} catch (e: Exception) {
loadingUser = ""
error = e.message
} catch (e: Error) {
loadingUser = ""
groupMembers.remove(userId)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddMemberToGroupSheet(
channelId: String,
viewModel: AddMemberToGroupSheetViewModel = viewModel(),
onHideSheet: suspend () -> Unit
)
{
LaunchedEffect(Unit) {
viewModel.filterFriends()
viewModel.fetchGroupParticipants(channelId);
}
Column(
Modifier
.imePadding()
)
{
AnimatedVisibility(visible = viewModel.error?.isNotBlank() ?: false) {
Text(
text = viewModel.error ?: "",
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
}
LazyColumn(contentPadding = PaddingValues(bottom = 78.0.dp)) {
items(viewModel.friendsFilteredBySearch.size) { index ->
val friend = PeptideAPI.userCache[viewModel.friendsFilteredBySearch[index]]
?: return@items
val isMember = viewModel.groupMembers.contains(friend.id)
val isLoading = viewModel.loadingUser == friend.id
MemberListItem(
member = null,
user = friend,
serverId = null,
userId = friend.id!!,
modifier = Modifier.clickable {
if (isMember) {
viewModel.removeMemberFromGroup(channelId, friend.id)
} else {
viewModel.addMemberToGroup(channelId, friend.id)
}
},
trailingContent = {
if(isLoading){
CircularProgressIndicator(Modifier.size(24.dp))
}else{
Checkbox(
checked = isMember,
onCheckedChange = null,
enabled = (isMember.not() && viewModel.groupMembers.size >= MAX_ADDABLE_PEOPLE_IN_GROUP).not()
)
}
}
)
}
}
}
}

View File

@ -46,6 +46,7 @@ import kotlinx.coroutines.launch
fun ChannelInfoSheet(channelId: String, onHideSheet: suspend () -> Unit) {
val channel = PeptideAPI.channelCache[channelId]
var memberListSheetShown by remember { mutableStateOf(false) }
var addMemberSheetShown by remember { mutableStateOf(false) }
var inviteDialogShown by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val permissions by rememberChannelPermissions(channelId)
@ -66,6 +67,24 @@ fun ChannelInfoSheet(channelId: String, onHideSheet: suspend () -> Unit) {
}
}
if (addMemberSheetShown) {
val addMemberSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
sheetState = addMemberSheetState,
onDismissRequest = {
addMemberSheetShown = false
}
) {
AddMemberToGroupSheet(
channelId = channelId,
onHideSheet = {
addMemberSheetShown = false
}
)
}
}
if (inviteDialogShown) {
Dialog(
onDismissRequest = {
@ -176,7 +195,9 @@ fun ChannelInfoSheet(channelId: String, onHideSheet: suspend () -> Unit) {
contentDescription = null
)
},
onClick = {}
onClick = {
addMemberSheetShown = true
}
)
}
@ -184,20 +205,22 @@ fun ChannelInfoSheet(channelId: String, onHideSheet: suspend () -> Unit) {
}
}
SheetButton(
headlineContent = {
Text(
text = stringResource(id = R.string.channel_info_sheet_options_notifications_manage),
)
},
leadingContent = {
Icon(
painter = painterResource(R.drawable.icn_notification_settings_24dp),
contentDescription = null
)
},
onClick = {}
)
if(channel.channelType != ChannelType.Group){
SheetButton(
headlineContent = {
Text(
text = stringResource(id = R.string.channel_info_sheet_options_notifications_manage),
)
},
leadingContent = {
Icon(
painter = painterResource(R.drawable.icn_notification_settings_24dp),
contentDescription = null
)
},
onClick = {}
)
}
if (
(permissions has PermissionBit.ManageChannel || permissions has PermissionBit.ManageRole)

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/splash_screen_icon"
android:insetLeft="48dp"
android:insetRight="48dp"
android:insetTop="40dp"
android:insetBottom="40dp" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 735 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -5,6 +5,6 @@
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowSplashScreenBackground">@color/background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_screen_icon</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_screen_icon_square</item>
</style>
</resources>

View File

@ -2,9 +2,8 @@
<resources>
<style name="Theme.Peptide.Starting" parent="Theme.SplashScreen">
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_screen_icon</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_screen_icon_square</item>
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
<item name="android:windowSplashScreenBehavior">icon_preferred</item>
<item name="postSplashScreenTheme">@style/Theme.Peptide</item>
</style>
</resources>

View File

@ -11,7 +11,7 @@
<!-- We temporarily don't use a dedicated splash screen theme due to Samsung specific issues.
This theme is unused. -->
<style name="Theme.Peptide.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenAnimatedIcon">@drawable/zeko_rounded_icon</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_screen_icon_square</item>
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
<item name="postSplashScreenTheme">@style/Theme.Peptide</item>
</style>