Reactions: iMessage-style Tapbacks panel - #44
Draft
decoder-dev wants to merge 219 commits into
Draft
Conversation
Reserve left-of-avatar slots for Calls outgoing/voice-chat icons so they no longer clamp under the 52pt photo. Match the composer capsule to the 40pt side circles (2/2 insets). Unify Day/Night accent and folder pill on system blue; make Night incoming links readable; center Contacts thread avatar icons on the avatar frame.
Idealism pass: Calls/Contacts overlaps, composer 40pt, unified #007AFF.
Stock Telegram parked send at capsule.maxX − width so the blue disc ate the pill’s rounded end. Place send at maxX + 6 with a reserved right slot (8 + size + 6) for both empty and has-text — capsule width no longer jumps on the first character, emoji stays inside without clearing an overlay, and ActionButtons returns the true 40pt circle (title pills still expand).
Replace the left paperclip with a drawn Plus (Messages). Keep mic and send in one right-hand slot and crossfade/scale them in place — drop the off-screen mic slide, paper-plane fly-in, and blur. Z-order swaps with recording so lock/stop stay hittable. Telegram attach menu, video notes, long-press send, bots, slowmode, and paid send stay.
Recording lock/stop live on mediaActionButtons; if send was above it from a prior text state, hits would miss the mic chrome. Always elevate media when recording (or when send is not in the slot).
Icon audit: cross-referenced all 823 bundleImageName literals against the
1511 imagesets in the tree. Five names resolve to nothing.
Star the call-rating success HUD asked for a bare
"Star"; the asset is "Call/Star", so the HUD
showed text with no glyph. Path corrected.
Media Editor/MediaVideo the picker grid's video, slo-mo and time-lapse
Media Editor/MediaSlomo badges. No imageset by those names ships (the
Media Editor/MediaTimelapse only MediaVideo in the tree belongs to the watch
target), so every video thumbnail drew its
duration beside an empty 19 pt square. Now taken
from SF Symbols - video.fill, slowmo, timelapse -
the same three Photos uses for the same three
subtypes.
Settings/Permissions/Siri genuinely absent and left absent: the sibling
permission screens use full-colour illustrations
and no substitute matches. PermissionContentNode
already lays out cleanly for a nil icon.
Chat List/Tabs/Holiday/* are also absent but unreachable - useSpecialTabBarIcons
gates them on 24 Dec 2020 to 2 Jan 2021, a window that cannot recur.
Calls list avatar column. The overlap fix reserved the glyph slot per row:
CallListCallItem only when the row had an outgoing arrow and at that arrow's
own 20 pt, CallListGroupCallItem always at the indicator's 22 pt. Three kinds
of row in one list therefore had three avatar origins - 16, 42 and 44 pt - and
the avatars stopped forming a column. Both items now reserve one shared 22 pt
slot unconditionally and centre their glyph inside it: avatars land at 44 pt
everywhere, the 20 pt arrow sits at +1 within its slot.
Composer action slot. frameForInputActionButton() decided the mic was visible
by testing frame.minX against the bounds. That worked while the mic was parked
at width + 8; it now stays in the slot and collapses under a scale transform,
so its frame degenerates to a sub-point rect at the slot centre - still inside
the bounds. Tooltips and recording affordances would anchor to an invisible
control. The panel now records the slot rect during layout and reports nil when
send owns the slot. The z-order swap between mic and send also ran an
unconditional insertSubview on every layout pass, i.e. on every keystroke; it
now reorders only when the two are actually inverted.
Contrast. outgoingSecondaryTextColor is white at 0.5 in Night and 0.65 in Day -
alphas tuned against the stock outgoing bubble, which is darker than the #007AFF
this fork pins. On that blue they measure 2.02:1 and 2.50:1, so the timestamp
and ticks stop being readable. Both to 0.8, i.e. 3.08:1. The voice waveform's
unplayed part rode on the same value and reads by its distance from the played
part rather than absolute legibility, so it keeps its old alpha through a
separate outgoingInactiveControlColor. Night's three list hairlines go from
#545458 at 0.55 - Apple's value, which assumes a #1C1C1E ground - to 0.8, since
plain lists and the chat list here sit on pure black where 0.55 is 1.55:1.
Moving send outside the capsule left three cases deriving the right-hand reservation from whichever control happened to be showing. .empty is the serious one. It means "no custom right control", not "no send button" — MessageInputPanelComponent sets it whenever sendStarsAction is nil, which covers the story reply and story camera composers, and those delegate their send button to this node. Its branch kept a flat 8 pt right inset, which was correct only while send was drawn at maxX − width, inside the pill. With send at maxX + 6 the capsule runs to 8 pt from the panel edge and the 40 pt button starts 2 pt short of it: typing a story reply put roughly 38 of those 40 pt past the edge. The stars and live-mic branches reserved their own widths, but those controls park off-screen the moment there is text — which is exactly when send arrives in the same slot at a different width. And the has-text branch reserved send while the empty branch reserved mic, so any width difference between them resized the capsule on the first keystroke, the very thing moving send outside was meant to stop. All four collapse into one reservation: 8 pt margin, 6 pt gap, and a slot as wide as the widest control that can occupy it. Every case keeps the geometry it already had except .empty, which gains the 46 pt it was missing. Two things fell out of that. hasForward was left with no readers once its branch went, so sendOccupiesActionSlot now uses it instead of recomputing forwardMessageIds != nil inline — same value, and it would otherwise have failed the build under -warnings-as-errors. And effectiveActionButtonsSize survived only to shrink the recorded-voice preview inside the capsule, a clearance the preview needed when send overlapped the pill; with send outside it was 46 pt of dead space at the end of every waveform. Night placeholder text. The palette sweep set itemSecondaryTextColor, itemDisabledTextColor and itemPlaceholderTextColor all to #8E8E93, so an empty field's prompt reads exactly like a filled field's secondary label. Day keeps them apart at #8E8E93 against #C8C8CE, about half the contrast; #636366 mirrors that ratio on a #1C1C1E card, 5.22:1 against 2.84:1. Disabled matching secondary is upstream behaviour in Day too, so that pair is left alone.
A custom right action (stars, or .empty) takes the right slot over, so the mic
node is removed from layout entirely rather than faded. That gate was written
as a bare 'if customRightAction != nil { isHidden = true }' with no else, while
customRightAction itself is assigned in both directions by
ChatTextInputPanelComponent — including back to nil. Any node that made that
transition kept its microphone hidden for the rest of its life.
Everything else driving the mic in this method works in both directions
already, through alpha and scale; the gate now matches.
Messages composer polish, Calls column, contrast, icon fixes, story-reply slot.
The chat-list folder tab strip picks its selection pill's frame with an index taken from reorderedFilters, then bounds that index against filters.count before reading selectionFrames[currentIndex + 1]. Three different arrays. They do line up today. The reconciliation earlier in the method rebuilds reorderedItemIds to exactly the set of ids in filters, and the loop that fills selectionFrames can only skip a filter whose item node is missing, which the loop immediately before it has just created for every filter. So the bound is correct — by a chain of three unstated invariants living in three separate places, any one of which drifting gives an out-of-range read mid-drag. Bounding against selectionFrames itself cannot drift, and the sibling branch higher up in the same method already bounds against the right array. No behaviour change while the invariants hold.
Wrapping. calculateTextFieldMetrics decides how tall the field must be by measuring the text at the width the field will get. It called textFieldInsets(metrics:) itself, which returns the base insets — 8 pt on the right. Layout then overwrote that right inset with 54 pt to reserve the action slot. So the height was measured for a line 46 pt wider than the field really is, and the measure pass also skipped the 10 pt trim layout applies to the in-field accessory clearance: 36 pt of disagreement in total. The visible effect is text that wraps onto a second line while the panel still believes it fits on one, so the field does not grow to meet it. The inset block now resolves before the measurement rather than after it, and is handed to calculateTextFieldMetrics instead of being recomputed there. The accessory clearance moves into one resolvedTextInputRealInsets() shared by both passes, so the trim cannot be applied on one side only. updateTextHeight, which re-measures outside a layout pass, reads the last resolved insets from a stored property, exactly as it already does for leftMenuInset and rightSlowModeInset. Separators. Telegram insets row separators 16 pt on the trailing side, so every hairline in the chat list, Calls and Contacts stopped short of the edge and read as an unfinished line rather than a divider. iOS tables — and Messages, which this fork follows — inset only on the leading side. All three lists now run to the trailing edge; the leading inset, which aligns the hairline with the row's text, is unchanged.
Field height measured on a different width than the field gets, part two. updateTextHeight re-measures the composer outside a layout pass, and its baseWidth carried two terms updateLayout's does not: an offset fed from a hardcoded 0.0, dead since it was introduced, and a full subtraction of additionalSideInsets.right, which double-counts now that the resolved insets are passed in — those already carry that inset, and carry it as the third updateLayout actually applies rather than the whole of it. Both formulas are now character for character the same expression, and the dead offset is gone rather than left to be re-derived by the next reader. Attachment slot reserved for a button parked off-screen. attachmentButtonX sends the button to -8 - leftButtonsWidth for either of two reasons: media recording, or !displayMediaButton. Only the recording half had a matching branch clearing the 46 pt reservation, so the one state that sets displayMediaButton = false — the business-link composer — kept a gap on the capsule's left where no button is drawn. Cloud-password fields invisible on black. The OLED pass flattened freeInputField and freePlainInputField to a #000000 fill. Every consumer of those two draws the field as generateStretchableFilledCircleImage(color: backgroundColor) and never reads strokeColor — the Passport password box in SecureIdAuthPasswordOptionContentNode, and both cloud-password fields in TwoFactorAuthDataInputScreen — so the boxes were black on a black page, and the only state that showed an edge was failure, which paints its own red tint. Back to a #1C1C1E fill, matching itemInputField, whose greys they now share as well; the last two #98989E in the theme went with them.
The screenshot settled it, and not the way I guessed: the deciding term was hasSolidWallpaper, not displayHeader. Stock draws the bubble behind media on any wallpaper that is not a flat colour or a two-stop gradient, so the same photo gets a frame of bubble colour in a chat with a picture background and no frame at all in a chat on the default one. Nothing about the message decides it — only which chat it landed in, which is why some sent photos had the outline and others did not. On stock's blue gradient that frame reads as the bubble. On this fork's pinned flat #007AFF it reads as a blue outline drawn around the image. hideBackground now depends only on whether there is a header to hold. The header term stays: a reply preview or author name needs the bubble behind it to stay readable. The date falls back to the floating pill, which the theme already defines separately for custom wallpapers — serviceMessage.components .withCustomWallpaper.dateFillFloating, an opaque #2C2C2E, so it stays legible over any picture. This widens a path that is already the default rather than opening a new one: both shipped themes use a solid background, so bubble-less media is what every user on the default wallpaper already sees. hasSolidWallpaper had no other reader and is removed rather than left to be written and ignored. Incoming media on a custom wallpaper loses its frame too, by the same rule. Worth a look on a device with a picture background, in both directions, since this is message rendering and I cannot see it from here.
Composer wrap/measure, separators, media bubble rule, password fields, folder bound.
Alternate app icons are the one Premium exclusive in this codebase that is entirely client-side. The artwork ships inside the bundle, selection goes through UIApplication.setAlternateIconName, and nothing about the choice is requested from, granted by or recorded on the server — so the Premium marker on some of them locked an asset the build already contains. Two gates, both in ThemeSettingsController: the entry passed the account's real premium flag to ThemeSettingsAppIconItem, which drew the padlock badge, and selectAppIcon looked the account up purely to decide between applying the icon and pushing PremiumDemoScreen instead. The first now passes true; the second applies directly, and the peer lookup that existed only for that branch is gone with it. Left alone deliberately: the isPremiumDisabled filter above, which removes these icons from the list entirely where the server says Premium is not offered at all. That is a different question from locking them, and this change is about the padlock.
Swiping between tabs was clean and tapping one duplicated the list. That asymmetry is the whole clue, and it points at chatListLocationGeneration — a counter this fork invented that upstream has no equivalent of. How it fails. mapToSignal captures the counter when it subscribes, tags every emission of that chatListViewForLocation with it, and mapToQueue drops any emission whose tag no longer matches. The counter is bumped by setChatListLocation. But the location feeding mapToSignal goes through distinctUntilChanged, so setting a location equal to the current one bumps the counter without producing a new subscription — and the live signal is left tagged with a number that can never match again. From that moment every update it produces is dropped, previousView stops advancing while the list keeps rendering, and the next real location change diffs against a base that is stale by however many updates were swallowed. Rows already on screen get inserted a second time. Only one of the eight setChatListLocation call sites checks that the location actually changed — the pagination one. The scroll-to-top pair does not, and a tab tap reaches it with identical parameters when the list is already at top. A swipe never goes near it, which is exactly the reported asymmetry. The guard was never needed. mapToSignal disposes the previous chatListViewForLocation the moment the location changes, so a superseded view cannot arrive after a switch — that is what mapToSignal is for. Upstream has no filter anywhere along this path. Both halves are removed: the tag is gone from the tuple, the guard is gone from mapToQueue, and setChatListLocation is back to two lines. Verified mapToSignal, the destructure and dequeueTransition are now character for character upstream's. The comment in dequeueTransition described the old mechanism and is rewritten to describe the invariant instead.
Audited the fork's own divergence from upstream rather than the app at large: 325 files carry a real hand-written difference, 17413 added lines. Two findings, both the same shape as every other one this session — a value computed in one place and then re-computed by hand somewhere else. ghostMode. A stored Bool mirroring ghostDontReadMessages && ghostDontSendOnline && ghostDontSendTyping, with that expression written out six times: decode, encode, the memberwise init, and three separate settings mutations — plus isFullGhostMode, which is the same expression under a better name. Nothing reads it: Ghost Mode is driven from the three raw flags in SharedAccountContext, and neither isFullGhostMode nor suppressesMessageReads has a single consumer. A stored copy of a derived value is one forgotten assignment away from disagreeing with what it mirrors, and a fourth ghost toggle would have been exactly that assignment. It is computed now, delegating to isFullGhostMode; the encode stays so an older build still reads a coherent value out of the blob. Equatable is synthesised and its behaviour is unchanged, since the three flags it derives from are still compared. Forwarded media size. convertMediaForAyuForward fell back to the filesystem size when a file arrived without one, handed that to LocalFileReferenceMediaResource, and then built the TelegramMediaFile with the original file.size — so a file with no size produced a resource that knew how big it was and a media object that did not. One resolvedSize now feeds both. Swept clean, reported as such rather than padded: every force-unwrap the fork added (15) is safe by construction — literal keys in a dictionary literal declared beside them, or a dictionary pre-populated from allCases immediately above, which is upstream's own idiom at 98 other sites in the same file. No try!, three as! on statically known types, six fatalError/preconditionFailure all in unreachable protocol stubs. Every Timer, CADisplayLink and NotificationCenter observer the fork added either has matching teardown or is a documented, idempotent process-lifetime singleton.
…ls 44 - Drop leftover Int from ChatListNode filter map (compile break after folders guard drop). - Align sendOccupiesActionSlot with keepSend/slowmode/search; reserve paid-stars width; tooltip respects mic isHidden. - Apply base Night/Day outgoing white@0.8 and Night chrome separators @0.8 on the default theme path. - Calls avatars 44pt; Contacts thread rows keep the shared left column.
Unify mic↔send morph on 0.18s easeInOut, gate hits to the occupying slot, use schedule icons (not filled discs) on the blue well, size send/apply/schedule icons and the stretchable disc to 40pt, align pointer circles, rename media hide case to whenNoHeader, and match Night waveform inactive to Day @0.65.
Release Swift treats unused binding as error (#no-usage). After the folders generation guard drop the weak-self capture was only a nil gate — use a boolean test instead.
Previous commit only updated the changelog note; the #no-usage binding remained. Gate with `self != nil` so the release IPA builds.
CDN decryption. MTAesCtrDecrypt passes both buffers straight to MTAesCtr with a hardcoded keyLength:32, and the initializer reads a full AES block out of the IV (MtProtoKit/Sources/MTEncryption.m:713). Neither encryptionKey nor encryptionIv is validated anywhere upstream of the call, and both arrive in the CDN redirect response, so a short buffer is an out-of-bounds read inside the C layer on an ordinary media download that gets redirected to a CDN datacenter. The existing guard covered only the memcpy on the Swift side and asked for four bytes, so an IV between 4 and 15 bytes passed it and still under-ran the block read. Both sites now require exactly 16 bytes of IV and exactly 32 of key before calling, failing through each file's own error case. Round video transcription. animateTo gated its whole hand-off block on snapshotView(afterScreenUpdates:) returning non-nil - including the completion that sets isHidden and canAttachContent. That call returns nil for a zero-sized or off-screen view, and when it did the node stayed in the state it had already been animated out of. Only the crossfade depends on the snapshot now. Also documented, not changed: ChatTextInputPanelComponent's primary rightAction switch assigns .liveMicrophone to the *secondary* slot. That reads like a typo and I nearly corrected it - but ChatTextInputPanelNode only reads .liveMicrophone out of customSecondaryRightAction, and any non-nil customRightAction hides the mic, so the corrected-looking version would hide the mic and draw nothing. The existing routing is the one that would work; it now says why. Attempted and reverted: removing higChatListCardInset from ChatListItem, which returns 0.0 from all three branches and is threaded through eight sites. A mechanical edit corrupted the expression "width - higCardInset * 2.0" into "width * 2.0". It is inert dead weight with no behavioural cost, and removing it correctly needs per-site edits for no functional gain, so it stays.
In dual-camera round-video mode two threads run processVideoRecording on the same instance - the master's own videoQueue, and the additional output's videoQueue reaching the master through masterOutput.processVideoRecording. The composition branch ran entirely unguarded. Two of the fields it touches are read-modify-write, and those are corruption, not just torn reads: lastSampleTimestamp - read, compared, then conditionally appended and written. Both threads can read the same value, both pass the ordering check and both append, putting out-of-order frames into the recording. The append has to be inside the critical section; leaving it out puts the interleaving straight back. needsCrossfadeTransition - checked and cleared. Both threads can see it set, both clear it, and both arm a crossfade. Guarded by a dedicated compositionSemaphore rather than the existing one: processRoundVideoSampleBuffer takes `semaphore` internally, so holding that across the branch would deadlock on a non-recursive semaphore. The two field sets do not overlap, and the new lock is never held across a call that takes the old one, so they cannot form a cycle. markPositionChange writes currentPosition and lastSwitchTimestamp from the camera-control layer while both capture queues read them every frame, so it takes the lock too; it is not reachable from processVideoRecording, so there is no re-entrancy. Deliberately not locked: the plain reads of currentPosition, lastSwitchTimestamp and crossfadeTransitionStart scattered through the branch. Taking a lock around every read in a 30-60fps callback costs more than it buys, and a stale read there picks a slightly different transition factor for one frame rather than corrupting the file. The read-modify-writes were the actual defect. Not verifiable here - this needs a device with dual-camera round video and, to prove the race is gone rather than narrowed, a Thread Sanitizer run.
MessageMediaPlaylistItem.playbackData and .displayData are instance lazy vars, which Swift does not synchronise. Confirmed both readers rather than assuming: - arePlaylistItemsEqual reaches them from the distinctUntilChanged at MediaManager.swift:217, comparing SharedMediaPlayerItemPlaybackStateOrLoading on the producing queue. That sits before the pipeline's deliverOnMainQueue, not after it. - MediaNavigationAccessoryHeaderNode and OverlayAudioPlayerControlsNode read the same properties on main, after that hop. Two threads racing the first access can both run the initialiser, or one can observe a half-written value. Forced in init, while the object is still private to the constructing thread. That costs nothing: exactly three of these exist per state update - current, previous and next, built at PeerMessagesMediaPlaylist.swift:530-535 - and arePlaylistItemsEqual reads both properties on all three regardless. The work is identical, it just happens somewhere single-threaded. Chose this over hoisting both closures into static factories and storing lets. That is the tidier shape, but it moves eighty lines I cannot compile-check, and it buys nothing the two-line version does not already guarantee. Upstream code, not a fork regression.
The intermittent launch crash has been reported several times and is still undiagnosed, because there is no crash log and guessing at it by reading a codebase this size is not a method. This makes the next occurrence produce evidence. ForkLaunchBreadcrumbs installs as the first statement of didFinishLaunchingWithOptions, before anything that can fault, and records which launch stage was running when the process died. Seven stages are marked, from didFinishLaunchingBegan through didBecomeActive, so a crash narrows to a span of launch rather than to the whole of it. The record is read back and logged on the following launch, once Logger.setSharedLogger has run - the file is written independently of Logger precisely because Logger does not exist yet at the point where this has to be armed. Async-signal-safety is the design constraint on the signal path. Almost nothing is legal inside a handler: no allocation, no locks, no Foundation, no String. So the file descriptor and the four-byte record buffer are both prepared at install time, the static the handler touches is initialised there too - first access to a Swift static goes through swift_once, which takes a lock - and the handler itself does nothing but fill four bytes and write() them. The NSException handler is not in signal context and uses Foundation freely. Every handler restores the default disposition and re-raises, so the operating system still produces its own crash report; this only adds the one thing that report cannot carry, which is where in launch the app was. There is no other signal or exception handler in this app, so nothing competes. Nothing here changes behaviour on a launch that succeeds: install truncates the file, a clean run writes nothing to it, and the next launch finds it empty.
The debug screen has always existed but was reachable only through the tg://settings/debug deep link, which is not something anyone finds without being told it is there. It now has a row at the end of the Advanced section, below the everyday settings. New PeerInfoSettingsSection case, dispatched in PeerInfoScreenSettingsActions to the same makeDebugSettingsController the deep link uses. Title follows the fork's existing bilingual pattern, next to forkCustomizationSettingsTitle, because the localisation catalogue has no entry for this. Icon reuses Item List/Icons/Settings on grey, matching how Privacy already pairs that colour with a system-ish glyph. Half that screen was hidden. Two blocks were gated on appBuildType == .internal, and this fork builds from appstore-configuration.json, where is_appstore_build is true - so appBuildType resolves to .public and a Developer Mode row would have opened a screen with pieces missing. Both blocks are now unconditional: reaching the screen is the opt-in, and gating rows inside it on top of that only produces a half-populated screen. Gated there rather than by flipping appBuildType, which has three other readers. One is a commented-out no-op in ChatListNode. One shows the video quality debug overlay. The third sets the default for automatic call-reflector benchmarking in AppDelegate, which starts periodic network work - not something a Settings row should switch on as a side effect. Only the debug screen's own gates moved.
Both ways of unlocking the Archive end in the same conclusion - the owner proved who they are - but only one of them cleared the brute-force failure counter. The password path calls clearFailureState on a match; the biometric path called ArchiveLockSession.unlock() and nothing else. So a few wrong password attempts followed by a successful Face ID left the counter and its cooldown in place, and the next password entry could still be refused. The counter throttles guessing, not the owner. Not a bypass in the other direction: the biometric branch cannot be used to skip a cooldown that is protecting against someone who does not have the password, because passing Face ID means passing Face ID. Verified while here, and left alone: - The trim applied to the entered password is symmetric. Both the set path and the verify path trim whitespace and newlines identically, so a password with a trailing space cannot be stored and then never match. - bindBackgroundRelock claims its slot atomically with a placeholder before subscribing, so two concurrent callers cannot both subscribe and orphan one subscription. - ManagedAutomaticMtProxy's switch hysteresis reads correctly: a voluntary move needs sixty seconds since the last one and a candidate at least thirty per cent faster, and a server that actually failed is in excludedActiveServer and skips both checks. excludedActiveServer is cleared when the connection comes back online and on settings changes, so a server that recovers is not excluded forever and the pool does not shrink over time. Flagged, not changed: archivePasswordHash is a single round of SHA-256 with a per-account salt. The salt does what its comment claims - one precomputed table will not work against every account - but without key stretching a short password still falls quickly if the Keychain is ever extracted. PBKDF2 with a real iteration count would be the right shape, and the file already has a hash-generation upgrade path to model a migration on. It is a security-design change with a migration, on a threat model that starts with the attacker already holding the Keychain, so it wants a deliberate decision rather than being folded into a bug-fix pass.
The typing-suppression gate sits at the top of requestActivity, which is the right place - it is the single chokepoint for outgoing activity, feeding both messages.setTyping and messages.setEncryptedTyping. But it is not only typing that passes through it. PresentationGroupCall acquires .speakingInGroupCall on that same path to drive the live "is talking" highlight other participants see while you speak in a voice chat. So turning on "do not send typing" also stopped that indicator. Other people in the call no longer saw you light up while talking - and nothing in the setting says it does that. It is about not advertising that you are composing a message, not about going quiet in a call you already joined. .speakingInGroupCall is now exempt. Written with the same shape the function uses a few lines further down, where the broadcast-channel branch already singles out this one activity. Checked the two sibling flags for the same over-reach and found none: suppressOnline is enforced in ManagedAccountPresence at the single site that calls account.updateStatus, and suppressMessageReads is applied at the read sites; neither passes through a chokepoint shared with unrelated traffic.
Telegram carries its own language setting, independent of iOS. Every string the fork added itself - the ones with no entry in the localisation catalogue - picked Russian or English by reading Locale.preferredLanguages, which is the device's language. Run iOS in English with Telegram set to Russian and the Archive lock, the extras settings screen, the saved-messages history screen and two context menu entries all came back in English among Russian ones. The reverse pairing gave the mirror image. Six sites, three of them whole string tables: ArchiveLockLocalizedString the entire Archive lock vocabulary ForkExtrasController the extras settings screen MessageSavingHistoryController the saved-messages history screen ChatInterfaceStateContextMenus "Save to Saved Messages", "Select from Author" PeerInfoSettingsItems the proxy row's Auto label DataAndStorageSettingsController the same label, second copy ForkPresentationLanguage holds the presentation language, pushed from SharedAccountContext on every presentation-data emission - the same pushdown the fork already uses for its cross-module values. Set unconditionally rather than inside the stringsUpdated branch, because that flag compares against the value seeded from initialPresentationDataAndSettings, so the first emission can match and skip, leaving the override nil for the rest of the session. Every reader still falls back to its old device-language lookup while the override is nil, so nothing regresses in the window before the first push. Separately: the Edit History context menu entry was a raw English literal, in a file where the fork's two other added entries are bilingual. It now goes through the same helper the Settings rows use, which also gives it uk and be rather than only ru.
- BrowserDocumentContent: register/unregister KVO observers on the webView so title, URL, load progress, and back/forward state actually update for loaded documents (observeValue was dead code). - BrowserReadability: trimEnd() recursed into trimStart() for every styled run (bold/italic/link/etc.), so trailing whitespace inside formatted text was never trimmed from the end in Reader Mode. - BrowserWebContent.WebView.point(inside:): fixed a hit-test check that compared point.y against frame.width twice instead of checking point.x against frame.width, letting touches register outside the view's actual horizontal bounds. - BrowserWebContent: avoid a forced cast crash when a page's blob download JS resolves to something other than a string, and give the download progress alert an actual title (the filename). - BrowserAddressListComponent: replace a force-unwrapped webPage with a guard, matching the existing bail-out pattern used for bookmarks with no URL. - BrowserBookmarksScreen: replace a leftover "___close" placeholder bar button title with the localized Close string. - BrowserScreen: use the actual "Open in Safari" option title from availableOpenInOptions instead of a hardcoded "Safari" fallback.
`entryCompleted` routes into `cancelEntry(id, isCompleted: true)`, and the line it logs there said "Cancel fetching" either way. Reading the 3967 log that made 4559 of 6514 started fetches look abandoned, with a median lifetime of ten milliseconds — which reads as the client thrashing, and is very probably a video streaming in ranges, each range beginning and completing exactly as it should. One document appears 146 times; whether that is a loop or a normal stream was not answerable from the log, because the one word that distinguishes them was not in it.
The 3967 log settles what the footprint is not. It is not a leak: the heap does not climb, it oscillates — 565 → 412 → 387 → 496 → 390 MB across five consecutive minutes, with the block count moving with it, 2.4M → 1.6M → 2.0M → 1.7M. What kills the app is the amplitude, not a trend; the peaks reach 623 MB and the system takes the process while it is suspended, eight times in twelve hours. Nor is it activity. The episode that grew most, 485 MB, had eight chat opens, four sends and 695 fetch parts — nearly idle. The busiest episode, with 152 opens, 188 sends and 19,347 parts, grew less. Correlating per-minute block growth against eight logged signals put the strongest at 0.17 and the rest below 0.10: whatever allocates 700,000 objects inside a minute does not write to the log at all. So two changes, both aimed at seeing it. **The cache clear was measured with the wrong gauge.** `clearCaches` releases into the allocator, which keeps the pages, so reading `getMemoryConsumption()` a second later measures whether the kernel happened to reclaim — not whether anything was freed. Across 259 clears that read as a median of 1 MB and a mean of minus 0.3, which is why the instrument looked useless. It reports the malloc heap and block count now, with resident alongside, on both the background path and the memory-warning path. **Rows are counted.** A message node and its text layout are thousands of small objects; they are built and dropped by scrolling; and scrolling is not logged anywhere, which makes them the obvious candidate that the correlation could not have found. `ListViewItemNode` keeps a live count across every list in the app, and the heartbeat carries it. If `rows` moves with `blocks`, the churn is the list surface and there is somewhere to go; if it does not, that whole family is ruled out, which is worth as much.
A report of crashes with a VPN enabled cannot be confirmed or dismissed from these logs, because nothing in them records the network. Searching three collections for the word finds 375 hits in the newest, every one of them the name of a channel the tester follows — "Amnezia VPN (RU)" — and nothing else. There is no path, no interface, no transition, in 38 MB. `Reachability` does run an `NWPathMonitor`, but it collapses every path to cellular / wifi / none and logs none of it. A VPN presents as an interface of type `.other`, so it is not cellular and gets filed under wifi: invisible twice over. So the path is observed and written down. Transitions log the full shape — status, every interface by name and type, whether any of them is a tunnel, expensive, constrained — and the heartbeat carries a short form on every line, because a crash is read from the minute around it and "was a tunnel up" has to be answerable there without hunting backwards. Interface names are not private data: `utun3` says a tunnel exists, not whose. What the existing logs do say, for the record: none of the crashes look like a network fault. All eight in this collection, and eight of the twenty-one in the previous one, are the system reclaiming a suspended process — a footprint problem. The two real crashes anywhere in the set were the stack overflow, which has not recurred. The one plausible VPN interaction already found is the WEB carrier, whose long poll used to treat a dropped connection as fatal; a tunnel reconnecting produces exactly that, and it is retried now.
This is the self-contained half: nothing outside Display is touched, and `AlertContentNode.contentSizeCategoryUpdated()` is an empty `open` default, so the twelve subclasses elsewhere are unaffected. What it adds: alerts and action sheets rebuild on a Dynamic Type change and re-theme on a Reduce Transparency change, both observed and both unregistered in `deinit`; an alert restores the accessibility focus it took when it goes away; `AccessibilityAreaNode` sets `accessibilityRespondsToUserInteraction` from whether it actually has an action, so VoiceOver stops offering to activate things that do nothing; and action sheet items carry proper labels, values and traits. Two of these are plain bugs rather than accessibility gaps. `ListView`'s `accessibilityScroll` mapped every direction it did not recognise to "up" via a `default:` case, so a left or right scroll gesture scrolled the list vertically; unknown directions are now refused. And the focus search only looked at the node's own view, never its subviews, so a list whose focused element sat inside a child — which is every message — read as unfocused; it recurses now, and posts the first real accessibility element rather than the container view.
Second stage. 49 files across TelegramUI's components: message bubbles, stickers, instant video and rich-data content nodes get labels that describe what they are rather than reading their raw contents; the peer info panes (media, stories, gifts, chats, chat list) become navigable; the tab selector, archive info screen, context controller and entity keyboard get labels, traits and focus handling. The composer's three hunks were applied by hand — it is the one file the patch could not land on, because this fork rewrote it for the native rich text editor. Its content stands regardless: the input is several nested views deep and the outer one is not a `UITextInput`, so VoiceOver was landing on a container that could not be typed into. The real field is found by walking down for the first `UITextInput`, given the identifier and a frame matching the hit-test slop, and swapping the legacy input for the native editor now posts a layout change so VoiceOver stops reading the view that just left.
Third stage. 17 files in TelegramUI/Sources: the chat history list keeps VoiceOver's focus across a history transition, the navigation buttons and search results announce what they are and where they land, the URL-auth alert becomes navigable, and the six input context panels — commands, mentions, hashtags, emoji, inline results — carry labels instead of reading as unlabelled rows. The focus restoration lands beside code this session has been editing and merged without conflict. It has two levels: an explicit navigation target, set when the user jumps somewhere and cleared once it arrives, takes precedence over simply restoring whatever was focused before the transition. Both are read only when VoiceOver is actually running, so a session without it pays a single boolean.
…control Fourth stage. 18 files: the share sheet's peer grid, search and topic picker announce selection state and counts instead of reading as an unlabelled mesh of avatars; the contact list and chat list search panes label their rows and section headers; `SegmentedControlNode` becomes an adjustable element rather than a set of untitled buttons; the sticker pane search item and the entities alert get labels.
Final stage of the port, and its verification. `Tests/VoiceOverContracts` is a plain-Python checker that reads the sources and asserts the accessibility wiring is still there — every top-level message renderer builds `ChatMessageAccessibilityData`, the shared contract assigns label, value, hint, traits, identifier and custom actions, modals contain traversal and restore focus. No build, no simulator, no Mac: `python3 -m unittest discover -s Tests/VoiceOverContracts` runs anywhere in about a hundredth of a second. Run against this tree it passes 20 of 20, which is the evidence that the four preceding commits ported the work completely rather than approximately. It also turned up that the whole `PeerInfoScreen` half — 22 files, all 215 added lines — was already in this tree before the port began, so those commits carry nothing for it. Two upstream assertions were dropped rather than satisfied: a pull-request template and a CI workflow. Both are repository-wide process choices rather than code, and this fork's pull requests are mostly networking, so putting a ten-point VoiceOver checklist on every one of them was not this port's call to make. The docstring says so, and says what to restore if that changes.
…nto no network Two remaining ways the carrier gave up on conditions it should have ridden out. **The uplink still failed fatally on any error.** The downlink learned to retry; the uplink could not be given the same rule, because its batch leaves the buffer when it is sent and its `X-Up-Seq` is spent — the relay orders the stream by that sequence, so replaying a request it may already have processed would duplicate frames rather than recover them. But that argument only covers requests that reached a socket. One that failed before a connection existed — not connected to internet, DNS, cannot connect, roaming off, data not allowed — delivered nothing, so re-sending the identical bytes under the identical sequence is exactly what the relay is still waiting for. Those are resent, up to four times on a backoff, and the send keeps the uplink slot while it retries so a later batch cannot overtake it. A timeout or a dropped connection stays fatal: both are ambiguous about whether the body was processed, and guessing wrong corrupts the stream rather than delaying it. **The cooldown was being earned offline.** A bootstrap with no network path cannot succeed, but it counted as a failure and advanced the backoff all the same — so when the radio came back the proxy sat out a cooldown it had earned entirely while unable to try. In one tester's log 34 of 55 bootstrap failures were `NSURLErrorNotConnectedToInternet`. The manager watches the path now: starts are held while there is none, the counters are cleared when one appears, and the path appearing is itself the trigger to start. That start is forced past the in-flight check on purpose. A start held for want of a path leaves `startingConfiguration` set — from `sequentialRestart`, or from an attempt that never got off the ground — and the ordinary path would read that marker as a bootstrap already running and return, stranding the proxy for as long as the marker lived. The monitor is optimistic until it reports: a manager that never hears from it must still try.
… not just the good one A reconnect keeps the carrier it is replacing alive until the new one is ready, and hands it back on success. The other two exits did not. The 45s timeout and the build failure both went straight to `stopLocked`, which closes `self.carrier` and `self.urlSession` — by then the *new* ones — so the carrier being replaced kept running, with its long polls in flight, on a `URLSession` nothing would ever invalidate. One leaked session per attempt, on exactly the bad network that makes an attempt time out in the first place. Releasing it is now part of `finish`, which every terminal path already goes through, rather than three separate obligations one of which was met. Audited the rest of the module for the same shape while here, and it holds: every stored closure — the carrier's failure and downlink handlers, the listener's connection and state handlers, the connection state handler, the path monitor — captures self weakly; the uplink buffers are capped and compacted; and the only carrier assigned away without being stopped is the one this commit hands to `finish`. The closed-lane tombstones in the carrier are deliberate and say so: they exist to make a late uplink from a racing close a no-op instead of resurrecting the lane, and they are bounded by stream churn. Kept.
Long-pressing a round video drew the reaction pill across the top third of the circle. The obvious explanation — a square item rect against an inscribed circle — is wrong, and checking it was worth the time: for a round video the anchor rect is the circle's exact bounding box, and `calculateBackgroundFrame` always places the pill above `anchorRect.minY`. At layout time the bar is where it should be. It is the scroll that moves the message out from under it. The extracted content and the actions menu both live inside `scrollNode`; the reaction bar and the reaction preview are siblings of that container, so they stay where `contentRect` put them. When content plus menu overflows the screen the scroller comes to rest at its bottom, lifting the message by that much — straight into the band `contentTopInset` reserved above it for the bar. And whenever it overflows, the clamp on `contentRect.minY` leaves exactly zero slack in that band, so the lift spends all of it. Round video is simply the message type that reliably overflows: 212pt at rest, but near the full screen width while playing, which is the state a long press usually finds it in. So the resting lift is bounded by the slack the layout actually left, and the bar and preview are anchored to where the message ends up rather than where it started. The gating is narrow — extracted source, no extractable container, not `keepInPlace`, no position locks, and a bar or preview present — and outside it the lift is zero and both call sites are what they were. It is also inert wherever content and menu already fit, which is every ordinary bubble and sticker at normal menu lengths, since the scroller rests at zero there anyway. One consequence worth stating: for tall content whose menu previously only fitted because the message was hidden under the bar, the tail of the menu is now below the fold and reached by scrolling. Overlapping the message is the worse of the two. Separately, `calculateBackgroundFrame`'s vertical floor was `insets.top + sideInset`, and `sideInset` is horizontal — the caller loads it with the container's left safe area. A side inset therefore pushed the pill down onto its anchor, by 8pt with the tapbacks inset and by a landscape safe area's worth on iPad. Only the constant part of it is a vertical margin. The new floor is never higher than the old one, and in portrait with no left inset the result is identical.
cursor
Bot
force-pushed
the
dec/zalupa-mess
branch
3 times, most recently
from
September 1, 2026 00:26
a193355 to
f94feda
Compare
Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
ASDisplayNode has no traitCollection; use the loaded view's traits with UITraitCollection.current as fallback so alert action layout compiles. Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
…e API UIAccessibilityElement exposes accessibilityFrameInContainerSpace; UIView needs screen coordinates via UIAccessibility.convertToScreenCoordinates. Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Expose visibleItemNodes on the ListView protocol for VoiceOver focus preservation during list reloads, and rename the recursive focus helper to avoid clashing with UIView.accessibilityElementIsFocused(). Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
`sigaction` names both the struct being built here and the C function called three lines later. Swift resolves a bare `sigaction()` to the initialiser because the function needs three arguments, but the annotation says so outright rather than relying on overload resolution — this file is the only Swift in the tree that touches the API, so there is no local precedent to lean on and nothing to compare against when it goes wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LD9rT1B8mc4YRgymdiKmu
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Chat message long-press reaction panel restyled closer to iMessage Tapbacks, with balanced sizes.
Pre-release
Tag
v12.9.2-3905-pre— CI building sideload IPA.Design sizes (glass / Tapbacks mode)
10 + 32 + 10)Other
Stories / video-chat panels keep legacy sizing.