mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-15 05:56:04 +00:00
Merge branch 'master' into ae/oklch-color-space-plan
This commit is contained in:
+3
-1
@@ -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 {
|
||||
|
||||
+50
-13
@@ -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")
|
||||
|
||||
+8
-3
@@ -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 {
|
||||
|
||||
+22
-6
@@ -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
-10
@@ -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()
|
||||
|
||||
+237
@@ -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)
|
||||
}
|
||||
}
|
||||
+52
-4
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+79
-30
@@ -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 = {}) {
|
||||
|
||||
+7
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+85
-30
@@ -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),
|
||||
|
||||
+1
-1
@@ -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),
|
||||
|
||||
+31
-21
@@ -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 = {
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+7
@@ -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 (
|
||||
|
||||
+2
-2
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-8
@@ -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)
|
||||
|
||||
+2
-6
@@ -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 |
+46
@@ -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 |
+44
@@ -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 |
+67
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+22
-13
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+23
@@ -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()) }
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user