Merging keepActiveUser into apiCreateActiveUser changed a signature that two Kotlin
docs quote. spec/api.md still described the old parameters and said the command sets
the profile as active, full stop; product/flows/onboarding.md quoted the old signature
one step above the line this branch had already updated to mention the flag. The iOS
spec table was fixed in the previous commit and these were missed - the same drift on
the other platform.
Both iOS call sites now use listUsersAsync, which is what changeActiveUserAsync_ uses
a few lines later; the synchronous listUsers blocks whatever executor the Task is on,
and SwiftUI infers that as the main actor.
Not compiled: the Kotlin changes here are markdown only, and the Swift ones cannot be
built on this machine.
Correctness.
The one-time link picker's profile list was keyed on the active user as well as the
count. That defeats the "don't change order after a user was selected" behaviour its own
comment protects - changeActiveUser_ sets currentUser first and only then reloads chats,
so the list visibly re-sorted and stayed re-sorted for the whole of getUserChatData - and
it was redundant: the count alone covers a profile being created from the picker.
iOS, compose picker: the old-core fallback relied on the user switch tearing this view
down, which only happens when the switch succeeded. If it threw, the form was left open
over a profile that already existed, and the only way out was to swipe it away. It is now
dismissed unconditionally.
iOS: alerts raised right after a sheet is dismissed are presented on a controller that is
going away and are dropped. The 0.5s wait was applied only to the old-core path, while the
ordinary reassignment failures - the ones that actually happen - went unguarded.
alertAfterDismissal now covers all of them, in one place.
iOS: dropped the chatId write that Kotlin removed for the same reason two commits ago. It
is a no-op on the happy path, but not if something legitimately closed the chat meanwhile
- SimpleX lock re-auth clears it, and this would reopen the chat behind the lock screen.
iOS: the create-profile row is now gated on profileChangeProhibited, as every other row on
both platforms already was.
Surface.
apiCreateActiveUser takes keepActiveUser as a defaulted argument, exactly as it already
takes pastTimestamp, instead of a public wrapper plus a private helper. Two symbols fewer
on each platform, and every existing call site passes its arguments by name, so none of
them changes.
Reverted the activeOrder sort flip in the compose picker: it changes the order of an
existing screen for every user, is not needed for this feature, and with active_order 0
would push a newly created profile to the bottom. It deserves its own commit if wanted.
Also: apps/ios/spec/api.md tracks the command enum and had gone stale; the plan records
the two limits of the flag the review surfaced - it is ignored when there is no active
user, and active_order 0 ties rather than sorts last on migrated databases.
No behaviour change. The review rounds left the flow carrying their own reasoning:
267 of the 759 added lines were comments, and the fixes had accreted structure that
was no longer needed.
createProfileForInvitation runs on Main via withApi, as the pickers' own handlers
always did, instead of withBGApi with three withContext(Dispatchers.Main) blocks
inside it. Every call it makes suspends into IO - sendCmd hops there itself - so Main
is never blocked, and the whole class of off-main model mutation that two rounds of
review found simply cannot arise. That removes the two coroutine imports as well.
hasModalOpenNotClosing is gone: while the form is open it is necessarily the last
modal on the fullscreen stack, so the entry guard uses isLastModalOpenNotClosing and
one function covers both call sites.
The rest is comments. Kept the ones that record why something non-obvious is the way
it is - the placement, the main-thread choice, the guards - and dropped the narration
of what the earlier attempts got wrong, which belongs in the history and the plan.
One comment described code that is not there at all; it is the commit message's job
to say why the chatId write was removed.
759 -> 667 added lines, 267 -> 189 of them comments.
Third review pass on this flow. Four things, all in code the previous two passes
touched.
The one-time link picker's close(). selectProfileAsync ended with an unconditional
close(), which pops whatever is on top of the start stack. The reassignment before it
can wait indefinitely on the retry alert, and back is not blocked while it does - so
backing out and then letting the call complete dismissed the New chat screen as well.
The picker now has its own ModalViewId and only closes if it is still the top, which is
what isLastModalOpenNotClosing was added for; the create-profile flow was using it and
this, the older hazard, was not.
The entry guard used hasModalOpen, which counts a modal that is still animating out, so
re-opening the form straight after backing out of it was a silent no-op for the length
of the animation - the exact bug the NotClosing variant exists for. Adds the matching
hasModalOpenNotClosing.
The old-core fallback switched the active user and returned without closing the form,
leaving it on top of a chat list belonging to a different profile, and did it off the
main thread. It now closes first and runs on Main like the rest of the flow.
chatModel.chatId is no longer written after the switch. changeActiveUser_ passes
keepingChatId and updateChats only clears chatId when the chat is missing from the
reloaded list, so on success the write was a no-op - and in the one case the guard does
fire it re-pointed the chat view at a chat that is not there. It also undid a
notification tap that had navigated to another chat of the same profile, which the
active-user check cannot catch.
Also restores the profileChangeProhibited guard that the previous commit dropped from
the new row while collapsing its click handler; keys the one-time link picker's profile
list on the active user as well as the count, so the checkmark is not stale after a
switch that leaves the count unchanged; dims the compose picker while it is busy, which
was the only picker with no indication at all; and logs the silent abort.
Follow-up to the previous commit, which fixed the centre-pane placement by moving
the form to the picker's own pane. That was wrong in two different ways, and the
shared modal helpers it changed had a blast radius that was not accounted for.
Placement. ModalManager.end, used for the compose picker, puts the form in a pane
*beside* the chat: the picker stays fully live next to it, with no scrim, so a profile
can be selected while the form is open - and apiChangeConnectionUser recreates the
connection, invalidating the id the form is about to use. It also calls
desktopExpandWindowToWidth, which is grow-only, so the window is permanently widened.
ModalManager.start, used for the one-time link picker, does cover it, but disposes it,
losing whatever was typed in its search box. ModalManager.fullscreen is an opaque
Surface over every pane: nothing can be operated while the form is up, nothing is torn
down, chatId is untouched and the window does not move. The manager is no longer a
parameter - there is only one right answer.
Modal helpers. hasModalOpen/isLastModalOpen were taught to ignore modals waiting out
their close animation, but they have five other callers - ChatListView, ChatView twice,
ChatItemView and SimpleXAPI - all deciding when to tear down secondary chats, and all
silently changed by it. isLastModalOpen also went from an indexed read to a full
iteration on a path called off the main thread. Both are reverted; the new behaviour
lives in isLastModalOpenNotClosing, which only this flow calls and which walks by
index.
Main thread. Making onCreated suspending moved the reassignment onto withBGApi's
single-thread dispatcher, so changeProfileTo mutated chatsContext, chatId and (via
changeActiveUser_) chatModel.users off Main, where the receiver loop writes the same
structures - the very hazard the same commit called out for its own users refresh.
onCreated and close() are now handed back to Dispatchers.Main, which is where both
pickers ran this before it became a callback.
Also: the in-flight flag moves to ChatModel so it is not a top-level val first created
inside a composition; the entry guard consults it, since with one manager per pane the
per-manager check alone let a second form open and silently drop its submit; the
one-time link picker's filteredProfiles is keyed on the profile count, so a profile
created from it appears without relying on the composition being rebuilt; and the
progress indicator skips its 500ms grace while creating, since that timer restarts
from zero in the rebuilt composition and would otherwise leave the rows looking idle.
Six defects, all in the window between creating the profile and moving the
invitation onto it.
Pane. The form was shown in ModalManager.center, which on desktop sets
ChatModel.chatId to null - and the chat view *is* the centre pane. Tapping "Add
profile" in a prepared invitation therefore closed that chat, discarding the typed
compose draft with it, and only the success path reopened it: cancelling the form,
or any failure, left "No selected chat" with the invitation nowhere on screen. From
the one-time link picker, which is itself a start-pane modal, the form appeared in a
different pane with the picker still live beside it and no scrim, so a profile could
be selected while the form was open - and apiChangeConnectionUser recreates the
connection, invalidating the pccConnId the form was about to use. The manager is now
passed in: end for the compose picker, matching the incognito modal it already opens
there, and start for the one-time link picker, matching its own.
In-flight guard. The flag was per-picker and released when the profile existed, not
when the invitation had moved. Both are wrong: onCreated only launches the
reassignment, and on Android the picker's composition is disposed while the form is
on top of it, so the flag it comes back with is a fresh false. There was no spinner,
no dimming and every row was live during the switch, so a second tap created a second
profile or started a competing reassignment. onCreated is now suspending so the flag
covers the whole flow, and the flag is a single top-level state both pickers read.
The compose picker had no in-progress state at all and now has one, and its new row
was also the only one missing the profileChangeProhibited guard.
Active user and host. The reassignment resolves the invitation under whatever profile
is active when it runs. A notification tap switches the active user from another
dispatcher, and a remote host connect/disconnect switches the host, so both are
checked before handing over rather than reassigning under the wrong profile.
Torn-down form. isLastModalOpen was used only to decide whether to close the form,
and the flow continued when it was false - reassigning and switching under a screen
the user had already left. It now stops.
Connection not moved. The one-time link picker dismissed itself and turned the
app-wide incognito default off whether or not apiChangeConnectionUser succeeded.
Unlike the prepared-chat reassignment, that call provisions a new queue, so it is
what fails offline - leaving a profile just created for the invitation stranded with
nothing pointing at it and the picker gone. It now keeps the picker open and only
clears incognito once the connection has actually moved.
Reopening the chat. chatModel.chatId was set unconditionally after the switch, before
the check that the switch actually happened, pointing the chat view at a chat that had
just moved to another profile and defeating the guard in updateChats that clears it.
Also applies the users refresh on the main thread, where the receiver loop updates
the same list, and skips it when the host has changed underneath.
closeModal leaves an animated modal in modalViews and only stages its index in
toRemove, drained when the animation ends. hasModalOpen and isLastModalOpen scanned
modalViews directly, so for the ~250ms of the close animation they still reported a
dismissed modal as open.
A caller that closes on "is my modal still the last one?" therefore popped the modal
underneath as well - on Android, where every placement shares one stack, backing out
of the create-profile form while creation was in flight dismissed the New chat screen
too. Both checks now skip the entries already staged for removal.
createProfileInNoProfileSetup passed the image positionally as Profile's 4th
argument, which is description, not image - so the chosen avatar was stored as
the profile description and no picture was set.
Both are String?, so it type-checked silently. The sibling createProfileInProfiles
avoids this by passing image by name.
Only reachable on desktop with no local chat profile: the user picker then offers
"Create chat profile" (desktopNoUserNoRemote), which opens the profile form with
its avatar picker, and localUserCreated is false so submission takes this branch.
Onboarding is unaffected - both first-profile screens take a name only, with no
avatar picker, and call createProfileOnboarding instead.
Unrelated to the rest of this branch - safe to drop or move to its own PR.
Adds "Add profile" to the profile picker reached from "Share profile" on a
one-time link, so a link can be handed out under a new identity without setting
one up in settings first. Reuses the existing users_add string.
Extracts the existing selection handler into selectProfile so the new profile
takes exactly the same path as picking an existing one - the connection change
and the switch cannot drift apart.
The row sits outside the active-profile branch: inside it, it would disappear
whenever the active profile is filtered out by the search text. It is only
offered when there is a connection to move, so it does not appear in the share
list, where a new profile would have nothing to share into.
ActiveProfilePicker performed the whole profile change - connection reassignment,
switch, alerts, close - inside the row's onSelected lambda, where nothing else can
reach it.
Lift it to selectProfile(user), called by the row. Pure move: the body is unchanged
apart from indentation, so picking a profile behaves exactly as before. It gives the
next commit a way to take the same path for a newly created profile rather than
duplicating it.
Opening someone's link prepares a chat and shows the profile picker above the
compose box. It listed existing profiles and Incognito, so connecting as
someone new meant leaving the invitation, creating a profile in settings, and
coming back to the link.
Adds "Add profile" to that picker, reusing the existing users_add string. The
profile is created without becoming active, so the profile that owns the
prepared chat stays active for the reassignment, which then switches once -
rather than switching away and back.
The row is emitted last, so with the reversed layout it renders at the top of
the expanded list with the current selection nearest the compose box.
After switching, the chat is reopened explicitly: keepingChatId only preserves
its place in the reloaded list, so without that the switch lands on the chat
list of the new profile rather than the invitation it was chosen for. This
applies to picking an existing profile too.
The form is only dismissed once creation succeeds, so a rejected duplicate name
does not discard what was typed. A dismissal guard keeps a back-tap during
creation from switching the profile anyway, and the created profile is put into
the picker list immediately so it stays visible if the reassignment fails.
If the core activated the profile despite keepActiveUser - an older remote host
ignoring the field - the reassignment would fail, so that case resyncs to what
the host actually did instead.
Updates the onboarding flow doc, which names the arguments of the command that
gains the field.
changeActiveUser_ was called outside the guards that check whether
apiChangePreparedContactUser / apiChangePreparedGroupUser actually succeeded.
When the reassignment failed the app still switched to the other profile,
leaving the invitation behind in the original one - and keepingChatId then held
a chat belonging to a different user.
Reachable today: open a link, expand the profile picker, and have the connection
start in the window before tapping. profileChangeProhibited flips to true, the
API returns null, and the switch happened anyway.
The sibling picker in NewChatView already guards this correctly. iOS is
unaffected: there apiChangePreparedContactUser throws, so a failure skips the
switch by control flow.
CreateProfile hardcoded what happens after submission, choosing between
createProfileInProfiles and createProfileInNoProfileSetup inside the button
handler. That conditional is a hidden premise: two call sites invoke the
composable and neither can see from the call that submission behaves
differently for them.
Lift the choice to the callers. The callback passes raw fields rather than a
Profile because the two paths do not build the same one - the no-profile-setup
path drops shortDescr and passes a null remote host id - so a Profile-shaped
callback would silently change its behaviour.
The chatModel parameter was redundant besides: UserPicker passed the
platform-level chatModel global, which the parameter merely shadowed.
No behaviour change - the conditional moves verbatim into createProfileFromForm,
which both call sites invoke, rather than being copied into each.
Group chat and its support chats have the same chat id, so a draft saved
in "chat with admins" was restored in the group chat and shown as its draft
in the chat list. Include chat scope in the draft chat id.
Also save the draft when support chat is closed without changing chat id -
in that case KeyChangeEffect that saves drafts doesn't run.
* Translated using Weblate (German)
Currently translated at 100.0% (2899 of 2899 strings)
Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/
* Translated using Weblate (Russian)
Currently translated at 97.5% (2829 of 2899 strings)
Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/
* Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.9% (2898 of 2899 strings)
Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/
* Translated using Weblate (Spanish)
Currently translated at 100.0% (2899 of 2899 strings)
Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/
* Translated using Weblate (Spanish)
Currently translated at 100.0% (2497 of 2497 strings)
Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/
* ru translations
* Translated using Weblate (German)
Currently translated at 100.0% (2899 of 2899 strings)
Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/
* Translated using Weblate (Russian)
Currently translated at 97.5% (2829 of 2899 strings)
Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/
* Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.9% (2898 of 2899 strings)
Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/
* Translated using Weblate (Spanish)
Currently translated at 100.0% (2899 of 2899 strings)
Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/
* Translated using Weblate (Spanish)
Currently translated at 100.0% (2497 of 2497 strings)
Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/
* process translations
* ui: fix translation errors in es, ru, de, it, hu
Review of the translations in this PR against the English originals.
Values only - no keys added, removed or renamed.
Spanish:
- "The sender deleted the connection request." said "El servidor..."
(the server), attributing the deletion to a server rather than the
sender. Restored "El remitente".
- "Require/Do not require signing messages." were rendered as statements
("Se requieren mensajes firmados."), making the toggle options read
identically to the separate status strings "Message signing is
(not) required.". Restored the imperative.
- "...compare (or scan) the code on your devices." said "en tu
dispositivo" (singular); the code has to be compared on both.
- "How to register a test name" was pluralised to "nombres en pruebas".
Russian:
- "Помошь и поддержка" -> "Помощь и поддержка" (spelling).
- "File servers" was the calque "Файл серверы" -> "Серверы файлов",
matching the iOS translation of the same string.
- "Если Вы выберите" -> "выберете" (imperative used for future).
- "Create web preview." said "Создать веб страницу." (web page, also
missing the hyphen) -> "Создать веб-предпросмотр.".
- webpage_info dropped "to show your channel preview to visitors before
they subscribe"; webpage_url_footer said "страницы канала" instead of
the preview. Both restored from the iOS wording.
- "Enter description (optional)" lost "(optional)"; "Error adding
relays" was truncated to "Ошибка добавления".
- "Add contributors." used "авторов"; the member role is "соавтор".
- "удален(а)" -> "удалён", "подтвержденный" -> "подтверждённый".
German, Italian, Hungarian:
- The source strings "Names for your channel or business." and
"SimpleX names (BETA)" were renamed to "Public names..." and
"SimpleX public names (BETA)", but the old values were carried over,
so "public" was missing. Android German and Chinese had already been
updated in this PR; this brings iOS German, and both platforms for
Italian and Hungarian, in line.
- iOS German "How to register a test name" still read "Einen Test-Namen
registrieren", while Android German was updated in this PR.
---------
Co-authored-by: mlanp <github@lang.xyz>
Co-authored-by: Evgeny Poberezkin <e.poberezkin@me.com>
Co-authored-by: Hosted Weblate user 54392 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: No name <usir.alerts@onionmail.org>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com>
* android, desktop: switch to the request's profile when accepting contact request from notification
* plans: justify accepting contact request from notification fix
* desktop: fix forwarding moving message draft to another chat
The same ComposeView is reused when switching chats, so the `chat`
captured by the desktop-only `onDispose` is the chat that was open when
the view was first composed, not the one the message is forwarded from.
The draft was saved under that stale chat id, and when it happened to be
the chat forwarded to, sending the forward restored the draft there.
* plan: justify desktop forward/draft fix and cross-platform findings
* desktop: fix chat switch deleting the in-app updater's download
createTmpFileAndDelete registered every temp file it created in
ChatModel.filesToDelete, which ComposeView.deleteUnusedFiles() empties on every
chat open/switch/close. The updater's ~350 MB download lives in such a file for
minutes, so switching chats during it deleted the file. copyTo kept writing
through the still-open fd, so the transfer completed and progress reached 100%;
only the following Files.move failed, with NoSuchFileException on the source,
and that went into a catch that logs to stdout only - no dialog, no error, no
file.
The registration was redundant for every caller: the helper's own
finally { tmpFile.delete() } already removes the file, deleteOnExit() covers a
clean JVM exit, and Main.kt wipes tmpDir at startup for crashes. It only ever
duplicated the finally while arming a trap for long-running lambdas - the same
window also existed for the settings and themes writers.
* desktop: download the app update into a file the updater owns
Per review of #7295: instead of removing the ChatModel.filesToDelete
registration from createTmpFileAndDelete, stop using that helper for the
downloaded file.
downloadAsset now writes into "<asset name>.part" in tmpDir and moves it onto
the asset name when the transfer completes, so the download is never registered
in ChatModel.filesToDelete and ComposeView.deleteUnusedFiles() can no longer
delete it mid-transfer. Cleanup is unchanged in substance: the finally block
removes the partial file when the download fails or is cancelled (and is a
no-op after a successful move, as the helper's own finally was), deleteOnExit
covers a clean exit, and Main.kt wipes tmpDir at startup after a crash.
createTmpFileAndDelete is restored to its previous behaviour, with a comment
warning that its file does not survive a chat switch.
* desktop: reduce comments in updater download fix
* desktop: fix saving files with '[', '*', or '?' in name on Linux
JFileChooser's BasicFileChooserUI treats filenames containing
'[', '*', or '?' as glob patterns on Unix when the file does not
exist on disk. The Save button's ApproveSelectionAction then
applies a GlobFilter and returns without saving — the dialog
stays open and nothing is written.
Regression introduced in #2789 (commit 02d00944f, 2023-07-28),
which started pre-populating the filename via selectedFile =
File(filename). Before that the save dialog ran in
DIRECTORIES_ONLY mode and the JDK glob check never saw the
filename.
Fix: on Linux save dialogs, find the Save button by identity
(BasicFileChooserUI.getApproveSelectionAction() is public and
returns the exact ActionListener wired on the button) and
replace it with a wrapper that delegates to the original
action for non-glob names and calls approveSelection()
directly for glob names.
Windows (per the JDK only '*' and '?' are glob chars there)
and macOS (uses AWT FileDialog) are unaffected. Open dialogs
keep glob filtering for power-user search.
* desktop: always bypass JFileChooser glob-on-save on Linux
Earlier in this branch we added a heuristic to delegate to the
original ApproveSelectionAction for non-'[' names so that typing
'*.pdf' in the save dialog would still glob-filter the listing.
That preserved a Swing-only quirk that no native OS save dialog
implements:
- macOS uses NSSavePanel (already used in this app via AWT
FileDialog) — no glob-on-save.
- Native Windows / Linux GTK / KDE save dialogs — no glob-on-save.
- JFileChooser on Windows — has it, but '*' and '?' aren't even
legal NTFS filename chars, so users won't be typing them.
It also blocks legitimate filenames containing '*' or '?' on Linux
(legal on POSIX filesystems) from being saved, since they too get
intercepted as glob.
Replace the Save button's action with a handler that always treats
the typed text as a literal filename. The result: any filename can
be saved on Linux, behaviour aligns with macOS and with every
native OS save dialog. The open dialog is untouched, so '*.pdf'
glob-filtering there still works.
* desktop: restore directory-only save dialog on Linux
The previous commit installed the literal-filename glob bypass for
every Linux save dialog (!isLoad && isLinux), including the
DIRECTORIES_ONLY mode used when filename == null — e.g. the
"Save QR code as image" flow (saveTempImageUncompressed →
saveDialog.awaitResult() with no params).
In that mode the bypass broke directory selection entirely:
selecting a subfolder + Save traversed into it instead of approving
it, and Save with an empty filename field no-oped. Net result:
QR-code image saving was unusable on Linux.
The glob-on-save bug only arises because a filename is pre-populated
(selectedFile = File(filename)), which never happens in
DIRECTORIES_ONLY mode. Gate the bypass on filename != null so the
directory-save path keeps JFileChooser's original approve action.
* desktop: simplify Linux save glob bypass via getDefaultButton
---------
Co-authored-by: shum <github.shum@liber.li>
The swipe handler is created with rememberDismissState, whose lambda is frozen
at first composition and captures a stale cItem. When a message is marked
deleted while on screen, that lambda still sees the pre-deletion item, so an
in-lambda itemDeleted check is bypassed and the quote is set, producing
SEInvalidQuote on send. Disable the swipe modifier (recomputed live each
recomposition) when itemDeleted != null so the gesture is removed entirely.
* android, desktop: fix video preview expanding into empty black area
The video message box (CHAT_IMAGE_LAYOUT_ID) never gave itself a definite
height - it took the height of its tallest child. That child is the player
surface, which has no stable intrinsic height until playback starts: on
Android an unprepared StyledPlayerView reports no video size, so its
AspectRatioFrameLayout (RESIZE_MODE_FIXED_WIDTH, aspect 0) does not shrink.
Before #6726 this was masked: the box was measured with unbounded height, so
the surface collapsed to 0. #6726 bounded the box to 2.33x width to constrain
tall images, and the unprepared surface then expanded to fill that height,
showing as a long empty black strip below the preview once a video downloads.
Give the box a definite size from the preview aspect ratio, mirroring
CIImageView. Height follows the measured (clamped) width via Modifier.layout
so wide videos on narrow screens don't leave an empty strip (the #7223 fix).
* plans: justify video preview black-area fix
* android, desktop: remove stray card dividers in server info, connect-to-desktop, appearance and migration screens
PR #6777 turned these into card screens, where SectionView draws a 2dp near-black divider between every direct child, but left content patterns that pre-date the card chrome: nested SectionViews (SMP/XFTP server detail), loose Text + Spacer + Text (connect-to-desktop device name and version), and a SectionDividerSpaced() as a middle child (Appearance with an image wallpaper, migrate-from-device "error stopping chat"). Each draws stray lines inside the card, most visible in dark/black themes.
Restructure to the sibling pattern already used by DetailedSMPStatsLayout: un-nest the server-detail sub-sections (address its own card, stats/subs/sessions as siblings; subscription rows carded), wrap each device name/version block in a single Column, and drop the in-card spacers (the adjacent-row auto-divider already separates them). Layout-only; iOS is a separate codebase and is unaffected.
* docs: add plan justifying removing stray card section dividers
* desktop: fix pointer cursor not changing to hand over clickable commands (dropped hover events)
* desktop: set hover cursor directly on every hover move; compose pointerHoverIcon is edge-triggered and loses updates when chat items shift under the cursor
* plans: investigation and justification for command hover cursor fix
* desktop: harden hover cursor fix after adversarial review: refresh on release, ignore button-held events, reset icon state on exit, cache canvas lookup failures
* desktop: document two-stage verification, exit icon asymmetry and cache guard rationale in hover cursor fix
* plans: reconcile performance and testing claims with hardened code
* plans: correct recomposition count and note commit subject scoping
* plans: clarify user-facing problem description
* desktop: shorten comments in hover cursor fix
* desktop: clarify comments in hover cursor fix
* multiplatform: fix command clicks lost on quick successive clicks (serialize gesture press scope, don't restart pointerInput on recomposition)
* plans: document lost-click defect (press-scope race, pointerInput restart) and fixes
* multiplatform: don't cancel command click when chat list shifts under the pointer
A press is cancelled when it goes out of the node's bounds, but when a sent
message inserts into the chat the node moves out from under a stationary
pointer, which is not a drag-away. Exempt out-of-bounds cancellation when the
pointer did not move in window coordinates (within touch slop).
* multiplatform: stop pointer handler resets on chat item recomposition (lost clicks, cursor flicker)
bigTouchSlop() created a new ViewConfiguration instance on every recomposition
of every chat item; pointer input nodes observe ViewConfiguration and reset
their handler when it changes, so each inserted message killed all in-flight
presses (lost command clicks) and hover handlers (hand cursor flicker) in the
viewport. Provide a remembered instance instead.
* plans: document list-shift press cancellation and ViewConfiguration reset defects
* desktop: remove imperative hover cursor workaround, superseded by ViewConfiguration fix
The evidence that the declarative path was insufficient was gathered while
every message insertion was resetting all hover handlers (the ViewConfiguration
identity defect, fixed in 6d24bd5d4) — which alone explains those failures.
With handlers stable, the full hover matrix passes on pointerHoverIcon plus the
lossless detectCursorMove alone, so the AWT canvas write and its expect/actuals
are removed; only the icon state reset on Exit remains.
* plans: document hover workaround removal and stage-1 evidence contamination
The desktop app polled full clipboard contents every second on the AWT
event thread to update ChatModel.clipboardHasText. On X11, each read
blocks up to sun.awt.datatransfer.timeout (10s) when the selection owner
does not respond (e.g. after KeePassXC's clipboard auto-clear), making
every click/scroll wait ~9s. clipboardHasText has had no readers since
its only consumer was removed in #4398, so the whole mechanism (desktop
poll, Android clip listener, onResume refresh, expect/actual, state
field) is deleted. See plans/2026-07-11-fix-desktop-clipboard-freeze.md.
* android, desktop: save draft when switching to chat where user cannot send messages
On desktop, chat and chatId change in the same recomposition when another
chat is opened from the always-visible chat list. The effect clearing
compose state of a non-sendable chat (observer, channel subscriber,
review by admins) was composed before the draft-saving KeyChangeEffect
and ran first, wiping the live compose state before it could be saved
and then clearing the previously saved draft via clearPrevDraft.
Effects launch in composition order, so the clearing effect is moved
after KeyChangeEffect: the previous chat's draft is saved first, and
clearCurrentDraft is a no-op for it because draftChatId no longer
matches the opened chat. Clearing the opened chat's own draft when it
cannot send is preserved, as is clearing when the open chat itself
becomes non-sendable (only the sendMsgEnabled key changes).
* plans: investigation and justification for draft message loss fix
Surface the XFTP servers that hosted a file's chunks in the message info
screen (Android, desktop, iOS), so a user can see which servers they are
downloading from (or, for sent files, uploading to) whenever they want to know.
APIGetChatItemInfo now returns fileXftpServers, derived from the stored file
description (private snd descr for sent items, rcv descr for received items);
extraction is best-effort and never fails the call.
* core, ui: plan per-server roles for self-hosted servers
* core: add per-server roles field to UserServer
* core: add nullable role columns to protocol_servers
* core: persist per-server roles
* core: validate server coverage using per-server roles
* test: cover per-server roles resolution and coverage
* multiplatform: per-server role toggles for self-hosted servers
* ios: per-server role toggles for self-hosted servers
* core: per-server role overrides with per-role defaults
* test: cover three-state per-server role resolution
* fix: derive Eq for ServerRolesOverride
* multiplatform: three-state role dropdowns on saved servers
* ios: three-state role pickers on saved servers
* test: per-server roles independent across two servers
* test: enable names role in name-resolution tests
* style: trim comments in per-server roles code
* chore: rename server_roles migration to 20260716 (last)
* core: per-server roles override operator roles, inherit when unset
* multiplatform: per-server role default inherits from operator
* ios: per-server role default inherits from operator
* refactor(servers): tidy per-server roles per review
- dedup no-operator default into ServerRoles.noOperatorDefault (Kotlin/Swift)
- iOS: move roles-section control flow to the call site via a named gate,
and parse the server address once instead of up to three times
- Kotlin: collapse redundant derivedState; revert defaultOn->default rename
for cross-platform parity
- drop unused Hashable conformance on Swift ServerRoles
- add agentServerCfgs test for names inheritance from an operator
- remove no-op enableNamesRole calls from dormant DirectoryTests
- fix ChatClient import ordering
* chore(migration): date server_roles migration 20260720
* only show roles when server is enabled, move section above QR code
---------
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
On an incoming call the desktop window blinked continuously (FileNotFoundException
from SoundPlayer.start writing into a missing ...\Temp\simplex).
Root cause: Files.desktop top-level val initializers run in any process that
touches the facade class, including a transient second instance (which reaches
acquireSingleInstance -> dataDir). Two of them deleted shared state used by the
running primary:
- tmpDir had .also { deleteOnExit() } - the second instance's normal exit deleted
...\Temp\simplex, so the primary then failed to write call sounds/recordings.
- preferencesTmpDir had .also { deleteRecursively() } - the second instance's
<clinit> wiped configPath\tmp (same anti-pattern, firing even earlier).
Make both declarations pure and do the destructive cleanup in Main, past the
single-instance check, so only the owning instance performs it (preferencesTmpDir
kept early, before any settings write). The remaining val-initializer side effects
are idempotent creations (mkdirs) that destroy nothing and are left in place.
Wide images size their preview box with a fixed width of DEFAULT_MAX_IMAGE_WIDTH
(500dp). #7125 switched the box from .aspectRatio() to a fixed .height() computed
from that nominal width; on screens narrower than 500dp, .width(500dp) is clamped
to the available width but the fixed height is not, so the top-aligned FillWidth
image is shorter than its box, leaving an empty strip below.
Compute the height from the width actually granted via a small Modifier.layout so
it tracks the clamped width, restoring the self-correcting behaviour .aspectRatio()
had. coerceAtMost(w) keeps the box within its nominal width (and bounds the
unbounded intrinsic-measurement pass), and coerceAtLeast(0) mirrors what
Modifier.width()'s SizeNode does for a negative w on a tiny window - so both
dimensions stay in range and the #7123 Constraints overflow crash cannot recur.
getLoadedImage subsampled with an OR cap on the larger side, so tall
images (e.g. phone screenshots) were reduced far below the display size
and rendered blurry. Mirror Android's calculateInSampleSize (keep the
smaller side at the target) so previews stay sharp, and add a decoded-
pixel ceiling so extreme aspect ratios can't blow up decode memory.
* desktop: show startup errors in a copyable window instead of bare "Failed to launch JVM"
When any exception escapes main() before the app window appears - a missing
DLL, a failed migration, broken AWT init - the jpackage launcher shows only
"Failed to launch JVM" and the cause is recorded nowhere: the launcher runs
without a console, so stderr is lost. Every report in #4146 stalled on this.
Catch the error and show it in a native Win32 window laid out like a message
box: an error icon and message, two clickable report links (the GitHub issue
tracker and the support email) above a read-only selectable box with the stack
trace, and an OK button. The links are SS_NOTIFY statics opened with
ShellExecute (browser for the URL, mail client for the email). Native, not
Swing, because broken AWT initialization is one of the failure causes. On
Windows the process then exits cleanly so the launcher does not also show its
own box; on other systems the error is rethrown to stderr.
* docs: plan justifying desktop startup error window (#4146)
* desktop: bundle jdk.accessibility to fix "Failed to launch JVM" when assistive technologies are enabled (#4146)
The jlinked runtime shipped in desktop packages did not include the
jdk.accessibility module. On Windows, when Java Access Bridge is enabled
(jabswitch -enable, "Enable Java Access Bridge" in Ease of Access, or a
screen reader creating %USERPROFILE%\.accessibility.properties), AWT
throws AWTError "Assistive Technology not found:
com.sun.java.accessibility.AccessBridge" during Toolkit init, before any
window or log output, and the jpackage launcher reports "Failed to
launch JVM".
* docs: plan justifying jdk.accessibility fix for Windows JVM launch (#4146)
* desktop: bundle jdk.accessibility only when building the Windows package