diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt
new file mode 100644
index 00000000..dccfc5a2
--- /dev/null
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt
@@ -0,0 +1,123 @@
+package com.utun.chat.ui.components
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.utun.chat.ChatApplication
+import com.utun.chat.R
+import com.utun.chat.data.*
+import kotlinx.coroutines.launch
+
+/** Один диалог для меню списка и кнопки в шапке группового чата. */
+@Composable
+fun GroupPropertiesDialog(channel: Channel, onDismiss: () -> Unit) {
+ val repository = ChatApplication.instance.avatars
+ val groups by repository.groups.collectAsState()
+ val edits by repository.edits.collectAsState()
+ val state = groups[channel.id]
+ val editState = edits["group:${channel.id}"] ?: AvatarEditState.IDLE
+ val working = editState == AvatarEditState.WORKING
+ var soundOn by rememberSaveable(channel.id) { mutableStateOf(!NotificationSound.isMuted(channel.id)) }
+ var pickedAccount by rememberSaveable { mutableStateOf(0L) }
+ var pickedGroup by rememberSaveable { mutableStateOf("") }
+ val scope = rememberCoroutineScope()
+ val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
+ if (uri != null) repository.editGroup(pickedGroup, uri, pickedAccount)
+ }
+ LaunchedEffect(channel.id) { repository.ensureGroup(channel.id, true) }
+ AlertDialog(onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.group_properties)) },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ GroupAvatar(channel.id, channel.name, size = 64.dp)
+ Column { Text(channel.name, style = MaterialTheme.typography.titleMedium); Text(channel.id) }
+ }
+ if (state?.loading == true || working) LinearProgressIndicator(Modifier.fillMaxWidth())
+ if (state?.error == true) {
+ Text(stringResource(R.string.avatar_load_failed), color = MaterialTheme.colorScheme.error)
+ TextButton(onClick = { repository.ensureGroup(channel.id, true) }) { Text(stringResource(R.string.avatar_retry)) }
+ }
+ if (state?.canEdit == true) {
+ Text(stringResource(R.string.group_avatar_limits), style = MaterialTheme.typography.bodySmall)
+ Row {
+ TextButton(enabled = !working, onClick = {
+ pickedAccount = repository.accountId; pickedGroup = channel.id; picker.launch("image/*")
+ }) { Text(stringResource(R.string.avatar_upload)) }
+ TextButton(enabled = !working && state.hasImage, onClick = {
+ repository.editGroup(channel.id, null, repository.accountId)
+ }) { Text(stringResource(R.string.avatar_remove)) }
+ }
+ } else Text(stringResource(R.string.group_avatar_key_required), style = MaterialTheme.typography.bodySmall)
+ if (editState == AvatarEditState.ERROR)
+ Text(stringResource(R.string.avatar_edit_failed), color = MaterialTheme.colorScheme.error)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(stringResource(R.string.sound), modifier = Modifier.weight(1f))
+ Switch(checked = soundOn, onCheckedChange = {
+ soundOn = it; NotificationSound.setMuted(channel.id, !it)
+ scope.launch { ChatApplication.instance.configProvider.setChannelMuted(channel.id, !it) }
+ })
+ }
+ }
+ },
+ confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) } })
+}
+
+@Composable
+fun ProfileAvatarControls() {
+ val repository = ChatApplication.instance.avatars
+ val members by repository.members.collectAsState()
+ val edits by repository.edits.collectAsState()
+ val servers = remember(members) { repository.storageServers() }
+ var selectedServer by rememberSaveable { mutableStateOf(0L) }
+ var menu by remember { mutableStateOf(false) }
+ var pickedAccount by rememberSaveable { mutableStateOf(0L) }
+ var pickedServer by rememberSaveable { mutableStateOf(0L) }
+ val state = edits["profile"] ?: AvatarEditState.IDLE
+ LaunchedEffect(servers) {
+ if (servers.none { it.nodeId == selectedServer }) selectedServer = servers.firstOrNull()?.nodeId ?: 0L
+ }
+ val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
+ if (uri != null) repository.editProfile(pickedServer, uri, pickedAccount)
+ }
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text(stringResource(R.string.profile_avatar), style = MaterialTheme.typography.titleMedium)
+ UserAvatar(null, repository.accountId, "", size = 64.dp, profile = true)
+ Text(stringResource(R.string.profile_avatar_hint), style = MaterialTheme.typography.bodySmall)
+ if (servers.isEmpty()) Text(stringResource(R.string.avatar_no_storage), style = MaterialTheme.typography.bodySmall)
+ else Box {
+ TextButton(onClick = { menu = true }, enabled = state != AvatarEditState.WORKING) {
+ Text(stringResource(R.string.avatar_storage_server, servers.firstOrNull { it.nodeId == selectedServer }?.let {
+ "${it.name.ifBlank { AvatarProtocol.hex(it.nodeId) }} (${AvatarProtocol.hex(it.nodeId)})"
+ } ?: "…"))
+ }
+ DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
+ servers.forEach { server ->
+ DropdownMenuItem(text = { Text("${server.name} (${AvatarProtocol.hex(server.nodeId)})") },
+ onClick = { selectedServer = server.nodeId; menu = false })
+ }
+ }
+ }
+ Row {
+ TextButton(enabled = selectedServer != 0L && state != AvatarEditState.WORKING, onClick = {
+ pickedAccount = repository.accountId; pickedServer = selectedServer; picker.launch("image/*")
+ }) { Text(stringResource(R.string.avatar_upload)) }
+ TextButton(enabled = repository.accountId != 0L && state != AvatarEditState.WORKING, onClick = {
+ repository.editProfile(0, null, repository.accountId)
+ }) { Text(stringResource(R.string.avatar_remove)) }
+ }
+ when (state) {
+ AvatarEditState.WORKING -> LinearProgressIndicator(Modifier.fillMaxWidth())
+ AvatarEditState.ERROR -> Text(stringResource(R.string.avatar_edit_failed), color = MaterialTheme.colorScheme.error)
+ AvatarEditState.SAVED -> Text(stringResource(R.string.profile_avatar_saved), style = MaterialTheme.typography.bodySmall)
+ else -> Unit
+ }
+ }
+}
diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml
index bba08903..b1726367 100644
--- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml
+++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml
@@ -347,4 +347,17 @@
Не удалось подключиться к группе (код %1$d). Возможно, срок ссылки истёк; попросите отправителя создать новое приглашение.
Чат-сервис недоступен.
Повторить
+ Свойства группы
+ Аватар профиля
+ Загрузить аватар
+ Удалить аватар
+ Повторить
+ Не удалось загрузить аватар группы.
+ Не удалось обновить аватар. Подробности в логе Avatar.
+ Изображение будет обрезано до квадрата: не более 128 × 128 и 16 КиБ.
+ Для изменения аватара нужен приватный ключ группы.
+ Изображение будет обрезано до 256 × 256. Аватар публикуется в группах, где выбранный сервер имеет роль хранителя.
+ В ваших группах нет доступных серверов хранения.
+ Сервер хранения: %1$s
+ Аватар сохранён. Публикация в группы продолжается; недоступные серверы будут опрошены повторно.
diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml
index 033a2d9c..e5f6c65e 100644
--- a/tools/chatgui-android/app/src/main/res/values/strings.xml
+++ b/tools/chatgui-android/app/src/main/res/values/strings.xml
@@ -345,4 +345,17 @@
Could not join the group (code %1$d). The link may have expired; ask the sender for a new invitation.
The chat service is unavailable.
Retry
+ Group properties
+ Profile avatar
+ Upload avatar
+ Remove avatar
+ Retry
+ Could not load the group avatar.
+ Could not update the avatar. See the Avatar log for details.
+ The image will be cropped to a square, up to 128 × 128 and 16 KiB.
+ Changing the avatar requires the private group key.
+ The image will be cropped to 256 × 256. It is published in groups where the selected server has the storage role.
+ No storage server is available in your groups.
+ Storage server: %1$s
+ Avatar saved. Publishing to groups; unavailable servers will be retried.