Merge branch 'master' into ae/oklch-color-space-plan

This commit is contained in:
Evgeny Poberezkin
2026-05-13 16:11:15 +01:00
167 changed files with 19532 additions and 708 deletions
@@ -92,6 +92,7 @@ object ChannelRelaysModel {
if (groupId.value == groupInfo.groupId) {
val i = groupRelays.indexOfFirst { it.groupRelayId == relay.groupRelayId }
if (i >= 0) groupRelays[i] = relay
else groupRelays.add(relay)
}
}
@@ -3734,7 +3735,8 @@ sealed class CIForwardedFrom {
enum class CIDeleteMode(val deleteMode: String) {
@SerialName("internal") cidmInternal("internal"),
@SerialName("internalMark") cidmInternalMark("internalMark"),
@SerialName("broadcast") cidmBroadcast("broadcast");
@SerialName("broadcast") cidmBroadcast("broadcast"),
@SerialName("history") cidmHistory("history");
}
interface ItemContent {
@@ -91,6 +91,13 @@ enum class SimplexLinkMode {
}
}
enum class CloseBehavior {
Ask, Quit, MinimizeToTray;
companion object { val default = Ask }
}
class HintPref(val reset: () -> Unit, val isUnchanged: () -> Boolean)
// Spec: spec/state.md#AppPreferences
class AppPreferences {
// deprecated, remove in 2024
@@ -99,6 +106,7 @@ class AppPreferences {
SHARED_PREFS_NOTIFICATIONS_MODE,
if (!runServiceInBackground.get()) NotificationsMode.OFF else NotificationsMode.default
) { NotificationsMode.values().firstOrNull { it.name == this } }
val closeBehavior: SharedPreference<CloseBehavior> = mkSafeEnumPreference(SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR, CloseBehavior.default)
val notificationPreviewMode = mkStrPreference(SHARED_PREFS_NOTIFICATION_PREVIEW_MODE, NotificationPreviewMode.default.name)
val canAskToEnableNotifications = mkBoolPreference(SHARED_PREFS_CAN_ASK_TO_ENABLE_NOTIFICATIONS, true)
val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false)
@@ -257,17 +265,23 @@ class AppPreferences {
val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, true)
val chatBottomBar = mkBoolPreference(SHARED_PREFS_CHAT_BOTTOM_BAR, true)
val hintPreferences: List<Pair<SharedPreference<Boolean>, Boolean>> = listOf(
laNoticeShown to false,
oneHandUICardShown to false,
addressCreationCardShown to false,
liveMessageAlertShown to false,
showHiddenProfilesNotice to true,
showMuteProfileAlert to true,
showReportsInSupportChatAlert to true,
showDeleteConversationNotice to true,
showDeleteContactNotice to true,
privacyLinkPreviewsShowAlert to true,
val hintPreferences: List<HintPref> = listOf(
hintPref(laNoticeShown, false),
hintPref(oneHandUICardShown, false),
hintPref(addressCreationCardShown, false),
hintPref(liveMessageAlertShown, false),
hintPref(showHiddenProfilesNotice, true),
hintPref(showMuteProfileAlert, true),
hintPref(showReportsInSupportChatAlert, true),
hintPref(showDeleteConversationNotice, true),
hintPref(showDeleteContactNotice, true),
hintPref(privacyLinkPreviewsShowAlert, true),
hintPref(closeBehavior, CloseBehavior.default),
)
private fun <T> hintPref(pref: SharedPreference<T>, default: T) = HintPref(
reset = { pref.set(default) },
isUnchanged = { pref.state.value == default },
)
private fun mkIntPreference(prefName: String, default: Int) =
@@ -479,6 +493,7 @@ class AppPreferences {
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto"
private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast"
private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState"
private const val SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR = "DesktopCloseBehavior"
private const val SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice"
private const val SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice"
private const val SHARED_PREFS_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
@@ -1198,14 +1213,14 @@ object ChatController {
suspend fun apiDeleteChatItems(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List<Long>, mode: CIDeleteMode): List<ChatItemDeletion>? {
val r = sendCmd(rh, CC.ApiDeleteChatItem(type, id, scope, itemIds, mode))
if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions
Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}")
apiErrorAlert("apiDeleteChatItems", generalGetString(MR.strings.error_deleting_message), r)
return null
}
suspend fun apiDeleteMemberChatItems(rh: Long?, groupId: Long, itemIds: List<Long>): List<ChatItemDeletion>? {
val r = sendCmd(rh, CC.ApiDeleteMemberChatItem(groupId, itemIds))
if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions
Log.e(TAG, "apiDeleteMemberChatItem bad response: ${r.responseType} ${r.details}")
apiErrorAlert("apiDeleteMemberChatItems", generalGetString(MR.strings.error_deleting_message), r)
return null
}
@@ -2163,6 +2178,19 @@ object ChatController {
return emptyList()
}
sealed class AddGroupRelaysResult {
data class Added(val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List<GroupRelay>): AddGroupRelaysResult()
data class AddFailed(val addRelayResults: List<AddRelayResult>): AddGroupRelaysResult()
}
suspend fun apiAddGroupRelays(groupId: Long, relayIds: List<Long>): AddGroupRelaysResult? {
val r = sendCmdWithRetry(null, CC.ApiAddGroupRelays(groupId, relayIds))
if (r is API.Result && r.res is CR.GroupRelaysAdded) return AddGroupRelaysResult.Added(r.res.groupInfo, r.res.groupLink, r.res.groupRelays)
if (r is API.Result && r.res is CR.GroupRelaysAddFailed) return AddGroupRelaysResult.AddFailed(r.res.addRelayResults)
if (r != null) throw Exception("${r.responseType}: ${r.details}")
return null
}
suspend fun apiAddMember(rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? {
val r = sendCmd(rh, CC.ApiAddMember(groupId, contactId, memberRole))
if (r is API.Result && r.res is CR.SentGroupInvitation) return r.res.member
@@ -3666,6 +3694,7 @@ sealed class CC {
class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC()
class ApiNewPublicGroup(val userId: Long, val incognito: Boolean, val relayIds: List<Long>, val groupProfile: GroupProfile): CC()
class ApiGetGroupRelays(val groupId: Long): CC()
class ApiAddGroupRelays(val groupId: Long, val relayIds: List<Long>): CC()
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
class ApiJoinGroup(val groupId: Long): CC()
class ApiAcceptMember(val groupId: Long, val groupMemberId: Long, val memberRole: GroupMemberRole): CC()
@@ -3870,6 +3899,7 @@ sealed class CC {
is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}"
is ApiNewPublicGroup -> "/_public group $userId incognito=${onOff(incognito)} ${relayIds.joinToString(",")} ${json.encodeToString(groupProfile)}"
is ApiGetGroupRelays -> "/_get relays #$groupId"
is ApiAddGroupRelays -> "/_add relays #$groupId ${relayIds.joinToString(",")}"
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
is ApiJoinGroup -> "/_join #$groupId"
is ApiAcceptMember -> "/_accept member #$groupId $groupMemberId ${memberRole.memberRole}"
@@ -4053,6 +4083,7 @@ sealed class CC {
is ApiNewGroup -> "apiNewGroup"
is ApiNewPublicGroup -> "apiNewPublicGroup"
is ApiGetGroupRelays -> "apiGetGroupRelays"
is ApiAddGroupRelays -> "apiAddGroupRelays"
is ApiAddMember -> "apiAddMember"
is ApiJoinGroup -> "apiJoinGroup"
is ApiAcceptMember -> "apiAcceptMember"
@@ -6402,6 +6433,8 @@ sealed class CR {
@Serializable @SerialName("publicGroupCreated") class PublicGroupCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List<GroupRelay>): CR()
@Serializable @SerialName("publicGroupCreationFailed") class PublicGroupCreationFailed(val user: UserRef, val addRelayResults: List<AddRelayResult>): CR()
@Serializable @SerialName("groupRelays") class GroupRelays(val user: UserRef, val groupInfo: GroupInfo, val groupRelays: List<GroupRelay>): CR()
@Serializable @SerialName("groupRelaysAdded") class GroupRelaysAdded(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List<GroupRelay>): CR()
@Serializable @SerialName("groupRelaysAddFailed") class GroupRelaysAddFailed(val user: UserRef, val addRelayResults: List<AddRelayResult>): CR()
@Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR()
@Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR()
@Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR()
@@ -6591,6 +6624,8 @@ sealed class CR {
is PublicGroupCreated -> "publicGroupCreated"
is PublicGroupCreationFailed -> "publicGroupCreationFailed"
is GroupRelays -> "groupRelays"
is GroupRelaysAdded -> "groupRelaysAdded"
is GroupRelaysAddFailed -> "groupRelaysAddFailed"
is SentGroupInvitation -> "sentGroupInvitation"
is UserAcceptedGroupSent -> "userAcceptedGroupSent"
is GroupLinkConnecting -> "groupLinkConnecting"
@@ -6773,6 +6808,8 @@ sealed class CR {
is PublicGroupCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays")
is PublicGroupCreationFailed -> withUser(user, "addRelayResults: $addRelayResults")
is GroupRelays -> withUser(user, "groupInfo: $groupInfo\ngroupRelays: $groupRelays")
is GroupRelaysAdded -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays")
is GroupRelaysAddFailed -> withUser(user, "addRelayResults: $addRelayResults")
is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member")
is UserAcceptedGroupSent -> json.encodeToString(groupInfo)
is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember")
@@ -211,7 +211,7 @@ fun ChatView(
withContext(Dispatchers.Main) {
ChannelRelaysModel.set(cInfo.groupInfo.groupId, relays)
}
} else {
} else if (cInfo.groupInfo.membership.memberCurrent) {
val gInfo = chatModel.controller.apiGetUpdatedGroupLinkData(chatRh, cInfo.groupInfo.groupId)
if (gInfo != null) {
withContext(Dispatchers.Main) {
@@ -317,6 +317,7 @@ fun ChatView(
itemIds.sorted(),
questionText = questionText,
forAll = canDeleteForAll,
editorial = publicGroupEditor(chatInfo),
deleteMessages = { ids, forAll ->
deleteMessages(chatRh, chatInfo, ids, forAll, moderate = false) {
selectedChatItems.value = null
@@ -3351,7 +3352,9 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List<Long
id = chatInfo.apiId,
scope = chatInfo.groupChatScope(),
itemIds = itemIds,
mode = if (forAll) CIDeleteMode.cidmBroadcast else CIDeleteMode.cidmInternal
mode = if (forAll) CIDeleteMode.cidmBroadcast
else if (publicGroupEditor(chatInfo)) CIDeleteMode.cidmHistory
else CIDeleteMode.cidmInternal
)
}
if (deleted != null) {
@@ -3597,7 +3600,6 @@ fun providerForGallery(
override fun scrollToStart() {
initialIndex = 0
initialChatId = chatItems.firstOrNull { canShowMedia(it) }?.id ?: return
}
override fun onDismiss(index: Int) {
@@ -3614,6 +3616,9 @@ fun providerForGallery(
typealias ChatViewItemKey = Pair<Long, Long>
fun publicGroupEditor(chatInfo: ChatInfo): Boolean =
chatInfo is ChatInfo.Group && chatInfo.groupInfo.useRelays && chatInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator
private fun keyForItem(item: ChatItem): ChatViewItemKey = ChatViewItemKey(item.id, item.meta.createdAt.toEpochMilliseconds())
private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration {
@@ -1543,14 +1543,14 @@ fun ComposeView(
) {
if (gInfo.membership.memberRole == GroupMemberRole.Owner) {
ownerRelayState?.let { s ->
if (s.activeCount < s.relays.size) {
if (s.relays.isEmpty() || s.activeCount < s.relays.size) {
OwnerChannelRelayBar(chatModel, s.relays, s.activeCount, s.failedCount, s.removedCount, relayListExpanded)
}
}
} else {
val hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?: emptyList()).sorted()
val relayMembers = chatModel.groupMembers.value
.filter { it.memberRole == GroupMemberRole.Relay }
.filter { it.memberRole == GroupMemberRole.Relay && it.memberStatus !in listOf(GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) }
.sortedBy { hostFromRelayLink(it.relayLink ?: "") }
val showProgress = !gInfo.nextConnectPrepared || composeState.value.inProgress
val removedCount = relayMembers.count { relayMemberRemoved(it.memberStatus) }
@@ -1558,7 +1558,7 @@ fun ComposeView(
val failedCount = relayMembers.count { !relayMemberRemoved(it.memberStatus) && it.activeConn?.connFailedErr != null }
val resolvedCount = connectedCount + removedCount + failedCount
val total = if (relayMembers.isNotEmpty()) relayMembers.size else hostnames.size
if (total > 0 && (removedCount + failedCount > 0 || resolvedCount < total)) {
if (total == 0 || removedCount + failedCount > 0 || resolvedCount < total) {
SubscriberChannelRelayBar(hostnames, relayMembers, connectedCount, removedCount, failedCount, total, showProgress, relayListExpanded)
}
}
@@ -1756,7 +1756,15 @@ private fun OwnerChannelRelayBar(
if (!allBroken && activeCount + failedCount + removedCount < total) {
RelayProgressIndicator(active = activeCount, total = total)
}
if (allBroken) {
if (total == 0) {
Text(generalGetString(MR.strings.relay_bar_no_relays), color = MaterialTheme.colors.secondary)
Icon(
painterResource(MR.images.ic_warning),
contentDescription = null,
tint = WarningOrange,
modifier = Modifier.size(18.dp)
)
} else if (allBroken) {
val statusText = if (removedCount == total) {
generalGetString(MR.strings.relay_bar_all_relays_removed)
} else if (failedCount == total) {
@@ -1842,7 +1850,15 @@ private fun SubscriberChannelRelayBar(
val allBroken = connectedCount == 0 && errorCount == total
Column(Modifier.background(MaterialTheme.colors.surface)) {
RelayBarHeader(relayListExpanded) {
if (allBroken) {
if (total == 0) {
Text(generalGetString(MR.strings.relay_bar_no_relays), color = MaterialTheme.colors.secondary)
Icon(
painterResource(MR.images.ic_warning),
contentDescription = null,
tint = WarningOrange,
modifier = Modifier.size(18.dp)
)
} else if (allBroken) {
val statusText = if (removedCount == total) {
generalGetString(MR.strings.relay_bar_all_relays_removed)
} else if (failedCount == total) {
@@ -1990,7 +2006,7 @@ private fun ownerRelayState(chat: Chat, chatModel: ChatModel): OwnerRelayState?
gInfo.membership.memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)
) return null
val relays = if (ChannelRelaysModel.groupId.value == gInfo.groupId) ChannelRelaysModel.groupRelays.toList() else emptyList()
if (relays.isEmpty()) return null
if (relays.isEmpty()) return OwnerRelayState(emptyList(), 0, 0, 0, true)
val relayMembers = relays.map { relay ->
relay to chatModel.groupMembers.value.firstOrNull { it.groupMemberId == relay.groupMemberId }
}
@@ -36,6 +36,7 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.item.itemPrefixText
import chat.simplex.common.views.chat.item.itemSegmentDisplayText
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
@@ -52,8 +53,10 @@ val LocalItemContext = compositionLocalOf { ItemContext() }
data class SelectionRange(
val startIndex: Int,
val startItemId: Long,
val startOffset: Int,
val endIndex: Int,
val endItemId: Long,
val endOffset: Int
)
@@ -79,11 +82,13 @@ class SelectionManager {
var viewportPosition by mutableStateOf(Offset.Zero)
var focusCharRect by mutableStateOf(Rect.Zero) // X: absolute window, Y: relative to item
var listState: State<LazyListState>? = null
var mergedItemsState: State<MergedItems>? = null
var onCopySelection: (() -> Unit)? = null
private var autoScrollJob: Job? = null
fun startSelection(startIndex: Int, anchorY: Float, anchorX: Float) {
range = SelectionRange(startIndex, -1, startIndex, -1)
val id = mergedItemsState?.value?.items?.getOrNull(startIndex)?.newest()?.item?.id ?: return
range = SelectionRange(startIndex, id, -1, startIndex, id, -1)
selectionState = SelectionState.Selecting
anchorWindowY = anchorY
anchorWindowX = anchorX
@@ -96,7 +101,8 @@ class SelectionManager {
fun updateFocusIndex(index: Int) {
val r = range ?: return
range = r.copy(endIndex = index)
val id = mergedItemsState?.value?.items?.getOrNull(index)?.newest()?.item?.id ?: return
range = r.copy(endIndex = index, endItemId = id)
}
fun updateFocusOffset(offset: Int, charRect: Rect = Rect.Zero) {
@@ -175,6 +181,15 @@ class SelectionManager {
updateFocusIndex(idx)
}
fun resyncIndices() {
val r = range ?: return
val items = mergedItemsState?.value?.items ?: return
val newStartIndex = items.indexOfFirst { it.newest().item.id == r.startItemId }
val newEndIndex = items.indexOfFirst { it.newest().item.id == r.endItemId }
if (newStartIndex < 0 || newEndIndex < 0) clearSelection()
else range = r.copy(startIndex = newStartIndex, endIndex = newEndIndex)
}
fun updateAutoScroll(draggingDown: Boolean, pointerY: Float, scope: CoroutineScope) {
val edgeDistance = if (draggingDown) viewportBottom - pointerY else pointerY - viewportTop
if (edgeDistance !in 0f..AUTO_SCROLL_ZONE_PX) {
@@ -240,15 +255,22 @@ fun selectedRange(range: SelectionRange?, index: Int): IntRange? {
}
// Extracts source text for the selected range within one item.
// Selection offsets are in display-text space. For transformed segments (mentions, links with showText),
// the full source is emitted if any part is selected. For untransformed segments, partial substring works.
// Selection offsets are in display-text space (which includes any leading itemPrefixText).
// For transformed segments (mentions, links with showText), the full source is emitted if any part
// is selected. For untransformed segments, partial substring works.
private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: SimplexLinkMode): String {
val formattedText = ci.formattedText ?: return ci.text.substring(
sel.first.coerceAtMost(ci.text.length),
(sel.last + 1).coerceAtMost(ci.text.length)
)
val prefix = itemPrefixText(ci)
val sb = StringBuilder()
var displayOffset = 0
if (sel.first < prefix.length) {
sb.append(prefix, sel.first, minOf(prefix.length, sel.last + 1))
}
val formattedText = ci.formattedText ?: run {
val start = (sel.first - prefix.length).coerceAtLeast(0).coerceAtMost(ci.text.length)
val end = (sel.last + 1 - prefix.length).coerceAtMost(ci.text.length)
if (start < end) sb.append(ci.text, start, end)
return sb.toString()
}
var displayOffset = prefix.length
for (ft in formattedText) {
val segDisplay = itemSegmentDisplayText(ft, ci, linkMode)
val displayEnd = displayOffset + segDisplay.length
@@ -269,7 +291,7 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple
// Snaps a boundary offset to include full transformed segments.
private fun snapOffset(ci: ChatItem, offset: Int, linkMode: SimplexLinkMode, expandRight: Boolean): Int {
val formattedText = ci.formattedText ?: return offset
var displayOffset = 0
var displayOffset = itemPrefixText(ci).length
for (ft in formattedText) {
val segDisplay = itemSegmentDisplayText(ft, ci, linkMode)
val displayEnd = displayOffset + segDisplay.length
@@ -312,11 +334,15 @@ fun BoxScope.SelectionHandler(
}
manager.listState = listState
manager.mergedItemsState = mergedItems
manager.onCopySelection = {
clipboard.setText(AnnotatedString(manager.getSelectedCopiedText(mergedItems.value.items, revealedItems.value, linkMode)))
showToast(generalGetString(MR.strings.copied))
}
// Resync after the items list mutates (new message arrives, item deleted).
SideEffect { manager.resyncIndices() }
return Modifier
.focusRequester(focusRequester)
.focusable()
@@ -0,0 +1,237 @@
package chat.simplex.common.views.chat.group
import SectionBottomSpacer
import SectionCustomFooter
import SectionDividerSpaced
import SectionItemView
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.chatRelayDisplayName
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.launch
data class AvailableRelay(
val relayId: Long,
val relay: UserChatRelay,
val operatorName: String?
)
@Composable
fun AddGroupRelayView(
groupInfo: GroupInfo,
existingRelayIds: Set<Long>,
onRelayAdded: () -> Unit,
close: () -> Unit
) {
var availableRelays by remember { mutableStateOf<List<AvailableRelay>>(emptyList()) }
var selectedRelayIds by remember { mutableStateOf<Set<Long>>(emptySet()) }
var isLoading by remember { mutableStateOf(true) }
var isAdding by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
BackHandler(onBack = close)
LaunchedEffect(Unit) {
try {
val servers = ChatController.getUserServers(null)
if (servers != null) {
val relays = mutableListOf<AvailableRelay>()
for (op in servers) {
if (op.operator != null && op.operator.enabled != true) continue
val opName: String? = if (op.operator?.operatorTag != null) op.operator.tradeName else null
for (relay in op.chatRelays) {
val relayId = relay.chatRelayId
if (relay.enabled && !relay.deleted && relayId != null && relayId !in existingRelayIds) {
relays.add(AvailableRelay(relayId, relay, opName))
}
}
}
availableRelays = relays
}
} catch (e: Exception) {
Log.e(TAG, "loadAvailableRelays error: ${e.message}")
}
isLoading = false
}
AddGroupRelayLayout(
availableRelays = availableRelays,
selectedRelayIds = selectedRelayIds,
isLoading = isLoading,
isAdding = isAdding,
onToggleRelay = { relayId ->
selectedRelayIds = if (relayId in selectedRelayIds) selectedRelayIds - relayId else selectedRelayIds + relayId
},
onAddRelays = {
val relayIds = selectedRelayIds.toList()
if (relayIds.isEmpty()) return@AddGroupRelayLayout
isAdding = true
scope.launch {
addSelectedRelays(groupInfo, relayIds, selectedRelayIds, availableRelays, onRelayAdded, close) { newSelectedIds, newAvailableRelays ->
selectedRelayIds = newSelectedIds
availableRelays = newAvailableRelays
isAdding = false
}
}
}
)
}
@Composable
private fun AddGroupRelayLayout(
availableRelays: List<AvailableRelay>,
selectedRelayIds: Set<Long>,
isLoading: Boolean,
isAdding: Boolean,
onToggleRelay: (Long) -> Unit,
onAddRelays: () -> Unit
) {
ColumnWithScrollBar {
AppBarTitle(generalGetString(MR.strings.add_relays_title))
if (isLoading) {
Box(Modifier.fillMaxWidth().padding(vertical = DEFAULT_PADDING), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (availableRelays.isEmpty()) {
SectionView {
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(
generalGetString(MR.strings.no_available_relays),
color = MaterialTheme.colors.secondary
)
}
}
} else {
SectionView {
AddRelaysButton(
onClick = onAddRelays,
disabled = selectedRelayIds.isEmpty() || isAdding
)
}
SectionCustomFooter {
val count = selectedRelayIds.size
Text(
if (count == 0) generalGetString(MR.strings.no_relays_selected)
else String.format(generalGetString(MR.strings.num_relays_selected), count),
color = MaterialTheme.colors.secondary,
lineHeight = 18.sp,
fontSize = 14.sp
)
}
SectionDividerSpaced(maxTopPadding = true)
SectionView(generalGetString(MR.strings.select_relays).uppercase()) {
availableRelays.forEach { item ->
val selected = item.relayId in selectedRelayIds
SectionItemView(
click = { onToggleRelay(item.relayId) },
padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 4.dp)
) {
Column(Modifier.weight(1f)) {
Text(
chatRelayDisplayName(item.relay),
maxLines = 1,
color = MaterialTheme.colors.onBackground
)
if (item.operatorName != null) {
Text(
item.operatorName,
fontSize = 12.sp,
maxLines = 1,
color = MaterialTheme.colors.secondary
)
}
}
Spacer(Modifier.width(8.dp))
Icon(
painterResource(if (selected) MR.images.ic_check_circle_filled else MR.images.ic_circle),
contentDescription = null,
tint = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
modifier = Modifier.size(24.dp)
)
}
}
}
}
SectionBottomSpacer()
}
}
@Composable
private fun AddRelaysButton(onClick: () -> Unit, disabled: Boolean) {
SettingsActionItem(
painterResource(MR.images.ic_check),
generalGetString(MR.strings.add_relays_title),
click = onClick,
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
disabled = disabled,
)
}
private suspend fun addSelectedRelays(
groupInfo: GroupInfo,
relayIds: List<Long>,
selectedRelayIds: Set<Long>,
availableRelays: List<AvailableRelay>,
onRelayAdded: () -> Unit,
close: () -> Unit,
updateState: (Set<Long>, List<AvailableRelay>) -> Unit
) {
try {
val result = ChatController.apiAddGroupRelays(groupInfo.groupId, relayIds)
if (result == null) {
updateState(selectedRelayIds, availableRelays)
return
}
when (result) {
is ChatController.AddGroupRelaysResult.Added -> {
ChannelRelaysModel.set(groupId = result.groupInfo.groupId, groupRelays = result.groupRelays)
onRelayAdded()
close()
}
is ChatController.AddGroupRelaysResult.AddFailed -> {
val results = result.addRelayResults
val successIds = results.filter { it.relayError == null }.mapNotNull { it.relay.chatRelayId }.toSet()
var newSelectedIds = selectedRelayIds
var newAvailableRelays = availableRelays
if (successIds.isNotEmpty()) {
newSelectedIds = selectedRelayIds - successIds
newAvailableRelays = availableRelays.filter { it.relayId !in successIds }
onRelayAdded()
}
val errorLines = results.filter { it.relayError != null }
.map { "${chatRelayDisplayName(it.relay)}: ${it.relayError?.let { e -> ChatController.connErrorText(e) } ?: ""}" }
val successNames = results.filter { it.relayError == null }
.map { chatRelayDisplayName(it.relay) }
var msg = errorLines.joinToString("\n")
if (successNames.isNotEmpty()) {
msg += "\n" + String.format(generalGetString(MR.strings.relays_added_format), successNames.joinToString(", "))
}
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.error_adding_relays),
text = msg
)
updateState(newSelectedIds, newAvailableRelays)
}
}
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.error_adding_relays),
text = e.message ?: ""
)
updateState(selectedRelayIds, availableRelays)
}
}
@@ -2,6 +2,7 @@ package chat.simplex.common.views.chat.group
import SectionBottomSpacer
import SectionItemView
import SectionItemViewLongClickable
import SectionTextFooter
import SectionView
import androidx.compose.foundation.layout.*
@@ -16,9 +17,11 @@ import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chatlist.setGroupMembers
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
fun ChannelRelaysView(
@@ -29,16 +32,18 @@ fun ChannelRelaysView(
showMemberInfo: (GroupMember, GroupRelay?) -> Unit
) {
BackHandler(onBack = close)
var groupRelays by remember { mutableStateOf<List<GroupRelay>>(emptyList()) }
val groupRelays = ChannelRelaysModel.groupRelays
LaunchedEffect(Unit) {
setGroupMembers(rhId, groupInfo, chatModel)
if (groupInfo.isOwner) {
groupRelays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId)
val relays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId)
ChannelRelaysModel.set(groupId = groupInfo.groupId, groupRelays = relays)
}
}
ChannelRelaysLayout(
rhId = rhId,
groupInfo = groupInfo,
chatModel = chatModel,
groupRelays = groupRelays,
@@ -48,13 +53,14 @@ fun ChannelRelaysView(
@Composable
private fun ChannelRelaysLayout(
rhId: Long?,
groupInfo: GroupInfo,
chatModel: ChatModel,
groupRelays: List<GroupRelay>,
showMemberInfo: (GroupMember, GroupRelay?) -> Unit
) {
val relayMembers = remember { chatModel.groupMembers }.value
.filter { it.memberRole == GroupMemberRole.Relay }
.filter { it.memberRole == GroupMemberRole.Relay && it.memberStatus != GroupMemberStatus.MemRemoved && it.memberStatus != GroupMemberStatus.MemGroupDeleted }
ColumnWithScrollBar {
AppBarTitle(generalGetString(MR.strings.channel_relays_title))
@@ -74,11 +80,24 @@ private fun ChannelRelaysLayout(
if (index > 0) {
Divider()
}
SectionItemView(
val showMenu = remember { mutableStateOf(false) }
SectionItemViewLongClickable(
click = { showMemberInfo(member, groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }) },
longClick = { showMenu.value = true },
minHeight = 54.dp,
padding = PaddingValues(horizontal = DEFAULT_PADDING)
) {
// TODO [relays] re-enable when relay management ships
/*
if (groupInfo.isOwner && member.canBeRemoved(groupInfo)) {
DefaultDropdownMenu(showMenu) {
ItemAction(generalGetString(MR.strings.button_remove_relay), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
removeMemberAlert(rhId, groupInfo, member)
showMenu.value = false
})
}
}
*/
val statusText = if (groupInfo.isOwner) {
ownerRelayStatusText(member, groupRelays)
} else {
@@ -90,6 +109,35 @@ private fun ChannelRelaysLayout(
}
SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages))
}
// TODO [relays] re-enable when relay management ships
/*
if (groupInfo.isOwner) {
SectionView {
SectionItemView(click = {
val existingRelayIds = groupRelays.filter { it.relayStatus != RelayStatus.RsInactive }.mapNotNull { it.userChatRelay.chatRelayId }.toSet()
ModalManager.end.showModalCloseable(true) { close ->
AddGroupRelayView(
groupInfo = groupInfo,
existingRelayIds = existingRelayIds,
onRelayAdded = { withBGApi { setGroupMembers(rhId, groupInfo, chatModel) } },
close = close
)
}
}, padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Icon(
painterResource(MR.images.ic_add),
contentDescription = null,
tint = MaterialTheme.colors.primary
)
Spacer(Modifier.width(4.dp))
Text(
generalGetString(MR.strings.add_relay_button),
color = MaterialTheme.colors.primary
)
}
}
}
*/
SectionBottomSpacer()
}
}
@@ -239,39 +239,88 @@ fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl
)
}
private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) {
val titleId = if (groupInfo.useRelays) MR.strings.button_remove_subscriber_question
else MR.strings.button_remove_member_question
val messageId = if (groupInfo.useRelays)
MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone
else if (groupInfo.businessChat == null)
MR.strings.member_will_be_removed_from_group_cannot_be_undone
else
MR.strings.member_will_be_removed_from_chat_cannot_be_undone
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(titleId),
generalGetString(messageId),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) {
if (mem.memberRole == GroupMemberRole.Relay) {
val isLastActive = groupInfo.useRelays && mem.memberCurrent && run {
val activeRelays = ChatModel.groupMembers.value.filter { it.memberRole == GroupMemberRole.Relay && it.memberCurrent }
activeRelays.size <= 1
}
val message = if (isLastActive) generalGetString(MR.strings.last_active_relay_warning)
else generalGetString(MR.strings.relay_will_be_removed_from_channel)
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_relay_question),
message,
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
})
} else if (groupInfo.useRelays) {
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_subscriber_question),
generalGetString(MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
})
} else {
val titleId = MR.strings.button_remove_member_question
val messageId = if (groupInfo.businessChat == null)
MR.strings.member_will_be_removed_from_group_cannot_be_undone
else
MR.strings.member_will_be_removed_from_chat_cannot_be_undone
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(titleId),
generalGetString(messageId),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
})
})
}
}
private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: List<Long>, onSuccess: () -> Unit = {}) {
@@ -281,8 +281,13 @@ fun GroupLinkLayout(
)
}
if (creatingGroup && close != null) {
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
ContinueButton(close)
SettingsActionItem(
painterResource(MR.images.ic_check),
stringResource(MR.strings.continue_to_next_step),
click = close,
iconColor = MaterialTheme.colors.primary,
textColor = MaterialTheme.colors.primary,
)
}
}
}
@@ -242,34 +242,86 @@ fun GroupMemberInfoView(
}
fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
val messageId = if (groupInfo.businessChat == null)
MR.strings.member_will_be_removed_from_group_cannot_be_undone
else
MR.strings.member_will_be_removed_from_chat_cannot_be_undone
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_member_question),
generalGetString(messageId),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
if (member.memberRole == GroupMemberRole.Relay) {
val isLastActive = groupInfo.useRelays && run {
val activeRelays = chatModel.groupMembers.value.filter { it.memberRole == GroupMemberRole.Relay && it.memberCurrent }
activeRelays.size <= 1
}
val message = if (isLastActive) generalGetString(MR.strings.last_active_relay_warning)
else generalGetString(MR.strings.relay_will_be_removed_from_channel)
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_relay_question),
message,
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
})
} else if (groupInfo.useRelays) {
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_subscriber_question),
generalGetString(MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
})
} else {
val messageId = if (groupInfo.businessChat == null)
MR.strings.member_will_be_removed_from_group_cannot_be_undone
else
MR.strings.member_will_be_removed_from_chat_cannot_be_undone
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_member_question),
generalGetString(messageId),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
})
})
}
}
fun deleteMemberMessagesDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
@@ -368,6 +420,7 @@ fun GroupMemberInfoLayout(
@Composable
fun ModeratorDestructiveSection() {
val canBlockForAll = member.canBlockForAll(groupInfo)
// TODO [relays] re-enable when relay management ships
val canRemove = member.canBeRemoved(groupInfo) && member.memberRole != GroupMemberRole.Relay
if (canBlockForAll || canRemove) {
SectionDividerSpaced(maxBottomPadding = false)
@@ -380,10 +433,10 @@ fun GroupMemberInfoLayout(
}
}
if (canRemove) {
if (member.memberStatus == GroupMemberStatus.MemRemoved || member.memberStatus == GroupMemberStatus.MemLeft) {
if (member.memberStatus != GroupMemberStatus.MemRemoved && (member.memberStatus != GroupMemberStatus.MemLeft || member.memberRole == GroupMemberRole.Relay)) {
RemoveMemberButton(groupInfo.useRelays, member.memberRole == GroupMemberRole.Relay, removeMember)
} else if (member.memberRole != GroupMemberRole.Relay) {
DeleteMemberMessagesButton(deleteMemberMessages)
} else {
RemoveMemberButton(groupInfo.useRelays, removeMember)
}
}
}
@@ -753,8 +806,10 @@ fun UnblockForAllButton(onClick: () -> Unit) {
}
@Composable
fun RemoveMemberButton(useRelays: Boolean = false, onClick: () -> Unit) {
val label = if (useRelays) MR.strings.button_remove_subscriber else MR.strings.button_remove_member
fun RemoveMemberButton(useRelays: Boolean = false, isRelay: Boolean = false, onClick: () -> Unit) {
val label = if (isRelay) MR.strings.button_remove_relay
else if (useRelays) MR.strings.button_remove_subscriber
else MR.strings.button_remove_member
SettingsActionItem(
painterResource(MR.images.ic_delete),
stringResource(label),
@@ -182,7 +182,7 @@ fun CIImageView(
.then(
if (!smallView) {
val w = if (previewBitmap.width * 0.97 <= previewBitmap.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH
Modifier.width(w).aspectRatio(previewBitmap.width.toFloat() / previewBitmap.height.toFloat())
Modifier.width(w).aspectRatio((previewBitmap.width.toFloat() / previewBitmap.height.toFloat()).coerceAtLeast(1f / 2.33f))
} else Modifier
)
.desktopModifyBlurredState(!smallView, blurred, showMenu),
@@ -374,7 +374,7 @@ fun ChatItemView(
@Composable
fun DeleteItemMenu() {
DefaultDropdownMenu(showMenu) {
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
if (cItem.canBeDeletedForSelf) {
Divider()
SelectItemAction(showMenu, selectChatItem)
@@ -392,7 +392,7 @@ fun ChatItemView(
if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) {
ArchiveReportItemAction(cItem.id, cInfo.groupInfo.membership.memberActive, showMenu, archiveReports)
}
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report))
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report))
Divider()
SelectItemAction(showMenu, selectChatItem)
}
@@ -482,7 +482,7 @@ fun ChatItemView(
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
}
if (!(live && cItem.meta.isLive) && !preview) {
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
}
if (cItem.chatDir !is CIDirection.GroupSnd) {
val groupInfo = cItem.memberToModerate(cInfo)?.first
@@ -508,7 +508,7 @@ fun ChatItemView(
ExpandItemAction(revealed, showMenu, reveal)
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
if (cItem.canBeDeletedForSelf) {
Divider()
SelectItemAction(showMenu, selectChatItem)
@@ -518,7 +518,7 @@ fun ChatItemView(
cItem.isDeletedContent -> {
DefaultDropdownMenu(showMenu) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
if (cItem.canBeDeletedForSelf) {
Divider()
SelectItemAction(showMenu, selectChatItem)
@@ -532,7 +532,7 @@ fun ChatItemView(
} else {
ExpandItemAction(revealed, showMenu, reveal)
}
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
if (cItem.canBeDeletedForSelf) {
Divider()
SelectItemAction(showMenu, selectChatItem)
@@ -541,7 +541,7 @@ fun ChatItemView(
}
else -> {
DefaultDropdownMenu(showMenu) {
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
if (selectedChatItems.value == null) {
Divider()
SelectItemAction(showMenu, selectChatItem)
@@ -558,7 +558,7 @@ fun ChatItemView(
RevealItemAction(revealed, showMenu, reveal)
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
if (cItem.canBeDeletedForSelf) {
Divider()
SelectItemAction(showMenu, selectChatItem)
@@ -587,7 +587,7 @@ fun ChatItemView(
DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
DefaultDropdownMenu(showMenu) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
if (cItem.canBeDeletedForSelf) {
Divider()
SelectItemAction(showMenu, selectChatItem)
@@ -661,7 +661,7 @@ fun ChatItemView(
ExpandItemAction(revealed, showMenu, reveal)
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages)
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages)
if (cItem.canBeDeletedForSelf) {
Divider()
SelectItemAction(showMenu, selectChatItem)
@@ -866,6 +866,7 @@ fun ItemInfoAction(
@Composable
fun DeleteItemAction(
chatsCtx: ChatModel.ChatsContext,
cInfo: ChatInfo,
cItem: ChatItem,
revealed: State<Boolean>,
showMenu: MutableState<Boolean>,
@@ -898,13 +899,13 @@ fun DeleteItemAction(
deleteMessages = { ids, _ -> deleteMessages(ids) }
)
} else {
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
deleteMessageAlertDialog(cItem, questionText, cInfo, deleteMessage = deleteMessage)
}
} else {
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
deleteMessageAlertDialog(cItem, questionText, cInfo, deleteMessage = deleteMessage)
}
} else {
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
deleteMessageAlertDialog(cItem, questionText, cInfo, deleteMessage = deleteMessage)
}
},
color = Color.Red
@@ -1371,7 +1372,9 @@ fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction
)
}
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, chatInfo: ChatInfo, deleteMessage: (Long, CIDeleteMode) -> Unit) {
val canDeleteForEveryone = chatItem.meta.deletable && !chatItem.localNote && !chatItem.isReport
val editorial = publicGroupEditor(chatInfo)
AlertManager.shared.showAlertDialogButtons(
title = generalGetString(MR.strings.delete_message__question),
text = questionText,
@@ -1382,11 +1385,18 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes
.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = {
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
AlertManager.shared.hideAlert()
}) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) }
if (chatItem.meta.deletable && !chatItem.localNote && !chatItem.isReport) {
if (editorial) {
TextButton(onClick = {
deleteMessage(chatItem.id, CIDeleteMode.cidmHistory)
AlertManager.shared.hideAlert()
}) { Text(stringResource(MR.strings.from_history), color = MaterialTheme.colors.error) }
} else {
TextButton(onClick = {
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
AlertManager.shared.hideAlert()
}) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) }
}
if (canDeleteForEveryone) {
Spacer(Modifier.padding(horizontal = 4.dp))
TextButton(onClick = {
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
@@ -1398,7 +1408,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes
)
}
fun deleteMessagesAlertDialog(itemIds: List<Long>, questionText: String, forAll: Boolean, deleteMessages: (List<Long>, Boolean) -> Unit) {
fun deleteMessagesAlertDialog(itemIds: List<Long>, questionText: String, forAll: Boolean, editorial: Boolean = false, deleteMessages: (List<Long>, Boolean) -> Unit) {
AlertManager.shared.showAlertDialogButtons(
title = generalGetString(MR.strings.delete_messages__question).format(itemIds.size),
text = questionText,
@@ -1412,7 +1422,7 @@ fun deleteMessagesAlertDialog(itemIds: List<Long>, questionText: String, forAll:
TextButton(onClick = {
deleteMessages(itemIds, false)
AlertManager.shared.hideAlert()
}) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) }
}) { Text(stringResource(if (editorial) MR.strings.from_history else MR.strings.for_me_only), color = MaterialTheme.colors.error) }
if (forAll) {
TextButton(onClick = {
@@ -365,7 +365,7 @@ fun FramedItemView(
is MsgContent.MCReport -> {
val prefix = buildAnnotatedString {
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
append(itemPrefixText(ci))
}
}
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix)
@@ -85,6 +85,13 @@ fun itemDisplayText(ci: ChatItem, linkMode: SimplexLinkMode): String {
return formattedText.joinToString("") { itemSegmentDisplayText(it, ci, linkMode) }
}
// Display-only prefix rendered before ci.text (e.g. "Spam: " for reports).
// Renderers and selection code MUST share this string — otherwise selection offsets drift from screen.
fun itemPrefixText(ci: ChatItem): String = when (val mc = ci.content.msgContent) {
is MsgContent.MCReport -> if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: "
else -> ""
}
// Text transformations in MarkdownText must match itemSegmentDisplayText above
@Composable
fun MarkdownText (
@@ -255,11 +255,11 @@ fun ChatPreviewView(
ci.content.msgContent is MsgContent.MCChat -> null
else -> ci.formattedText
}
val prefix = when (val mc = ci.content.msgContent) {
val prefix = when (ci.content.msgContent) {
is MsgContent.MCReport ->
buildAnnotatedString {
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
append(itemPrefixText(ci))
}
}
@@ -404,11 +404,6 @@ private fun ProgressStepView(
ModalView(
close = { showCancelAlert() },
showClose = false,
endButtons = {
TextButton(onClick = { showCancelAlert() }) {
Text(generalGetString(MR.strings.button_delete_channel))
}
}
) {
ColumnWithScrollBar {
AppBarTitle(generalGetString(MR.strings.creating_channel))
@@ -481,9 +476,16 @@ private fun ProgressStepView(
Spacer(Modifier.height(16.dp))
SectionView {
SettingsActionItem(
painterResource(MR.images.ic_delete),
generalGetString(MR.strings.button_cancel_and_delete_channel),
click = { showCancelAlert() },
textColor = Color.Red,
iconColor = Color.Red,
)
val enabled = activeCount > 0
SettingsActionItem(
painterResource(MR.images.ic_link),
painterResource(MR.images.ic_check),
generalGetString(MR.strings.continue_to_next_step),
click = {
if (activeCount >= total) {
@@ -586,7 +588,7 @@ fun relayDisplayName(relay: GroupRelay): String {
return "relay ${relay.groupRelayId}"
}
private fun chatRelayDisplayName(relay: UserChatRelay): String {
fun chatRelayDisplayName(relay: UserChatRelay): String {
if (relay.displayName.isNotEmpty()) return relay.displayName
return relay.address
}
@@ -595,7 +597,7 @@ private fun chatRelayDisplayName(relay: UserChatRelay): String {
fun RelayStatusIndicator(status: RelayStatus, connFailed: Boolean = false, memberStatus: GroupMemberStatus? = null) {
val removed = memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)
val color = if (connFailed || removed) Color.Red else if (status == RelayStatus.RsActive) Color.Green else WarningYellow
val text = if (connFailed) generalGetString(MR.strings.relay_status_failed) else if (memberStatus == GroupMemberStatus.MemLeft) generalGetString(MR.strings.relay_conn_status_removed_by_operator) else status.text
val text = if (connFailed) generalGetString(MR.strings.relay_status_failed) else if (memberStatus == GroupMemberStatus.MemLeft) generalGetString(MR.strings.relay_conn_status_removed_by_operator) else if (removed) generalGetString(MR.strings.relay_conn_status_removed) else status.text
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
@@ -295,14 +295,10 @@ fun ChatLockItem(
}
private fun resetHintPreferences() {
for ((pref, def) in appPreferences.hintPreferences) {
pref.set(def)
}
appPreferences.hintPreferences.forEach { it.reset() }
}
fun unchangedHintPreferences(): Boolean = appPreferences.hintPreferences.all { (pref, def) ->
pref.state.value == def
}
fun unchangedHintPreferences(): Boolean = appPreferences.hintPreferences.all { it.isUnchanged() }
@Composable
fun AppVersionItem(showVersion: () -> Unit) {
@@ -207,6 +207,7 @@
<string name="error_deleting_group">Error deleting group</string>
<string name="error_deleting_note_folder">Error deleting private notes</string>
<string name="error_deleting_contact_request">Error deleting contact request</string>
<string name="error_deleting_message">Error deleting message</string>
<string name="error_deleting_pending_contact_connection">Error deleting pending contact connection</string>
<string name="error_changing_address">Error changing address</string>
<string name="error_aborting_address_change">Error aborting address change</string>
@@ -424,6 +425,7 @@
<string name="moderate_messages_will_be_marked_warning">The messages will be marked as moderated for all members.</string>
<string name="for_me_only">Delete for me</string>
<string name="for_everybody">For everyone</string>
<string name="from_history">From history</string>
<string name="stop_file__action">Stop file</string>
<string name="stop_snd_file__title">Stop sending file?</string>
<string name="stop_snd_file__message">Sending file will be stopped.</string>
@@ -1887,6 +1889,7 @@
<string name="group_info_member_you">you: %1$s</string>
<string name="button_delete_group">Delete group</string>
<string name="button_delete_channel">Delete channel</string>
<string name="button_cancel_and_delete_channel">Cancel and delete channel</string>
<string name="button_delete_chat">Delete chat</string>
<string name="delete_group_question">Delete group?</string>
<string name="delete_channel_question">Delete channel?</string>
@@ -2985,6 +2988,7 @@
<string name="relay_conn_status_deleted">deleted</string>
<string name="relay_conn_status_failed">failed</string>
<string name="relay_conn_status_removed_by_operator">removed by operator</string>
<string name="relay_conn_status_removed">removed</string>
<string name="relay_status_new">new</string>
<string name="relay_status_invited">invited</string>
<string name="relay_status_accepted">accepted</string>
@@ -3006,7 +3010,8 @@
<string name="relay_bar_connected_with_failures">%1$d/%2$d relays connected, %3$d failed</string>
<string name="relay_bar_connected_with_removed">%1$d/%2$d relays connected, %3$d removed</string>
<string name="relay_bar_connected">%1$d/%2$d relays connected</string>
<string name="relay_bar_owner_no_delivery">Adding relays will be supported later.</string>
<string name="relay_bar_no_relays">No relays</string>
<string name="relay_bar_owner_no_delivery">Add relays to restore message delivery.</string>
<string name="relay_bar_subscriber_waiting">Waiting for channel owner to add relays.</string>
<!-- GroupMemberInfoView.kt channel-related -->
@@ -3021,6 +3026,10 @@
<string name="relay_section_footer_owner">Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel.</string>
<string name="relay_section_footer_subscriber">You connected to the channel via this relay link.</string>
<string name="button_remove_subscriber">Remove subscriber</string>
<string name="button_remove_relay">Remove relay</string>
<string name="button_remove_relay_question">Remove relay?</string>
<string name="relay_will_be_removed_from_channel">Relay will be removed from channel - this cannot be undone!</string>
<string name="last_active_relay_warning">This is the last active relay. Removing it will prevent message delivery to subscribers.</string>
<string name="block_subscriber_for_all_question">Block subscriber for all?</string>
<!-- AddChannelView.kt -->
@@ -3040,6 +3049,15 @@
<string name="your_profile_shared_with_channel_relays">Your profile %1$s will be shared with channel relays and subscribers.\nRelays can access channel messages.</string>
<string name="configure_relays">Configure relays</string>
<string name="relay_status_failed">failed</string>
<string name="add_button">Add</string>
<string name="add_relay_button">Add relay</string>
<string name="add_relays_title">Add relays</string>
<string name="no_available_relays">No available relays</string>
<string name="error_adding_relays">Error adding relays</string>
<string name="relays_added_format">Relays added: %1$s.</string>
<string name="select_relays">Select relays</string>
<string name="no_relays_selected">No relays selected</string>
<string name="num_relays_selected">%d relay(s) selected</string>
<string name="relay_connection_failed">Relay connection failed</string>
<string name="not_all_relays_connected">Not all relays connected</string>
<string name="wait_verb">Wait</string>
@@ -3063,4 +3081,16 @@
<string name="link_previews_alert_desc_socks">Link preview will be requested via SOCKS proxy. DNS lookup may still happen locally via your DNS resolver.</string>
<string name="link_previews_alert_enable">Enable</string>
<string name="link_previews_alert_disable">Disable</string>
<!-- Desktop tray / minimize-to-tray -->
<string name="close_behavior_dialog_title">Minimize to tray?</string>
<string name="close_behavior_dialog_text">If you choose Close, messages won\'t be received.\nYou can change it later in Appearance settings.</string>
<string name="close_behavior_dialog_close">Close the app</string>
<string name="close_behavior_dialog_minimize">Minimize to tray</string>
<string name="tray_show">Show SimpleX</string>
<string name="tray_quit">Quit SimpleX</string>
<string name="tray_tooltip">SimpleX</string>
<string name="tray_tooltip_unread">SimpleX — %d unread</string>
<string name="appearance_minimize_to_tray">Minimize to tray when closing window</string>
<string name="appearance_minimize_to_tray_desc">Keep SimpleX running in the background to receive messages.</string>
</resources>
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="120"
height="120"
viewBox="121 0 40 40"
fill="none"
version="1.1"
id="svg3"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="m 126.52238,11.425398 5.80302,5.716401 5.88962,-5.889626 2.8582,2.858201 L 135.1836,20 l 5.7164,5.716402 -2.94482,2.8582 -5.7164,-5.629789 -5.88962,5.803014 -2.8582,-2.858201 5.88962,-5.803014 -5.803,-5.716402 z"
fill="#030749"
id="path1"
style="stroke-width:0.866122" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="m 137.86858,28.661214 2.94481,-2.944812 v 0 l 5.88963,-5.803014 -5.80302,-5.62979 v 0 l -2.8582,-2.8582 -5.7164,-5.7164023 2.94481,-2.9448129 5.7164,5.7164017 5.88963,-5.8030138 2.8582,2.8582008 -5.88962,5.8030145 5.7164,5.716401 5.88962,-5.803014 2.8582,2.858201 -5.88962,5.803014 5.803,5.716402 -2.9448,2.858201 -5.7164,-5.716402 -5.88963,5.803013 5.7164,5.716402 -2.8582,2.944813 -5.80301,-5.716402 -5.80302,5.803015 -2.8582,-2.858201 z"
fill="url(#paint0_linear_40_164)"
id="path2"
style="fill:url(#paint0_linear_40_164);stroke-width:0.866122" />
<!-- Unread dot in bottom-right; cy ≤ 34 to keep it inside the 40×40 viewBox bottom edge -->
<circle cx="155" cy="34" r="6" fill="#e53935" />
<defs
id="defs3">
<linearGradient
x1="135.948"
y1="-0.81632602"
x2="132.09599"
y2="36.985699"
gradientUnits="userSpaceOnUse"
id="paint0_linear_40_164"
gradientTransform="matrix(0.86612147,0,0,0.86612147,18.863485,2.6775707)">
<stop
stop-color="#01f1ff"
id="stop2" />
<stop
offset="1"
stop-color="#0197ff"
id="stop3" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="120"
height="120"
viewBox="121 0 40 40"
fill="none"
version="1.1"
id="svg3"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="m 126.52238,11.425398 5.80302,5.716401 5.88962,-5.889626 2.8582,2.858201 L 135.1836,20 l 5.7164,5.716402 -2.94482,2.8582 -5.7164,-5.629789 -5.88962,5.803014 -2.8582,-2.858201 5.88962,-5.803014 -5.803,-5.716402 z"
fill="#ffffff"
id="path1"
style="stroke-width:0.866122" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="m 137.86858,28.661214 2.94481,-2.944812 v 0 l 5.88963,-5.803014 -5.80302,-5.62979 v 0 l -2.8582,-2.8582 -5.7164,-5.7164023 2.94481,-2.9448129 5.7164,5.7164017 5.88963,-5.8030138 2.8582,2.8582008 -5.88962,5.8030145 5.7164,5.716401 5.88962,-5.803014 2.8582,2.858201 -5.88962,5.803014 5.803,5.716402 -2.9448,2.858201 -5.7164,-5.716402 -5.88963,5.803013 5.7164,5.716402 -2.8582,2.944813 -5.80301,-5.716402 -5.80302,5.803015 -2.8582,-2.858201 z"
fill="url(#paint0_linear_40_164)"
id="path2"
style="fill:url(#paint0_linear_40_164);stroke-width:0.866122" />
<!-- Unread dot in bottom-right; cy ≤ 34 to keep it inside the 40×40 viewBox bottom edge -->
<circle cx="155" cy="34" r="6" fill="#e53935" />
<defs
id="defs3">
<linearGradient
x1="135.948"
y1="-0.81632602"
x2="132.09599"
y2="36.985699"
gradientUnits="userSpaceOnUse"
id="paint0_linear_40_164"
gradientTransform="matrix(0.86612147,0,0,0.86612147,18.863485,2.6775707)">
<stop
stop-color="#01f1ff"
id="stop2" />
<stop
offset="1"
stop-color="#0197ff"
id="stop3" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="120"
height="120"
viewBox="121 0 40 40"
fill="none"
version="1.1"
id="svg3"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="m 126.52238,11.425398 5.80302,5.716401 5.88962,-5.889626 2.8582,2.858201 L 135.1836,20 l 5.7164,5.716402 -2.94482,2.8582 -5.7164,-5.629789 -5.88962,5.803014 -2.8582,-2.858201 5.88962,-5.803014 -5.803,-5.716402 z"
fill="#ffffff"
id="path1"
style="stroke-width:0.866122" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="m 137.86858,28.661214 2.94481,-2.944812 v 0 l 5.88963,-5.803014 -5.80302,-5.62979 v 0 l -2.8582,-2.8582 -5.7164,-5.7164023 2.94481,-2.9448129 5.7164,5.7164017 5.88963,-5.8030138 2.8582,2.8582008 -5.88962,5.8030145 5.7164,5.716401 5.88962,-5.803014 2.8582,2.858201 -5.88962,5.803014 5.803,5.716402 -2.9448,2.858201 -5.7164,-5.716402 -5.88963,5.803013 5.7164,5.716402 -2.8582,2.944813 -5.80301,-5.716402 -5.80302,5.803015 -2.8582,-2.858201 z"
fill="url(#paint0_linear_40_164)"
id="path2"
style="fill:url(#paint0_linear_40_164);stroke-width:0.866122" />
<defs
id="defs3">
<linearGradient
x1="135.948"
y1="-0.81632602"
x2="132.09599"
y2="36.985699"
gradientUnits="userSpaceOnUse"
id="paint0_linear_40_164"
gradientTransform="matrix(0.86612147,0,0,0.86612147,18.863485,2.6775707)">
<stop
stop-color="#01f1ff"
id="stop2" />
<stop
offset="1"
stop-color="#0197ff"
id="stop3" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,67 @@
package chat.simplex.app
import chat.simplex.common.model.*
import chat.simplex.common.platform.chatModel
import chat.simplex.common.views.chat.providerForGallery
import kotlinx.datetime.Clock
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
// Regression for PR #6869: scrollToStart() must not rewrite initialChatId.
class ProviderForGalleryTest {
// Synthetic items pass canShowMedia only when chatModel.connectedToRemote() is true.
@BeforeTest
fun connectChatModelToRemote() {
chatModel.currentRemoteHost.value = RemoteHostInfo(
remoteHostId = 0L,
hostDeviceName = "",
storePath = "",
bindAddress_ = null,
bindPort_ = null,
sessionState = null,
)
}
@AfterTest
fun resetChatModel() {
chatModel.currentRemoteHost.value = null
}
@Test
fun testScrollToStartPreservesAnchor() {
val items = listOf(imageItem(1L), imageItem(2L), imageItem(3L))
var scrolledTo: Int? = null
val provider = providerForGallery(items, cItemId = 3L) { scrolledTo = it }
provider.currentPageChanged(provider.initialIndex - 1)
provider.scrollToStart()
provider.onDismiss(0)
assertEquals(1, scrolledTo)
}
// Pins the onDismiss early-return contract that testScrollToStartPreservesAnchor
// relies on to read the anchor back through the scrollTo callback.
@Test
fun testOnDismissOnActiveItemDoesNotScroll() {
val items = listOf(imageItem(1L), imageItem(2L), imageItem(3L))
var scrolledTo: Int? = null
val provider = providerForGallery(items, cItemId = 3L) { scrolledTo = it }
provider.onDismiss(provider.initialIndex)
assertEquals(null, scrolledTo)
}
private fun imageItem(id: Long): ChatItem =
ChatItem(
chatDir = CIDirection.DirectRcv(),
meta = CIMeta.getSample(id, Clock.System.now(), text = ""),
content = CIContent.RcvMsgContent(MsgContent.MCImage(text = "", image = "")),
reactions = emptyList(),
file = CIFile.getSample(fileId = id, fileName = "img-$id.jpg", filePath = "img-$id.jpg"),
)
}
@@ -31,8 +31,11 @@ import kotlin.system.exitProcess
val simplexWindowState = SimplexWindowState()
fun showApp() {
val closedByError = mutableStateOf(true)
while (closedByError.value) {
// Probe SystemTray off the EDT — the lazy's first read would otherwise block the
// EDT during composition; JDK-8322750's GNOME detection forks a subprocess.
trayIsAvailable
while (true) {
val closedByError = mutableStateOf(false)
application(exitProcessOnExit = false) {
CompositionLocalProvider(
LocalWindowExceptionHandlerFactory provides WindowExceptionHandlerFactory { window ->
@@ -43,8 +46,9 @@ fun showApp() {
shareText = true
)
Log.e(TAG, "App crashed, thread name: " + Thread.currentThread().name + ", exception: " + e.stackTraceToString())
window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING))
// Must precede dispatchEvent — handleCloseRequest reads this flag.
closedByError.value = true
window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING))
includeMoreFailedComposables()
// If the left side of screen has open modal, it's probably caused the crash
if (ModalManager.start.hasModalsOpen()) {
@@ -73,9 +77,11 @@ fun showApp() {
}
}
) {
SimplexTray()
AppWindow(closedByError)
}
}
if (!closedByError.value) break
}
exitProcess(0)
}
@@ -115,7 +121,7 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState<Boolean>) {
simplexWindowState.windowState = windowState
// Reload all strings in all @Composable's after language change at runtime
if (remember { ChatController.appPrefs.appLanguage.state }.value != "") {
Window(state = windowState, icon = painterResource(MR.images.ic_simplex), onCloseRequest = { closedByError.value = false; exitApplication() }, onKeyEvent = {
Window(state = windowState, visible = simplexWindowState.windowVisible.value, icon = painterResource(MR.images.ic_simplex), onCloseRequest = { handleCloseRequest(closedByError) }, onKeyEvent = {
if (it.key == Key.Escape && it.type == KeyEventType.KeyUp) {
simplexWindowState.backstack.lastOrNull()?.invoke() != null
} else {
@@ -224,6 +230,30 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState<Boolean>) {
}
}
// Not invoked for macOS Cmd+Q — that goes through AWT's default QuitHandler and
// exits the process directly. Intentional: Cmd+Q is canonical "always quit" on macOS.
private fun ApplicationScope.handleCloseRequest(closedByError: MutableState<Boolean>) {
// Crash dispatch — bypass user-facing policy and exit; outer loop will restart.
if (closedByError.value) {
exitApplication()
return
}
val pref = ChatController.appPrefs.closeBehavior
when (pref.get()) {
CloseBehavior.Quit -> exitApplication()
CloseBehavior.MinimizeToTray -> if (trayIsAvailable) {
simplexWindowState.windowVisible.value = false
} else exitApplication()
CloseBehavior.Ask -> if (trayIsAvailable) {
requestCloseBehavior()
} else {
// Tray unavailable — Minimize is not a real option; remember Quit and exit.
pref.set(CloseBehavior.Quit)
exitApplication()
}
}
}
class SimplexWindowState {
lateinit var windowState: WindowState
val backstack = mutableStateListOf<() -> Unit>()
@@ -232,6 +262,7 @@ class SimplexWindowState {
val saveDialog = DialogState<File?>()
val toasts = mutableStateListOf<Pair<String, Long>>()
var windowFocused = mutableStateOf(true)
val windowVisible = mutableStateOf(true)
var window: ComposeWindow? = null
}
@@ -0,0 +1,127 @@
package chat.simplex.common
import SectionItemView
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.window.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.CloseBehavior
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.Log
import chat.simplex.common.platform.TAG
import chat.simplex.common.ui.theme.isInDarkTheme
import chat.simplex.common.views.helpers.AlertManager
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import java.awt.AWTException
import java.awt.SystemTray
import java.awt.TrayIcon
import java.awt.image.BufferedImage
// Probed once at startup. False on stock GNOME ≥ JDK 21.0.3 per JDK-8322750, and
// also when SystemTray.add() fails despite isSupported() returning true (an older
// JDK pattern Compose-MP does not catch). When false: the Appearance toggle is
// hidden, the first-close dialog is skipped (Ask migrates silently to Quit), and
// the close handler treats MinimizeToTray as Quit.
val trayIsAvailable: Boolean by lazy {
if (!SystemTray.isSupported()) return@lazy false
try {
val tray = SystemTray.getSystemTray()
val probe = TrayIcon(BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB))
tray.add(probe)
tray.remove(probe)
true
} catch (e: AWTException) {
Log.w(TAG, "SystemTray probe failed: ${e.stackTraceToString()}")
false
} catch (e: SecurityException) {
Log.w(TAG, "SystemTray probe denied: ${e.stackTraceToString()}")
false
}
}
fun showWindow() {
simplexWindowState.windowVisible.value = true
simplexWindowState.window?.toFront()
simplexWindowState.window?.requestFocus()
}
@Composable
fun ApplicationScope.SimplexTray() {
if (!trayIsAvailable) return
if (remember { appPrefs.closeBehavior.state }.value != CloseBehavior.MinimizeToTray) return
// Sum of per-profile unread (UserInfo.unreadCount, the same field UserPicker renders
// per row). Skip muted profiles unless they're the active one.
val unread by remember {
derivedStateOf {
ChatModel.users.sumOf {
if (!it.user.showNtfs && !it.user.activeUser) 0 else it.unreadCount
}
}
}
val iconRes = if (unread > 0) {
if (isInDarkTheme()) MR.images.ic_simplex_tray_dot_light else MR.images.ic_simplex_tray_dot
} else {
if (isInDarkTheme()) MR.images.ic_simplex_tray_light else MR.images.ic_simplex
}
val tooltip =
if (unread > 0) stringResource(MR.strings.tray_tooltip_unread, unread)
else stringResource(MR.strings.tray_tooltip)
Tray(
icon = painterResource(iconRes),
tooltip = tooltip,
onAction = ::showWindow,
menu = {
Item(stringResource(MR.strings.tray_show), onClick = ::showWindow)
Separator()
Item(stringResource(MR.strings.tray_quit), onClick = { exitApplication() })
}
)
}
// Renders in the main app window via AlertManager (same surface as e.g. the link
// previews confirmation). Lambdas close over the calling ApplicationScope; if the
// app crashes while the dialog is open, the crash handler's alert replaces it, so
// stale closures never get clicked.
fun ApplicationScope.requestCloseBehavior() {
val pref = appPrefs.closeBehavior
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.close_behavior_dialog_title),
text = AnnotatedString(generalGetString(MR.strings.close_behavior_dialog_text)),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
pref.set(CloseBehavior.Quit)
exitApplication()
}) {
Text(
stringResource(MR.strings.close_behavior_dialog_close),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = Color.Red
)
}
SectionItemView({
AlertManager.shared.hideAlert()
pref.set(CloseBehavior.MinimizeToTray)
simplexWindowState.windowVisible.value = false
}) {
Text(
stringResource(MR.strings.close_behavior_dialog_minimize),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colors.primary
)
}
}
}
)
}
@@ -86,8 +86,8 @@ actual fun PlatformTextField(
// Different padding here is for a text that is considered RTL with non-RTL locale set globally.
// In this case padding from right side should be bigger
val startEndPadding = if (cs.message.text.isEmpty() && showVoiceButton && isRtlByCharacters && isLtrGlobally) 95.dp else 50.dp
val startPadding = if (isRtlByCharacters && isLtrGlobally) startEndPadding else 0.dp
val endPadding = if (isRtlByCharacters && isLtrGlobally) 0.dp else startEndPadding
val startPadding = 0.dp
val endPadding = startEndPadding
val padding = PaddingValues(startPadding, 12.dp, endPadding, 0.dp)
var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = cs.message.text, selection = cs.message.selection)) }
val textFieldValue = textFieldValueState.copy(text = cs.message.text, selection = cs.message.selection)
@@ -17,6 +17,7 @@ import org.nanohttpd.protocols.http.response.Response.newFixedLengthResponse
import org.nanohttpd.protocols.http.response.Status
import org.nanohttpd.protocols.websockets.*
import java.io.IOException
import java.net.BindException
import java.net.URI
private const val SERVER_HOST = "localhost"
@@ -157,17 +158,18 @@ fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
}
val server = remember {
try {
uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/")
} catch (e: Exception) {
Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}")
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.unable_to_open_browser_title),
text = generalGetString(MR.strings.unable_to_open_browser_desc)
)
endCall()
startServer(onResponse).apply {
try {
uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/")
} catch (e: Exception) {
Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}")
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.unable_to_open_browser_title),
text = generalGetString(MR.strings.unable_to_open_browser_desc)
)
endCall()
}
}
startServer(onResponse)
}
fun processCommand(cmd: WCallCommand) {
val apiCall = WVAPICall(command = cmd)
@@ -206,8 +208,8 @@ fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (
}
}
fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD {
val server = object: NanoWSD(SERVER_HOST, SERVER_PORT) {
fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): NanoWSD {
val server = object: NanoWSD(SERVER_HOST, port) {
override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session)
fun resourcesToResponse(path: String): Response {
@@ -231,7 +233,14 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD {
}
}
}
server.start(60_000_000)
try {
server.start(60_000_000)
} catch (e: BindException) {
if (port == 0) throw e
Log.w(TAG, "Call server port $port is busy, using a random port: ${e.message}")
server.stop()
return startServer(onResponse, port = 0)
}
return server
}
@@ -3,6 +3,7 @@ package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionDividerSpaced
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -18,7 +19,9 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.CloseBehavior
import chat.simplex.common.model.SharedPreference
import chat.simplex.common.trayIsAvailable
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.helpers.*
@@ -65,6 +68,11 @@ fun AppearanceScope.AppearanceLayout(
SectionDividerSpaced()
ThemesSection(systemDarkTheme)
if (trayIsAvailable) {
SectionDividerSpaced()
MinimizeToTraySection()
}
SectionDividerSpaced()
AppToolbarsSection()
@@ -84,6 +92,21 @@ fun AppearanceScope.AppearanceLayout(
}
}
@Composable
private fun MinimizeToTraySection() {
val pref = remember { appPrefs.closeBehavior.state }
val on = pref.value == CloseBehavior.MinimizeToTray
SectionView {
PreferenceToggle(
stringResource(MR.strings.appearance_minimize_to_tray),
checked = on,
) { checked ->
appPrefs.closeBehavior.set(if (checked) CloseBehavior.MinimizeToTray else CloseBehavior.Quit)
}
}
SectionTextFooter(stringResource(MR.strings.appearance_minimize_to_tray_desc))
}
@Composable
fun DensityScaleSection() {
val localDensityScale = remember { mutableStateOf(appPrefs.densityScale.get()) }
+2 -2
View File
@@ -119,9 +119,9 @@ The `actual` platform implementation of `ActiveCallView()` and supporting compos
Desktop calls run WebRTC in the system browser, not an embedded WebView:
- **NanoWSD server** ([line 209](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L209)): `startServer()` creates a `NanoWSD` instance bound to `localhost:50395`. The server serves `call.html` from JAR resources at `/assets/www/desktop/call.html` for the path `/simplex/call/`. All other paths serve resources from `/assets/www/`.
- **NanoWSD server** ([line 209](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L209)): `startServer()` creates a `NanoWSD` instance bound to `localhost:50395`. If that port is already in use it falls back to an OS-assigned free port (`port 0`); `WebRTCController` reads `server.listeningPort` for the browser URL. The server serves `call.html` from JAR resources at `/assets/www/desktop/call.html` for the path `/simplex/call/`. All other paths serve resources from `/assets/www/`.
- **WebSocket communication** ([line 238](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L238)): `MyWebSocket` handles WebSocket frames from the browser. `onMessage` deserializes JSON into `WVAPIMessage` and forwards to the response handler. `onClose` triggers `WCallResponse.End`.
- **WebRTCController** ([line 153](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L153)): Opens `http://localhost:50395/simplex/call/` via `LocalUriHandler`. Processes `WCallCommand` queue by sending JSON over WebSocket to all active connections. On dispose, sends `WCallCommand.End` and stops the server.
- **WebRTCController** ([line 153](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L153)): Starts the server, then opens `http://localhost:<listeningPort>/simplex/call/` (normally `50395`) via `LocalUriHandler`. Processes `WCallCommand` queue by sending JSON over WebSocket to all active connections. On dispose, sends `WCallCommand.End` and stops the server.
- **SendStateUpdates** ([line 137](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L137)): Sends `WCallCommand.Description` with call state and encryption info text to the browser for display.
- **ActiveCallView** ([line 28](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L28)): Handles `WCallResponse` messages identically to Android (same state machine), plus a `WCallCommand.Permission` message on `Capabilities` error for browser permission denial guidance.