feat/ui-redesign #36

Merged
fmodf merged 96 commits from feat/ui-redesign into master 2026-11-07 08:18:08 +00:00
Contributor
No description provided.
UI-REDESIGN.md: the plan of the new UI (iOS 26 and native macOS 26, Signal/WhatsApp-like, the current colours, bubble radius 18, tabs Chats, Calls, Settings), split into local packages (AnotherDesign, AnotherComponents, AnotherModels and one Feature package a screen family, each with a Foundation-only Core target tested on Linux), phase by phase with checkboxes.

remote.sh: `./remote.sh serve` on the Mac runs the jobs a client queues through .remote/ (git-ignored), one at a time under a time limit, a whitelist of verbs with their arguments checked: ping, project, backend, build, test, pkgtest, run, launch, stop, uninstall, shot, ui, tap, tap-id, tap-label, type, swipe, button, openurl, appearance, logs. The builds go through the Makefile's targets; the UI verbs through AXe. The queue, the argument checks, pkgtest and the time-out (the job's process group killed) run on Linux against a stand-in package; the Mac verbs have not run (no Mac here).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Makefile: `backend` builds, after ios.mk's two iOS slices, the backend's native archive (its tcl.mk `lib`, which anotherim-js's `make libtacky` builds on a Mac) under build/backend/macos at MACOS_MIN 26.0, folds it into AnotherTacky.xcframework as a third slice and puts AnotherTacky.Package.swift (iOS 26 and macOS 26; Security and SystemConfiguration on macOS, as anotherim-js links them) in place of ios.mk's iOS-only package. IOS_MIN 26.0; MACOS_DESTINATION the native app (platform=macOS,arch=arm64), not "Designed for iPad".

project.yml: AnotherIM has supportedDestinations iOS and macOS at 26.0, the extension and the tests 26.0. The macOS build is AnotherIM/Mac alone (a window, and tacky_destroy taken as a value so the slice is linked); the rest of the sources, AnotherIMShared and the iOS-only frameworks and packages carry destinationFilters: [iOS] until the new UI has a macOS shell. TackyConfiguration and AppSharedPaths name the macOS product another-im-macos (a `pc` client) apart from iOS's another-im-ios.

README: Xcode 26, the macOS slice. Not built or run: the watchman (remote.sh) was not serving yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Packages/TranscriptSpike: is a SwiftUI ScrollView + LazyVStack, bottom-anchored (`defaultScrollAnchor(.bottom)`, also for size changes), with a ScrollPosition and onScrollTargetVisibilityChange, good enough for the chat? Its Core (SpikeTranscript: 10 000 seeded messages, a page of 200 at a time, older ones prepended, newer ones after a jump, new ones received) is Foundation + Observation with six tests, run here on Linux; its UI target, which the manifest leaves out on Linux, logs (category spike) the visible ids before and half a second after each prepend, page, receive and jump, so `remote.sh logs` shows whether the reader stayed in place. Launch arguments pick the variants: -SpikeSizeAnchor NO, -SpikePrependFix YES, -SpikeTotal. The app shows it instead of the root with -TranscriptSpike YES, on iOS and on the macOS placeholder.

swift-tools 6.2 here and in AnotherTacky.Package.swift: `.v26` is PackageDescription 6.2's (Xcode 26).

Makefile: test-packages, `swift test` in each package. remote.sh: `build mac` ad hoc signed like ios, `run mac [args]` builds and opens the Mac app, `stop mac`.

Not built in Xcode or run (the watchman was not serving).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Makefile (IOS_MIN 18.1, MACOS_MIN 15.0), project.yml (the app iOS 18.1 and macOS 15.0, the extension and the tests iOS 18.1), AnotherTacky.Package.swift and the spike's manifest (`.iOS(.v18), .macOS(.v15)`, swift-tools 6.0 again, which has them). The spike's APIs (ScrollPosition, onScrollTargetVisibilityChange, onScrollGeometryChange, defaultScrollAnchor for size changes) are iOS 18 and macOS 15.

remote.sh: `@18`, `@26` or `@27` before the verb picks the simulator's iOS (the newest installed without one); within it a booted iPhone, else REMOTE_SIM, else iPhone 17 Pro, else iPhone 16 Pro, else the first. The simulator is resolved once before the job runs, so a missing one stops it (it went on with an empty UDID). ping lists the Xcodes, the runtimes and the iPhones. Checked on Linux against a made-up listing with iOS 18, 26 and 27 runtimes.

UI-REDESIGN.md: system elements are each OS's own; ours take Liquid Glass on 26+ and a material of the same shape on 18 and 15 through one modifier; every UI change is checked on @18, @26, @27 and the Mac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
project.yml: the deployment targets as IPHONEOS_DEPLOYMENT_TARGET 18.1 and MACOSX_DEPLOYMENT_TARGET 15.0 build settings, since xcodegen leaves a target with supportedDestinations at the SDK's version (the app wanted iOS 26.5 on a 26.1 simulator); no entitlements on macOS for now (ad hoc signing with the iOS ones asks for a provisioning profile). .swiftlint.yml excludes Packages/*/.build, where `swift test` left the generated test runner that failed the lint.

The spike: the "at the bottom" test is the visible rect's bottom against the content's; a prepend with the list at rest (spike.older); -SpikePrependFix next; -SpikeInverted (the list upside down, older pages appended at its end); -SpikeScript, a run with no input (a jump, three prepends at rest, 20 incoming) that macOS, where nothing taps, is checked by.

Result (the first visible id before and after, from the log): a plain list keeps its place on iOS 26 with or without help, jumps by up to 200 on iOS 18 unless scrolled back to the old first id, and drifts by 3 to 6 on macOS when it is; the inverted list keeps it on all three. ChatLayout is not needed; the inverted SwiftUI list is the candidate once the Mac's wheel direction, the context menu preview and VoiceOver order are checked by hand.

remote.sh: tap-id taps the centre of the first element with the identifier from describe-ui (AXe's --id refuses a toolbar's text buttons, a Group and a Button with one identifier); `logs mac`; `shot mac` says whether the app runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The spike's bubbles are min(78%, ~72 characters) of the transcript's width, as in anotherim-js, instead of a fixed 300 pt that left them narrow on the Mac. -SpikeTrace logs every frame (offset, content height, the on-screen y of one visible row); on the Mac, 29 pages of 50 and 200 rows over about 5600 frames, the row never moved more than 2 pt beyond the scroll. -SpikePage sets the page size, -SpikeAhead the paging threshold, now 50 rows: at 10 a fast scroll reached the window's edge before the page came.

remote.sh prefers the Mac's "… mySim" simulators (iOS 27 picked the two-display iPhone Duo, where a screenshot fails) and gains `shutdown`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
remote.sh `swipe` takes an optional duration: AXe's default drags slowly, 0.1 s is a flick with momentum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The spike's bubbles get a context menu; remote.sh gains `press x y [seconds]`, a long press.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The colours are the old catalog's, copied under new names; the avatar tones keep the order the JID hash picks from. Two dark colours are new: an avatar's fill and ink swap places (a deep circle, a light letter, the same contrast), and errors take the system's dark red, since the old red is 4.2:1 on a dark cell. The tests read the catalog's JSON on Linux: AA contrast for every text/background pair in both themes, every named colour present, the tones' order.

DesignGallery runs with -DesignGallery YES on iOS and fills the Mac window until the shell comes; checked on iOS 18, 26, 27 (light) and the Mac (dark). Icons and the platform helpers wait for the first component that needs them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AnotherFormat (Foundation, tested on Linux) is the one place for the text and the small types: the avatar hash (the old one, pinned by values), initials, the display-name chain, badge text, times and days, durations, file symbols, fingerprint groups, Presence and Delivery. The old UI had five copies of the duration, six of the display name, three of "Today/Yesterday", and they disagreed.

The SwiftUI components: AvatarView and PresenceDot, PeerRow with SelectionMark and DecisionButtons, PeerHeader, ActionButton (tile, round glass), UnreadBadge, DeliveryTicks, TypingDots, Pill, Tag, Snippet, QuoteView, Banner, ProgressRing, MediaThumbnail, FileTile, QRCodeView, FingerprintView. ComponentGallery (-ComponentGallery YES) shows them in their states; checked on iOS 18, 26, 27 and the Mac, which caught buttons hyphenated in a narrow row, a fingerprint wrapping and a placeholder under a thumbnail's badge. With the accent grey in the dark theme, a muted badge is dimmed and read ticks are full strength rather than accent, or neither would show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A row's id is our own id for what we sent (its timestamp key changes when the server confirms it), else the timestamp; the key for calls is kept apart. Delivery takes the peer's word over the server's. Presence reads "" as online from `presence get` and as offline from a chat list row, the two conventions the wire has. A call's direction comes from the call (the old transcript took is_outgoing, always false there, so every arrow pointed in); only a missed or failed call is red. Previews say You, Photo, Video, Album, Message deleted, with SF symbols, the same on every screen; the old UI and the web client disagreed on several.

Fixtures (30 chats in every state, a transcript of every kind and delivery, every call outcome, contacts, occupants, accounts) live in their own target, outside the app. Clients wait for the features that shape them. 14 tests, on Linux and macOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A TabView sidebar around the split view nested two sidebars, and on the Mac the list column slid under the chat; one split view, which a phone folds into a stack by itself, serves every width with one set of columns. The accent comes from a tint on the shell's root, so the old UI keeps its own. -NewUI YES opens the shell on iOS; the Mac shows only the new UI. -ShellChat <jid> and -ShellInfo YES open a chat and its info at launch, for screenshots where nothing taps; remote.sh now lets '@' through in launch arguments. Checked on iOS 18, 26, 27 and the Mac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The user settled two questions: errors that need an answer are alerts, states such as no connection live in one place of the shell, and there are no toasts; "mark as unread" goes, since neither libtacky nor the web client has it, and UnreadBadge loses its markedUnread dot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ConversationCore keeps the open chat's window of history as anotherim-js's history.ts does, with what the old window got wrong put right: one request per direction, a failed one quiet until Retry or the connection; live rows only at the tail, and the tail learnt from the newest page as well as Tail, so a jump away in a quiet chat finds its way back; the newest page read again with the rows left on screen; an edit outside the window not inserted; a confirmed send re-keyed under its id; a jump lighting its row, replacing the window when the row is not in it, said when nothing is found, and retried when it failed; receipts held through a catch-up; every change said for what it is. TranscriptLayout makes the rows: a run of one author's messages within five minutes joins, the name over its first and the picture by its last; the day's header and "New messages" belong to the message under them. ConversationModel has the composer (reply, edit, files, mentions with suggestions), drafts through DraftSync (moved here with its tests, the old screen uses it from here), typing both ways, read marks by what was seen, the actions and the selection. 29 tests.

ConversationUI draws it over a plain ScrollView: pages a screen before the edge, follows the newest only while the reader is there, counts what arrives below on the down button, holds a page above until a fling stops (rows put in under one moved the reader by the page, measured on iOS 18 and 26). Bubbles in the gradient with the joined corners smaller, XEP-0393 styling as libtacky parsed it, links, mentions, quotes that jump, pictures and albums sized before their bytes, files in Quick Look, voice notes (the new VoiceNoteView), link cards (LinkPreviewCard), reactions, the footer with the lock and ticks, calls, notices and unreadable runs in the middle; the menu with six reactions and more, reply, forward, copy, edit, delete, select, info; a swipe to the right to reply through a pan that begins only sideways (a DragGesture took the scroll from the list). The composer on the glass with photos, the camera, files, drops and pastes, a voice note held, locked or slid away, and why one cannot write where one cannot; the header with presence or typing and the calls; the pinned message, a stranger, no encryption and keys to review as banners; search in the chat; Return, Command-Up and Escape on the Mac; the wallpaper (Wallpaper and WallpaperBackground in AnotherDesign). The live client works over message history, goto, search, the sends and AttachmentService.

FeatureCompose (built alongside): New Chat with contacts by letter, Add Contact, Join Group with the room directory, New Group, the recipient picker forwarding uses, and contact requests, each with its live client and tests.

The shell opens a chat from the list, a search hit, New Chat (Command-N) or -ShellChat with -ShellAccount, and -ShellFixtures YES shows the whole shell on fixtures on iOS too. Checked on the iOS 26 simulator with the test accounts (a chat read, a message sent with OMEMO), on iOS 18 and on the Mac on fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page above no longer moves the reader. It waits while the transcript is flung and goes in once it stops (rows put in under a running deceleration moved the reader by the page on iOS 18 and 26); the day's header and "New messages" are part of the message under them, so every row's id is a message's (a header of its own moved up with a page of its day and took the reader with it); nothing comes and goes above the rows (the spinner and Retry float over them); paging starts once the opening page is laid out, and after a page only when still at the edge, with no request tied to a view task that a changing id cancels (a cancelled request is no failure any more). Every scroll the transcript makes goes through ScrollViewProxy: a ScrollPosition set to the value it already holds did not move, and iOS 18 opened a chat short of its bottom. Following stops only on a rest after the reader's own scroll. The swipe to reply is a pan that begins only sideways (a DragGesture on a row took the vertical scroll from the list). Measured with -TranscriptTrace on the simulators: after each page the reader is a page away from the top again.

The chat list marks blocked chats by the XEP-0191 list, as anotherim-js does, and reads it again on blocking <Changed> (a row is not sent again when its peer is blocked); a row's menu has the chat's newest messages over it (ConversationPeek, read-only: it marks nothing read). Checked live on the iOS 26 simulator: a draft left and taken up again, mute, block and unblock, a chat read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FeatureSecurity: a chat's keys, pushed after its details on a phone and a sheet beside the inspector. A contact's devices with their fingerprints in coloured groups of eight, the trust picker, Set all, inactive devices folded away, a changed key shown with Accept, Reset Sessions; a room's OMEMO version, why it cannot encrypt and its members' devices; this device's key and Compare Keys with a QR code and a pasted xmpp:…?omemo-sid code (as Conversations and Dino). The account's keys under its settings (Keys and Certificates): this device, renamed, its code for our other devices, blind trust, other devices removed or the inactive cleared, the accepted certificates; the app's certificates under Security. The untrusted-certificate question over the new UI (the old sheet is the old UI's alone). SecurityClient in AnotherModels with a fixture; the live one reads the trust list, the room status and mode, pulls on open and reads again on OMEMO events; the trust row carries verified and last_active. A link whose destination was a slot's AnyView made anew on each redraw hung the app; it goes through an Equatable view now.

FeatureCalls: the log by day, missed and failed in red (an incoming call nobody answered is missed), call back, All | Missed, delete; the call itself full screen on iOS and a sheet on the Mac: the other side's video or their picture blurred, our own a tile to drag or swap, the timer, the encryption and its emoji, round glass buttons (no speaker or camera flip on the Mac), Accept and Decline, Call Again; folded into a bar over the app; a room's call as a grid. CallsClient in AnotherModels with a fixture; the live one reads every enabled account's history, follows CallsModel and the groupcall events, and draws video through WebRTCRenderView. -CallScreen audio|video|incoming|ended|group shows a call on fixtures.

Calls on the Mac: CallsModel and WebRTC build there; CallKit and the audio session stay iOS's, and the Mac's CallKitController performs at once what the model asks and rings an incoming call as a notification with Answer and Decline while the app is not in front. The Mac links WebRTC and may use the camera and microphone. A Mac app hidden or minimised keeps its accounts: it no longer suspends them in the background.

With tests (FeatureSecurity 22, FeatureCalls 11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lost and restored: signing in to an account already on the device is refused instead of removing its history and keys, with a time limit; Welcome turns a disabled account back on; a room says why we are out of it (kicked, banned, destroyed, password, nickname taken) and joins again; a group call in progress has its banner and Join; Leave says what it does and warns the last owner; the Mac adds accounts; a room's encryption asks first and shows the server's refusal; a message OMEMO did not encrypt can go without encryption; a room's message menu has Message privately, Mention and the author's card; Missed in the call log pages; contact requests are one row with a count, opening a sheet.

HIG: the Mac's menus (New Chat replaces New Window, View and Edit commands, call shortcuts); iPad Settings closes; deleting selected messages and blocking from a stranger's banner ask first; names and quotes in the avatar tone's ink; VoiceOver labels and actions on bubbles, rows and the microphone; glass only on controls, interactive, in containers; safeAreaBar on 26; close and confirm roles on sheets (CloseButton, ConfirmButton); 44pt targets; five call buttons fit a phone; large text scrolls on the lock and welcome screens; notifications are asked for after sign-in; error alerts shown over sheets; the chat list plain with an empty state that acts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The accent is an indigo action colour (light #4B3FC7, dark #6F5FF0) with white on it; switches on iOS keep the system's green (SystemSwitchStyle at the root). The wallpaper's new default is a pattern of SF Symbols doodles drawn in a Canvas over the gradient (Wallpaper.pattern), also behind the lock, the welcome, empty lists and the Mac's and iPad's empty detail.

The lock: the system's prompt (Face ID, Touch ID, the passcode, the Mac's password) opens the app by itself. A new store is encrypted under the device key and split with the presence key at once (storage encrypt -key, split -mainKey); the PIN every install had to make before is cleared at the first unlock. A PIN of the app's own is an option in Settings (App PIN, Change PIN), a second factor after Face ID without the passcode sheet, and offered at the setup of a device without a passcode. The lock screen is the app's mark and one button, or six dots over the secure field.

The chat: the time and ticks end the last line of text; pictures and videos fill their bubble with the time on them; notices are plain text, folded when several come together, and the day floats while scrolling; an open lock only on a message in the clear in an encrypted chat; a camera button beside the microphone; a voice note's waveform. The list: filter chips, the unread time in the accent, attachments named by kind (a .mov sent as a file is a video), the requests' count as a badge. Details: Audio, Video, Mute and Search tiles under the name, Edit and a menu in the toolbar, the technical rows lower down; the member card gets its tiles too. Calls: New Call, a contact picker with audio and video per row, from the toolbar and the empty log. Settings: profile cards first; an account's page is its profile, with its connection and the rest under Account; a device's key is a row with its state, its page holding the fingerprint and the trust choice.

With tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FeatureContacts: every enabled account's contacts by letter, with the side index and a search by name or address, the contact requests row, Add Contact, and a tap to the chat. The context menu (with the contact's card as its preview) holds Message, Audio and Video Call, Info, Rename, Refresh Picture, the status-sharing action that applies, Block or Unblock and Remove from Contacts; Block, Remove and Stop Sharing ask first; a swipe removes, or messages and calls. ContactsClient in AnotherModels with a fixture; the live one over the roster, presence and blocking the compose and details clients already read and write. The iPhone's tabs are Contacts · Calls · Chats · Settings again, as in the previous version, Chats selected at launch; the Mac's and iPad's list switches between Chats, Calls and Contacts (⌘3).

The chosen wallpaper, the pattern unless changed, is behind the chat list and the contacts as behind a chat. Its setting has a new key, so everyone starts on the pattern (the old one held Plain or Gradient from before the pattern was there).

The jump to the newest message sits in the bottom bar over the composer: over the transcript, iOS 26's bar lay above it and took its taps. It scrolls to the transcript's edge, then to its last row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tacky submodule moves to 2f42a2f2 (v1.2.0-beta2) and TackyWire.swift is regenerated from its schema (tools/api/api.py gen --swift): the Matrix, Signal and Pubsub modules, Fluux polls (urn:fluux:poll:0, PollOption), tag filters with chats and exclude, notify <SealedRead>, file <Sweep>, omemo_left_out, the OTR and MTProto direct-chat fields, XEP-0507's call category. The only signatures that changed (polls send, the microblog methods, webxdc send_update) are ones the app does not call; the generated file typechecks. The crates now ask for Rust 1.91.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pin and Unpin in a message's menu (ConversationClient.setPinned now takes the message's key). The banner under the header shows the newest pin, says "Pinned message 2 of 3" when there are more, goes to the one shown and to the one pinned before it on the next tap, and its cross unpins the one shown. chats <PinnedMessages> reads the header again, so a pin from another device shows too.

The app icon and the Dock count the unread messages of the chats not muted. AppUnreadMirror keeps them by chat thread in the app group, and the push extension sets its chat's count from the push and badges the notice with the total, so the icon counts while the app is not running.

A shared place (XEP-0080) is a card with its name, address, coordinates and Open in Maps. No map is drawn: fetching one would tell the map's server which place was shared, as anotherim-js decided. A shared contact (urn:tacky:contact:0) shows its name, address and phones (a tap copies one), with Message and Add Contact; Add Contact opens with the address and name filled in. A poll (urn:fluux:poll:0) votes with a tap, or with ticks and Vote where it takes several. Once we voted or it is over it shows each option's share, unless its creator hid the counts until we vote. It has Retract Vote, Close Poll for its creator, and its end time, at which it turns to the final results.

A room's or our server's CAPTCHA (captcha <Challenge>) opens a sheet over the shell: its words, the form with its picture, Send and Cancel. A wrong answer says so, and the room's own refusal offers to join again in the chat.

On the Mac, while the window is not in front, notify <Notify> posts a notice, one per chat. A newer one replaces it, it goes when the chat is read, and a message sent quietly comes without a sound.

Every catalog has ru, uk, et and es: anotherim-js's own translations where the English is the same, new ones for the rest, plural forms included. The Info.plist permission texts and the push extension's "N new messages" are translated too. The previews and call titles AnotherModels words ("You:", "Photo", "Missed call") come from a catalog of its own, through Bundle.localizedString, which Linux has too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chat's header has a lock: a tap opens the chat's keys, its menu picks OMEMO or no encryption (off asks first), and it turns amber while new keys wait for review.

A message of ours in an OMEMO room says "Not delivered to …" under it when it reached none of some members' devices (Message.omemo_left_out, and message <Status> as it changes).

Block's question, in the stranger's banner and in the details, also offers Report Spam and Block (blocking report, XEP-0377); a server that takes no reports only blocks.

A name its person chose and nobody vouched for (name_source carried, or published by someone not in the roster) never stands alone: the chat list and the header show the address beside it, so "Mom" from a stranger cannot pass for the roster's Mom.

A key's row says which OMEMO versions it speaks (2, 0.3, both); its page says when a message of it last decrypted here.

The account page opens Saved messages: on a phone the Chats tab comes up with it, on an iPad the settings sheet closes onto it.

The account's status is the one status (status set/clear, XEP-0107 mood mirrored into every device's presence), read back with status get.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Disappearing messages (XEP-0466): the chat's details pick its timer (off, 5 minutes to 4 weeks), in a room only for a moderator, as the room takes it from no one else. The header carries the timer's mark while it runs, and ephemeral <TimerChanged> reads it again. The notice of a change is a centred row in the app's words ("You set messages to disappear after 5 minutes").

Contact groups: the contacts tab filters by roster group from its toolbar, and a contact's menu has Groups…, every known group with a tick and a new one. roster item goes with the groups alone, which keeps the name.

Link previews (XEP-0511): Privacy picks None, Others' Only or Others' and Mine (libtacky's link_previews never, sender, always). With Mine, the words' first https link shows as a chip over the field. Nothing is fetched until Show Preview (linkmeta preview), and the cross sends the message with no preview (link_preview 0).

XEP-0439: a bot's buttons sit under its message, a press is message select_action. Its suggested replies are chips over the field for the newest received message, each sending its value, shown beside the label when the two differ. Neither in a room, and no buttons under an encrypted message, whose press would name the action in the clear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ScanCodeButton (AnotherComponents) reads a QR code off the camera with VisionKit's DataScannerViewController and hands its text over; where no camera can scan, it says to paste the link instead. iOS only: the Mac pastes. It is used to sign in from another device, to compare keys (the scanned code is verified at once), and in Add Contact.

xmpp: links open the app (CFBundleURLTypes on iOS and the Mac). XMPPLink (AnotherModels) reads RFC 5122 and XEP-0147. A chat link opens the chat, ?roster or ?subscribe opens Add Contact filled in, ?join asks before joining, and an invitation (?roster;preauth=) is accepted and its chat opened. The shell's requests (these, and Saved messages from Settings) are one ShellRequest the chats' column carries out.

The account page has Link a Device: account link_device's code and link, which another device signs in with and which verifies the two devices' keys for each other.

Add Contact has Invite with a Link (roster invite, XEP-0379): a one-time code and link that add us to each other. A scanned or pasted invitation is followed at once (roster accept_invite), and a plain contact link fills the fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scheduled messages (urn:tacky:schedule:0): where the chat's scheduler takes them (schedule supported), Send's menu has Schedule…. It offers an hour from now, tonight, tomorrow morning, or a time picked up to the scheduler's horizon, and the message goes with message send at. A capsule over the transcript counts the chat's waiting messages, and its list (schedule list) sends one now or cancels it. schedule <Changed> reads the header again, so another device's changes show.

The account page lists Sessions: the clients the server keeps (XEP-0494, clients list), with last seen and address, or the account's online resources where the server keeps none. A list to read, as in anotherim-js; ending one waits.

The account page edits the whole Profile: full name, pronouns, bio, emails and phones (one a line), website, organization, title, city, country, birthday, and whether contacts or everyone may see it. vcard set writes the vCard4 item and its vcard-temp copy, and what the page does not name stays on the card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The user's tags (libtacky's tags, Telegram's folders, synced to the user's other devices) follow All, Unread and Groups as chips over the list on a phone, and in the Show menu on the Mac. A button beside them opens the tags: a new one, each one's editor, and a swipe to remove. The editor names the tag and picks what it shows: chosen chats, people or groups, only the unread. The keys it does not show (accounts, a JID pattern, roster groups, excluded chats) go back as they were. tags <Changed> reads them again, and a tag gone takes the list back to All.

TagFilter (AnotherModels) is the backend's rule, as anotherim-js reads it: excluded chats never, the account, unread and muted conditions for every chat, then chosen chats, then the selectors (roster groups, a JID glob, the kind) all holding. A chat row now carries its roster groups for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The composer's + menu has Sticker: the account's packs (XEP-0449, stickers packs), one at a time, a tap sending the sticker (stickers send). Packs lists the account's own, which a swipe lets go (remove_pack), and the server's public catalogue, which Add takes in (import_pack). The pictures load from their https URLs. Making a pack is not here.

New Chat has Find People: the server's people directories (each XEP-0055 service of the domain that is no gateway) asked with one line of text, as anotherim-js asks them. Eleven digits are a personal code, seven to fifteen a phone number, and anything else is a name, which ejabberd's vjud is asked for as a start. The answers make one list, each person once, and a tap opens the chat. A server with no directory says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The account page lists the gateways its server offers (XEP-0100: the domain's services with a gateway identity, Telegram and Matrix on another.im) and whether the account is linked to each. A gateway's page shows the service's own words and form (XEP-0077 gateway registration: a phone number, a code, or the QR code to scan with the other app, which DataFormView draws) and links with Link. The form is answered as it was shown, since a CAPTCHA's or a QR code's changes each asking, and the next form follows until the service says it is done. Once linked, Unlink asks first and goes (gateway unregister, which also takes the network's people off the roster).

The wire-to-DataForm conversion is one, shared by the CAPTCHA sheet and the gateways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Data and Storage has Back Up: a passphrase typed twice (eight characters at least), whether the accounts' passwords go in, and Make Backup. storage export writes the install, every account with its keys and history, as one XEP-0227 document sealed under the passphrase (.aimbak), which the share sheet then saves or sends.

Restoring one is not here yet: storage import meets the store's device keys and PIN, and wants its own design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add Contact takes what is no XMPP address and asks the domain's gateways, as anotherim-js's does. A Matrix ID (@alice:matrix.org) becomes the Matrix gateway's address for it, as mod_matrix_gw writes it (alice%matrix.org@<gateway>). A Telegram @username or id is translated by the Telegram gateway (gateway translate) once the account is linked to it; before that the field says to link it in Other Networks. Anything else, a phone number say, is read by gateway resolve. The line under the field names the address and its network.

A contact's details list the commands it offers (XEP-0050, adhoc list): a bot's, a gateway's own. One runs stage by stage in a sheet: the service's notes and form (DataFormView, its pictures too), Back, Next or Done as the stage offers. The form goes back as it was shown, and closing the sheet cancels a session still open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A voice note is Ogg Opus elsewhere: Conversations records it, and Telegram takes nothing else as one. The AAC recording is now encoded with the system's own Opus codec (AudioToolbox through AVAudioConverter, 48 kHz mono, 20 ms packets) and laid in Ogg pages before it is sent as .ogg (audio/ogg). Should the codec fail, the AAC recording goes as before.

An Ogg Opus note that comes in is decoded the same way to a CAF that AVAudioPlayer plays, since AVAudioPlayer reads no Ogg, and the encoder's pre-skip is dropped.

OggOpus (AnotherFormat, tested on Linux) writes and reads the framing (RFC 7845): OpusHead and OpusTags, pages under 255 lacing values, Ogg's CRC-32, granule positions from each packet's TOC byte. OpusCodecTests, on the simulator, takes two seconds of tone through Ogg Opus and back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server page lists the connection as negotiated (account server_info): the transport and endpoint, the TLS version and cipher, the sign-in (SASL or SASL2, the mechanism, the channel binding), Stream Management, Client State Indication, FAST, Bind 2, and since when.

XML Console, under it, turns the account's console on while it is open (account xml_console): what the session kept comes first, then each element of the stream as it goes, ours and the server's, credentials redacted. Share takes it all, and leaving the page turns the console off.

The account pages added since the list grew (link a device, sessions, profile, gateways, console) are AccountPages.swift, the live client's longer bodies functions of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Appearance has Accent Color: the indigo (the default) or blue, teal, green, orange, pink, purple, each a light and a dark that white words read on. The pick is kept with the other appearance settings (AccentChoice, @AppStorage), Color.accent reads it, and the screens' own tints follow it at once (accentTint(), in place of tint(.accent)).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A video message alone (an attachment of kind video_note, no words) is a circle 220 points across with no bubble, as Telegram draws it.

The composer's + menu on an iPhone has Video Message: the front camera in a circle, Record and Stop (a minute at most), and the square middle of the recording, 384 a side, as an mp4. That goes as a file of kind video_note with its length (AttachmentService.sendVideoNote).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewed-on: #10
libtacky files a chat under the exact JID the host passes, while everything
it reads from the server is lowercased: a ChatID spelled `Bob@x` was a second,
empty chat beside the roster's `bob@x` -- no history, the local part for a
name, "not in your contacts", and a sent message that never came back to the
open screen (#11, #9). ChatID now lowercases the bare part of both JIDs; sign-in
lowercases the account's local part as well, since libtacky keys accounts as
typed.

A chat's header channel was one slot, and a closing screen's stream cleared it
after another had taken it, so the header was never read again and Add seemed
to do nothing (#8). Each stream has its own slot now; Add says so offline, and
the roster lookups behind the header ignore case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`-UITestFixtures [shell|welcome|lock|setup]` (Debug only) builds the shell
from the fixture clients: no store, network or push, so XCUITest sees the
same screens every run. AnotherIMUITests drives it: the chat list, the tabs,
sending a message, the welcome and the lock.

`make test` now records coverage to build/coverage.json; `make coverage`
runs the packages' tests with coverage on Linux, and coverage.py merges both
into one table per target (generated code, fixtures and tests left out).
`remote.sh pbcopy` puts Latin text on the simulator's pasteboard, which AXe
cannot type on a Russian keyboard.

New tests for SettingsCore (99.5%) and ConversationCore (94%): every refusal
is said, the pass-through actions reach the client, marks, typing, search,
selection, drafts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LiveSupport.swift gives the live clients' tests an environment over
FakeTransport that answers each method from a table (null for the rest, a
refusal where asked) and a collector for their streams. ChatListLiveTests:
the rows of every enabled account with the blocked marked, events as
changes, each action's frames, invitations, blocking, search pages, tags.

The conversation tests lose their force unwraps (SwiftLint).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Compose, Contacts, Settings, Lock, Media, Avatars and Onboarding: each
closure's frames, the answers read into models, the events turned into
changes, and the refusals. LiveSupport's environment takes the presence
keys, and StandInPerson answers for the person at the device, so the lock
and PIN tests raise no system prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every *Core target, AnotherModels and AnotherFormat now runs every line
under its tests (about 110 new ones: refusals and cancellations, the
follow() loops, the registration flow, transcript paging, client
defaults). coverage.py joins llvm-cov's lines across the packages' reports
instead of keeping one report per file.

Snapshot.swift draws a view in a window of the test host (ImageRenderer
draws no List or text field) and compares it pixel by pixel with its
picture in __Snapshots__/, recording a missing one. ScreenSnapshotTests:
lock, welcome, add account, settings, account keys, chat encryption and
new call, light and dark, on @26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Over the fake backend, as the others: each closure's frames, the answers
read into models, events into changes, refusals. The calls run through
the real CallsModel on calls/groupcall events (no microphone prompt: only
rooms, ourselves and a blocked contact are started). WireModelsTests
covers the wire-to-model mappings the client tests left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
History pages and jumps, sends in a 1:1 and a room, edits, files and
albums, voice and video notes, downloads, polls, stickers, scheduled
messages, link previews, headers of every kind of chat, and each message
and room event. The regressions: a chat opened as Bob@Example.ORG talks
to the backend as bob@example.org (#11, #9); a closing screen leaves the
header channel of another screen of the same chat alone, and Add says so
when offline (#8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While the UI changes every day, a comparison with pictures fails on every
change and catches nothing the unit and UI tests do not. The helper and
the tests stay in 07d9d96 (Snapshot.swift, ScreenSnapshotTests.swift); the
pictures are recorded on the first run and stay out of git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AppEnvironment's lifecycle and storage gate, CallsModel and CallKit
over calls events, two WebRTC wrappers negotiating over loopback, the
attachment cipher, store, inspector, upload and download, the wire
decoders, TackyAPI, the network monitor, the unread mirror, the logger
and the keychain. XMPP 97%, Tacky 98%, App 87%, Calls 80%, Shared 92%.
Tests that touch the device key, defaults or the app group put them back.

Two suspected bugs are pinned with XCTExpectFailure until fixed: a
nameless attachment is named "/" (AttachmentNaming), and the cipher's
open assumes a zero-based Data and crashes on seal's own result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`make test` (and `remote.sh test` with no filter) runs AnotherIMTests
alone, in minutes; `make uitest` / `remote.sh uitest [Class[/m]...]` runs
AnotherIMUITests, which take most of an hour, into its own result bundle
and build/coverage-ui.json, under a two-hour limit. A filtered `test`
runs what it names, as before; `make coverage` reads both reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 XCUITests over the app on fixtures: chat list and tags, the
conversation and composer, chat details and rooms, calls and the call
screen, contacts, new chat, media, security and its prompts, settings
and account pages, onboarding and the lock. Helpers in UITestSupport
print the accessibility tree when an element does not show.

FixtureRoot grows a call in progress (-UITestCall), a certificate or
captcha prompt (-UITestPrompt), room headers, account pages and tags;
chat details, rooms and media get the identifiers the tests find them by.
One test marks a suspected bug with XCTExpectFailure: Saved messages
from an account page leaves the phone on Settings without its tab bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xccov leaves a random few package targets out of a report, so `make
test`/`uitest` copy theirs into build/coverage-runs and `make coverage`
takes each file's best. Writing to an address and joining a group open a
chat from New Chat, which can leave the app animating for good (BACKLOG):
they expect that failure, non-strictly, until it is fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AnotherIME2ETests (opt-in: `make e2e`, `remote.sh e2e [Class[/m]...]`,
never part of `test` or `uitest`) signs testman001 and testman052 into one
libtacky of its own, in a store under the test host's caches that every
run reuses (a new store would publish one more OMEMO device per account
each run); the app's store, app group and keychain are never touched.
Credentials come from AnotherIME2ETests/credentials.json, kept out of git;
without it the tests skip.

Ten scenarios: a text with its receipt and read marker, an edit and a
retraction, a reaction, a file over HTTP upload with the same bytes, a
subscription asked and approved, a capitalised address in the same chat
(#11), the history after a restart (#9), a room created, joined and
destroyed, OMEMO between the two. What a test changes on the server is
undone in tearDown. A roster set the server refuses goes unreported (#8):
pinned with XCTExpectFailure. The restart test can crash libtacky when a
backend is created soon after one is destroyed (BACKLOG).

coverage.py leaves the packages' generated .build sources out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It opens the new room's chat from New Chat, which can leave the app
animating for good (BACKLOG): the failure is expected, non-strictly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Attachments: the cipher opens a slice of any Data, a nameless share is
named from its URL (an empty path was the working directory), and a type
guessed from the kind no longer beats the bytes. Settings' Saved messages
turns to the Chats tab, a removed account's pages close, a device's page
follows the trust it was given, and a phone's 1:1 chat keeps its header
with one call button (video in its menu). Rows are accessibility
containers, so their buttons keep their own identifiers. Room removal,
registration cancel, backup export, a blank Telegram id and the
conversation client's raw errors and per-screen header reads are fixed;
the fixtures tell every listener of a change.

The scenarios that open a chat from New Chat or Settings still meet a
snapshot timeout now and then (BACKLOG): they expect it, non-strictly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#13: a sign-in that reaches no answer says the server is not responding,
apart from one that cannot reach it. #14: removing a contact deletes
their chat from this device too. #16: an upload slot is asked under a
fresh name; the share keeps the real one. #17: no direct file transfer is
accepted by itself or offers the device's address. #18: an invitation
link from outside asks before it makes mutual contacts. #19: nothing is
kept behind a correction, a retraction or an expired message
(preserve_history 0). #21: a server that takes no spam reports is said to
have only blocked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fixture chat's sending message spun forever; when a chat opened at its
bottom, XCTest never saw the app idle. A queued message no longer animates
for as long as it waits either. `remote.sh sample [ui] [mac]` takes the app's
stacks for a hang, and launch arguments may hold parentheses ("(en)").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chat subscribed to its events only after the header and the first
page were read: a message, the catch-up's end or coming back online that
came meanwhile was lost, and a first page that hung (a poor network, a
reconnect) kept the chat empty and deaf to new messages until it was
opened again. The events are followed first and the page loads beside
them; the first page is always a new window, so the transcript still goes
to the bottom when a live row beat it. The live stream subscribes before
its state pull.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewed-on: #23
Calls take their new argument order (reason, plaintext, to before the now optional timestamp). Unit 570/570, E2E 10/10 on another.im.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tests: noHiddenFieldIsSentSoLibtackySolvesTheProofOfWork replaces aRetryAnswersTheNewChallengeNotTheOld, and the submit test no longer expects FORM_TYPE; FeatureOnboarding's 34 tests pass. Not checked by a live sign-up on another.im (one registration per IP per 600 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewed-on: #34
AppDelegate (iOS) gets the background-push handler; it reuses ChatNotificationWithdrawal, whose `withdraw` is now async so the handler waits for it before iOS suspends the app (the OwnRead path runs it in a Task). AppPushPayload reads `kind: "read"` with a chat as `.read(account:chat:)`. Unlike the master PR, a message summary with `count: 0` stays a message (as PushEnvelopeTests pinned since 8846f6a): it comes as an alert through the notification service extension, which shows only a summary. project.yml adds the `remote-notification` background mode, without which iOS delivers no silent push; wake pushes now reach the app too and are answered `.noData`.

Tests: PushEnvelopeTests (the read push, one without a chat refused, apushRead only on the APNs token), TackyAPITests' set_target frame. All 572 AnotherIMTests pass on the iOS 27 simulator; the macOS app builds. Not yet tried end to end on a device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewed-on: #35
fmodf force-pushed feat/ui-redesign from 14e332f9e9 to 02f44fcc9c 2026-11-07 08:17:55 +00:00 Compare
fmodf merged commit efae88f2a9 into master 2026-11-07 08:18:08 +00:00
fmodf deleted branch feat/ui-redesign 2026-11-07 08:18:08 +00:00
Sign in to join this conversation.
No reviewers
No labels
done
fixed
returned
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
another-im/anotherim-swift!36
No description provided.