android, desktop, ios: extract progress indicator timeout

The effect that turns on the progress indicator after a delay was copied
into 5 Kotlin and 4 Swift views. Two of the Swift copies had drifted: the
deferred write captured the value that started the delay rather than
reading the current one, so an operation that finished before the delay
elapsed left the indicator on with nothing to turn it off.

Timing is unchanged.
This commit is contained in:
Narasimha-sc
2026-08-20 11:32:42 +00:00
parent bc80c0e6e8
commit 683434625e
12 changed files with 51 additions and 98 deletions
@@ -75,15 +75,7 @@ struct CIGroupInvitationView: View {
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
.onChange(of: inProgress) { inProgress in
if inProgress {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
progressByTimeout = inProgress
}
} else {
progressByTimeout = false
}
}
.progressByTimeout(inProgress, $progressByTimeout)
if action {
v.simultaneousGesture(TapGesture().onEnded {
@@ -584,15 +584,7 @@ struct ComposeView: View {
clearState()
}
}
.onChange(of: composeState.inProgress) { inProgress in
if inProgress {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
composeState.progressByTimeout = composeState.inProgress
}
} else {
composeState.progressByTimeout = false
}
}
.progressByTimeout(composeState.inProgress, $composeState.progressByTimeout)
.confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
showTakePhoto = true
@@ -44,15 +44,7 @@ struct ContextContactRequestActionsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.onChange(of: inProgress) { inPrgrs in
if inPrgrs {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
progressByTimeout = inProgress
}
} else {
progressByTimeout = false
}
}
.progressByTimeout(inProgress, $progressByTimeout)
}
private func showRejectRequestAlert() {
@@ -79,15 +79,7 @@ struct ChatListNavLink: View {
invalidJSONPreview(json)
}
}
.onChange(of: inProgress) { inProgress in
if inProgress {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
progressByTimeout = inProgress
}
} else {
progressByTimeout = false
}
}
.progressByTimeout(inProgress, $progressByTimeout)
.actionSheet(item: $actionSheet) { $0.actionSheet }
}
@@ -26,8 +26,23 @@ extension View {
self
}
}
func progressByTimeout(_ inProgress: Bool, _ progressByTimeout: Binding<Bool>) -> some View {
task(id: inProgress) {
if inProgress {
try? await Task.sleep(nanoseconds: progressTimeout)
if !Task.isCancelled {
progressByTimeout.wrappedValue = true
}
} else {
progressByTimeout.wrappedValue = false
}
}
}
}
private let progressTimeout: UInt64 = 1_000_000000
extension Notification.Name {
static let chatViewWillBeginScrolling = Notification.Name("chatWillBeginScrolling")
}
@@ -21,7 +21,6 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.delay
@Composable
fun ComposeContextContactRequestActionsView(
@@ -31,21 +30,11 @@ fun ComposeContextContactRequestActionsView(
val inProgressLocal = rememberSaveable { mutableStateOf(false) }
// the request can also be accepted from another view, e.g. via notification
val inProgress = remember(contactRequestId) { derivedStateOf { inProgressLocal.value || contactRequestId in chatModel.acceptingContactRequests } }
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
val progressByTimeout by rememberProgressByTimeout(inProgress)
KeyChangeEffect(chatModel.chatId.value) {
if (inProgressLocal.value) {
inProgressLocal.value = false
progressByTimeout = false
}
}
LaunchedEffect(inProgress.value) {
progressByTimeout = if (inProgress.value) {
delay(1000)
inProgress.value
} else {
false
}
}
@@ -28,21 +28,11 @@ fun ComposeContextMemberContactActionsView(
groupDirectInv: GroupDirectInvitation
) {
val inProgress = rememberSaveable { mutableStateOf(false) }
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
val progressByTimeout by rememberProgressByTimeout(inProgress)
KeyChangeEffect(chatModel.chatId.value) {
if (inProgress.value) {
inProgress.value = false
progressByTimeout = false
}
}
LaunchedEffect(inProgress.value) {
progressByTimeout = if (inProgress.value) {
delay(1000)
inProgress.value
} else {
false
}
}
@@ -1568,14 +1568,8 @@ fun ComposeView(
chatModel.sharedContent.value = null
}
LaunchedEffect(composeState.value.inProgress) {
val newProgressByTimeout = if (composeState.value.inProgress) {
delay(1000)
composeState.value.inProgress
} else {
false
}
composeState.value = composeState.value.copy(progressByTimeout = newProgressByTimeout)
ProgressByTimeoutEffect(composeState.value.inProgress) {
composeState.value = composeState.value.copy(progressByTimeout = it)
}
val relayListExpanded = remember { mutableStateOf(false) }
@@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.buildAnnotatedString
@@ -20,7 +19,6 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
@Composable
fun CIGroupInvitationView(
@@ -35,15 +33,7 @@ fun CIGroupInvitationView(
val sent = ci.chatDir.sent
val action = !sent && groupInvitation.status == CIGroupInvitationStatus.Pending
val inProgress = remember { mutableStateOf(false) }
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(inProgress.value) {
progressByTimeout = if (inProgress.value) {
delay(1000)
inProgress.value
} else {
false
}
}
val progressByTimeout by rememberProgressByTimeout(inProgress)
@Composable
fun groupInfoView() {
@@ -48,16 +48,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
val selectedChat = remember(chat.id) { derivedStateOf { chat.id == chatModel.chatId.value } }
val showChatPreviews = chatModel.showChatPreviews.value
val inProgress = remember { mutableStateOf(false) }
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(inProgress.value) {
progressByTimeout = if (inProgress.value) {
delay(1000)
inProgress.value
} else {
false
}
}
val progressByTimeout by rememberProgressByTimeout(inProgress)
val scope = rememberCoroutineScope()
@@ -0,0 +1,25 @@
package chat.simplex.common.views.helpers
import androidx.compose.runtime.*
import kotlinx.coroutines.delay
private const val PROGRESS_TIMEOUT_MS = 1000L
@Composable
fun ProgressByTimeoutEffect(inProgress: Boolean, setProgressByTimeout: (Boolean) -> Unit) {
LaunchedEffect(inProgress) {
if (inProgress) {
delay(PROGRESS_TIMEOUT_MS)
setProgressByTimeout(true)
} else {
setProgressByTimeout(false)
}
}
}
@Composable
fun rememberProgressByTimeout(inProgress: State<Boolean>): State<Boolean> {
val progressByTimeout = remember { mutableStateOf(false) }
ProgressByTimeoutEffect(inProgress.value) { progressByTimeout.value = it }
return progressByTimeout
}
@@ -303,16 +303,7 @@ fun ActiveProfilePicker(
filteredProfiles(chatModel.users.map { it.user }.sortedBy { !it.activeUser }, searchTextOrPassword.value)
}
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(switchingProfile.value) {
progressByTimeout = if (switchingProfile.value) {
delay(500)
switchingProfile.value
} else {
false
}
}
val progressByTimeout by rememberProgressByTimeout(switchingProfile)
@Composable
fun ProfilePickerUserOption(user: User) {