diff --git a/.bazelrc b/.bazelrc index 5b12464e70..c291e22ff6 100644 --- a/.bazelrc +++ b/.bazelrc @@ -1,4 +1,3 @@ -build --action_env=ZERO_AR_DATE=1 build --enable_platform_specific_config @@ -39,3 +38,8 @@ common:index_build --noannounce_rc common:index_build --noshow_timestamps common:index_build --curses=no common:index_build --color=no + +# Demote new Xcode 27 / iOS 27 deprecation and implicit-capture diagnostics to warnings. +build --features=-treat_warnings_as_errors +build --@build_bazel_rules_swift//swift:copt=-no-warnings-as-errors +build --copt=-Wno-deprecated-declarations diff --git a/.gitignore b/.gitignore index e7f834374d..4f79b1009d 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ xcuserdata # ----------------------------------------------------------------------------- build-system/wintergram-development-configuration.json build-system/fake-codesigning-wintergram/ +build-system/fake-codesigning-generated/ # ----------------------------------------------------------------------------- # Editor / AI assistant / local tooling @@ -121,3 +122,6 @@ AppBinary.xcworkspace/* Project.xcodeproj/* Watch/Watch.xcodeproj/* AppBundle.xcworkspace/* + +# wintergram badge preview gifs +.wintergram/icons/preview_*.gif diff --git a/.wintergram/icons/README.md b/.wintergram/icons/README.md new file mode 100644 index 0000000000..27ea8718df --- /dev/null +++ b/.wintergram/icons/README.md @@ -0,0 +1,44 @@ +# WinterGram dynamic badges + +Profile badges are data-driven from this folder. The app fetches `manifest.json` +(and the referenced assets) from GitHub on a timer and recomposes badges **without an +app update**. Offline / before the first fetch, a bundled fallback manifest is used. + +## Files +- `manifest.json` - the live manifest (see `manifest.schema.json` for the full schema). +- `manifest.schema.json` - JSON Schema (draft-07). Editors that honour the `$schema` + key in `manifest.json` will autocomplete and validate as you type. +- `/...` — layer assets (`.png`/`.jpg` rasters, or `.tgs`/`.json` Lottie). + +## A badge in one glance +A badge is a stack of layers on a `canvas` (default 1024) coordinate space: + +```json +{ + "name": "backplate", + "source": "developer/backplate.png", + "x": 0, "y": 0, "width": 1024, "height": 1024, + "tint": "theme", + "animation": { "type": "rotate", "duration": 8.0, "direction": "cw", "loop": true } +} +``` + +- `source` — path under `.wintergram/icons/`. `.png/.jpg` → raster layer; `.tgs/.json` + → native Lottie layer (then `animation` is ignored; the Lottie plays itself). +- `tint` — `"theme"` (theme accent), `"#RRGGBB"` (fixed), or `"none"` (original colours). +- `animation.type` — `none | rotate | blink | pulse | bounce | shake | lottie`. +- `peers` — raw int64 ids; for channels use the part after the `-100` prefix + (e.g. `-1003999337820` → `3999337820`). Highest `priority` wins on overlap. + +## Validate / preview before pushing +```sh +# validate structure, tints, animations, asset existence +scripts/wintergram-badge-tool.py .wintergram/icons/manifest.json + +# also render an animated GIF per badge (needs: pip install Pillow) +scripts/wintergram-badge-tool.py .wintergram/icons/manifest.json --preview +``` +Useful flags: `--badge `, `--size 256`, `--fps 30`, `--bg "#1C1C1E"`, +`--accent "#3478F6"` (colour used for `tint: "theme"` in the preview), `--out DIR`. + +Bump `version` on every change so clients pick up the update. diff --git a/.wintergram/icons/developer/backplate.png b/.wintergram/icons/developer/backplate.png new file mode 100644 index 0000000000..ee2ce3d24a Binary files /dev/null and b/.wintergram/icons/developer/backplate.png differ diff --git a/.wintergram/icons/developer/icon.png b/.wintergram/icons/developer/icon.png new file mode 100644 index 0000000000..491ca46b01 Binary files /dev/null and b/.wintergram/icons/developer/icon.png differ diff --git a/.wintergram/icons/manifest.json b/.wintergram/icons/manifest.json new file mode 100644 index 0000000000..e34aa45067 --- /dev/null +++ b/.wintergram/icons/manifest.json @@ -0,0 +1,51 @@ +{ + "$schema": "manifest.schema.json", + "version": 1, + "canvas": 1024, + "badges": [ + { + "id": "developer", + "description": "WinterGram developer", + "peers": { "users": [885166226, 5665997196], "channels": [] }, + "priority": 100, + "layers": [ + { + "name": "backplate", + "source": "developer/backplate.png", + "x": 0, "y": 0, "width": 1024, "height": 1024, + "tint": "theme", + "animation": { "type": "rotate", "duration": 8.0, "direction": "cw", "loop": true } + }, + { + "name": "icon", + "source": "developer/icon.png", + "x": 134, "y": 134, "width": 756, "height": 756, + "tint": "#FFFFFF", + "animation": { "type": "none" } + } + ] + }, + { + "id": "official", + "description": "Official WinterGram channel", + "peers": { "users": [], "channels": [3943351959, 4316373875, 3999337820, 4348385636] }, + "priority": 50, + "layers": [ + { + "name": "backplate", + "source": "official/backplate.png", + "x": 0, "y": 0, "width": 1024, "height": 1024, + "tint": "theme", + "animation": { "type": "rotate", "duration": 8.0, "direction": "cw", "loop": true } + }, + { + "name": "icon", + "source": "official/icon.png", + "x": 134, "y": 134, "width": 756, "height": 756, + "tint": "#FFFFFF", + "animation": { "type": "none" } + } + ] + } + ] +} diff --git a/.wintergram/icons/manifest.schema.json b/.wintergram/icons/manifest.schema.json new file mode 100644 index 0000000000..ac7b6625ee --- /dev/null +++ b/.wintergram/icons/manifest.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/reekeer/WinterGram/master/.wintergram/icons/manifest.schema.json", + "title": "WinterGram dynamic badge manifest", + "description": "Layered, GitHub-driven WinterGram profile badges. Geometry is in canvas units (default 1024) and scaled to the render size.", + "type": "object", + "required": ["version", "badges"], + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "version": { + "type": "integer", + "minimum": 0, + "description": "Bump on every change so clients pick up the new manifest." + }, + "canvas": { + "type": "number", + "exclusiveMinimum": 0, + "default": 1024, + "description": "Coordinate space for all layer x/y/width/height." + }, + "badges": { + "type": "array", + "maxItems": 64, + "items": { "$ref": "#/definitions/badge" } + } + }, + "definitions": { + "badge": { + "type": "object", + "required": ["id", "peers", "layers"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1, "description": "Stable identifier; \"developer\" is treated specially (developer badge)." }, + "priority": { "type": "integer", "default": 0, "description": "On a peer matching several badges, the highest priority wins." }, + "description": { "type": "string", "description": "Text shown when the badge is pressed." }, + "peers": { "$ref": "#/definitions/peers" }, + "layers": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { "$ref": "#/definitions/layer" } + } + } + }, + "peers": { + "type": "object", + "additionalProperties": false, + "description": "Raw int64 ids. For channels use the part after the -100 marker (e.g. -1003999337820 -> 3999337820).", + "properties": { + "users": { "type": "array", "items": { "type": "integer" } }, + "channels": { "type": "array", "items": { "type": "integer" } } + } + }, + "layer": { + "type": "object", + "required": ["source"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "source": { + "type": "string", + "minLength": 1, + "description": "Path under .wintergram/icons/. .png/.jpg -> raster layer; .tgs/.json -> native Lottie layer (then 'animation' is ignored)." + }, + "x": { "type": "number" }, + "y": { "type": "number" }, + "width": { "type": "number", "exclusiveMinimum": 0 }, + "height": { "type": "number", "exclusiveMinimum": 0 }, + "tint": { + "type": "string", + "pattern": "^(theme|none|#[0-9a-fA-F]{6})$", + "description": "\"theme\" (accent), \"#RRGGBB\" (fixed), or \"none\" (original colours)." + }, + "animation": { "$ref": "#/definitions/animation" } + } + }, + "animation": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "enum": ["none", "rotate", "blink", "pulse", "bounce", "shake", "lottie"], + "description": "Procedural animation applied to a raster layer. Ignored for Lottie sources." + }, + "duration": { "type": "number", "exclusiveMinimum": 0, "description": "Seconds per cycle." }, + "loop": { "type": "boolean" }, + "direction": { "enum": ["cw", "ccw"], "description": "Rotation direction (rotate only)." }, + "amplitude": { "type": "number", "minimum": 0, "description": "Strength for pulse/bounce/shake (fraction of layer size)." } + } + } + } +} diff --git a/.wintergram/icons/official/backplate.png b/.wintergram/icons/official/backplate.png new file mode 100644 index 0000000000..ee2ce3d24a Binary files /dev/null and b/.wintergram/icons/official/backplate.png differ diff --git a/.wintergram/icons/official/icon.png b/.wintergram/icons/official/icon.png new file mode 100644 index 0000000000..491ca46b01 Binary files /dev/null and b/.wintergram/icons/official/icon.png differ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..8595afc783 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,163 @@ +# AGENTS.md + +This file provides guidance to AI assistants when working with code in this repository. + +## Build + +The app is built using Bazel via the `Make.py` wrapper. There is no selective per-module build — the only supported invocation builds the full `Telegram/Telegram` target. + +**Convenience wrapper:** `scripts/build-wintergram.sh` wraps the Make.py invocations for the WinterGram dev config and emits WinterGram-named IPAs in `build/`: `sim` (simulator), `sideload` / `livecontainer` (device), `all`, plus `--install`/`--run` (simulator-only: build + install into the booted Simulator), `--clean`, `--open-build-dir`. It sources `~/.zshrc` itself. App icons are regenerated from `branding/wnt-app-icon-*.png` by `scripts/generate-app-icons.sh`. + +**Raw command (canonical):** + +```sh +python3 build-system/Make/Make.py --overrideXcodeVersion \ + --cacheDir ~/telegram-bazel-cache \ + build \ + --configurationPath build-system/appstore-configuration.json \ + --gitCodesigningRepository \ + --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 +``` + +Add `--continueOnError` after `build` (forwards to bazel's `--keep_going`) when verifying changes that may surface errors in many files at once — it lets the full set of errors land in one pass instead of stopping at the first failing target. + +The build needs `TELEGRAM_CODESIGNING_GIT_PASSWORD` in the environment. It is set in `~/.zshrc` but Codex's bash tool does NOT source shell config by default. Prefix build commands with `source ~/.zshrc 2>/dev/null;` to pick it up. + +## Code Style Guidelines +- **Naming**: PascalCase for types, camelCase for variables/methods +- **Imports**: Group and sort imports at the top of files +- **Error Handling**: Properly handle errors with appropriate redaction of sensitive data +- **Formatting**: Use standard Swift/Objective-C formatting and spacing +- **Types**: Prefer strong typing and explicit type annotations where needed +- **Documentation**: Document public APIs with comments + +## Project Structure +- Core launch and application extensions code is in `Telegram/` directory +- Most code is organized into libraries in `submodules/` +- External code is located in `third-party/` +- No tests are used at the moment + +## Embedded watch app (`Telegram/WatchApp`) + +A standalone watchOS Telegram client (developed in the separate `~/build/tgwatch` repo) is vendored into this repo at `Telegram/WatchApp/` and can be embedded into the **device** IPA under `Telegram.app/Watch/`. It is built by `xcodebuild` (not Bazel) and codesigned by the Bazel build. + +**Build it:** add `--embedWatchApp` to a Make.py **device** build (`--configuration=debug_arm64` or `release_arm64`) together with `--watchApiId`, `--watchApiHash`, `--watchSigningIdentity`, `--watchProvisioningProfile`. Off by default (it adds a ~4-min xcodebuild step); simulator builds never embed, and the default `debug_sim_arm64` build is unaffected. + +**`Telegram/WatchApp/` is a synced snapshot — do not hand-edit it.** The source of truth and dev tooling live in the `tgwatch` repo. To change the watch app, edit it there, then re-sync with `tgwatch/tools/export-sources.sh /abs/path/to/telegram-ios/Telegram/WatchApp` and commit the result. The committed `tgwatch.xcodeproj` is generated (kept via a `!tgwatch.xcodeproj` negation in `Telegram/WatchApp/.gitignore`, since the root `.gitignore` ignores `*.xcodeproj`); `.build`/`.swiftpm`/`xcuserdata` are excluded. + +**How it's wired:** `//Telegram:TelegramWatchApp` (rule in `Telegram/prebuilt_watchos.bzl`) runs in **two actions**: `PrebuiltWatchosCompile` (`Telegram/prebuilt_watchos_compile.sh`) runs xcodebuild on the snapshot in a writable temp copy with PLACEHOLDER version/api values (the bundle ids are baked from the snapshot's pbxproj/Info.plist — `ph.telegra.Telegraph.watchkitapp` / `ph.telegra.Telegraph`), emitting an unsigned `.app`; `PrebuiltWatchosPatchSign` (`Telegram/prebuilt_watchos_patch.sh`) then rewrites **six** per-build Info.plist keys (`CFBundleShortVersionString`, `CFBundleVersion`, `TG_API_ID`, `TG_API_HASH`, `CFBundleIdentifier`, `WKCompanionAppBundleIdentifier`) and codesigns the `.app` + nested `TDLibFramework.framework` (identity + the watchkitapp profile from `--define`s). The result feeds the `Telegram` `ios_application`'s `watch_application` slot (gated by the `//Telegram:embedWatchApp` flag). The rule takes `bundle_id` (set to `"{telegram_bundle_id}.watchkitapp"` in `Telegram/BUILD`) and derives the host bundle id by stripping the `.watchkitapp` suffix; both are passed to the patch worker as args (not action inputs), so the patch action re-runs when the host bundle id changes but the (expensive) compile stays cached. **The compile action's only inputs are the snapshot (+ its worker)** — so changing the version, build number, api id/hash, host bundle id, or signing identity re-runs only the cheap patch+sign action, not xcodebuild; xcodebuild re-runs only when the snapshot changes. This is correct because none of those values reach the compiled binary: each lands only in the Info.plist (via `$(...)` substitution and a runtime `Bundle.main.object(forInfoDictionaryKey:)` lookup in `Secrets.swift`, except for the bundle-id keys which only Info.plist consumers read). + +**Non-obvious invariants** (also in the `.bzl` comments): `AppleBundleInfo`'s public init is banned — use the internal `new_applebundleinfo`; `watch_application` requires BOTH `AppleBundleInfo` (with a non-None `infoplist` File) AND `WatchosApplicationBundleInfo`; the embedded watch app's `CFBundleShortVersionString`/`CFBundleVersion` must exactly equal the host's (sourced from `versions.json['app']` + `--define=buildNumber`); the host does NOT re-sign the embedded watch app, so the worker must sign it; the watch bundle id `ph.telegra.Telegraph.watchkitapp` must track the host `telegram_bundle_id`. + +**Status:** verified with **development** signing on `debug_arm64` only. Open follow-ups before App Store shipping: secure timestamp (drop `codesign --timestamp=none`), distribution profile (`get-task-allow=false`), `release_arm64` + `altool --validate-app`, and committing a `Package.resolved` for hermetic remote-SwiftPM resolution. + +## View frame ownership + +A view does not control its own `frame`. The parent (or a layout system) sets the frame; the view positions its own subviews against `self.bounds` in response. + +This matters in two places specifically: + +- **Reusable components (`UIView`/`ASDisplayNode` subclasses).** Public methods like `update(...)` / `apply(...)` rebuild internal state, mutate child frames, and read `self.bounds` to lay them out — but they do not write `self.frame`. The caller has already chosen the frame; mutating it from inside the component overrides that choice and fights the parent's next layout pass. +- **`asyncLayout`-style content nodes.** The measure pass runs off-main and returns a size; the apply step runs on main and the chat layout system positions the node. A child view that writes `self.frame` from `update()` corrupts the size the parent just measured. + +Rare exceptions: top-level view-controller views integrating with the system's first-responder/inset model. If you find yourself wanting `self.frame = …` from inside a child view, refactor so the parent positions it instead. + +## InstantPage V2 & rich-text messages + +Typed markdown with structure the regular message-entity set can't represent (headings, lists, tables, formulas, nested blockquotes) is sent as a **rich message** — a `RichTextMessageAttribute` carrying an `InstantPage`, drawn by `ChatMessageRichDataBubbleContentNode` via the **InstantPage V2** renderer (with AI-streaming progressive reveal, inline custom emoji, and entity cases). The detailed architecture and non-obvious invariants — streaming reveal, V2 table/text-box layout, custom-emoji & entity round-trips, task-list checkboxes, nested blockquotes, thinking blocks, the markdown send / edit / copy / paste paths, and surfacing rich-message media through the shared-media/gallery/preview pipelines via `Message.effectiveMedia` — live in [`docs/instantpage-richtext.md`](docs/instantpage-richtext.md). + +## Postbox → TelegramEngine refactor (in progress) + +A gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`. + +**Historical record:** Wave-by-wave outcomes, the running tally of Postbox-free modules, the full wave-selection guidance, and the `TelegramEngine.Resources` facade inventory (also authoritatively defined in `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`) live in [`docs/superpowers/postbox-refactor-log.md`](docs/superpowers/postbox-refactor-log.md). Read that file when you need wave-specific context, a full worked example of a pattern, or the history of a particular module's migration. + +See the log for per-wave detail; the current wave count and the list of still-open migration opportunities live in the `project_postbox_refactor_next_wave.md` memory file. + +### Rules that apply to every wave + +1. `TelegramCore` does **not** `@_exported import Postbox`. Once a consumer drops `import Postbox`, every remaining Postbox-type reference must use an engine-typealiased equivalent. +2. **Never typealias `Postbox`, `Account`, or `MediaBox`.** These umbrella types rename without encapsulating. Narrow utility typealiases (`MemoryBuffer`, `PostboxDecoder`, `PostboxEncoder`, `AdaptedPostboxDecoder`, `MediaResource`, …) remain allowed and expected. +3. No new engine wrapper **structs** unless the wave's spec explicitly allows — only typealiases and thin forwarding methods. +4. **Discovery first:** before adding any new engine wrapper/typealias, grep `submodules/TelegramCore/Sources/TelegramEngine/` for existing equivalents. Record the search result in the commit message. +5. **Abandonment protocol:** if a module can only be refactored by violating rule 2 or by editing a module outside the current wave's list, mark the task Abandoned with a recorded reason. Do NOT substitute a new module mid-wave. +6. Full project build per module. No unit tests exist in this project. +7. **TelegramCore never imports UIKit/Display.** `TelegramCore` is shared with the Telegram-Mac codebase; its Bazel `deps` and source files must not reference UIKit, Display, or any Apple-UI framework. UIKit-needing helpers (image scaling, rendering, etc.) stay in consumer-side submodules. +8. **Never substitute Postbox protocols (`Media`, `Peer`, `Message`) with `Any` / `AnyObject`** in code that previously used them. Type erasure throws away the domain semantics that the next reader expects. Use the matching engine wrapper (`EngineMedia`, `EnginePeer`, `EngineMessage`) — extending it as needed (e.g. add a missing case-init or convenience). If neither typealias nor wrapper covers the use site, restore the original Postbox import + type for now and flag the case for a future facade. Existing `Any`/`AnyObject` parameters predating the refactor are not in scope for this rule. + +### Engine typealias cheat sheet (existing aliases) + +``` +PeerId → EnginePeer.Id +MessageId → EngineMessage.Id +MessageIndex → EngineMessage.Index +MessageTags → EngineMessage.Tags +MessageAttribute → EngineMessage.Attribute +MessageFlags → EngineMessage.Flags +MessageForwardInfo → EngineMessage.ForwardInfo +MediaId → EngineMedia.Id +PreferencesEntry → EnginePreferencesEntry +TempBox → EngineTempBox +PinnedItemId → EngineChatList.PinnedItem.Id +MemoryBuffer → EngineMemoryBuffer (added 2026-04) +PostboxDecoder → EnginePostboxDecoder (added 2026-04) +PostboxEncoder → EnginePostboxEncoder (added 2026-04) +AdaptedPostboxDecoder → EngineAdaptedPostboxDecoder (added 2026-04) +ItemCollectionId → EngineItemCollectionId (added 2026-04-20) +FetchResourceSourceType → EngineFetchResourceSourceType (added 2026-04-20) +FetchResourceError → EngineFetchResourceError (added 2026-04-20) +StoryId → EngineStoryId (added 2026-05-02) +ChatListIndex → EngineChatListIndex (added 2026-05-03) +TempBoxFile → EngineTempBoxFile (added 2026-05-03) +ItemCollectionItemIndex → EngineItemCollectionItemIndex (added 2026-05-03) +ItemCollectionViewEntryIndex → EngineItemCollectionViewEntryIndex (added 2026-05-03) +ValueBoxEncryptionParameters → EngineValueBoxEncryptionParameters (added 2026-05-03) +MessageAndThreadId → EngineMessageAndThreadId (added 2026-05-03) +PeerStoryStats → EnginePeerStoryStats (added 2026-05-03) +MessageHistoryAnchorIndex → EngineMessageHistoryAnchorIndex (added 2026-05-03) +ChatListTotalUnreadStateCategory → EngineChatListTotalUnreadStateCategory (added 2026-05-03) +ChatListTotalUnreadStateStats → EngineChatListTotalUnreadStateStats (added 2026-05-03) +PeerSummaryCounterTags → EnginePeerSummaryCounterTags (added 2026-05-03) +ChatListTotalUnreadState → EngineChatListTotalUnreadState (added 2026-05-04) +ItemCacheEntryId → EngineItemCacheEntryId (added 2026-05-04) +HashFunctions → EngineHashFunctions (added 2026-05-04 wave 251) +CachedMediaResourceRepresentationResult → EngineCachedMediaResourceRepresentationResult (added 2026-05-04 wave 265) +MediaResourceDataFetchResult → EngineMediaResourceDataFetchResult (added 2026-05-04 wave 266) +MediaResourceDataFetchError → EngineMediaResourceDataFetchError (added 2026-05-04 wave 266) +MediaResourceStatus → EngineMediaResourceStatus (added 2026-05-04 wave 272) +``` + +**Free-function thin forwarders in TelegramCore** (rule 3 allows): +- `engineFileSize(_ path:, useTotalFileAllocatedSize: Bool = false)` — forwards to Postbox's `fileSize(...)` (added 2026-05-04 wave 268) + +**TelegramEngineUnauthorized.resources facade**: `UnauthorizedResources.storeResourceData(id: EngineMediaResource.Id, data:, synchronous:)` — bridges to `account.postbox.mediaBox.storeResourceData` (added 2026-05-04 wave 271) + +For the `MediaResource` Postbox protocol, prefer the TelegramCore subtype `TelegramMediaResource` when the consumer's usage allows (note: `EngineMediaResource` is a wrapper **class**, not a typealias, so it is not interchangeable with the protocol). + +### MediaResource → EngineMediaResource consumer migration + +`EngineMediaResource` is a `final class` in `TelegramCore` wrapping a `MediaResource` value. Unlike the typealiases above it is **not** interchangeable with the protocol, but it does provide wrap/unwrap helpers: + +- `EngineMediaResource(rawResource)` — wrap a raw `MediaResource`. +- `engineResource._asResource()` — unwrap to the raw `MediaResource`. +- `EngineMediaResource.ResourceData(rawResourceData)` — wrap `MediaResourceData`. +- `EngineMediaResource.Id(rawMediaResourceId)` — wrap `MediaResourceId`. + +**Pattern for facade functions:** when a `TelegramEngine.` method leaks raw `MediaResource` in its public signature, **change the facade signature in place** to `EngineMediaResource` (and change any closure parameter types the same way). Bridge inside the facade body by calling the existing `_internal_*` function with `engineResource._asResource()` / wrapping raw inputs from inner closures with `EngineMediaResource(rawResource)`. Update all call sites in the same commit. The `_internal_*` function stays on raw `MediaResource` — it is the Postbox-facing layer. + +Do **not** add opt-in `EngineMediaResource` overloads alongside raw-`MediaResource` overloads. Duplicate signatures fragment the public API and leave the leak in place forever. + +For consumer modules, prefer `EngineMediaResource` as the type in properties, locals, generic arguments and function parameters when the usage is a pure type reference. Do **not** try to use `EngineMediaResource` where a class must conform to `TelegramMediaResource` (Postbox protocol) or override `isEqual(to: MediaResource)` — those remain `import Postbox`. + +## tgcalls Testbench + +This repo includes a tgcalls testbench (CLI tool, Go/Pion SFU, Docker build) layered on top of the iOS source. All testbench code, build instructions, and architecture docs live inside the tgcalls submodule: + +- `submodules/TgVoipWebrtc/tgcalls/AGENTS.md` — top-level testbench overview, build/run commands +- `submodules/TgVoipWebrtc/tgcalls/tools/cli/AGENTS.md` — CLI test tool architecture +- `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/AGENTS.md` — Go SFU internals +- `submodules/TgVoipWebrtc/AGENTS.md` — tgcalls library internals + macOS/Linux build patches + +Build the test binary from this directory with: + +`./build-input/bazel-8.4.2 build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli` diff --git a/Telegram/BUILD b/Telegram/BUILD index 4231b24d96..c4797fe958 100644 --- a/Telegram/BUILD +++ b/Telegram/BUILD @@ -160,7 +160,7 @@ genrule( ], ) -minimum_os_version = "13.0" +minimum_os_version = "26.5" notificationServiceExtensionVersion = "v1" @@ -1850,7 +1850,7 @@ ios_application( xcodeproj( name = "Telegram_xcodeproj", bazel_path = telegram_bazel_path, - project_name = "Telegram", + project_name = "WinterGram", tags = ["manual"], top_level_targets = [ top_level_target(":Telegram", target_environments = ["device", "simulator"]), diff --git a/Telegram/NotificationService/Sources/NotificationService.swift b/Telegram/NotificationService/Sources/NotificationService.swift index 66f39d8a5b..256e08c9ce 100644 --- a/Telegram/NotificationService/Sources/NotificationService.swift +++ b/Telegram/NotificationService/Sources/NotificationService.swift @@ -2612,60 +2612,36 @@ typealias CMJImage = UIImage // MARK: - Public Namespace -/// A lightweight utility for turning ordinary bitmap images into ``NSAdaptiveImageGlyph`` (Apple “Genmoji”) objects. -/// -/// ## Overview -/// The public API is minimal — a single call to generate a glyph and helper utilities for -/// de/serialising attributed strings. -/// -/// ```swift -/// // Synchronous usage -/// let glyph = try Customoji.makeGlyph( -/// from: image, -/// description: "alt text", -/// cropToSquare: true -/// ) -/// let attr = NSAttributedString(adaptiveImageGlyph: glyph, attributes: [:]) -/// ``` -/// -/// ## Requirements -/// * iOS 18 / macOS 15 -/// * Swift 5.10+ -/// -/// ## Topics -/// - Generating glyphs: ``makeGlyph(from:description:identifier:tileSizes:cropToSquare:heicQuality:)`` -/// - Generating glyphs asynchronously: ``makeGlyphAsync(from:description:identifier:tileSizes:cropToSquare:heicQuality:)`` -/// - Serialising: ``decompose(_:)`` / ``recompose(plain:ranges:blobs:)`` -/// +/// Utility for converting bitmaps into ``NSAdaptiveImageGlyph`` (Genmoji). public struct Customoji { // MARK: Library-scoped Errors - /// Errors that can be thrown by ``Customoji``. + /// Errors thrown by ``Customoji``. public enum Error: Swift.Error, LocalizedError { - /// The input image is not square and ``makeGlyph(from:description:identifier:tileSizes:cropToSquare:heicQuality:)`` was called with `cropToSquare == false`. + /// Input image is not square and cropping is disabled. case nonSquare - /// The longest side of the input image exceeds ``maxSide`` pixels (defaults to 4096). + /// Input image exceeds ``maxSide`` pixels. case imageTooLarge - /// Failed to create an in-memory HEIC container via *Image I/O*. + /// Failed to create the in-memory HEIC container. case heicDestinationCreationFailed - /// Failed to finalise the HEIC container after writing all tiles. + /// Failed to finalize the HEIC container. case heicFinalizeFailed - /// Internal resize operation failed when generating a tile. + /// Resizing a tile failed. case imageScaleFailed - /// A `CGImage` representation could not be extracted from the source image. + /// Could not extract a `CGImage` from the source image. case cgImageUnavailable - /// The `tileSizes` parameter was empty, not strictly ascending, contained duplicates, or exceeded the source size. + /// `tileSizes` was empty, not strictly ascending, contained duplicates, or exceeded the source size. case invalidTileSizes - /// No tiles were generated — normally unreachable unless all requested sizes were invalid. + /// No tiles were generated; normally unreachable unless all requested sizes were invalid. case noTilesGenerated - /// The current platform is neither UIKit- nor AppKit-based (unlikely in practice). + /// Unsupported platform. case unsupportedPlatform - /// The `heicQuality` parameter was outside the 0.0 … 1.0 range. + /// `heicQuality` was outside the 0.0...1.0 range. case invalidHeicQuality /// Cropping or scaling to a square failed. case squareCropFailed - /// Textual description suitable for presenting to users. + /// Localized error description. public var errorDescription: String? { switch self { case .nonSquare: return "Input image must be square." @@ -2694,26 +2670,16 @@ public struct Customoji { } } - /// Default tile sizes (in pixels) officially used by Apple for Genmoji. + /// Default Genmoji tile sizes. public static let defaultTileSizes = [40, 64, 96, 160, 320] - /// Maximum allowed side length (in pixels) to guard against excessive memory consumption. + /// Maximum allowed side length, in pixels. private static let maxSide = 4_096 // MARK: - Synchronous Builder #if swift(>=5.10) @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *) - /// Generates an ``NSAdaptiveImageGlyph`` synchronously on the **current** thread. - /// - /// - Parameters: - /// - image: Source image. Can be rectangular; see `cropToSquare`. - /// - description: A human-readable accessibility description (VoiceOver/Narrator). - /// - identifier: A globally unique content identifier. Defaults to a fresh UUID. - /// - tileSizes: Desired tile sizes, in pixels. Must be strictly ascending. Defaults to ``defaultTileSizes``. - /// - cropToSquare: When `true`, the central square area is cropped if the input image is not square. - /// - heicQuality: Lossy compression quality between `0.0` and `1.0`. Defaults to `0.8`. - /// - Throws: ``Customoji/Error`` if validation or encoding fails. - /// - Returns: A fully-formed glyph ready for attribution. + /// Generate an ``NSAdaptiveImageGlyph`` from a bitmap. static func makeGlyph( from image: CMJImage, description: String, @@ -2739,19 +2705,7 @@ public struct Customoji { // MARK: - AttributedString Utilities #if swift(>=5.10) @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *) - /// Breaks an ``NSAttributedString`` that may contain adaptive-image glyphs into three parts. - /// - /// This is handy when you need to **serialise** rich text for network transport or persistent - /// storage, because `NSAdaptiveImageGlyph` itself is not `Codable`. - /// - /// ```swift - /// let (plain, ranges, blobs) = Customoji.decompose(attrString) - /// ``` - /// - /// - Returns: A tuple where: - /// - plain: UTF-16 plain text. - /// - ranges: An array mapping text ranges to glyph identifiers. - /// - blobs: A dictionary mapping identifier → raw HEIC data. + /// Split an attributed string into plain text, glyph ranges, and HEIC data. @inlinable public static func decompose(_ attr: NSAttributedString) -> ( plain: String, ranges: [(NSRange, String)], blobs: [String: Data] @@ -2773,13 +2727,7 @@ public struct Customoji { } @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *) - /// Reassembles an attributed string previously produced by ``decompose(_:)``. - /// - /// - Parameters: - /// - plain: The plain UTF-16 text. - /// - ranges: An array of `(NSRange, identifier)` pairs. - /// - blobs: A dictionary of identifier → HEIC data. *Must* contain every identifier referenced by `ranges`. - /// - Returns: A fully restored `NSAttributedString` with adaptive-image glyphs reinstated. + /// Reassemble an attributed string from ``decompose(_:)`` output. public static func recompose( plain: String, ranges: [(NSRange, String)], @@ -2805,8 +2753,7 @@ public struct Customoji { #endif // MARK: - Helper: Pixel-Side Validation - /// Ensures that the image’s **longer edge** does not exceed ``maxSide`` pixels, guarding against - /// excessive memory usage during HEIC encoding. + /// Reject images whose longer edge exceeds ``maxSide``. private static func validatePixelSide(of image: CMJImage) throws { #if canImport(UIKit) let scale = image.scale == 0 ? 1 : image.scale @@ -2821,11 +2768,6 @@ public struct Customoji { } } -// ============================================================= -// Below this line lies the internal implementation. -// Public-facing symbols have already been documented above. -// ============================================================= - // MARK: - Core Builder @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *) extension Customoji { diff --git a/Telegram/Telegram-iOS/Info.plist b/Telegram/Telegram-iOS/Info.plist index e603c8d817..111a0f4e25 100644 --- a/Telegram/Telegram-iOS/Info.plist +++ b/Telegram/Telegram-iOS/Info.plist @@ -353,6 +353,8 @@ remote-notification voip + UIDesignRequiresCompatibility + UIDeviceFamily 1 diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 9a92601e2f..a2ce03feab 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -402,22 +402,22 @@ // Tour "Tour.Title1" = "WinterGram"; -"Tour.Text1" = "The world's **fastest** messaging app.\nIt is **free** and **secure**."; +"Tour.Text1" = "A native **AyuGram** port for iPhone.\nAn open-source **Telegram** fork."; -"Tour.Title2" = "Fast"; -"Tour.Text2" = "**WinterGram** delivers messages\nfaster than any other application."; +"Tour.Title2" = "Telegram Fork"; +"Tour.Text2" = "Fully compatible with **Telegram**.\nYour chats, channels and accounts."; -"Tour.Title3" = "Powerful"; -"Tour.Text3" = "**WinterGram** has no limits on\nthe size of your media and chats."; +"Tour.Title3" = "Power Tools"; +"Tour.Text3" = "Saved **deleted messages**, full **edit\nhistory** and a **hidden archive**."; -"Tour.Title4" = "Secure"; -"Tour.Text4" = "**WinterGram** keeps your messages\nsafe from hacker attacks."; +"Tour.Title4" = "Make It Yours"; +"Tour.Text4" = "Deep **customization**, **Liquid Glass**,\nlocal **Premium** and a device **spoofer**."; -"Tour.Title5" = "Cloud-Based"; -"Tour.Text5" = "**WinterGram** lets you access your\nmessages from multiple devices."; +"Tour.Title5" = "Open Source"; +"Tour.Text5" = "Free and fully **open-source**.\nInspect and build it yourself."; -"Tour.Title6" = "Free"; -"Tour.Text6" = "**WinterGram** provides free unlimited\ncloud storage for chats and media."; +"Tour.Title6" = "Ghost Mode"; +"Tour.Text6" = "Read messages without sending\nread receipts, online or typing status."; "Tour.StartButton" = "Start Messaging"; @@ -16509,6 +16509,7 @@ Error: %8$@"; "WinterGram.ShowMessageSeconds" = "Show Message Seconds"; "WinterGram.ShowPeerID" = "Show Peer ID"; "WinterGram.ShowRegistrationDate" = "Show Registration Date"; +"WinterGram.RegistrationDate" = "Registration date"; "WinterGram.SingleCornerRadius" = "Single Corner Radius"; "WinterGram.SomeSettingsWillTakeEffectAfterRestart" = "Some settings will take effect after restart."; "WinterGram.SpoofAppVersion" = "App Version"; diff --git a/Telegram/Tests/Sources/UITests.swift b/Telegram/Tests/Sources/UITests.swift index 6df5294fe7..4210be8018 100644 --- a/Telegram/Tests/Sources/UITests.swift +++ b/Telegram/Tests/Sources/UITests.swift @@ -34,12 +34,12 @@ class UITests: XCTestCase { deleteTestAccount(phone: "9996625296") app.launch() - // Welcome screen — tap Start Messaging + // Start messaging. let startButton = app.buttons["Auth.Welcome.StartButton"] XCTAssert(startButton.waitForExistence(timeout: 5.0)) startButton.tap() - // Phone entry screen — enter test phone number + // Enter the test phone number. let countryCodeField = app.textFields["Auth.PhoneEntry.CountryCodeField"] XCTAssert(countryCodeField.waitForExistence(timeout: 10.0)) countryCodeField.tap() @@ -57,12 +57,12 @@ class UITests: XCTestCase { XCTAssert(continueButton.isEnabled) continueButton.tap() - // Confirmation dialog — tap Continue + // Confirm. let confirmButton = app.buttons["Auth.PhoneConfirm.ContinueButton"] XCTAssert(confirmButton.waitForExistence(timeout: 5.0)) confirmButton.tap() - // Code entry screen — enter verification code + // Enter the verification code. let codeEntryTitle = app.staticTexts["Auth.CodeEntry.Title"] XCTAssert(codeEntryTitle.waitForExistence(timeout: 15.0)) @@ -70,7 +70,7 @@ class UITests: XCTestCase { XCTAssert(codeField.waitForExistence(timeout: 3.0)) codeField.typeText("22222") - // Set name screen — enter name and submit + // Set the name. let firstNameField = app.textFields["Auth.SetName.FirstNameField"] XCTAssert(firstNameField.waitForExistence(timeout: 15.0)) firstNameField.tap() diff --git a/Telegram/prebuilt_watchos.bzl b/Telegram/prebuilt_watchos.bzl index 3002c45892..386b0a3b6d 100644 --- a/Telegram/prebuilt_watchos.bzl +++ b/Telegram/prebuilt_watchos.bzl @@ -4,8 +4,8 @@ against an exported tgwatch source tree, optionally codesigns the result, and exposes it through the providers that `ios_application(watch_application = ...)` consumes: - * AppleBundleInfo — bundle metadata (the host reads only `.product_type`). - * AppleEmbeddableInfo — `watch_bundles` (the zipped .app placed under Watch/). + * AppleBundleInfo: bundle metadata (the host reads only `.product_type`). + * AppleEmbeddableInfo: `watch_bundles` (the zipped .app placed under Watch/). The watch source tree is the committed in-repo snapshot at `Telegram/WatchApp/` (tracked inputs). To update it, re-sync from the standalone tgwatch repo via diff --git a/Telegram/prebuilt_watchos_build.sh b/Telegram/prebuilt_watchos_build.sh index 9acc2fd93e..9b460f8c95 100755 --- a/Telegram/prebuilt_watchos_build.sh +++ b/Telegram/prebuilt_watchos_build.sh @@ -2,7 +2,7 @@ # Worker for the apple_prebuilt_watchos_application Bazel rule. # # Builds the tgwatch watch app via xcodebuild (device, Release, UNSIGNED), then -# — if a provisioning profile is supplied — codesigns the app and its nested +# If a provisioning profile is supplied, codesigns the app and its nested # frameworks with the watchkitapp provisioning profile and a matching identity, # and finally zips the .app into the rule's output archive. # @@ -45,7 +45,7 @@ DD="$(mktemp -d)" trap 'rm -rf "$DD"' EXIT # Build from a writable copy so xcodebuild/SwiftPM never write into the (possibly -# in-repo, read-only) source tree — e.g. SwiftPM's Package.resolved or the workspace. +# in-repo, read-only) source tree, such as SwiftPM's Package.resolved or the workspace. # The tree is small (~12M); a plain cp on each (uncached) build is acceptable. WORKSRC="$DD/src" mkdir -p "$WORKSRC" @@ -84,7 +84,7 @@ fi # Sign the watch app whenever a provisioning profile is available. When no explicit # identity is supplied, derive it from the certificate embedded in that profile, so # the watch app is signed with the same distribution/development identity as the host -# app (resolved from the shared codesigning material) — required for App Store, where +# app (resolved from the shared codesigning material), as required for App Store, where # every nested bundle must carry the Apple submission certificate. Without a profile # the app is left unsigned (the host does not re-sign it). if [ -n "$PROFILE" ]; then diff --git a/build-system/Make/BuildEnvironment.py b/build-system/Make/BuildEnvironment.py index 2cb64aae7b..287bbb46c0 100644 --- a/build-system/Make/BuildEnvironment.py +++ b/build-system/Make/BuildEnvironment.py @@ -14,7 +14,9 @@ def is_apple_silicon(): def get_clean_env(use_clean_env=True): clean_env = os.environ.copy() if use_clean_env: - clean_env['PATH'] = '/usr/bin:/bin:/usr/sbin:/sbin' + # Force xcode-select to return the Xcode-beta path instead of CommandLineTools. + wrapper_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'build-system', 'xcode-wrapper') + clean_env['PATH'] = wrapper_dir + ':/usr/bin:/bin:/usr/sbin:/sbin' return clean_env diff --git a/build-system/Make/Make.py b/build-system/Make/Make.py index 66071f8186..476bd24210 100644 --- a/build-system/Make/Make.py +++ b/build-system/Make/Make.py @@ -533,13 +533,10 @@ def resolve_configuration(base_path, bazel_command_line: BazelCommandLine, argum ) aps_environment = codesigning_data.aps_environment if aps_environment is None: - # WinterGram: for unsigned sideload builds (provisioning disabled), the fake profiles - # don't match the custom bundle id, so no aps-environment is found. Push won't work - # until AltStore / SideStore / LiveContainer re-signs with the user's profile, so we - # fall back to a placeholder instead of failing the build. - if getattr(arguments, 'disableProvisioningProfiles', False): - print('No aps-environment found; defaulting to "development" for unsigned sideload build.') - aps_environment = 'development' + # Unsigned builds use a placeholder aps-environment instead of failing. + if getattr(arguments, 'disableProvisioningProfiles', False) or getattr(arguments, 'disableExtensions', False): + print('No aps-environment found; disabling APNs entitlement for unsigned sideload build.') + aps_environment = '' else: print('Could not find a valid aps-environment entitlement in the provided provisioning profiles') sys.exit(1) @@ -636,7 +633,7 @@ def resolve_watch_provisioning_profile(arguments, base_path): Returns the absolute path of the profile to sign the embedded watch app with, or None to build the watch app UNSIGNED. None is only returned (with a warning) for - non-distribution codesigning — a distribution build (appstore/adhoc/enterprise) raises + non-distribution codesigning. A distribution build (appstore/adhoc/enterprise) raises instead, because the host ios_application does NOT re-sign the embedded watch app, so an unsigned Watch/ payload ships as-is and is silently rejected at install time. Failing here turns that silent "won't install" into a build error that names the missing profile. @@ -664,11 +661,11 @@ def resolve_watch_provisioning_profile(arguments, base_path): '--embedWatchApp is set for a distribution build (--gitCodesigningType={ct}), but no watchkitapp ' 'provisioning profile resolved (looked for {p}).\n' 'The {ct} codesigning material does not contain a `.watchkitapp` profile, so the embedded watch app ' - 'would be UNSIGNED — the host app is not re-signed over it, so it ships unsigned and is silently ' + 'would be UNSIGNED. The host app is not re-signed over it, so it ships unsigned and is silently ' 'rejected when installing on a watch.\n' - 'Fix: fetch the latest codesigning material so the watchkitapp profile is present — drop ' + 'Fix: fetch the latest codesigning material so the watchkitapp profile is present. Drop ' '--gitCodesigningUseCurrent (it skips the fetch), or run ' - '`git -C build-input/configuration-repository-workdir/encrypted pull` — or create/register the {ct} ' + '`git -C build-input/configuration-repository-workdir/encrypted pull`, or create/register the {ct} ' 'watchkitapp provisioning profile, or pass an explicit --watchProvisioningProfile.'.format( ct=arguments.gitCodesigningType, p=resolved_watch_profile ) diff --git a/build-system/Make/ProjectGeneration.py b/build-system/Make/ProjectGeneration.py index 93579210bd..fa33e1e8c2 100644 --- a/build-system/Make/ProjectGeneration.py +++ b/build-system/Make/ProjectGeneration.py @@ -49,7 +49,10 @@ def generate_xcodeproj(build_environment: BuildEnvironment, disable_extensions, call_executable(bazel_generate_arguments) - xcodeproj_path = '{}.xcodeproj'.format(app_target_spec.replace(':', '/')) + if target_name == 'Telegram': + xcodeproj_path = 'Telegram/WinterGram.xcodeproj' + else: + xcodeproj_path = '{}.xcodeproj'.format(app_target_spec.replace(':', '/')) return xcodeproj_path diff --git a/build-system/SwiftTL/Sources/SwiftTL/CodeGeneration.swift b/build-system/SwiftTL/Sources/SwiftTL/CodeGeneration.swift index 8437591b40..d220e98f95 100644 --- a/build-system/SwiftTL/Sources/SwiftTL/CodeGeneration.swift +++ b/build-system/SwiftTL/Sources/SwiftTL/CodeGeneration.swift @@ -207,8 +207,8 @@ enum CodeGenerator { let structName = "\(apiPrefix)\(layerNumber)" let filename = "\(apiPrefix)Layer\(layerNumber).swift" - // All nested type refs (inside the struct) use `structName` as their prefix — - // `apiPrefix` is used only to compute `structName` and `filename`. Helpers like + // Nested type refs use `structName` as their prefix. + // `apiPrefix` only computes `structName` and `filename`. Helpers like // typeReferenceRepresentation and generateFieldParsing get `structName`, not // `apiPrefix`, so e.g. fields render as `media: SecretApi8.DecryptedMessageMedia`. diff --git a/build-system/xcode-wrapper/xcode-select b/build-system/xcode-wrapper/xcode-select new file mode 100755 index 0000000000..f486ae170d --- /dev/null +++ b/build-system/xcode-wrapper/xcode-select @@ -0,0 +1,9 @@ +#!/bin/bash +# Wrapper to force xcode-select -p to return Xcode-beta path, +# since the global xcode-select points to CommandLineTools which lacks iOS SDKs. +if [ "$1" = "-p" ]; then + echo "/Applications/Xcode-beta.app/Contents/Developer" + exit 0 +fi +# For all other invocations, delegate to the real xcode-select. +exec /usr/bin/xcode-select "$@" diff --git a/docs/superpowers/scratch/reveal-pacing-sim.py b/docs/superpowers/scratch/reveal-pacing-sim.py index ed4454b48e..0f07632a25 100644 --- a/docs/superpowers/scratch/reveal-pacing-sim.py +++ b/docs/superpowers/scratch/reveal-pacing-sim.py @@ -23,7 +23,7 @@ from typing import List, Optional, Tuple # --------------------------------------------------------------------------- -# Controller — must stay byte-for-byte equivalent to the Swift implementation. +# Keep the controller byte-for-byte equivalent to the Swift implementation. # --------------------------------------------------------------------------- CONTROLLER_NAME = "v1" # set by --algo flag in main() @@ -130,7 +130,7 @@ class TextRevealControllerV2: FRAME_DT_CAP = 0.05 INITIAL_INPUT_RATE = 40.0 # fallback velocity for the first chunk # When predicted_next_arrival has passed (stream stalled), don't speed up - # further — clamp time_to_next at this minimum. + # Clamp time_to_next at this minimum. STALL_FLOOR = 0.10 def __init__(self, initial_revealed_count: int, initial_length: int) -> None: diff --git a/scripts/build-wintergram.sh b/scripts/build-wintergram.sh index 18f1f1cf2b..c2e2c7f96c 100755 --- a/scripts/build-wintergram.sh +++ b/scripts/build-wintergram.sh @@ -1,26 +1,9 @@ #!/bin/bash -# WinterGram build wrapper — produces WinterGram-named IPAs for every install target. -# Lives in scripts/; all paths are resolved relative to the repo root (the script cd's there). -# -# Usage: -# ./scripts/build-wintergram.sh -# ./scripts/build-wintergram.sh all -# ./scripts/build-wintergram.sh sideload -# ./scripts/build-wintergram.sh livecontainer -# ./scripts/build-wintergram.sh sim -# -# Convenience: -# ./scripts/build-wintergram.sh --install # build the simulator IPA and install it into the active Simulator (sim mode only) -# ./scripts/build-wintergram.sh --install --run # also launch the app in the active Simulator -# ./scripts/build-wintergram.sh --clean # remove ./build before building -# ./scripts/build-wintergram.sh --open-build-dir # open ./build in Finder after build -# ./scripts/build-wintergram.sh --help +# WinterGram build wrapper. set -euo pipefail -# Resolve to the repo root regardless of where the script is invoked from (it lives in scripts/). cd "$(dirname "$0")/.." REPO="$(pwd)" -source ~/.zshrc 2>/dev/null || true OUT_DIR="build" SIDELOAD_NAME="WinterGram.ipa" @@ -29,12 +12,19 @@ SIM_NAME="WinterGram-Simulator.ipa" WNT_BUNDLE_ID="dev.reekeer.wintergram" BAZEL="./build-input/bazel-8.4.2-darwin-arm64" DEVICE_SRC="bazel-bin/Telegram/Telegram.ipa" +_XCODE_DEV_DIR="${DEVELOPER_DIR:-$(xcode-select -p 2>/dev/null)}" +BAZEL_XCODE_ACTION_ENV="--action_env=DEVELOPER_DIR=${_XCODE_DEV_DIR} --host_action_env=DEVELOPER_DIR=${_XCODE_DEV_DIR}" +# Xcode 27 compatibility +BAZEL_SDK_COMPAT_ARGS="--features=-treat_warnings_as_errors --@build_bazel_rules_swift//swift:copt=-no-warnings-as-errors --copt=-Wno-deprecated-declarations" MODE="all" +INSTALL_REQUESTED=0 +INSTALL_DEVICE=0 INSTALL_SIM=0 -RUN_SIM=0 +RUN_APP=0 CLEAN=0 OPEN_BUILD_DIR=0 +DEVICE_SELECTOR="" usage() { cat < build/$SIDELOAD_NAME + sideload, device, + ios Build device sideload IPA -> build/$SIDELOAD_NAME livecontainer, lc Build unsigned LiveContainer IPA -> build/$LC_NAME sim, simulator Build simulator IPA -> build/$SIM_NAME Options: - --install Build the simulator IPA and install it into the active booted Simulator. - This forces "sim" mode (install only makes sense for the simulator). + --install Install after build. With ios/device/sideload mode, installs on a connected + iPhone/iPad via xcrun devicectl. With sim mode, installs into the active + booted Simulator. With no explicit mode, keeps the old simulator shortcut. + --device Device selector for devicectl. Optional if exactly one iPhone/iPad is connected. --run Launch the app after --install (implies --install). --clean Remove ./build before building. --open-build-dir Open ./build in Finder after build. -h, --help Show this help. Examples: + $0 ios --install + $0 ios --install --run + $0 ios --install --device "Del's iPhone" $0 --install $0 --install --run $0 sideload --clean @@ -83,16 +79,21 @@ MODE_WAS_EXPLICIT=0 while [ "$#" -gt 0 ]; do case "$1" in - all|sideload|device|livecontainer|lc|sim|simulator) + all|sideload|device|ios|livecontainer|lc|sim|simulator) MODE="$1" MODE_WAS_EXPLICIT=1 ;; --install) - INSTALL_SIM=1 + INSTALL_REQUESTED=1 ;; --run) - RUN_SIM=1 - INSTALL_SIM=1 + RUN_APP=1 + INSTALL_REQUESTED=1 + ;; + --device) + shift + [ "$#" -gt 0 ] || die "--device requires a value" + DEVICE_SELECTOR="$1" ;; --clean) CLEAN=1 @@ -111,13 +112,26 @@ while [ "$#" -gt 0 ]; do shift done -# --install is simulator-only: installing a device/livecontainer IPA into a Simulator makes no -# sense, so --install always forces "sim" mode (warning if a conflicting mode was given). -if [ "$INSTALL_SIM" -eq 1 ]; then - if [ "$MODE_WAS_EXPLICIT" -eq 1 ] && [ "$MODE" != "sim" ] && [ "$MODE" != "simulator" ]; then - echo "==> --install is simulator-only; ignoring mode '$MODE' and building 'sim'." >&2 - fi - MODE="sim" +if [ "$INSTALL_REQUESTED" -eq 1 ]; then + case "$MODE" in + sim|simulator) + INSTALL_SIM=1 + ;; + sideload|device|ios) + INSTALL_DEVICE=1 + ;; + all) + if [ "$MODE_WAS_EXPLICIT" -eq 1 ]; then + die "--install with 'all' is ambiguous. Use 'ios --install' for a device or 'sim --install' for Simulator." + fi + + MODE="sim" + INSTALL_SIM=1 + ;; + livecontainer|lc) + die "--install does not support LiveContainer IPA. Use 'ios --install' for direct device install." + ;; + esac fi if [ "$CLEAN" -eq 1 ]; then @@ -135,8 +149,10 @@ build_sim() { --cacheDir "$HOME/telegram-bazel-cache" \ build \ --configurationPath build-system/wintergram-development-configuration.json \ - --codesigningInformationPath build-system/fake-codesigning-wintergram \ + --codesigningInformationPath build-system/fake-codesigning \ + --disableProvisioningProfiles \ --disableExtensions \ + --bazelArguments="$BAZEL_XCODE_ACTION_ENV $BAZEL_SDK_COMPAT_ARGS" \ --buildNumber=1 --configuration=debug_sim_arm64 [ -f "$DEVICE_SRC" ] || die "simulator artifact not found at $DEVICE_SRC" @@ -181,7 +197,7 @@ install_sim() { echo "==> [Simulator] installed: $(basename "$APP_PATH")" - if [ "$RUN_SIM" -eq 1 ]; then + if [ "$RUN_APP" -eq 1 ]; then [ -n "$INSTALLED_BUNDLE_ID" ] || { rm -rf "$TMP_DIR" die "could not read CFBundleIdentifier from app Info.plist" @@ -194,6 +210,135 @@ install_sim() { rm -rf "$TMP_DIR" } +select_ios_device() { + require_cmd xcrun + + if [ -n "$DEVICE_SELECTOR" ]; then + echo "$DEVICE_SELECTOR" + return 0 + fi + + local JSON_PATH + JSON_PATH="$(mktemp)" + + if ! xcrun devicectl list devices --timeout 20 --json-output "$JSON_PATH" >/dev/null; then + rm -f "$JSON_PATH" + die "could not list connected devices via xcrun devicectl. Unlock the phone, trust this Mac, and try again." + fi + + local SELECTED + if ! SELECTED="$(python3 - "$JSON_PATH" 2>&1 <<'PY' +import json +import sys + +path = sys.argv[1] + +with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + +devices = data.get("result", {}).get("devices", []) + +def get(obj, dotted, default=""): + current = obj + for part in dotted.split("."): + if not isinstance(current, dict): + return default + current = current.get(part) + return current if current is not None else default + +candidates = [] +for device in devices: + platform = str(get(device, "hardwareProperties.platform")).lower() + if platform not in ("ios", "ipados"): + continue + + identifier = ( + device.get("identifier") + or get(device, "hardwareProperties.udid") + or get(device, "hardwareProperties.serialNumber") + ) + if not identifier: + continue + + name = ( + get(device, "deviceProperties.name") + or device.get("name") + or get(device, "hardwareProperties.marketingName") + or identifier + ) + transport = get(device, "connectionProperties.transportType") + pair_state = get(device, "connectionProperties.pairingState") + candidates.append((str(identifier), str(name), str(transport), str(pair_state))) + +if len(candidates) == 1: + print(candidates[0][0]) + sys.exit(0) + +if not candidates: + print("no connected iPhone/iPad found by devicectl", file=sys.stderr) +else: + print("multiple iPhone/iPad devices found; pass --device with one of these:", file=sys.stderr) + for identifier, name, transport, pair_state in candidates: + details = ", ".join(part for part in (transport, pair_state) if part) + suffix = f" ({details})" if details else "" + print(f" {identifier} {name}{suffix}", file=sys.stderr) + +sys.exit(2) +PY +)"; then + rm -f "$JSON_PATH" + die "$SELECTED" + fi + + rm -f "$JSON_PATH" + echo "$SELECTED" +} + +install_device() { + require_cmd xcrun + + local IPA="$OUT_DIR/$SIDELOAD_NAME" + [ -f "$IPA" ] || die "device IPA not found at $IPA" + + echo "==> [Device] preparing app for install ..." + + local TMP_DIR + TMP_DIR="$(mktemp -d)" + + unzip -q "$IPA" -d "$TMP_DIR" + + local APP_PATH + APP_PATH="$(find "$TMP_DIR/Payload" -maxdepth 1 -type d -name "*.app" | head -n 1)" + + [ -n "${APP_PATH:-}" ] || { + rm -rf "$TMP_DIR" + die "no .app found inside $IPA" + } + + local INSTALLED_BUNDLE_ID + INSTALLED_BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$APP_PATH/Info.plist" 2>/dev/null || true)" + + local DEVICE + DEVICE="$(select_ios_device)" + + echo "==> [Device] installing $(basename "$APP_PATH") on $DEVICE ..." + xcrun devicectl device install app --device "$DEVICE" "$APP_PATH" + + echo "==> [Device] installed: $(basename "$APP_PATH")" + + if [ "$RUN_APP" -eq 1 ]; then + [ -n "$INSTALLED_BUNDLE_ID" ] || { + rm -rf "$TMP_DIR" + die "could not read CFBundleIdentifier from app Info.plist" + } + + echo "==> [Device] launching $INSTALLED_BUNDLE_ID on $DEVICE ..." + xcrun devicectl device process launch --device "$DEVICE" --terminate-existing "$INSTALLED_BUNDLE_ID" || true + fi + + rm -rf "$TMP_DIR" +} + # --- device build shared by sideload + livecontainer ----------------------- ensure_cert() { @@ -207,13 +352,17 @@ ensure_cert() { build_device() { ensure_cert + echo "==> [Device] generating fake provisioning profiles for ${WNT_BUNDLE_ID} ..." + python3 scripts/generate-fake-profiles.py build-system/fake-codesigning-generated + echo "==> [Device] build (debug_arm64, ${WNT_BUNDLE_ID}, extensions disabled) ..." python3 build-system/Make/Make.py --overrideXcodeVersion \ --cacheDir "$HOME/telegram-bazel-cache" \ build \ --configurationPath build-system/wintergram-development-configuration.json \ - --codesigningInformationPath build-system/fake-codesigning-wintergram \ + --codesigningInformationPath build-system/fake-codesigning-generated \ --disableExtensions \ + --bazelArguments="$BAZEL_XCODE_ACTION_ENV $BAZEL_SDK_COMPAT_ARGS" \ --buildNumber=1 --configuration=debug_arm64 [ -f "$DEVICE_SRC" ] || die "device artifact not found at $DEVICE_SRC" @@ -265,7 +414,7 @@ case "$MODE" in sim|simulator) build_sim ;; - sideload|device) + sideload|device|ios) build_device make_sideload ;; @@ -274,8 +423,7 @@ case "$MODE" in make_livecontainer ;; all) - # One device build feeds BOTH device deliverables; derive them before the sim build - # overwrites bazel-bin/Telegram/Telegram.ipa with the simulator artifact. + # Derive device deliverables before the simulator build overwrites the artifact. build_device make_sideload make_livecontainer @@ -287,14 +435,17 @@ case "$MODE" in esac if [ "$INSTALL_SIM" -eq 1 ]; then - # --install forced MODE=sim above, so the simulator IPA was just built — install it. install_sim fi +if [ "$INSTALL_DEVICE" -eq 1 ]; then + install_device +fi + echo echo "==> WinterGram deliverables in ./$OUT_DIR/:" ls -1 "$OUT_DIR"/WinterGram*.ipa 2>/dev/null | sed 's#^# #' || true if [ "$OPEN_BUILD_DIR" -eq 1 ]; then open "$OUT_DIR" -fi \ No newline at end of file +fi diff --git a/scripts/generate-fake-profiles.py b/scripts/generate-fake-profiles.py new file mode 100644 index 0000000000..9ee6f7dba9 --- /dev/null +++ b/scripts/generate-fake-profiles.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Generate fake provisioning profiles for the WinterGram dev bundle id.""" + +import datetime +import json +import os +import plistlib +import subprocess +import sys +import uuid +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.serialization import pkcs12, pkcs7 + +REPO_ROOT = Path(__file__).resolve().parent.parent +CONFIG_PATH = REPO_ROOT / "build-system/wintergram-development-configuration.json" +SOURCE_PROFILES_DIR = REPO_ROOT / "build-system/fake-codesigning/profiles" +SOURCE_CERTS_DIR = REPO_ROOT / "build-system/fake-codesigning/certs" +SELF_SIGNED_P12 = SOURCE_CERTS_DIR / "SelfSigned.p12" + +# mapping from application-identifier suffix -> output file name (without .mobileprovision) +PROFILE_NAME_MAPPING = { + ".SiriIntents": "Intents", + ".NotificationContent": "NotificationContent", + ".NotificationService": "NotificationService", + ".Share": "Share", + "": "Telegram", + ".watchkitapp": "WatchApp", + ".watchkitapp.watchkitextension": "WatchExtension", + ".Widget": "Widget", + ".BroadcastUpload": "BroadcastUpload", +} + + +def load_self_signed_cert_and_key(): + with open(SELF_SIGNED_P12, "rb") as f: + key, cert, _ = pkcs12.load_key_and_certificates(f.read(), password=b"") + if key is None or cert is None: + raise RuntimeError("Could not load key/cert from SelfSigned.p12") + return key, cert + + +def extract_plist(profile_path: Path) -> dict: + result = subprocess.run( + ["openssl", "smime", "-inform", "der", "-verify", "-noverify", "-in", str(profile_path)], + capture_output=True, + check=True, + ) + return plistlib.loads(result.stdout) + + +def replace_in_string(s: str, old_team: str, new_team: str, old_bundle: str, new_bundle: str) -> str: + s = s.replace(old_team + "." + old_bundle, new_team + "." + new_bundle) + s = s.replace(old_bundle, new_bundle) + s = s.replace(old_team, new_team) + return s + + +def replace_in_value(value, old_team: str, new_team: str, old_bundle: str, new_bundle: str): + if isinstance(value, str): + return replace_in_string(value, old_team, new_team, old_bundle, new_bundle) + if isinstance(value, list): + return [replace_in_value(v, old_team, new_team, old_bundle, new_bundle) for v in value] + if isinstance(value, dict): + return {k: replace_in_value(v, old_team, new_team, old_bundle, new_bundle) for k, v in value.items()} + if isinstance(value, bytes): + try: + text = value.decode("utf-8") + replaced = replace_in_string(text, old_team, new_team, old_bundle, new_bundle) + return replaced.encode("utf-8") + except UnicodeDecodeError: + return value + return value + + +def profile_suffix_for(plist_data: dict) -> str: + app_id = plist_data["Entitlements"]["application-identifier"] + # We expect . + return app_id.split(".", 2)[2] if "." in app_id else "" + + +def generate_profile(source_path: Path, new_team: str, new_bundle: str, key, cert) -> tuple[str, bytes]: + plist_data = extract_plist(source_path) + + old_team = plist_data["TeamIdentifier"][0] + old_bundle = plist_data["Entitlements"]["application-identifier"][len(old_team) + 1 :] + # strip known suffixes to find the base bundle id + old_bundle_base = old_bundle + for suffix in sorted(PROFILE_NAME_MAPPING.keys(), key=len, reverse=True): + if suffix and old_bundle.endswith(suffix): + old_bundle_base = old_bundle[: -len(suffix)] + break + + new_bundle_base = new_bundle + + plist_data = replace_in_value(plist_data, old_team, new_team, old_bundle_base, new_bundle_base) + + # Some fields can't be string-replaced blindly; set them explicitly. + plist_data["ApplicationIdentifierPrefix"] = [new_team] + plist_data["TeamIdentifier"] = [new_team] + plist_data["UUID"] = str(uuid.uuid4()).upper() + plist_data["Name"] = f"WinterGram {source_path.stem}" + plist_data["TeamName"] = "WinterGram Self-Signed" + plist_data["IsXcodeManaged"] = False + + now = datetime.datetime.now(datetime.timezone.utc) + plist_data["CreationDate"] = now + plist_data["ExpirationDate"] = now + datetime.timedelta(days=365) + + # Replace embedded developer certificate with the self-signed one. + cert_der = cert.public_bytes(serialization.Encoding.DER) + plist_data["DeveloperCertificates"] = [cert_der] + + # Strip capabilities we don't want/need for a sideload dev build. + entitlements = plist_data.get("Entitlements", {}) + entitlements["get-task-allow"] = True + entitlements.pop("beta-reports-active", None) + entitlements["aps-environment"] = "development" + plist_data["Entitlements"] = entitlements + + # Determine output name from the original suffix. + original_suffix = "" + for suffix in sorted(PROFILE_NAME_MAPPING.keys(), key=len, reverse=True): + if suffix and old_bundle.endswith(suffix): + original_suffix = suffix + break + output_name = PROFILE_NAME_MAPPING.get(original_suffix, source_path.stem) + ".mobileprovision" + + # Re-sign as CMS SignedData (DER) using the self-signed certificate. + plist_bytes = plistlib.dumps(plist_data) + builder = pkcs7.PKCS7SignatureBuilder( + data=plist_bytes, + signers=[(cert, key, hashes.SHA256(), None)], + additional_certs=[cert], + ) + signed = builder.sign(serialization.Encoding.DER, [pkcs7.PKCS7Options.Binary]) + + return output_name, signed + + +def main(): + if len(sys.argv) > 1: + output_dir = Path(sys.argv[1]) + else: + output_dir = REPO_ROOT / "build-system/fake-codesigning-generated" + + output_profiles_dir = output_dir / "profiles" + output_certs_dir = output_dir / "certs" + output_profiles_dir.mkdir(parents=True, exist_ok=True) + output_certs_dir.mkdir(parents=True, exist_ok=True) + + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + config = json.load(f) + + new_team = config["team_id"] + new_bundle = config["bundle_id"] + + print(f"Generating fake profiles for team={new_team} bundle={new_bundle} ...") + + key, cert = load_self_signed_cert_and_key() + + # Copy the self-signed cert material so the output dir is self-contained. + for src in SOURCE_CERTS_DIR.iterdir(): + if src.is_file(): + (output_certs_dir / src.name).write_bytes(src.read_bytes()) + + generated = [] + for source_path in sorted(SOURCE_PROFILES_DIR.glob("*.mobileprovision")): + output_name, signed = generate_profile(source_path, new_team, new_bundle, key, cert) + out_path = output_profiles_dir / output_name + out_path.write_bytes(signed) + generated.append(output_name) + print(f" {source_path.name} -> {output_name}") + + print(f"Wrote {len(generated)} profiles to {output_profiles_dir}") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate-wintergram-xcodeproj.sh b/scripts/generate-wintergram-xcodeproj.sh new file mode 100755 index 0000000000..9b39cb2d74 --- /dev/null +++ b/scripts/generate-wintergram-xcodeproj.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# Generate a WinterGram Xcode project configured for your Apple Developer Team. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +CONFIG="build-system/wintergram-development-configuration.json" +BUNDLE_ID="${WINTERGRAM_BUNDLE_ID:-com.reekeer.wintergram}" +TEAM_ID="${WINTERGRAM_TEAM_ID:-}" +API_ID="${WINTERGRAM_API_ID:-2040}" +API_HASH="${WINTERGRAM_API_HASH:-b18441a1ff607e10a989891a5462e627}" + +usage() { + cat <&2 + usage >&2 + exit 1 + ;; + esac +done + +if [ -z "$TEAM_ID" ]; then + echo "ERROR: --team-id is required." >&2 + echo "Find it in Xcode -> Settings -> Accounts -> your Apple ID -> Team ID." >&2 + exit 1 +fi + +python3 - "$CONFIG" "$BUNDLE_ID" "$TEAM_ID" "$API_ID" "$API_HASH" <<'PY' +import json +import sys + +path, bundle_id, team_id, api_id, api_hash = sys.argv[1:] + +with open(path) as f: + data = json.load(f) + +data["bundle_id"] = bundle_id +data["team_id"] = team_id +data["api_id"] = api_id +data["api_hash"] = api_hash +data["app_specific_url_scheme"] = "wnt" +data["enable_siri"] = False +data["enable_icloud"] = False + +with open(path, "w") as f: + json.dump(data, f, indent="\t") + f.write("\n") +PY + +echo "==> Wrote $CONFIG" +echo " bundle_id: $BUNDLE_ID" +echo " team_id: $TEAM_ID" + +python3 build-system/Make/Make.py --overrideXcodeVersion \ + --cacheDir "$HOME/telegram-bazel-cache" \ + generateProject \ + --configurationPath "$CONFIG" \ + --xcodeManagedCodesigning + +echo +echo "==> Generated WinterGram.xcodeproj" diff --git a/scripts/wintergram-badge-tool.py b/scripts/wintergram-badge-tool.py new file mode 100755 index 0000000000..08ef85d884 --- /dev/null +++ b/scripts/wintergram-badge-tool.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +"""Validate and optionally preview a WinterGram badge manifest.""" + +import argparse +import json +import math +import os +import re +import sys + +ANIMATION_TYPES = {"none", "rotate", "blink", "pulse", "bounce", "shake", "lottie"} +DIRECTIONS = {"cw", "ccw"} +TINT_RE = re.compile(r"^(theme|none|#[0-9a-fA-F]{6})$") +MAX_BADGES = 64 +MAX_LAYERS = 16 +LOTTIE_EXTS = (".tgs", ".json") +RASTER_EXTS = (".png", ".jpg", ".jpeg") + + +class Report: + def __init__(self): + self.errors = [] + self.warnings = [] + + def error(self, path, msg): + self.errors.append(f"{path}: {msg}") + + def warn(self, path, msg): + self.warnings.append(f"{path}: {msg}") + + def ok(self): + return not self.errors + + +def is_number(v): + return isinstance(v, (int, float)) and not isinstance(v, bool) + + +def is_int(v): + return isinstance(v, int) and not isinstance(v, bool) + + +def validate(manifest, manifest_dir, report): + if not isinstance(manifest, dict): + report.error("$", "manifest must be a JSON object") + return + + if "version" not in manifest: + report.error("$", 'missing required "version"') + elif not is_int(manifest["version"]) or manifest["version"] < 0: + report.error("$.version", "must be an integer >= 0") + + canvas = manifest.get("canvas", 1024) + if not is_number(canvas) or canvas <= 0: + report.error("$.canvas", "must be a positive number") + canvas = 1024 + + badges = manifest.get("badges") + if badges is None: + report.error("$", 'missing required "badges"') + return + if not isinstance(badges, list): + report.error("$.badges", "must be an array") + return + if len(badges) > MAX_BADGES: + report.warn("$.badges", f"{len(badges)} badges; client caps at {MAX_BADGES}") + + seen_ids = set() + for i, badge in enumerate(badges): + bpath = f"$.badges[{i}]" + if not isinstance(badge, dict): + report.error(bpath, "must be an object") + continue + + bid = badge.get("id") + if not isinstance(bid, str) or not bid: + report.error(f"{bpath}.id", "must be a non-empty string") + else: + if bid in seen_ids: + report.error(f"{bpath}.id", f'duplicate badge id "{bid}"') + seen_ids.add(bid) + + if "priority" in badge and not is_int(badge["priority"]): + report.error(f"{bpath}.priority", "must be an integer") + + if "description" in badge and not isinstance(badge.get("description"), str): + report.error(f"{bpath}.description", "must be a string") + + peers = badge.get("peers") + if not isinstance(peers, dict): + report.error(f"{bpath}.peers", "must be an object") + else: + for key in ("users", "channels"): + vals = peers.get(key, []) + if not isinstance(vals, list) or not all(is_int(v) for v in vals): + report.error(f"{bpath}.peers.{key}", "must be an array of integers") + if not peers.get("users") and not peers.get("channels"): + report.warn(f"{bpath}.peers", "no users or channels; badge will never match") + + layers = badge.get("layers") + if not isinstance(layers, list) or not layers: + report.error(f"{bpath}.layers", "must be a non-empty array") + continue + if len(layers) > MAX_LAYERS: + report.warn(f"{bpath}.layers", f"{len(layers)} layers; client caps at {MAX_LAYERS}") + + for j, layer in enumerate(layers): + validate_layer(layer, f"{bpath}.layers[{j}]", canvas, manifest_dir, report) + + +def validate_layer(layer, path, canvas, manifest_dir, report): + if not isinstance(layer, dict): + report.error(path, "must be an object") + return + + source = layer.get("source") + if not isinstance(source, str) or not source: + report.error(f"{path}.source", "must be a non-empty string") + source = "" + + lowered = source.lower() + is_lottie = lowered.endswith(LOTTIE_EXTS) + if source and not is_lottie and not lowered.endswith(RASTER_EXTS): + report.warn(f"{path}.source", "unrecognised extension (expected .png/.jpg or .tgs/.json)") + + for key in ("x", "y", "width", "height"): + if key in layer and not is_number(layer[key]): + report.error(f"{path}.{key}", "must be a number") + for key in ("width", "height"): + if is_number(layer.get(key)) and layer[key] <= 0: + report.error(f"{path}.{key}", "must be > 0") + + x, y = layer.get("x", 0), layer.get("y", 0) + w, h = layer.get("width", 0), layer.get("height", 0) + if all(is_number(v) for v in (x, y, w, h)): + if x < 0 or y < 0 or x + w > canvas or y + h > canvas: + report.warn(path, f"layer rect ({x},{y},{w},{h}) extends outside the {canvas} canvas") + + tint = layer.get("tint") + if tint is not None and (not isinstance(tint, str) or not TINT_RE.match(tint)): + report.error(f"{path}.tint", 'must be "theme", "none", or "#RRGGBB"') + + anim = layer.get("animation") + if anim is not None: + validate_animation(anim, f"{path}.animation", is_lottie, report) + + # Asset existence (best-effort, for path-style sources). + if source and ("/" in source or "." in source): + asset_path = os.path.join(manifest_dir, source) + if not os.path.isfile(asset_path): + report.warn(f"{path}.source", f'asset not found: "{source}"') + + +def validate_animation(anim, path, is_lottie, report): + if not isinstance(anim, dict): + report.error(path, "must be an object") + return + atype = anim.get("type") + if atype is not None and atype not in ANIMATION_TYPES: + report.warn(f"{path}.type", f'unknown type "{atype}"; client treats it as "none"') + if is_lottie and atype not in (None, "lottie", "none"): + report.warn(path, "ignored for Lottie sources (the .tgs/.json plays itself)") + if "duration" in anim and (not is_number(anim["duration"]) or anim["duration"] <= 0): + report.error(f"{path}.duration", "must be a positive number") + if "loop" in anim and not isinstance(anim["loop"], bool): + report.error(f"{path}.loop", "must be a boolean") + if "direction" in anim and anim["direction"] not in DIRECTIONS: + report.error(f"{path}.direction", 'must be "cw" or "ccw"') + if "amplitude" in anim and (not is_number(anim["amplitude"]) or anim["amplitude"] < 0): + report.error(f"{path}.amplitude", "must be a number >= 0") + + +def try_jsonschema(manifest, schema_path, report): + try: + import jsonschema + except ImportError: + return + try: + with open(schema_path, "r", encoding="utf-8") as f: + schema = json.load(f) + except OSError: + return + validator = jsonschema.Draft7Validator(schema) + for err in sorted(validator.iter_errors(manifest), key=lambda e: list(e.path)): + loc = "$" + "".join(f"[{p}]" if isinstance(p, int) else f".{p}" for p in err.path) + report.error(loc, f"[schema] {err.message}") + + +# --------------------------------------------------------------------------- +# Preview (GIF) generation +# --------------------------------------------------------------------------- + +def parse_hex(color): + color = color.lstrip("#") + return (int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)) + + +def tint_layer(img, tint, accent_rgb): + from PIL import Image + img = img.convert("RGBA") + if tint in (None, "none"): + return img + rgb = accent_rgb if tint == "theme" else parse_hex(tint) + solid = Image.new("RGBA", img.size, rgb + (0,)) + solid.putalpha(img.split()[3]) + solid.paste(Image.new("RGBA", img.size, rgb + (255,)), (0, 0), img.split()[3]) + return solid + + +def render_badge_gif(badge, canvas, manifest_dir, out_path, size, fps, bg, accent_rgb): + from PIL import Image + + layers = [] + for layer in badge["layers"]: + source = layer.get("source", "") + if source.lower().endswith(LOTTIE_EXTS): + print(f" note: Lottie layer '{source}' shown as a static frame in the preview") + asset = os.path.join(manifest_dir, source) + if not os.path.isfile(asset): + print(f" skip: missing asset '{source}'") + continue + try: + base = Image.open(asset).convert("RGBA") + except Exception as exc: # noqa: BLE001 + print(f" skip: cannot open '{source}': {exc}") + continue + lw = max(1, int(round(layer.get("width", canvas) / canvas * size))) + lh = max(1, int(round(layer.get("height", canvas) / canvas * size))) + base = base.resize((lw, lh), Image.LANCZOS) + base = tint_layer(base, layer.get("tint"), accent_rgb) + anim = layer.get("animation", {}) or {} + layers.append({ + "img": base, + "x": layer.get("x", 0) / canvas * size, + "y": layer.get("y", 0) / canvas * size, + "w": lw, + "h": lh, + "type": anim.get("type", "none"), + "duration": anim.get("duration", 1.0) or 1.0, + "cw": anim.get("direction", "cw") != "ccw", + "amplitude": anim.get("amplitude", 0.1), + "is_lottie": source.lower().endswith(LOTTIE_EXTS), + }) + + if not layers: + print(" skip: no renderable layers") + return False + + durations = [l["duration"] for l in layers if l["type"] in ("rotate", "blink", "pulse", "bounce", "shake") and not l["is_lottie"]] + loop_seconds = max(durations) if durations else 2.0 + frame_count = max(1, min(120, int(round(loop_seconds * fps)))) + + transparent = (bg == "none") + bg_rgba = (0, 0, 0, 0) if transparent else (parse_hex(bg) + (255,)) + + frames = [] + for f in range(frame_count): + t = (f / frame_count) * loop_seconds + canvas_img = Image.new("RGBA", (size, size), bg_rgba) + for l in layers: + frame_img = l["img"] + ox, oy = l["x"], l["y"] + cycles = max(1, round(loop_seconds / l["duration"])) if l["duration"] else 1 + phase = (t / loop_seconds) * cycles # whole cycles per loop -> seamless + angle = 2.0 * math.pi * phase + + if l["is_lottie"] or l["type"] in ("none",): + pass + elif l["type"] == "rotate": + deg = (phase * 360.0) % 360.0 + frame_img = frame_img.rotate(-deg if l["cw"] else deg, resample=Image.BICUBIC, expand=False) + elif l["type"] == "blink": + factor = 0.35 + 0.65 * (0.5 + 0.5 * math.cos(angle)) + frame_img = apply_alpha(frame_img, factor) + elif l["type"] == "pulse": + scale = 1.0 + l["amplitude"] * math.sin(angle) + frame_img, ox, oy = scaled(frame_img, scale, ox, oy, l["w"], l["h"]) + elif l["type"] == "bounce": + oy = oy - l["amplitude"] * l["h"] * abs(math.sin(angle)) + elif l["type"] == "shake": + ox = ox + l["amplitude"] * l["w"] * math.sin(2.0 * angle) + + canvas_img.alpha_composite(frame_img, (int(round(ox)), int(round(oy)))) + frames.append(canvas_img) + + save_gif(frames, out_path, fps, transparent) + print(f" wrote {out_path} ({frame_count} frames @ {fps}fps, loop {loop_seconds:.1f}s)") + return True + + +def apply_alpha(img, factor): + from PIL import Image + alpha = img.split()[3].point(lambda a: int(a * max(0.0, min(1.0, factor)))) + out = img.copy() + out.putalpha(alpha) + return out + + +def scaled(img, scale, ox, oy, w, h): + from PIL import Image + nw = max(1, int(round(w * scale))) + nh = max(1, int(round(h * scale))) + resized = img.resize((nw, nh), Image.LANCZOS) + return resized, ox - (nw - w) / 2.0, oy - (nh - h) / 2.0 + + +def save_gif(frames, out_path, fps, transparent): + from PIL import Image + duration_ms = int(round(1000.0 / fps)) + if transparent: + conv = [f.convert("P", palette=Image.ADAPTIVE, colors=255) for f in frames] + conv[0].save(out_path, save_all=True, append_images=conv[1:], loop=0, + duration=duration_ms, disposal=2, transparency=255) + else: + conv = [f.convert("RGB") for f in frames] + conv[0].save(out_path, save_all=True, append_images=conv[1:], loop=0, + duration=duration_ms, disposal=2) + + +def run_preview(manifest, manifest_dir, args): + try: + import PIL # noqa: F401 + except ImportError: + print("\n--preview needs Pillow. Install it with: pip install Pillow", file=sys.stderr) + return 1 + out_dir = args.out or manifest_dir + os.makedirs(out_dir, exist_ok=True) + canvas = manifest.get("canvas", 1024) + accent_rgb = parse_hex(args.accent) + any_done = False + print("\nGenerating previews:") + for badge in manifest.get("badges", []): + bid = badge.get("id", "badge") + if args.badge and bid != args.badge: + continue + out_path = os.path.join(out_dir, f"preview_{bid}.gif") + print(f" badge '{bid}':") + if render_badge_gif(badge, canvas, manifest_dir, out_path, args.size, args.fps, args.bg, accent_rgb): + any_done = True + if args.badge and not any_done: + print(f" no badge with id '{args.badge}'", file=sys.stderr) + return 1 + return 0 + + +def main(): + parser = argparse.ArgumentParser(description="Validate (and optionally preview) a WinterGram badge manifest.") + parser.add_argument("manifest", nargs="?", default=".wintergram/icons/manifest.json") + parser.add_argument("--schema", default=None) + parser.add_argument("--preview", action="store_true") + parser.add_argument("--badge", default=None) + parser.add_argument("--size", type=int, default=256) + parser.add_argument("--fps", type=int, default=30) + parser.add_argument("--bg", default="#1C1C1E") + parser.add_argument("--accent", default="#3478F6") + parser.add_argument("--out", default=None) + args = parser.parse_args() + + try: + with open(args.manifest, "r", encoding="utf-8") as f: + manifest = json.load(f) + except OSError as exc: + print(f"error: cannot read manifest: {exc}", file=sys.stderr) + return 2 + except json.JSONDecodeError as exc: + print(f"error: invalid JSON: {exc}", file=sys.stderr) + return 2 + + manifest_dir = os.path.dirname(os.path.abspath(args.manifest)) + schema_path = args.schema or os.path.join(manifest_dir, "manifest.schema.json") + + report = Report() + validate(manifest, manifest_dir, report) + try_jsonschema(manifest, schema_path, report) + + for w in report.warnings: + print(f"warning {w}") + for e in report.errors: + print(f"error {e}") + + if report.ok(): + n = len(manifest.get("badges", [])) + print(f"OK: manifest valid ({n} badge(s)).") + else: + print(f"\nFAILED: {len(report.errors)} error(s), {len(report.warnings)} warning(s).") + return 1 + + if args.preview: + return run_preview(manifest, manifest_dir, args) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/submodules/AuthorizationUI/BUILD b/submodules/AuthorizationUI/BUILD index 4757af3b84..305e89dbc2 100644 --- a/submodules/AuthorizationUI/BUILD +++ b/submodules/AuthorizationUI/BUILD @@ -31,6 +31,7 @@ swift_library( "//submodules/SolidRoundedButtonNode:SolidRoundedButtonNode", "//submodules/ImageCompression:ImageCompression", "//submodules/RMIntro:RMIntro", + "//submodules/AppBundle:AppBundle", "//submodules/QrCode:QrCode", "//submodules/PhoneInputNode:PhoneInputNode", "//submodules/CodeInputView:CodeInputView", diff --git a/submodules/AuthorizationUI/Sources/AuthorizationSequencePhoneEntryController.swift b/submodules/AuthorizationUI/Sources/AuthorizationSequencePhoneEntryController.swift index 9f9d886649..2cc1c3d69a 100644 --- a/submodules/AuthorizationUI/Sources/AuthorizationSequencePhoneEntryController.swift +++ b/submodules/AuthorizationUI/Sources/AuthorizationSequencePhoneEntryController.swift @@ -94,7 +94,8 @@ public final class AuthorizationSequencePhoneEntryController: ViewController, MF self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(self.cancelPressed)) } - if let countriesConfiguration { + if let countriesConfiguration, !countriesConfiguration.countries.isEmpty { + // Keep the bundled fallback when the server config is empty. AuthorizationSequenceCountrySelectionController.setupCountryCodes(countries: countriesConfiguration.countries, codesByPrefix: countriesConfiguration.countriesByPrefix) } } diff --git a/submodules/AuthorizationUI/Sources/AuthorizationSequenceSplashController.swift b/submodules/AuthorizationUI/Sources/AuthorizationSequenceSplashController.swift index 9056e2daab..c368c02b1a 100644 --- a/submodules/AuthorizationUI/Sources/AuthorizationSequenceSplashController.swift +++ b/submodules/AuthorizationUI/Sources/AuthorizationSequenceSplashController.swift @@ -9,6 +9,7 @@ import TelegramPresentationData import LegacyComponents import SolidRoundedButtonNode import RMIntro +import AppBundle public final class AuthorizationSequenceSplashController: ViewController { private var controllerNode: AuthorizationSequenceSplashControllerNode { @@ -78,7 +79,11 @@ public final class AuthorizationSequenceSplashController: ViewController { super.init(navigationBarPresentationData: nil) self._hasGlassStyle = true - + + // Brand icon for the first intro page, matching the theme. + let iconName = theme.overallDarkAppearance ? "IntroLogoDark" : "IntroLogoLight" + self.controller.firstPageIcon = UIImage(bundleImageName: iconName) + self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) self.statusBar.statusBarStyle = theme.intro.statusBarStyle.style diff --git a/submodules/BrowserUI/Sources/BrowserMarkdown.swift b/submodules/BrowserUI/Sources/BrowserMarkdown.swift index cd2de7521f..8dc27064ce 100644 --- a/submodules/BrowserUI/Sources/BrowserMarkdown.swift +++ b/submodules/BrowserUI/Sources/BrowserMarkdown.swift @@ -1173,7 +1173,7 @@ public func richMarkdownAttributeIfNeeded(context: AccountContext, attributedTex // .textCustomEmoji in markdownInlineContent. A link is entity-expressible, // so an emoji-only message still classifies as not-rich (and falls through // to the entity path, where its untouched attribute makes a .CustomEmoji - // entity) — custom emoji alone never forces a rich message. + // entity). Custom emoji alone never forces a rich message. let text = markdownSourceInjectingCustomEmojiMarkers(attributedText) guard markdownMightNeedRichLayout(text) else { return nil diff --git a/submodules/CountrySelectionUI/BUILD b/submodules/CountrySelectionUI/BUILD index fe0f0049ad..ba4f11a089 100644 --- a/submodules/CountrySelectionUI/BUILD +++ b/submodules/CountrySelectionUI/BUILD @@ -16,6 +16,7 @@ swift_library( "//submodules/TelegramCore:TelegramCore", "//submodules/TelegramPresentationData:TelegramPresentationData", "//submodules/TelegramStringFormatting:TelegramStringFormatting", + "//submodules/libphonenumber:libphonenumber", "//submodules/SearchBarNode:SearchBarNode", "//submodules/AppBundle:AppBundle", "//submodules/ComponentFlow", diff --git a/submodules/CountrySelectionUI/Sources/AuthorizationSequenceCountrySelectionController.swift b/submodules/CountrySelectionUI/Sources/AuthorizationSequenceCountrySelectionController.swift index 3b3cdc1fc2..d8023a9fce 100644 --- a/submodules/CountrySelectionUI/Sources/AuthorizationSequenceCountrySelectionController.swift +++ b/submodules/CountrySelectionUI/Sources/AuthorizationSequenceCountrySelectionController.swift @@ -11,6 +11,9 @@ import TelegramCore import ComponentFlow import BundleIconComponent import GlassBarButtonComponent +import libphonenumber + +private let countryPhoneNumberUtil = NBPhoneNumberUtil() private func loadCountryCodes() -> [Country] { guard let filePath = getAppBundle().path(forResource: "PhoneCountries", ofType: "txt") else { @@ -56,10 +59,38 @@ private func loadCountryCodes() -> [Country] { let countryName = locale.localizedString(forIdentifier: countryId) ?? "" if let _ = Int(countryCode) { - let code = Country.CountryCode(code: countryCode, prefixes: [], patterns: !pattern.isEmpty ? [pattern] : []) + let prefixes: [String] + if countryCode == "7" { + switch countryId { + case "RU": + prefixes = ["3", "4", "8", "9"] + case "KZ": + prefixes = ["6", "7"] + default: + prefixes = [] + } + } else if countryCode == "599" { + switch countryId { + case "BQ": + prefixes = ["3", "4", "7"] + case "CW": + prefixes = ["1", "6", "9"] + default: + prefixes = [] + } + } else { + prefixes = [] + } + let code = Country.CountryCode(code: countryCode, prefixes: prefixes, patterns: !pattern.isEmpty ? [pattern] : []) let country = Country(id: countryId, name: countryName, localizedName: nil, countryCodes: [code], hidden: false) result.append(country) - countriesByPrefix["\(code.code)"] = (country, code) + if !prefixes.isEmpty { + for prefix in prefixes { + countriesByPrefix["\(code.code)\(prefix)"] = (country, code) + } + } else if countriesByPrefix[code.code] == nil { + countriesByPrefix[code.code] = (country, code) + } } if let maybeNameRange = maybeNameRange { @@ -74,28 +105,36 @@ private func loadCountryCodes() -> [Country] { return result } -private var countryCodes: [Country] = loadCountryCodes() private var countryCodesByPrefix: [String: (Country, Country.CountryCode)] = [:] +private var countryCodes: [Country] = loadCountryCodes() + +private func updateCountryCodes(_ countries: [Country]) { + guard !countries.isEmpty else { + return + } + + countryCodes = countries + + var updatedCodesByPrefix: [String: (Country, Country.CountryCode)] = [:] + for country in countries { + for code in country.countryCodes { + if !code.prefixes.isEmpty { + for prefix in code.prefixes { + updatedCodesByPrefix["\(code.code)\(prefix)"] = (country, code) + } + } else { + updatedCodesByPrefix[code.code] = (country, code) + } + } + } + countryCodesByPrefix = updatedCodesByPrefix +} public func loadServerCountryCodes(accountManager: AccountManager, engine: TelegramEngineUnauthorized, completion: @escaping () -> Void) { let _ = (engine.localization.getCountriesList(accountManager: accountManager, langCode: nil) |> deliverOnMainQueue).start(next: { countries in - countryCodes = countries - - var countriesByPrefix: [String: (Country, Country.CountryCode)] = [:] - for country in countries { - for code in country.countryCodes { - if !code.prefixes.isEmpty { - for prefix in code.prefixes { - countriesByPrefix["\(code.code)\(prefix)"] = (country, code) - } - } else { - countriesByPrefix[code.code] = (country, code) - } - } - } - countryCodesByPrefix = countriesByPrefix - + updateCountryCodes(countries) + Queue.mainQueue().async { completion() } @@ -105,21 +144,8 @@ public func loadServerCountryCodes(accountManager: AccountManager, engine: TelegramEngine, completion: @escaping () -> Void) { let _ = (engine.localization.getCountriesList(accountManager: accountManager, langCode: nil) |> deliverOnMainQueue).start(next: { countries in - countryCodes = countries + updateCountryCodes(countries) - var countriesByPrefix: [String: (Country, Country.CountryCode)] = [:] - for country in countries { - for code in country.countryCodes { - if !code.prefixes.isEmpty { - for prefix in code.prefixes { - countriesByPrefix["\(code.code)\(prefix)"] = (country, code) - } - } else { - countriesByPrefix[code.code] = (country, code) - } - } - } - countryCodesByPrefix = countriesByPrefix Queue.mainQueue().async { completion() } @@ -204,6 +230,9 @@ public final class AuthorizationSequenceCountrySelectionController: ViewControll } public static func setupCountryCodes(countries: [Country], codesByPrefix: [String: (Country, Country.CountryCode)]) { + guard !countries.isEmpty else { + return + } countryCodes = countries countryCodesByPrefix = codesByPrefix } @@ -229,6 +258,7 @@ public final class AuthorizationSequenceCountrySelectionController: ViewControll public static func lookupCountryIdByNumber(_ number: String, preferredCountries: [String: String]) -> (Country, Country.CountryCode)? { let number = removePlus(number) var results: [(Country, Country.CountryCode)]? = nil + var matchedPrefixLength = 0 if number.count == 1, let preferredCountryId = preferredCountries[number], let country = lookupCountryById(preferredCountryId), let code = country.countryCodes.first { return (country, code) } @@ -244,10 +274,19 @@ public final class AuthorizationSequenceCountrySelectionController: ViewControll } } else { results = [country] + matchedPrefixLength = prefix.count } } } if let results = results { + if let firstResult = results.first, + matchedPrefixLength <= firstResult.1.code.count, + let parsedNumber = try? countryPhoneNumberUtil.parse("+\(number)", defaultRegion: nil), + let regionCode = countryPhoneNumberUtil.getRegionCode(for: parsedNumber), + let matchedCountry = countryCodes.first(where: { $0.id == regionCode }), + let matchedCode = matchedCountry.countryCodes.first(where: { number.hasPrefix($0.code) }) { + return (matchedCountry, matchedCode) + } if !preferredCountries.isEmpty, let (_, code) = results.first { if let preferredCountry = preferredCountries[code.code] { for (country, code) in results { diff --git a/submodules/Display/Source/LinkHighlightingNode.swift b/submodules/Display/Source/LinkHighlightingNode.swift index 7f4dbfffa3..5fd2994e82 100644 --- a/submodules/Display/Source/LinkHighlightingNode.swift +++ b/submodules/Display/Source/LinkHighlightingNode.swift @@ -62,8 +62,8 @@ private func drawRectsImageContent(size: CGSize, context: CGContext, color: UICo } else { // The path-stitching loop below assumes consecutive rects in a group // are adjacent (overlapping or sharing an edge). When they're disjoint - // — vertical gap, vertical inversion, or horizontal gap on the same - // line — it bridges them with a polygon perimeter that renders as a + // with a vertical gap, vertical inversion, or horizontal gap on the same + // line, it bridges them with a polygon perimeter that renders as a // diagonal bridge across empty space. Split groups whenever the next // rect doesn't intersect the last one (1pt slop in both axes matches // the snap loop's adjacency test). diff --git a/submodules/InstantPageUI/Sources/InstantPageAnchorPath.swift b/submodules/InstantPageUI/Sources/InstantPageAnchorPath.swift index ccb453ae50..1da6c31fba 100644 --- a/submodules/InstantPageUI/Sources/InstantPageAnchorPath.swift +++ b/submodules/InstantPageUI/Sources/InstantPageAnchorPath.swift @@ -4,9 +4,9 @@ import TelegramCore /// Recurses the `InstantPage` block tree to locate the anchor `name`, returning the /// details-sibling-ordinal path of enclosing `
` blocks (outermost first). /// -/// - `nil` — the anchor exists nowhere. -/// - `[]` — the anchor exists outside any `
` (top level, a table cell, a quote, etc.). -/// - `[2,0]`— inside the 3rd top-level `
`, then that details' 1st nested `
`. +/// - `nil`: the anchor does not exist. +/// - `[]`: the anchor is outside any `
`. +/// - `[2, 0]`: the third top-level `
`, then its first nested `
`. /// /// Ordinals (not layout indices) because the layout's details index counter is /// expansion-dependent. Consumers map ordinals to live indices via @@ -71,23 +71,7 @@ private func instantPageAnchorPathSearch( } } default: - // .unsupported/.divider/.formula/.image/.video/.audio/.webEmbed/.channelBanner/.map — - // leaf/media blocks with no anchor-bearing text. (.relatedArticles also lands here: the - // V2 layout discards its title and lays out only the article media, so its title text is - // never rendered.) - // - // CRITICAL — the recursion set here must match the containers the V2 layout recurses - // through layoutBlock (and thus counts
in via detailsIndexCounter). Those are - // exactly .blockQuote, .cover, and .list's .blocks items — all handled above, sharing - // detailsOrdinal. The following carry [InstantPageBlock] children but are deliberately - // NOT recursed because the V2 layout does NOT lay their children out as blocks, so it - // never counts a nested
in them — recursing here while sharing detailsOrdinal - // would desync our ordinals from the layout: - // • .collage/.slideshow — layoutCollage/layoutSlideshow lay out only .image/.video children. - // • .postEmbed — layoutMediaWithCaption lays out only its caption (a real .text item, - // so a caption anchor is found by anchorFrame directly); its `blocks` are ignored. - // Any anchor inside a non-laid-out child is unresolvable by anchorFrame anyway, so - // skipping it here is a no-op either way. + // Keep recursion aligned with the V2 block layout. break } } diff --git a/submodules/InstantPageUI/Sources/InstantPageAudioNode.swift b/submodules/InstantPageUI/Sources/InstantPageAudioNode.swift index 573281233d..271eccd540 100644 --- a/submodules/InstantPageUI/Sources/InstantPageAudioNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageAudioNode.swift @@ -206,7 +206,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode { // button. An ASControl's `.touchUpInside` is cancelled by the chat ListView's gesture // system (the control highlights on touch-down, but the action never fires), so an // embedded audio control in a rich-message bubble could never start playback. A gesture - // recognizer coordinates with the list's gestures and fires reliably — matching the V2 + // recognizer coordinates with the list's gestures and fires reliably, matching the V2 // image node, the details-title hit view, and the regular file/music message. The plain // view sits above `statusNode` and is positioned over the icon in `layout()`. (Works in // V1's full-page Instant View too; gesture recognizers fire inside its scroll view.) @@ -281,4 +281,3 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode { self.scrubbingNode.frame = CGRect(origin: CGPoint(x: leftScrubberInset, y: 26.0 + topOffset), size: CGSize(width: size.width - leftScrubberInset - rightScrubberInset, height: 15.0)) } } - diff --git a/submodules/InstantPageUI/Sources/InstantPageMediaOpen.swift b/submodules/InstantPageUI/Sources/InstantPageMediaOpen.swift index 22ec793857..d5e078e606 100644 --- a/submodules/InstantPageUI/Sources/InstantPageMediaOpen.swift +++ b/submodules/InstantPageUI/Sources/InstantPageMediaOpen.swift @@ -14,7 +14,7 @@ import LocationUI /// - `.webpage(webPage)` cover → single-entry `InstantPageGalleryController`. /// - `.file(file)` with `isAnimated` → single-entry gallery in "playing video" mode. /// - Default → multi-entry gallery built from `allMedias` (filtered to `.image` and non-audio -/// `.file` — audio/music siblings are excluded), centered on the tapped media; "playing +/// `.file`; audio and music siblings are excluded), centered on the tapped media; "playing /// video" mode is on. /// /// Behavior matches V1's `InstantPageControllerNode.openMedia(_:)` bit-for-bit. @@ -23,7 +23,7 @@ import LocationUI /// - allMedias: every laid-out media on the page, in laid-out order. Used to build sibling /// entries when the gallery needs them. Callers may pass `[]` for paths that don't need /// siblings (e.g. webpage-cover single-entry gallery), but it's safer to always pass the -/// full list — the helper filters/uses it only on the default branch. +/// full list. The helper filters it only on the default branch. /// - transitionArgsForMedia: invoked by the gallery presentation to find the source rect for /// the swipe-back animation; return `nil` if the source view is not on screen. /// - hiddenMediaCallback: invoked while the gallery is foregrounded so callers can hide the diff --git a/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift b/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift index b394f154ea..b5d83c494f 100644 --- a/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift +++ b/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift @@ -181,7 +181,7 @@ public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol // A blockquote is exploded by the layout into one text item per child line, // each stamped with quoteDepth > 0. Re-coalesce a run of consecutive quoted // segments into a single block whose lines are joined by `\n` (each carrying - // its own `> ` depth), matching the whole-message converter — otherwise every + // its own `> ` depth), matching the whole-message converter. Otherwise every // quote line would become its own block and be separated by a blank line. if depth > 0 { var lines: [String] = [] diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index 23d8d00a07..4b0dde4524 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -21,7 +21,7 @@ import ShimmeringMask /// Stable identity for an `InstantPageV2LaidOutItem` across `update()` calls. The renderer /// uses this to harvest existing item views and reuse them when the new layout still has -/// an item with the same id — preventing the media wrappers from torching their fetch +/// an item with the same id, preserving media fetch /// signals + image content on every chat-bubble re-apply. /// /// Media items use their `media.index` (already unique within a page and used as the @@ -42,7 +42,7 @@ public enum InstantPageV2ItemKind: Hashable { // MARK: - Render context /// Bundle of render-time dependencies required to display real media inside an InstantPage V2 -/// view. Tied to an `InstantPageV2View` for the view's lifetime — if any field would change +/// view. Tied to an `InstantPageV2View` for the view's lifetime. If any field would change /// (typically because the bubble was recycled with a different webpage), the caller must /// rebuild the V2View with a fresh render context. /// @@ -93,7 +93,7 @@ public final class InstantPageV2RenderContext { /// streaming bubble to reuse one V2View across `stableVersion` bumps instead of rebuilding. /// Only `webpage` changes across chunks; the `imageReference`/`fileReference` closures keep /// their construction-time `MessageReference` snapshot, which is acceptable because the message - /// id is stable across chunks (media resolves by id) and streamed AI content carries no media. + /// id is stable across chunks and streamed content carries no media. public func updateContent(webpage: TelegramMediaWebpage) { self.webpage = webpage } @@ -152,7 +152,7 @@ public final class InstantPageV2View: UIView { private var emojiEnableLooping: Bool = true /// Scroll-visibility rect in this view's coordinate space; gates emoji animation looping. - /// `nil` means "not visible" → emoji don't animate. The root rect is set by the bubble and + /// `nil` means not visible, so emoji do not animate. The root rect is set by the bubble and /// propagated down the nested tree (details/table) by `propagateVisibilityRect`. public var visibilityRect: CGRect? { didSet { @@ -179,7 +179,7 @@ public final class InstantPageV2View: UIView { /// Walks the `rootMediaRegistryHost` chain transitively until it finds a self-referencing /// host (the true root). Necessary because nested details blocks can leave an inner body's - /// `rootMediaRegistryHost` pointing at an intermediate body rather than the outer root — + /// `rootMediaRegistryHost` pointing at an intermediate body rather than the outer root. /// `propagateRegistryHost(to:)` only walks one hop, so the chain must be followed at lookup. var trueRegistryRoot: InstantPageV2View { var host: InstantPageV2View = self @@ -251,7 +251,7 @@ public final class InstantPageV2View: UIView { let newFrame = InstantPageV2View.actualFrame(forItem: item) // parent positions child if animation.isAnimated && reusedView.frame != newFrame { // A collapsing details view keeps its body alive; remove it once this - // frame-shrink (the clip that hides it) finishes — see finalizePendingCollapse(). + // frame shrink finishes. See finalizePendingCollapse(). let detailsView = reusedView as? InstantPageV2DetailsView animation.animator.updateFrame(layer: reusedView.layer, frame: newFrame, completion: { [weak detailsView] _ in detailsView?.finalizePendingCollapse() @@ -316,7 +316,7 @@ public final class InstantPageV2View: UIView { for view in self.itemViews { // Top-level `.text` items host their emoji directly. The thinking block hosts emoji on - // its shimmer-wrapped inner text view, which the page never sees as a top-level item — + // its shimmer-wrapped inner text view, which is not a top-level item, // so without this it is skipped and the emoji never get layers (invisible). Nested V2 // sub-layouts (details bodies, table cells) instead run their own updateInlineEmoji. let textView: InstantPageV2TextView @@ -574,7 +574,7 @@ public final class InstantPageV2View: UIView { // Pushes this view's `visibilityRect` down into every nested V2 view (details body, table // title + cells), converted into each child's coordinate space. Each child's `visibilityRect` - // didSet re-runs `updateEmojiVisibility`, which propagates one level further — so a single + // didSet re-runs `updateEmojiVisibility`, which propagates one level further, so a single // root assignment fans out across the whole tree. private func propagateVisibilityRect() { for view in self.itemViews { @@ -829,8 +829,7 @@ public final class InstantPageV2View: UIView { /// /// For most item types this is `item.frame`. `InstantPageV2TextView` widens its backing store /// by `v2TextViewClippingInset` on every side to accommodate glyph overhang and underline - /// rendering past the text's logical `maxY` — the same inset its `init` applies when - /// constructing the view. The reuse path must apply the same expansion so that re-layout + /// rendering past the text's logical `maxY`. The reuse path applies the same expansion so re-layout /// (theme change, bubble resize, etc.) does not clip italic glyphs or underlines. /// /// Keep this helper aligned with each view class's init-time frame computation. @@ -924,7 +923,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView { } } - // Reveal mask state — populated in Task 5. + // Reveal mask state. private var maxCharacterDrawCount: Int? private var previousMaxCharacterDrawCount: Int = 0 private var revealMaskLayer: SimpleLayer? @@ -962,7 +961,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView { self.item = item // Lay every container out from the item's own (clipping-inset-expanded) frame rather than // self.bounds, so the single path is correct regardless of when the parent assigns our - // frame — and so a reused text view that changed size (e.g. AI streaming) re-frames its + // frame. A reused text view that changes size also updates its // renderContainer/renderView too, which the old update path skipped. let containerBounds = CGRect(origin: .zero, size: item.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset).size) self.renderContainer.frame = containerBounds @@ -1165,7 +1164,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView { let currentLineInfos = self.computeRevealedLines(characterLimit: effectiveCharacterDrawCount) - // Snippet spawn pass — animate newly-revealed characters. + // Animate newly revealed characters. if self.previousMaxCharacterDrawCount < effectiveCharacterDrawCount, let contents = self.renderView.layer.contents, animateNewSegments { @@ -1235,9 +1234,9 @@ final class InstantPageV2TextView: UIView, InstantPageItemView { } } - // Mask rebuild — when snippets are in flight, clamp to the lowest animating one + // Clamp the mask to the lowest animating snippet // (so the mask never exposes a char a snippet is still flying for). With no animations - // in flight, snap directly to the current target — `previousMaxCharacterDrawCount` + // in flight, snap directly to the current target. `previousMaxCharacterDrawCount` // would lag by one call (it's updated at the end of this function) and is 0 on a fresh // view, which would hide every char until the next tick. let maskCharacterLimit: Int @@ -1257,7 +1256,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView { // Per-glyph rect captures descenders, italic overhang, accents exactly. Per // line we accumulate the union of revealed glyphs into one mask rect (one // CALayer sublayer per line), and consecutive fully-revealed lines collapse - // further into a single rect — so a fully-revealed prefix is always one + // further into a single rect, so a fully revealed prefix is always one // sublayer regardless of line count. // // Lines without per-character data (computeRevealCharacterRects == false on @@ -1298,7 +1297,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView { lineUnion = union remainingChars -= characterRects.count } else { - // No per-character data — expose the whole line. + // Expose the whole line without character data. lineUnion = line.range.length > 0 ? renderLocalLineFrame : nil isFullLine = remainingChars >= line.range.length remainingChars -= line.range.length @@ -1705,10 +1704,10 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView { var bodyView: InstantPageV2View? private let titleHitView: UIView - // The expanded chevron is the collapsed one rotated 180° (down → up). + // Rotate the collapsed chevron for the expanded state. private static let expandedChevronTransform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0) // On an animated collapse the body is kept until the toggle animation finishes (so the - // shrinking clip can hide it), then removed in finalizePendingCollapse() — which the parent + // shrinking clip can hide it), then removed in finalizePendingCollapse(). The parent // (InstantPageV2View.update) calls from the completion of the frame-shrink (clip) animation. private var bodyPendingRemoval = false @@ -1744,7 +1743,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView { super.init(frame: item.frame) self.backgroundColor = .clear // structural - self.clipsToBounds = true // structural — the parent's frame-height animation clips the body + self.clipsToBounds = true self.addSubview(self.titleTextView) self.addSubview(self.chevronView) @@ -1755,7 +1754,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView { self.titleHitView.addGestureRecognizer(tap) // All content (title, chevron tint/position, separator, titleHit frame, body) flows through - // update — its expanded branch lazily creates the body, so init no longer builds it itself. + // update. Its expanded branch creates the body lazily. self.update(item: item, theme: theme, renderContext: renderContext, animation: .None) } @@ -1786,7 +1785,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView { self.titleHitView.frame = item.titleFrame // Body lifecycle. The reveal/hide of the body is produced by the parent animating this - // view's own frame height (clipsToBounds = true), not by the body itself — see + // view's own frame height (clipsToBounds = true), not by the body itself. See // InstantPageV2View.update. The body's internal layout is forwarded `animation` so a // *nested* details block inside the body can also animate its own toggle. let blockHeight: CGFloat @@ -1842,7 +1841,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView { height: UIScreenPixel ), completion: nil) - // Chevron rotation. The body teardown on collapse is NOT tied to this completion — see + // Body teardown is not tied to chevron rotation. See // finalizePendingCollapse(), which the parent calls from the frame-shrink (clip) animation. let targetTransform = item.isExpanded ? InstantPageV2DetailsView.expandedChevronTransform : CATransform3DIdentity animation.animator.updateTransform(layer: self.chevronView.layer, transform: targetTransform, completion: nil) @@ -1851,7 +1850,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView { /// Removes the body kept alive across an animated collapse. The parent (InstantPageV2View.update) /// calls this from the completion of the frame-shrink animation that clips the body away, so the /// body is torn down exactly when it finishes being hidden. The guard makes a re-expand that - /// interrupts the collapse safe — the re-expand clears `bodyPendingRemoval` first. + /// interrupts the collapse safely because re-expanding clears `bodyPendingRemoval`. func finalizePendingCollapse() { if !self.item.isExpanded, self.bodyPendingRemoval { self.bodyView?.removeFromSuperview() @@ -1938,8 +1937,8 @@ final class InstantPageV2ThinkingView: UIView, InstantPageItemView { /// Parent positions self at the item frame (the bare line box). The shimmer and its gradient /// mask are sized to the text view's clipping-inset-EXPANDED frame and shifted to /// `(-inset, -inset)`, so the mask doesn't crop the glyph overhang the inset reserves (tall - /// ascenders, descenders, the last line's underline) — the symptom of sizing the mask to the - /// bare line box. The inner text view fills the shimmer; its `+inset` render translate lands the + /// ascenders, descenders, and the last line's underline). The inner text view fills the shimmer; + /// its `+inset` render translate lands the /// glyphs back at self's origin, so the text position is unchanged. Mirrors how a `.text` view's /// frame is inset-expanded (`actualFrame` / `InstantPageV2TextView.init`). private func layoutContents() { @@ -2026,7 +2025,7 @@ final class InstantPageV2TableView: UIView, InstantPageItemView { // every frame / colour / sub-layout below. Insertion order matches the original interleaved // build so the layer/subview z-order is unchanged (stripes at the bottom, then the title and // cell sub-views, then the inner grid lines). Cell-count changes on later reuse are not - // reconciled here (pre-existing limitation) — update's index-guarded loops refresh in place. + // reconciled here; update's index-guarded loops refresh in place. if item.titleSubLayout != nil { let v = InstantPageV2View(renderContext: renderContext) self.contentView.addSubview(v) @@ -2090,7 +2089,7 @@ final class InstantPageV2TableView: UIView, InstantPageItemView { } } - // Stripe layers (cell backgrounds) — update color + frame + corner rounding in original order. + // Update cell background layers in their original order. let effectiveBorderWidth = item.bordered ? v2TableBorderWidth : 0.0 let gridHeight = item.contentSize.height - gridOffsetY var stripeIndex = 0 @@ -2111,7 +2110,7 @@ final class InstantPageV2TableView: UIView, InstantPageItemView { } } - // Inner line layers — refresh colour AND frame in place. (`lineLayers` holds only inner grid + // Refresh inner grid line colors and frames. // lines; the outer border is the contentView layer's own rounded border, refreshed below.) // Frames are set here (not in init) so reuse with a different grid re-positions the lines. let lineRects = item.horizontalLines + item.verticalLines @@ -2122,7 +2121,7 @@ final class InstantPageV2TableView: UIView, InstantPageItemView { } } - // Rounded outer border — refresh radius/color/width (theme or `bordered` flag may change). + // Refresh the rounded outer border. self.contentView.layer.cornerRadius = v2TableCornerRadius self.contentView.layer.borderColor = item.borderColor.cgColor self.contentView.layer.borderWidth = item.bordered ? v2TableBorderWidth : 0.0 @@ -2168,7 +2167,7 @@ public extension InstantPageV2View { } /// The frame (pageView-space) of the anchor `name` in the *currently laid-out* layout. - /// Returns nil if the anchor isn't present — e.g. it's inside a collapsed `
` + /// Returns nil if the anchor is missing or inside collapsed `
` /// (whose inner blocks aren't laid out) or doesn't exist. Mirrors `findTextItem`. func anchorFrame(name: String) -> CGRect? { guard let layout = self.currentLayout else { return nil } @@ -2178,7 +2177,7 @@ public extension InstantPageV2View { /// Given a details-sibling-ordinal path (from `instantPageAnchorPath`), walk the live layout /// and return the `currentExpandedDetails` index of the FIRST not-yet-expanded `
` on /// the path. Returns nil if every details on the path is already expanded, or the path doesn't - /// match the live layout. Reads indices from the laid-out items — never reproduces them. + /// match the live layout. Reads indices from the laid-out items. func firstCollapsedDetails(forOrdinalPath path: [Int]) -> Int? { guard let layout = self.currentLayout else { return nil } var currentItems = layout.items @@ -2403,7 +2402,7 @@ private func collectSelectableTextItems( /// For block formulas wider than the bubble's available width, the layout sets /// `isScrollable = true`; this view then wraps the image in a horizontal `UIScrollView` /// matching V1's `InstantPageScrollableNode` (no bounce on non-overflowing content, -/// scroll indicator hidden — appropriate for content embedded inside a chat bubble). +/// scroll indicator hidden, for content embedded inside a chat bubble). final class InstantPageV2FormulaView: UIView, InstantPageItemView { private(set) var item: InstantPageV2FormulaItem var itemFrame: CGRect { return self.item.frame } diff --git a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift index b11e0b988a..ff0676ddef 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift @@ -364,8 +364,7 @@ public final class InstantPageTextItem: InstantPageItem { } public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? { - // Hit-testing (taps on links/entities) wants the character under the finger — keep the - // strict, clamping behavior. + // Clamp taps to the character under the finger. if !orNearest { return self.attributesAtPoint(point) } @@ -402,8 +401,7 @@ public final class InstantPageTextItem: InstantPageItem { } else if point.x >= lineFrame.maxX { // Trailing edge: return the line's upper bound (one past its last character) so a // right-handle drag can include the last character/item of the line. The selection - // upper bound is exclusive, so clamping to the last character's index — as the strict - // path does — would always leave it unselected. Mirrors Display.TextNode. + // upper bound is exclusive, so use the line end. Mirrors Display.TextNode. index = lineRange.location + lineRange.length } else { index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: point.x - lineFrame.minX, y: 0.0)) @@ -964,7 +962,7 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt // Size the inline emoji to the font's line height (A + D) plus a 4pt bump at the 17pt // body font (scaled proportionally). Must match the V2 layout's emoji cell size // (InstantPageV2Layout.swift). The run delegate still reports the font's own - // ascent/descent (below), so the line height is unchanged — only the emoji width changes. + // ascent/descent, so only the emoji width changes. let itemSize = font.ascender - font.descender + 4.0 * font.pointSize / 17.0 let extentBuffer = UnsafeMutablePointer.allocate(capacity: 1) extentBuffer.initialize(to: RunStruct(ascent: font.ascender, descent: font.descender, width: itemSize)) diff --git a/submodules/InstantPageUI/Sources/InstantPageV2AudioContentNode.swift b/submodules/InstantPageUI/Sources/InstantPageV2AudioContentNode.swift index a3b0dd0db5..019ac3e50b 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2AudioContentNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2AudioContentNode.swift @@ -195,8 +195,7 @@ final class InstantPageV2AudioContentNode: ASDisplayNode { self.descriptionAttributedString = InstantPageV2AudioContentNode.descriptionString(file: self.file, incoming: incoming, presentationData: presentationData) let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing self.statusNode.backgroundNodeColor = messageTheme.mediaActiveControlColor - // foreground/overlay also depend on incoming + theme (set at construction) — refresh them - // too so the play glyph isn't miscolored after an in-place theme/direction change. + // Refresh colors after an in-place theme or direction change. self.statusNode.foregroundNodeColor = (incoming && messageTheme.mediaActiveControlColor.rgb != 0xffffff) ? .white : .clear self.statusNode.overlayForegroundNodeColor = presentationData.theme.chat.message.mediaOverlayControlColors.foregroundColor diff --git a/submodules/InstantPageUI/Sources/InstantPageV2InlineImageView.swift b/submodules/InstantPageUI/Sources/InstantPageV2InlineImageView.swift index d0cffb82ee..4e269db559 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2InlineImageView.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2InlineImageView.swift @@ -9,14 +9,14 @@ import AccountContext import PhotoResources import MediaResources -/// Lightweight inline image view for InstantPage V2 — wraps a `TransformImageNode` +/// Inline InstantPage V2 image backed by `TransformImageNode`. /// to render a single `RichText.image` cell inside a text view. /// /// Owned by `InstantPageV2View` (not an `InstantPageItemView` conformer; not in /// the view-factory switch). Hosted inside the parent text view's /// `imageContainerView` (sibling of `renderContainer`, above the reveal mask, /// below `emojiContainerView`), so the streaming reveal can wipe text glyphs -/// while the image pops in independently. Non-interactive — taps pass through +/// while the image pops in independently. Taps pass through /// to the text view, so a URL-wrapping `RichText.url(text: .image(...))` /// continues to route taps to the URL handler. final class InstantPageV2InlineImageView: UIView { diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index 2f20740493..eb962bc380 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -20,7 +20,7 @@ public struct InstantPageV2Layout { /// `line.imageItems` MediaIds at update time. Nested layouts (details body, /// table cells, table title) carry the parent's same map. public let media: [EngineMedia.Id: EngineMedia] - /// Webpage carried for the same reason — `updateInlineImages()` needs it to + /// Webpage carried because `updateInlineImages()` needs it to /// form the `WebpageReference` for `ImageMediaReference.webPage(...)`. May /// be nil for non-webpage-anchored layouts; in that case the lookup proceeds /// but no fetch signal can be bound (image view simply isn't created). @@ -121,7 +121,7 @@ public enum InstantPageV2LaidOutItem { } /// Returns a copy of `self` with its top-level frame translated by `delta`. - /// Sub-layouts inside details/table cells are not re-translated — they're already + /// Sub-layouts inside details and table cells are already /// expressed in their parent's local coordinates. public func offsetBy(_ delta: CGPoint) -> InstantPageV2LaidOutItem { switch self { @@ -470,7 +470,7 @@ public func lastTextLineFrame(in layout: InstantPageV2Layout) -> CGRect? { } case let .table(table): // Walk cells in reverse row-major order (last cell of last row first). - // The renderer shifts cells down by gridOffsetY (title height) — match that here. + // Match the renderer's title offset. let gridOffsetY = table.titleFrame?.height ?? 0.0 for cell in table.cells.reversed() { if let subLayout = cell.subLayout, let frame = lastTextLineFrame(in: subLayout) { @@ -497,7 +497,7 @@ public func lastTextLineFrame(in layout: InstantPageV2Layout) -> CGRect? { /// anchor at `maxY + trailingBottomPadding` to align with where the text actually renders. The pad /// is 0 when the line is taller than its font line height (a tall inline attachment, e.g. a formula, /// already pushes maxY down to the right spot). Callers should NOT apply the pad when the status -/// wraps onto its own line below the text — there it should sit at the bare maxY. +/// wraps onto its own line below the text, where it sits at the bare maxY. public func lastTextLineFrameIfLastItemIsText(in layout: InstantPageV2Layout) -> (frame: CGRect, trailingBottomPadding: CGFloat)? { guard let bottomItem = layout.items.max(by: { $0.frame.maxY < $1.frame.maxY }), case let .text(text) = bottomItem, @@ -505,11 +505,9 @@ public func lastTextLineFrameIfLastItemIsText(in layout: InstantPageV2Layout) -> else { return nil } - // The stored line frame always has minX = 0 — alignment (center / right / natural-RTL) is - // applied at render time by `v2FrameForLine`. Apply the same correction here so the returned + // Alignment is applied at render time by `v2FrameForLine`. Apply the same correction here so the returned // frame's `maxX` reflects the line's actual on-screen right edge, not just its width anchored - // at the textItem's left. Without this, a right-aligned or RTL last line — whose visible right - // edge sits at `textItem.width`, all the way at the right text inset — would feed the status + // at the text item's left. Without this, a right-aligned or RTL last line would feed the status // node a `contentWidth` equal to just `lineWidth`. The trail/wrap decision would then think // the date fits trailing the line, and place it directly on top of the line at the right text // inset where the line itself ends. The width is unchanged; only `origin.x` shifts. @@ -602,8 +600,8 @@ private func layoutBlockSequence( var contentSize = CGSize(width: boundingWidth, height: contentHeight) if context.fitToWidth { - // Match V1 InstantPageLayout.swift:1114 — include `+ horizontalInset` so the contentSize - // reserves a right margin equal to the left inset. Without this, the longest text item's + // Match V1 and include `horizontalInset` in the content size. This + // reserves a right margin equal to the left inset. Without it, the longest text item's // right edge equals contentSize.width, and the bubble's containerNode (sized to // boundingSize.width - 2) clips the last 2pt of text. var maxX: CGFloat = 0.0 @@ -755,7 +753,7 @@ private func layoutBlock( context: &context ) } else { - // Fallback when the image is not present in the page's media dict — preserve V1 + // Preserve the V1 fallback when the image is missing. // behavior, which returns an empty layout for unknown image IDs (V1 // InstantPageLayout.swift:623). The existing layoutMediaWithCaption would emit a // grey rectangle; matching V1 instead. @@ -923,10 +921,8 @@ private func layoutBlock( horizontalInset: horizontalInset, context: &context) case let .map(latitude, longitude, zoom, dimensions, caption): - // AI/server-sent `.map` blocks can arrive with zero `dimensions` (the wire `w`/`h` are - // required, but the sender may put 0). A zero `naturalSize.height` collapses the media - // frame to height 0 (`instantPageV2MediaFrame`'s else branch) — the map takes no space, - // the caption slides up into it, and the pin floats over the caption — and a zero-sized + // `.map` blocks can arrive with zero dimensions. A zero `naturalSize.height` collapses the media + // frame to height 0 (`instantPageV2MediaFrame`'s else branch). A zero-sized // `MapSnapshotMediaResource` makes `MKMapSnapshotter` render nothing. Substitute a sensible // default (a 2:1 map strip) for BOTH the layout size and the snapshot resource. Real web // articles (the V1 renderer) always carry real dimensions, so only the rich-message path @@ -1757,15 +1753,12 @@ private let instantPageV2MediaEdgeBleed: CGFloat = 4.0 // `boundingWidth`) with corner radius forced to 0, relying on the bubble's rounded clipping // container to round media that meets the bubble's top/bottom edge. A media item that fills the // full width is widened by `instantPageV2MediaEdgeBleed` on the trailing edge (see the constant). -// A media item narrower than the full width (a small image — NOT upscaled, the `min(_, 1.0)` +// A media item narrower than the full width (a small image that is not upscaled; the `min(_, 1.0)` // scale cap is kept) stays at its natural size, flush-left at x = 0, with no bleed. -// (The `cornerRadius` argument is ignored when `flush == true` — flush media is always -// un-rounded; callers may still pass their legacy radius, it has no effect.) +// The `cornerRadius` argument is ignored when `flush == true`; flush media is unrounded. // -// `flush == false`: DEAD as of the V2 audio port — audio was its last caller and now has its -// own `layoutAudio` arm (in `layoutBlock`), so this branch is currently unreachable (follow-up: -// drop the `flush` parameter and this branch). Legacy behavior was: inset by `horizontalInset` -// on each side with the caller-supplied corner radius. +// `flush == false` is unused after the V2 audio port. +// Legacy behavior inset media by `horizontalInset` with the caller-supplied corner radius. // // Returns the frame, the un-bled scaled content size (the caption is offset by // `scaledSize.height`), and the effective corner radius to stamp on the item. @@ -1862,7 +1855,7 @@ private func layoutCollage( horizontalInset: CGFloat, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { - // 1. One size per inner block (zero for unresolved — V1 still reserves a mosaic slot). + // One size per inner block; V1 reserves a slot for unresolved media. var itemSizes: [CGSize] = [] for block in innerBlocks { switch block { @@ -1883,7 +1876,7 @@ private func layoutCollage( } } - // 2. Mosaic geometry — the same engine V1 uses. + // Use the V1 mosaic geometry. let (mosaic, mosaicSize) = chatMessageBubbleMosaicLayout(maxSize: CGSize(width: boundingWidth, height: boundingWidth), itemSizes: itemSizes) // 3. One typed media item per resolvable cell, at its mosaic frame. @@ -2009,7 +2002,7 @@ private func layoutMediaWithCaption( ) result.append(contentsOf: captionItems) - // isCover adds extra 14pt bottom padding — but only when caption/credit text was actually + // Covers add 14pt bottom padding when caption or credit text was // rendered (matches V1 lines 204-206: `contentSize.height > 0 && isCover`). For an // empty-caption cover image no padding is added. // Implemented by extending the last text item's frame rather than emitting an invisible shape @@ -2236,12 +2229,12 @@ private func layoutCodeBlock( // Top-level (and
) code blocks span the full boundingWidth flush (x=0), matching V1 // (line 348). Inside a blockquote the child inset is raised above the page inset (by - // lineInset), so honor it here — otherwise the full-width background bleeds out under the + // lineInset), so honor it here. Otherwise the full-width background bleeds under the // quote bar instead of insetting to the quote's content gutter like the quote's text does. let blockHeight = textSize.height + backgroundInset * 2.0 let isNestedInQuote = horizontalInset > context.pageHorizontalInset // Inset (quote-nested) code blocks get an 8pt rounded background; flush (top-level / details) - // ones stay square — the bubble's own rounded clip handles their edges. + // ones stay square. The bubble clip handles their edges. let cornerRadius: CGFloat = isNestedInQuote ? 8.0 : 0.0 let blockFrame = CGRect( x: isNestedInQuote ? horizontalInset : 0.0, @@ -2364,9 +2357,9 @@ private func layoutBlockQuote( attributedCaption, boundingWidth: innerBoundingWidth, alignment: context.rtl ? .right : .natural, - // The caption is single-inset (band [H+lineInset, B-H]), unlike the double-inset + // The caption uses a single inset, unlike the double-inset // child band, so it needs its own RTL mirror delta of -lineInset (→ [H, B-H-lineInset], - // tucked under the trailing bar) — NOT the children's bandOffsetX. + // tucked under the trailing bar), not the children's bandOffsetX. offset: CGPoint(x: innerHorizontalInset + (context.rtl ? -lineInset : 0.0), y: contentHeight), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects @@ -2477,7 +2470,7 @@ private func layoutQuoteText( kind: .line(thickness: 1.0), color: context.theme.textCategories.caption.color ) - // Insert bottom rule before caption trailing space is consumed — append after final verticalInset. + // Insert the bottom rule before caption trailing space. result.append(.shape(bottomLine)) } else { // blockQuote: vertical bar on the leading edge (V1 lines 547–549). @@ -2494,7 +2487,7 @@ private func layoutQuoteText( // Tag this quote's produced text items at quote depth 1 so the markdown // converter renders them with a `> ` prefix. Applies to BOTH block quotes - // (single-paragraph fast path) and pull quotes — the whole-message markdown + // (single-paragraph fast path) and pull quotes. The whole-message markdown // converter renders both flavors as `> `. Nested quotes are lifted further // by the outer multi-block path's own bumpQuoteDepth(result) call. bumpQuoteDepth(result) @@ -2514,12 +2507,10 @@ private func layoutList( ) -> [InstantPageV2LaidOutItem] { // Determine marker characteristics. var maxIndexWidth: CGFloat = 0.0 - // hasNums: at least one ordered item carries an explicit `num` — in which case items - // without one fall back to a blank " " (preserves the source's numbering gaps) rather + // hasNums means at least one ordered item carries an explicit `num`. Items + // without one fall back to a blank " " rather // than auto-generated `(i + 1).`. Unordered lists never auto-generate numbers, so this - // flag is only meaningful when `ordered` is true. (`hasTaskMarkers` is no longer derived - // — the uniform 8pt gap below replaced the per-list `indexSpacing` ternary that consumed - // it; column right-alignment handles mixed bullet/checkbox lists without flagging.) + // flag is only meaningful when `ordered` is true. var hasNums = false if ordered { for item in listItems { @@ -2542,10 +2533,8 @@ private func layoutList( stroke: context.theme.pageBackgroundColor, border: context.theme.controlColor ) - // Track maxIndexWidth for ALL marker kinds (ordered + unordered, all three shapes), not - // just ordered as V1/older V2 did. With every kind contributing to the marker column width - // we can right-align every marker to a single shared column edge — so in a mixed unordered - // list (bullets + checkboxes) both right-align flush to the same x, and the same uniform + // Track every marker kind and align them to one shared column edge. + // Bullets and checkboxes align to the same x, and the same uniform // gap separates them from the text. The column width simply equals the widest marker; for // a pure bullet list `maxIndexWidth == 6` and the bullet sits at `horizontalInset` (visually // identical to the pre-change formula), and for a pure checkbox list `maxIndexWidth == 18` @@ -2594,12 +2583,8 @@ private func layoutList( // Uniform 8pt marker→text gap across all four cases (ordered/unordered × bullet/number/ // checkbox). With markers right-aligned to a shared column of width `maxIndexWidth`, text - // starts at `horizontalInset + maxIndexWidth + indexSpacing` — so `indexSpacing` IS the - // gap, regardless of marker shape. V1 used 12/16/20/24 (a mix of marker-area-width and - // gap-after-marker, depending on alignment); the four gaps came out to 12/16/14/6 — far - // from uniform, and a 14pt bullet gap looked especially loose. 8pt is a standard iOS list - // gap; it tightens bullets (14→8), numbers (12→8) and ordered-checkbox (16→8), and only - // loosens unordered-checkbox very slightly (6→8) so all four kinds match. + // starts at `horizontalInset + maxIndexWidth + indexSpacing`, so `indexSpacing` is the + // gap, regardless of marker shape. let indexSpacing: CGFloat = 8.0 // Layout each item. @@ -2722,7 +2707,7 @@ private func layoutList( // Nil-guard in stampMarkdownContext preserves any richer kind (e.g. .heading) // already stamped by a child block's own layout. So a heading nested inside a - // .blocks list item keeps .heading, not .listItem — multi-block list items are + // Multi-block list items keep `.heading`. // a documented best-effort case for markdown reconstruction. stampMarkdownContext(translatedItems, kind: .listItem(ordered: ordered, marker: markdownMarker, checked: markdownChecked)) result.append(contentsOf: translatedItems) @@ -2732,7 +2717,7 @@ private func layoutList( // Mirror the .text case above (and what .checklist already does here): use the // first text line's midY for centering. `originY` is the sub-block's TOP, NOT a - // line midpoint — `markerFrameFor` then subtracts `size.height / 2`, so feeding + // line midpoint. `markerFrameFor` subtracts `size.height / 2`, so feeding // `originY` placed the marker straddling the sub-block boundary, ½·marker-height // ABOVE the first text line. V1 hid the same arithmetic under a 6×12 shape with a // 3pt internal offset (matching ½·fontLineHeight for 17pt paragraph text), which @@ -2773,7 +2758,7 @@ private func layoutList( /// For a pure-kind list `maxIndexWidth == markerWidth`, so the marker lands at `horizontalInset` /// exactly as before; for mixed unordered lists, bullets and checkboxes align flush at the /// column's inner edge. Column right-alignment is the single rule across every marker shape -/// — no `ordered` / `indexSpacing` split — which is why those parameters dropped. +/// without separate `ordered` or `indexSpacing` parameters. private func markerFrameFor( kind: InstantPageV2ListMarkerKind, naturalWidth: CGFloat, @@ -2802,7 +2787,7 @@ private func markerFrameFor( return CGRect(x: x, y: floorToScreenPixels(lineMidY - size.height / 2.0), width: size.width, height: size.height) } -/// Leading/trailing geometry helpers — the single source of truth for "which side is the +/// Leading and trailing geometry helpers. /// block gutter on", gated on the page's explicit `rtl` flag. The `rtl == false` branch returns /// the pre-existing literal so non-RTL pages are byte-identical. @@ -2969,7 +2954,7 @@ func v2FrameForLine(_ line: InstantPageTextLine, boundingWidth: CGFloat, alignme // Returns the leading-edge x offset (line-origin-relative) for an inline-attachment's string // `range`, correct for both LTR and RTL runs. `CTLineGetOffsetForStringIndex` at the start index // gives the glyph's LEFT edge in LTR text, but its RIGHT edge in RTL text (increasing string index -// moves leftward) — so using the start-index offset alone as the left edge shoves an RTL attachment +// moves leftward), so using the start-index offset alone shifts an RTL attachment // ~one advance too far right. Taking the min of the start- and end-index offsets yields the true // leading (left) edge in both directions. Mirrors `Display.TextNode`'s `addEmbeddedItem`, including // the directional-boundary secondary-offset handling. For a pure-LTR line this returns exactly the @@ -3195,7 +3180,7 @@ func layoutTextItem( // Inline emoji and images do NOT inflate the line: they are centered on the font // line box and allowed to bleed above/below (mirroring V1 `layoutTextItemWithString` // and the chat `InteractiveTextComponent`). Their run delegates already report the - // font's own ascent/descent, so CoreText lays the line out at the normal height — the + // font's own ascent/descent, so CoreText keeps the normal line height. // old `lineAscent = emoji.size` inflation both doubled the line height and (because the // baseline sits at the bottom of the box) shoved the text baseline down. Only formulas, // which carry their own typographic metrics, are allowed to grow the line. @@ -3335,9 +3320,7 @@ func layoutTextItem( } } } - // Per-character rects use each glyph's actual ink bounds via - // CTFontGetBoundingRectsForGlyphs — caret-position advance-width - // math (CTLineGetOffsetForStringIndex) is too tight for italics, + // Caret advance width is too tight for italics, // accented marks, and any glyph with side bearings, which causes // the reveal mask to visibly clip the glyph edges. Mirrors // InteractiveTextComponent.computeCharacterRectsForLine. @@ -3406,7 +3389,7 @@ func layoutTextItem( // Image cell is centered on the font line box (see frame loop). Baseline-relative // cell spans [fontLineHeight/2 − height/2, fontLineHeight/2 + height/2]; the full // width feeds the reveal cost map so the streaming cursor is charged the image's - // width when crossing it — same as an emoji cell. + // width when crossing it, like an emoji cell. rects[localIndex] = CGRect(x: x, y: fontLineHeight / 2.0 - image.size.height / 2.0, width: image.size.width, height: image.size.height) } } diff --git a/submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift b/submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift index 89ec1c04f3..9e5ebbf877 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift @@ -9,17 +9,14 @@ import TelegramPresentationData import GalleryUI import UniversalMediaPlayer -// Mutable weak box: lets a wrapper hand its `openMedia` closure a back-reference to itself, -// filled in after `super.init` (when `self` becomes usable). SwiftSignalKit's `Weak` requires -// a non-optional value at init time, so it can't be used here. +// Mutable weak reference filled after `super.init`. private final class WrapperRef { weak var view: UIView? } // MARK: - Shared media node factory -// Hosts a V1 `InstantPageImageNode` inside a V2 UIView wrapper. The caller sizes its own -// frame from `item.frame` and adds the returned node's view as a subview. +// Creates an image node for a V2 media wrapper. func makeMediaWrapper( frame: CGRect, media: InstantPageMedia, @@ -52,7 +49,7 @@ func makeMediaWrapper( return imageNode } -// Walks up the superview chain from `start` to find the nearest enclosing `InstantPageV2View`. +// Find the nearest enclosing V2 view. private func findEnclosingV2View(from start: UIView?) -> InstantPageV2View? { var view: UIView? = start while view != nil { @@ -64,18 +61,13 @@ private func findEnclosingV2View(from start: UIView?) -> InstantPageV2View? { return nil } -// Registers `wrapper` in the root V2View's `mediaRegistry` under `mediaIndex`. The root is -// reached by walking up the superview chain to the nearest `InstantPageV2View`, then walking -// its `rootMediaRegistryHost` chain transitively (nested details blocks can leave an inner -// body's host pointing at an intermediate body — see `trueRegistryRoot`). No-op if the wrapper -// isn't yet attached to a V2View ancestor. +// Register media at the root V2 view. func registerInRootRegistry(wrapper: UIView, mediaIndex: Int) { guard let v2 = findEnclosingV2View(from: wrapper.superview) else { return } v2.trueRegistryRoot.mediaRegistry[mediaIndex] = Weak(wrapper) } -// Routes a tap on `tapped` through `openInstantPageMedia`, sourcing sibling medias from the -// root V2View's `currentLayout`. No-op if the wrapper isn't currently in a V2View tree. +// Open media with siblings from the root layout. func handleOpenMediaTap( tapped: InstantPageMedia, wrapper: UIView, @@ -446,9 +438,7 @@ final class InstantPageV2MediaAudioView: UIView, InstantPageItemView { let fetchMedia = item.media self.audioNode.fetch = { guard case let .file(file) = fetchMedia.media, let message = fetchMessage, let messageId = message.id else { return } - // Route through the fetch manager (not freeMediaFileInteractiveFetched) so the - // messageMediaFileStatus signal — which keys progress off the fetch manager's - // `hasEntry` — surfaces .Fetching, letting the overlay show the animated ring. + // Use the fetch manager so status reports `.Fetching`. let _ = messageMediaFileInteractiveFetched(fetchManager: fetchContext.fetchManager, messageId: messageId, messageReference: message, file: file, userInitiated: true, priority: .userInitiated).startStandalone() } diff --git a/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift b/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift index caae5152be..b9f33a5410 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift @@ -28,7 +28,7 @@ extension InstantPageV2RevealCostMap { // Reveal cost is in width units (points along the reading direction). The unit is uniform // across all item kinds, so the streaming reveal pace ("points per second") is visually -// consistent — wide tables and media take proportionally longer than narrow inline text. +// consistent. Wide tables and media take longer than narrow inline text. // // For text items, the cost is the sum of glyph ink widths across all lines (the total ink // extent in reading direction). For non-text items, the cost is `item.frame.width`. Zero-width @@ -112,7 +112,7 @@ public extension InstantPageV2RevealCostMap { /// * `details` / `codeBlock`: the whole container appears as soon as revealedCount > entry.start /// (background and chrome pop in atomically, then inner text reveals char-by-char). /// - /// Used by the rich-data bubble to size itself to the revealed prefix during AI streaming, + /// Used by the rich-data bubble to size itself to the revealed prefix during streaming, /// mirroring TextBubble's `clippedGlyphCountLayout = textLayout.layoutForCharacterCount(...)`. func revealedContentSize(revealedCount: Int, layout: InstantPageV2Layout) -> CGSize { let bounds = computeRevealedBounds(items: layout.items, entries: self.topLevelEntries, revealedCount: revealedCount) @@ -121,7 +121,7 @@ public extension InstantPageV2RevealCostMap { } // The full layout reserves a closing spacing after the last top-level block (see // `closingSpacing` in layoutBlockSequence). Mirror that so a partially-revealed - // bubble has the same bottom padding as a fully-revealed one — otherwise the last + // bubble has the same bottom padding as a fully revealed one. Otherwise the last // revealed line sits flush against the bubble's bottom edge. let lastItemMaxY = layout.items.map { $0.frame.maxY }.max() ?? 0.0 let closingPad = max(0.0, layout.contentSize.height - lastItemMaxY) @@ -130,7 +130,7 @@ public extension InstantPageV2RevealCostMap { /// Returns the maxY of revealed items in `layout` coords (no closing pad). Use this to /// size the InstantPageV2View itself so its content never overflows past the revealed - /// extent — the bubble's closing pad sits in containerNode space *outside* the pageView, + /// extent. The bubble's closing pad sits in containerNode space outside the pageView, /// not inside it (where unrevealed items would otherwise draw). func revealedItemsMaxY(revealedCount: Int, layout: InstantPageV2Layout) -> CGFloat { let bounds = computeRevealedBounds(items: layout.items, entries: self.topLevelEntries, revealedCount: revealedCount) @@ -308,7 +308,7 @@ private func computeEntries(items: [InstantPageV2LaidOutItem], cursor: inout Int // Each cell consumes at least its frame.width worth of cursor advance, // even if its inner text ink width is smaller (or it has no subLayout // at all). Without this floor, narrow- or empty-cell tables ran through - // the cursor much faster than their visual width warrants — a 3-column + // the cursor much faster than their visual width warrants. A 3-column // table of "1"/"2"/"3" costs ~30pt while occupying ~200pt visually. // Text inside a cell still char-reveals against its own ink widths; the // "extra" cost (cell width − inner ink) is filler time during which the @@ -330,7 +330,7 @@ private func computeEntries(items: [InstantPageV2LaidOutItem], cursor: inout Int } entries.append(.table(start: start, end: cursor, rows: rows, title: titleMap)) case .thinking: - // Zero cost: do NOT advance the cursor. This is the linchpin — answer-content cursor + // Do not advance the cursor for thinking blocks. // positions are identical whether or not thinking blocks are present, so adding/ // removing a thinking block never jumps the answer's reveal position. entries.append(.thinking(start: cursor)) @@ -432,7 +432,7 @@ private func applyRevealEntry(view: InstantPageItemView, entry: InstantPageV2Rev let _ = start case let .thinking(start): // Whole-block 0.12s alpha fade-in at the index position; inner text is drawn fully - // (never char-reveal-masked) — the shimmer is the only ongoing animation. + // (never character-masked). The shimmer is the only ongoing animation. let visible = revealedCount >= start applyVisibility(view: view, visible: visible, animated: animated) } @@ -520,7 +520,7 @@ private func applyTableReveal(tableView: InstantPageV2TableView, start: Int, end let rowMaxY = cellsInRow.map { $0.frame.maxY }.max() ?? 0.0 maskHeight = gridOffsetY + rowMaxY } else { - // No rows revealed yet — but the title (if any) is still visible above the grid. + // Keep the title visible before the first row. maskHeight = (title != nil) ? gridOffsetY : 0.0 } diff --git a/submodules/InstantPageUI/Sources/InstantPageV2SlideshowView.swift b/submodules/InstantPageUI/Sources/InstantPageV2SlideshowView.swift index 25b963c0f4..1ddc731699 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2SlideshowView.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2SlideshowView.swift @@ -6,11 +6,7 @@ import AccountContext import TelegramCore import TelegramPresentationData -// A paged carousel for an `InstantPageBlock.slideshow`. Ports V1's InstantPageSlideshowNode / -// InstantPageSlideshowPagerNode (InstantPageSlideshowItemNode.swift), simplified to create all pages -// eagerly (slideshows are short; this avoids V1's central±1 index bookkeeping and makes the gallery -// transition source available for every page). Each image page hosts an `InstantPageImageNode` exactly -// like the static media views; non-image medias render an empty page (matches V1). +// Paged slideshow backed by `InstantPageImageNode`. final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollViewDelegate { private(set) var item: InstantPageV2SlideshowItem var itemFrame: CGRect { return self.item.frame } @@ -21,9 +17,7 @@ final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollVie private let scrollView: UIScrollView private let pageControlNode: PageControlNode - // One wrapper view per media (so page count stays aligned with the page control). `pageImageNodes` - // holds only the real image nodes; it may be shorter than `pageViews` if a non-image media appears - // (which `layoutSlideshow` currently filters out). Nothing relies on positional correspondence. + // Keep one page per media so the page control stays aligned. private var pageViews: [UIView] = [] private var pageImageNodes: [InstantPageImageNode] = [] @@ -68,7 +62,7 @@ final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollVie self.pageImageNodes = [] let renderContext = self.renderContext - // The image node owns this closure, and is owned (transitively) by self — capture weakly. + // The image node retains this closure. let openMedia: (InstantPageMedia) -> Void = { [weak self] tapped in guard let self else { return } handleOpenMediaTap(tapped: tapped, wrapper: self, renderContext: renderContext) @@ -91,23 +85,21 @@ final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollVie pageView.addSubview(node.view) self.pageImageNodes.append(node) } - // Non-image medias (none in practice — layoutSlideshow filters to images) get an empty page - // to keep page indices aligned with the page control. + // Keep an empty page for unexpected non-image media. self.scrollView.addSubview(pageView) self.pageViews.append(pageView) } self.pageControlNode.pagesCount = self.item.medias.count self.pageControlNode.setPage(0.0) - // Re-register media indices when rebuilding while already on-window (positional reuse with - // changed content); no-ops before the view is attached, where didMoveToWindow handles it. + // Re-register reused pages already attached to a window. self.registerMedias() self.setNeedsLayout() } private func registerMedias() { guard self.window != nil else { return } - // Register under every contained media index so transitionArgsFor(media) can find this view. + // Register every media index for gallery transitions. for media in self.item.medias { registerInRootRegistry(wrapper: self, mediaIndex: media.index) } diff --git a/submodules/MediaPlayer/Sources/FFMpegMediaVideoFrameDecoder.swift b/submodules/MediaPlayer/Sources/FFMpegMediaVideoFrameDecoder.swift index efad3a182f..448dd50518 100644 --- a/submodules/MediaPlayer/Sources/FFMpegMediaVideoFrameDecoder.swift +++ b/submodules/MediaPlayer/Sources/FFMpegMediaVideoFrameDecoder.swift @@ -248,8 +248,8 @@ public final class FFMpegMediaVideoFrameDecoder: MediaTrackFrameDecoder { guard format == .YUV || format == .YUVA else { return false } - // Reject bottom-up / negative-stride frames before any row-copy loop — ffmpeg - // rawdec flips linesize[0] for codec_tag WRAW/cyuv/BottomUp, and other decoders + // Reject bottom-up and negative-stride frames before copying rows. + // ffmpeg rawdec flips linesize[0] for WRAW, cyuv, and BottomUp. // (dxtory, mimic, mjpegdec, scpr) flip the chroma planes too. if frame.lineSize[0] < 0 || frame.lineSize[1] < 0 || frame.lineSize[2] < 0 { return false diff --git a/submodules/MtProtoKit/Sources/MTTcpConnection.m b/submodules/MtProtoKit/Sources/MTTcpConnection.m index 407748aa3a..464944f88f 100644 --- a/submodules/MtProtoKit/Sources/MTTcpConnection.m +++ b/submodules/MtProtoKit/Sources/MTTcpConnection.m @@ -460,7 +460,7 @@ static bool executeGenerationCodeRecursive(NSString *code, HelloParseState *stat break; } case HelloGenerationCommandEndAlternative: { - // signals end of current alternative — return to caller + // End the current alternative. return true; } case HelloGenerationCommandBeginAlternative: diff --git a/submodules/RMIntro/PublicHeaders/RMIntro/RMIntroViewController.h b/submodules/RMIntro/PublicHeaders/RMIntro/RMIntroViewController.h index 0ae475af14..4279cf1d7f 100644 --- a/submodules/RMIntro/PublicHeaders/RMIntro/RMIntroViewController.h +++ b/submodules/RMIntro/PublicHeaders/RMIntro/RMIntroViewController.h @@ -62,6 +62,9 @@ @property (nonatomic) CGRect defaultFrame; +// Brand icon overlaid on the first intro page animation. +@property (nonatomic, strong) UIImage *firstPageIcon; + - (instancetype)initWithBackgroundColor:(UIColor *)backgroundColor primaryColor:(UIColor *)primaryColor buttonColor:(UIColor *)buttonColor accentColor:(UIColor *)accentColor regularDotColor:(UIColor *)regularDotColor highlightedDotColor:(UIColor *)highlightedDotColor suggestedLocalizationSignal:(SSignal *)suggestedLocalizationSignal; @property (nonatomic, copy) void (^startMessaging)(void); diff --git a/submodules/RMIntro/Sources/platform/ios/RMIntroViewController.m b/submodules/RMIntro/Sources/platform/ios/RMIntroViewController.m index 9a8550ad84..03bce7513f 100644 --- a/submodules/RMIntro/Sources/platform/ios/RMIntroViewController.m +++ b/submodules/RMIntro/Sources/platform/ios/RMIntroViewController.m @@ -107,6 +107,8 @@ typedef enum { UIView *_wrapperView; UIView *_startButton; + UIImageView *_firstPageIconView; + bool _loadedView; } @end @@ -379,6 +381,14 @@ typedef enum { p.clearsContextBeforeDrawing = false; [_pageViews addObject:p]; [_pageScrollView addSubview:p]; + + // Brand icon on the first intro page; frame is set in -updateLayout. + if (i == 0 && _firstPageIcon != nil) { + p.clipsToBounds = false; + _firstPageIconView = [[UIImageView alloc] initWithImage:_firstPageIcon]; + _firstPageIconView.contentMode = UIViewContentModeScaleAspectFit; + [p addSubview:_firstPageIconView]; + } } [_pageScrollView setPage:0]; @@ -574,6 +584,17 @@ typedef enum { [_pageViews enumerateObjectsUsingBlock:^(UIView *pageView, NSUInteger index, __unused BOOL *stop) { pageView.frame = CGRectMake(index * self.view.bounds.size.width, (pageY - statusBarHeight), self.view.bounds.size.width, 150); }]; + + if (_firstPageIconView != nil) { + bool isIpad = ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad); + CGFloat glSize = isIpad ? 200.0f * 1.2f : 200.0f; + CGFloat iconSize = floor(glSize * 0.6f); + // Center the icon on the GL animation square, in page-local coordinates. + CGFloat glCenterY = (_glkView.frame.origin.y + glSize / 2.0f); + CGFloat pageTopAbsolute = _pageScrollView.frame.origin.y + (pageY - statusBarHeight); + CGFloat iconCenterYLocal = glCenterY - pageTopAbsolute; + _firstPageIconView.frame = CGRectMake(floor((self.view.bounds.size.width - iconSize) / 2.0f), iconCenterYLocal - iconSize / 2.0f, iconSize, iconSize); + } } - (void)viewWillAppear:(BOOL)animated diff --git a/submodules/SettingsUI/BUILD b/submodules/SettingsUI/BUILD index ade087b2b1..410876b8ec 100644 --- a/submodules/SettingsUI/BUILD +++ b/submodules/SettingsUI/BUILD @@ -56,6 +56,7 @@ swift_library( "//submodules/PhoneInputNode:PhoneInputNode", "//submodules/PhotoResources:PhotoResources", "//submodules/StickerResources:StickerResources", + "//submodules/SolidRoundedButtonNode:SolidRoundedButtonNode", "//submodules/ProgressNavigationButtonNode:ProgressNavigationButtonNode", "//submodules/RadialStatusNode:RadialStatusNode", "//submodules/SearchBarNode:SearchBarNode", diff --git a/submodules/SettingsUI/Sources/Data and Storage/WinterGramDeletedMessagesController.swift b/submodules/SettingsUI/Sources/Data and Storage/WinterGramDeletedMessagesController.swift index e01cd40e4e..5434e137bb 100644 --- a/submodules/SettingsUI/Sources/Data and Storage/WinterGramDeletedMessagesController.swift +++ b/submodules/SettingsUI/Sources/Data and Storage/WinterGramDeletedMessagesController.swift @@ -393,7 +393,9 @@ private enum WinterGramDeletedMessagesControllerEntry: ItemListNodeEntry { case let .topChat(_, peer, count, size, _): let sizeFormatting = DataSizeStringFormatting(strings: presentationData.strings, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator) let subtitle = "\(count) • \(dataSizeString(size, formatting: sizeFormatting))" - return ItemListPeerItem(presentationData: presentationData, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, context: arguments.context, peer: peer, presence: nil, text: .text(subtitle, .secondary), label: .none, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: nil), switchValue: nil, enabled: true, selectable: false, sectionId: self.section, action: nil, setPeerIdWithRevealedOptions: { _, _ in }, removePeer: { _ in }) + return ItemListPeerItem(presentationData: presentationData, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, context: arguments.context, peer: peer, presence: nil, text: .text(subtitle, .secondary), label: .none, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: nil), switchValue: nil, enabled: true, selectable: true, sectionId: self.section, action: { + arguments.openChat(peer) + }, setPeerIdWithRevealedOptions: { _, _ in }, removePeer: { _ in }) case let .delete(_, text, enabled): return ItemListPeerActionItem(presentationData: presentationData, style: .blocks, icon: PresentationResourcesItemList.deleteIconImage(presentationData.theme), title: text, sectionId: self.section, color: enabled ? .destructive : .disabled, editing: false, action: enabled ? { arguments.deleteSelected() @@ -414,11 +416,13 @@ private final class WinterGramDeletedMessagesControllerArguments { let context: AccountContext let toggleCategory: (WinterGramDeletedMessageCategory) -> Void let deleteSelected: () -> Void - - init(context: AccountContext, toggleCategory: @escaping (WinterGramDeletedMessageCategory) -> Void, deleteSelected: @escaping () -> Void) { + let openChat: (EnginePeer) -> Void + + init(context: AccountContext, toggleCategory: @escaping (WinterGramDeletedMessageCategory) -> Void, deleteSelected: @escaping () -> Void, openChat: @escaping (EnginePeer) -> Void) { self.context = context self.toggleCategory = toggleCategory self.deleteSelected = deleteSelected + self.openChat = openChat } } @@ -426,10 +430,19 @@ private func winterGramDeletedMessagesControllerEntries(stats: WinterGramDeleted let sizeFormatting = DataSizeStringFormatting(strings: strings, decimalSeparator: dateTimeFormat.decimalSeparator) var entries: [WinterGramDeletedMessagesControllerEntry] = [] - + entries.append(.overviewHeader(theme, strings.WinterGram_DeletedMessages_Title.uppercased())) + + // Empty state: when nothing has been saved yet, drop the (otherwise empty) pie chart, the category + // checklist and the delete action — they read as a "cheap" empty ring — and just explain where saved + // deletions come from, matching the calm empty states of the native Storage Usage screen. + if stats.totalCount == 0 { + entries.append(.overviewInfo(theme, strings.WinterGram_DeletedAndEditedMessagesAreKeptLocallyOnThisDeviceOnly)) + return entries + } + entries.append(.overviewInfo(theme, "\(strings.WinterGram_DeletedMessages_Total): \(stats.totalCount) • \(dataSizeString(stats.totalSize, formatting: sizeFormatting))")) - + let chartTotal = stats.categories.reduce(Int64(0)) { $0 + max(0, $1.size) } var chartItems: [PieChartComponent.ChartData.Item] = [] for stat in stats.categories where stat.size > 0 { @@ -468,9 +481,6 @@ private func winterGramDeletedMessagesControllerEntries(stats: WinterGramDeleted } } - let canDelete = !selectedCategories.isEmpty - entries.append(.delete(theme, strings.WinterGram_DeletedMessages_DeleteSelected, canDelete)) - return entries } @@ -517,8 +527,12 @@ public func winterGramDeletedMessagesController(context: AccountContext) -> View }) ])]) presentControllerImpl?(actionSheet, nil) + }, openChat: { peer in + if let navigationController = context.sharedContext.mainWindow?.viewController as? NavigationController { + context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer))) + } }) - + let statsSignal = winterGramDeletedMessagesStats(postbox: context.account.postbox) let topPeersSignal = statsSignal |> map { stats -> [EnginePeer.Id] in @@ -545,7 +559,22 @@ public func winterGramDeletedMessagesController(context: AccountContext) -> View ) |> map { presentationData, stats, selectedCategories, topPeers -> (ItemListControllerState, (ItemListNodeState, Any)) in let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(presentationData.strings.WinterGram_DeletedMessages_Title), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)) - let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: winterGramDeletedMessagesControllerEntries(stats: stats, topPeers: topPeers, selectedCategories: selectedCategories, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, theme: presentationData.theme), style: .blocks, animateChanges: true) + + let canDelete = !selectedCategories.isEmpty + let sizeFormatting = DataSizeStringFormatting(strings: presentationData.strings, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator) + let selectedSize = stats.categories.reduce(Int64(0)) { partial, stat in + return selectedCategories.contains(stat.category) ? partial + max(0, stat.size) : partial + } + var footerTitle = presentationData.strings.WinterGram_DeletedMessages_DeleteSelected + if canDelete && selectedSize > 0 { + footerTitle += " " + dataSizeString(selectedSize, formatting: sizeFormatting) + } + // No saved deletions → no destructive footer (matches the empty state from the entries builder). + let footerItem: WinterGramDeletedMessagesFooterItem? = stats.totalCount == 0 ? nil : WinterGramDeletedMessagesFooterItem(theme: presentationData.theme, title: footerTitle, isEnabled: canDelete, action: { + arguments.deleteSelected() + }) + + let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: winterGramDeletedMessagesControllerEntries(stats: stats, topPeers: topPeers, selectedCategories: selectedCategories, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, theme: presentationData.theme), style: .blocks, footerItem: footerItem, animateChanges: true) return (controllerState, (listState, arguments)) } diff --git a/submodules/SettingsUI/Sources/Data and Storage/WinterGramDeletedMessagesFooterItem.swift b/submodules/SettingsUI/Sources/Data and Storage/WinterGramDeletedMessagesFooterItem.swift new file mode 100644 index 0000000000..85de1d431e --- /dev/null +++ b/submodules/SettingsUI/Sources/Data and Storage/WinterGramDeletedMessagesFooterItem.swift @@ -0,0 +1,122 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import TelegramPresentationData +import ItemListUI +import PresentationDataUtils +import SolidRoundedButtonNode + +final class WinterGramDeletedMessagesFooterItem: ItemListControllerFooterItem { + let theme: PresentationTheme + let title: String + let isEnabled: Bool + let action: () -> Void + + init(theme: PresentationTheme, title: String, isEnabled: Bool, action: @escaping () -> Void) { + self.theme = theme + self.title = title + self.isEnabled = isEnabled + self.action = action + } + + func isEqual(to: ItemListControllerFooterItem) -> Bool { + if let item = to as? WinterGramDeletedMessagesFooterItem { + return self.theme === item.theme && self.title == item.title && self.isEnabled == item.isEnabled + } else { + return false + } + } + + func node(current: ItemListControllerFooterItemNode?) -> ItemListControllerFooterItemNode { + if let current = current as? WinterGramDeletedMessagesFooterItemNode { + current.item = self + return current + } else { + return WinterGramDeletedMessagesFooterItemNode(item: self) + } + } +} + +final class WinterGramDeletedMessagesFooterItemNode: ItemListControllerFooterItemNode { + private let backgroundNode: NavigationBackgroundNode + private let separatorNode: ASDisplayNode + private let buttonNode: SolidRoundedButtonNode + + private var validLayout: ContainerViewLayout? + + var item: WinterGramDeletedMessagesFooterItem { + didSet { + self.updateItem() + if let layout = self.validLayout { + let _ = self.updateLayout(layout: layout, transition: .immediate) + } + } + } + + init(item: WinterGramDeletedMessagesFooterItem) { + self.item = item + + self.backgroundNode = NavigationBackgroundNode(color: item.theme.rootController.tabBar.backgroundColor) + self.separatorNode = ASDisplayNode() + self.buttonNode = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(backgroundColor: .black, foregroundColor: .white), height: 50.0, cornerRadius: 11.0) + + super.init() + + self.addSubnode(self.backgroundNode) + self.addSubnode(self.separatorNode) + self.addSubnode(self.buttonNode) + + self.updateItem() + } + + private func updateItem() { + self.backgroundNode.updateColor(color: self.item.theme.rootController.tabBar.backgroundColor, transition: .immediate) + self.separatorNode.backgroundColor = self.item.theme.rootController.tabBar.separatorColor + + let backgroundColor = self.item.theme.list.itemDestructiveColor + let textColor = self.item.theme.list.itemCheckColors.foregroundColor + + self.buttonNode.updateTheme(SolidRoundedButtonTheme(backgroundColor: backgroundColor, foregroundColor: textColor), animated: false) + self.buttonNode.title = self.item.title + self.buttonNode.isUserInteractionEnabled = self.item.isEnabled + self.buttonNode.alpha = self.item.isEnabled ? 1.0 : 0.5 + + self.buttonNode.pressed = { [weak self] in + self?.item.action() + } + } + + override func updateBackgroundAlpha(_ alpha: CGFloat, transition: ContainedViewLayoutTransition) { + transition.updateAlpha(node: self.backgroundNode, alpha: alpha) + transition.updateAlpha(node: self.separatorNode, alpha: alpha) + } + + override func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) -> CGFloat { + self.validLayout = layout + + let buttonInset: CGFloat = 16.0 + let buttonWidth = layout.size.width - layout.safeInsets.left - layout.safeInsets.right - buttonInset * 2.0 + let buttonHeight = self.buttonNode.updateLayout(width: buttonWidth, transition: transition) + let topInset: CGFloat = 12.0 + let bottomInset: CGFloat = layout.size.width > 320.0 ? 16.0 : 12.0 + + let insets = layout.insets(options: []) + let panelHeight = buttonHeight + topInset + bottomInset + insets.bottom + + let panelFrame = CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - panelHeight), size: CGSize(width: layout.size.width, height: panelHeight)) + + transition.updateFrame(node: self.backgroundNode, frame: panelFrame) + self.backgroundNode.update(size: panelFrame.size, transition: transition) + + transition.updateFrame(node: self.buttonNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + buttonInset, y: panelFrame.minY + topInset), size: CGSize(width: buttonWidth, height: buttonHeight))) + + transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: panelFrame.origin, size: CGSize(width: panelFrame.width, height: UIScreenPixel))) + + return panelHeight + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return self.backgroundNode.frame.contains(point) + } +} diff --git a/submodules/SettingsUI/Sources/Themes/ThemeSettingsChatPreviewItem.swift b/submodules/SettingsUI/Sources/Themes/ThemeSettingsChatPreviewItem.swift index 64ac5ee99d..bd77d35c9f 100644 --- a/submodules/SettingsUI/Sources/Themes/ThemeSettingsChatPreviewItem.swift +++ b/submodules/SettingsUI/Sources/Themes/ThemeSettingsChatPreviewItem.swift @@ -276,10 +276,17 @@ class ThemeSettingsChatPreviewItemNode: ListViewItemNode { } node.updateFrame(CGRect(origin: CGPoint(x: 0.0, y: topOffset), size: node.frame.size), within: layoutSize) if avatarFrame == nil, nodeIndex < displayedMessageItems.count, !displayedMessageItems[nodeIndex].outgoing { - let avatarSize: CGFloat = 40.0 + // The message nodes live in `containerNode`, which is flipped 180° (π about Z), + // so a node placed at container-space top `topOffset` is drawn with its visual + // bottom at `contentSize.height - topOffset`. The avatar is a direct subnode of + // `self` (un-flipped), so compute its position in screen space and snap its + // bottom to the incoming bubble's tail — matching a real Telegram message row. + let avatarSize: CGFloat = 37.0 + let tailBottomInset: CGFloat = -2.0 + let visualBottom = contentSize.height - topOffset avatarFrame = CGRect( x: params.leftInset + 9.0, - y: topOffset + max(6.0, node.frame.height - avatarSize - 8.0), + y: visualBottom - avatarSize - tailBottomInset, width: avatarSize, height: avatarSize ) @@ -292,7 +299,11 @@ class ThemeSettingsChatPreviewItemNode: ListViewItemNode { let avatarRadius = min(avatarFrame.width, avatarFrame.height) * CGFloat(max(0, min(50, item.avatarCornerRadius))) / 100.0 strongSelf.avatarNode.layer.cornerRadius = avatarRadius strongSelf.avatarNode.layer.masksToBounds = true - strongSelf.avatarNode.setPeer(context: item.context, theme: item.componentTheme, peer: avatarPeer, synchronousLoad: false, displayDimensions: avatarFrame.size) + // Generate a SQUARE avatar image (clipStyle .none) and let this node's own + // cornerRadius define the shape. Otherwise AvatarNode bakes the *global* + // avatarCornerRadius into the image and early-returns on re-`setPeer`, so the + // live preview value (dragged slider) never takes effect. + strongSelf.avatarNode.setPeer(context: item.context, theme: item.componentTheme, peer: avatarPeer, clipStyle: .none, synchronousLoad: false, displayDimensions: avatarFrame.size) strongSelf.avatarNode.updateSize(size: avatarFrame.size) } else { strongSelf.avatarNode.isHidden = true diff --git a/submodules/SettingsUI/Sources/WinterGramBannerItem.swift b/submodules/SettingsUI/Sources/WinterGramBannerItem.swift index 755d47714d..db62d58f89 100644 --- a/submodules/SettingsUI/Sources/WinterGramBannerItem.swift +++ b/submodules/SettingsUI/Sources/WinterGramBannerItem.swift @@ -7,8 +7,6 @@ import TelegramPresentationData import ItemListUI import PresentationDataUtils -// A hero banner shown at the very top of the WinterGram settings menu, just under the navigation -// bar / Dynamic Island: a snowflake app-tile, the WinterGram name and a short tagline. private func winterGramBannerIcon(size: CGSize) -> UIImage { let renderer = UIGraphicsImageRenderer(size: size) return renderer.image { rendererContext in @@ -28,7 +26,6 @@ private func winterGramBannerIcon(size: CGSize) -> UIImage { } } -// Renders the user's current app icon as a rounded-rect tile for the banner. private func winterGramBannerRoundedIcon(image: UIImage, size: CGSize) -> UIImage { let renderer = UIGraphicsImageRenderer(size: size) return renderer.image { _ in @@ -137,7 +134,6 @@ class WinterGramBannerItemNode: ListViewItemNode { strongSelf.item = item let width = params.width - // No grey backplate behind the banner — it sits flat on the grouped background. strongSelf.backgroundNode.backgroundColor = .clear strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: contentHeight)) diff --git a/submodules/SettingsUI/Sources/WinterGramMainSettingsController.swift b/submodules/SettingsUI/Sources/WinterGramMainSettingsController.swift index 2388216ac6..34f28aaacc 100644 --- a/submodules/SettingsUI/Sources/WinterGramMainSettingsController.swift +++ b/submodules/SettingsUI/Sources/WinterGramMainSettingsController.swift @@ -35,28 +35,29 @@ private enum WinterGramMainSettingsSection: Int32 { case links } -// A link row shown in the "Links" section: SF Symbol, label, accent-coloured value and target URL. private struct WinterGramLink { let symbol: String let imageName: String? let title: String let value: String let url: String + let color: UIColor - init(symbol: String, imageName: String? = nil, title: String, value: String, url: String) { + init(symbol: String, imageName: String? = nil, title: String, value: String, url: String, color: UIColor = UIColor(rgb: 0x8E8E93)) { self.symbol = symbol self.imageName = imageName self.title = title self.value = value self.url = url + self.color = color } } private let winterGramLinks: [WinterGramLink] = [ - WinterGramLink(symbol: "paperplane.fill", title: "Channel", value: "@wntgram", url: "https://t.me/wntgram"), - WinterGramLink(symbol: "sparkles", title: "Beta", value: "@wntbeta", url: "https://t.me/wntbeta"), - WinterGramLink(symbol: "bubble.left.and.bubble.right.fill", title: "Chat", value: "@wntForum", url: "https://t.me/wntForum"), - WinterGramLink(symbol: "puzzlepiece.extension.fill", title: "Plugins", value: "@wntPlugins", url: "https://t.me/wntPlugins"), + WinterGramLink(symbol: "paperplane.fill", title: "Channel", value: "@wntgram", url: "https://t.me/wntgram", color: UIColor(rgb: 0x2AABEE)), + WinterGramLink(symbol: "sparkles", title: "Beta", value: "@wntbeta", url: "https://t.me/wntbeta", color: UIColor(rgb: 0xFF9F0A)), + WinterGramLink(symbol: "bubble.left.and.bubble.right.fill", title: "Chat", value: "@wntForum", url: "https://t.me/wntForum", color: UIColor(rgb: 0x34C759)), + WinterGramLink(symbol: "puzzlepiece.extension.fill", title: "Plugins", value: "@wntPlugins", url: "https://t.me/wntPlugins", color: UIColor(rgb: 0xBF5AF2)), WinterGramLink(symbol: "link", imageName: "Item List/Icons/GitHub", title: "GitHub", value: "reekeer/WinterGram", url: "https://github.com/reekeer/WinterGram") ] @@ -150,7 +151,7 @@ private enum WinterGramMainSettingsEntry: ItemListNodeEntry { case let .link(_, link): return ItemListDisclosureItem( presentationData: presentationData, - icon: winterGramCategoryIcon(symbolName: link.symbol, imageName: link.imageName, backgroundColor: UIColor(rgb: 0x8E8E93)), + icon: winterGramCategoryIcon(symbolName: link.symbol, imageName: link.imageName, backgroundColor: link.color), title: wntOption(link.title, presentationData.strings), label: link.value, labelStyle: .coloredText(accent), @@ -162,15 +163,15 @@ private enum WinterGramMainSettingsEntry: ItemListNodeEntry { } ) case .ayugram: - category = .ayugram; title = "Core"; iconName = "shield.fill"; iconColor = UIColor(rgb: 0x5856D6) + category = .ayugram; title = "Core"; iconName = "shield.fill"; iconColor = UIColor(rgb: 0x0A84FF) case .features: - category = .antiFeatures; title = "Features"; iconName = "sparkles"; iconColor = UIColor(rgb: 0xFF9500) + category = .antiFeatures; title = "Features"; iconName = "sparkles"; iconColor = UIColor(rgb: 0xFF9F0A) case .other: category = .other; title = "Other"; iconName = "ellipsis.circle"; iconColor = UIColor(rgb: 0x8E8E93) case .spoofing: - category = .spoofing; title = "Spoofing"; iconName = "theatermasks"; iconColor = UIColor(rgb: 0xFF3B30) + category = .spoofing; title = "Spoofing"; iconName = "theatermasks.fill"; iconColor = UIColor(rgb: 0xBF5AF2) case .hiddenArchive: - category = .stash; title = "Hidden Archive"; iconName = "tray.full.fill"; iconColor = UIColor(rgb: 0x34C759) + category = .stash; title = "Hidden Archive"; iconName = "tray.full.fill"; iconColor = UIColor(rgb: 0x30D158) } return ItemListDisclosureItem( presentationData: presentationData, @@ -187,38 +188,38 @@ private enum WinterGramMainSettingsEntry: ItemListNodeEntry { } } -/// Renders a rounded-rect backplate filled with `backgroundColor` and a white SF Symbol centred on -/// top — a clean settings tile look. private func winterGramCategoryIcon(_ symbolName: String, _ backgroundColor: UIColor) -> UIImage? { return winterGramCategoryIcon(symbolName: symbolName, imageName: nil, backgroundColor: backgroundColor) } private func winterGramCategoryIcon(symbolName: String, imageName: String?, backgroundColor: UIColor) -> UIImage? { - let size = CGSize(width: 44.0, height: 44.0) + // Match the iOS-Settings-style colored icons Telegram uses for settings rows: the image *is* the + // rounded colored backplate (~30pt), centered by ItemListDisclosureItem in its icon column, with a + // white glyph that has comfortable padding. The previous 44pt image held a small 34pt backplate, so + // icons looked oversized/off-centre next to standard rows and the backplate looked too small. + let size = CGSize(width: 32.0, height: 32.0) let renderer = UIGraphicsImageRenderer(size: size) return renderer.image { _ in let bounds = CGRect(origin: .zero, size: size) - // Smaller colored backplate with a transparent margin so the white icon dominates. - let backplateInset: CGFloat = 5.0 - let backplateRect = bounds.insetBy(dx: backplateInset, dy: backplateInset) - let backplate = UIBezierPath(roundedRect: backplateRect, cornerRadius: 9.0) + // ~0.225 of the side gives the iOS app-icon "squircle" feel at this size. + let backplate = UIBezierPath(roundedRect: bounds, cornerRadius: 7.5) backgroundColor.setFill() backplate.fill() let icon: UIImage? if let imageName = imageName { icon = UIImage(bundleImageName: imageName)?.withRenderingMode(.alwaysTemplate) } else { - icon = UIImage(systemName: symbolName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 28.0, weight: .medium))?.withRenderingMode(.alwaysTemplate) + icon = UIImage(systemName: symbolName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 20.0, weight: .bold))?.withRenderingMode(.alwaysTemplate) } guard let symbol = icon?.withTintColor(.white, renderingMode: .alwaysOriginal) else { return } - let maxIconSide: CGFloat = 30.0 + let maxIconSide: CGFloat = 21.0 let symbolScale = min(maxIconSide / max(symbol.size.width, 1.0), maxIconSide / max(symbol.size.height, 1.0), 1.0) let symbolSize = CGSize(width: symbol.size.width * symbolScale, height: symbol.size.height * symbolScale) symbol.draw(in: CGRect( - x: floor((size.width - symbolSize.width) / 2.0), - y: floor((size.height - symbolSize.height) / 2.0), + x: (size.width - symbolSize.width) / 2.0, + y: (size.height - symbolSize.height) / 2.0, width: symbolSize.width, height: symbolSize.height )) @@ -263,7 +264,6 @@ public func winterGramMainSettingsController(context: AccountContext) -> ViewCon pushControllerImpl?(winterGramSettingsController(context: context, category: category)) }, openUrl: { url in - // Open WinterGram channels in-app (resolve t.me links) rather than bouncing to the browser. let presentationData = context.sharedContext.currentPresentationData.with { $0 } context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: url, forceExternal: false, presentationData: presentationData, navigationController: getNavigationControllerImpl?(), dismissInput: {}) }, diff --git a/submodules/SettingsUI/Sources/WinterGramRadiusItem.swift b/submodules/SettingsUI/Sources/WinterGramRadiusItem.swift index 42f86f7ece..babaa9afa0 100644 --- a/submodules/SettingsUI/Sources/WinterGramRadiusItem.swift +++ b/submodules/SettingsUI/Sources/WinterGramRadiusItem.swift @@ -13,9 +13,6 @@ enum WinterGramRadiusPreviewKind { case bubble } -// Draws a small live preview that reflects the chosen corner radius: a sample avatar -// (the user's actual avatar when available, otherwise a gradient rounded square with a person glyph) -// or a sample chat bubble. private func winterGramRadiusPreviewImage(kind: WinterGramRadiusPreviewKind, value: Int32, minValue: Int32, maxValue: Int32, size: CGSize, theme: PresentationTheme, avatarImage: UIImage? = nil) -> UIImage { let renderer = UIGraphicsImageRenderer(size: size) return renderer.image { rendererContext in @@ -148,8 +145,6 @@ class WinterGramRadiusItemNode: ListViewItemNode { self.addSubnode(self.titleNode) self.addSubnode(self.valueNode) - // WinterGram: the inline radius preview next to the slider is intentionally NOT shown — the - // rounding is previewed in the chat preview instead. The slider spans the full width. } deinit { diff --git a/submodules/SettingsUI/Sources/WinterGramSettingsController.swift b/submodules/SettingsUI/Sources/WinterGramSettingsController.swift index 863ae1f440..462097db7b 100644 --- a/submodules/SettingsUI/Sources/WinterGramSettingsController.swift +++ b/submodules/SettingsUI/Sources/WinterGramSettingsController.swift @@ -76,9 +76,8 @@ public enum WinterGramSettingsSection: Int32, CaseIterable { case chat case liquidGlass case spoofing - // Combined "super-categories" shown in the redesigned main menu. - case ayugram // Ghost Mode + History + Hidden Archive - case other // Chat tweaks + Spoofing (show id, registration date, spoofer, …) + case ayugram + case other public var title: String { switch self { @@ -106,7 +105,6 @@ public enum WinterGramSettingsSection: Int32, CaseIterable { } public var iconName: String { - // SF Symbols used for the main menu cells. switch self { case .banner: return "" @@ -131,7 +129,6 @@ public enum WinterGramSettingsSection: Int32, CaseIterable { } } - // Maps a deep-link path/section name (wnt://wintergram/) to a settings subtab. public init?(deepLinkName: String) { switch deepLinkName.lowercased() { case "ghost", "ghostmode": self = .ghost @@ -156,8 +153,6 @@ private enum WinterGramDropdown: Equatable { case stashPrivacy } -// Single source of truth for each inline dropdown's options: display title (English; localized at -// render time), whether it is the current selection, and how to apply it. private func winterGramDropdownOptions(_ dropdown: WinterGramDropdown, settings: WinterGramSettings) -> [(title: String, selected: Bool, apply: (WinterGramSettings) -> WinterGramSettings)] { switch dropdown { case .stashPrivacy: @@ -209,8 +204,6 @@ private func winterGramDropdownOptions(_ dropdown: WinterGramDropdown, settings: } } -// Built-in device-model spoof presets shown as tappable cards in the Spoofing section. -// `model` is the string reported to Telegram (nil = the device's real model). `subtitle` describes it. private let winterGramDevicePresets: [(name: String, subtitle: String, model: String?)] = [ ("Real device", "Report this device's real model", nil), ("iPhone 16 Pro Max", "iPhone17,2 · A18 Pro", "iPhone 16 Pro Max"), @@ -309,7 +302,6 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry { case spoofingApiHash(String) case spoofingFooter - // Expandable single-select row: dropdown key, title, expanded flag, options. case expandableSelection(WinterGramDropdown, String, Bool, [ItemListExpandableSelectionItem.Option]) var section: ItemListSectionId { @@ -349,7 +341,6 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry { } var stableId: Int32 { - // Device preset cards nest between spoofingDevice (30000) and WebView platform (40000). if case let .spoofingDevicePreset(index, _) = self { return 30100 + Int32(index) } @@ -684,12 +675,15 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry { case .spoofPresetsHeader: return ItemListSectionHeaderItem(presentationData: presentationData, text: lang.WinterGram_Templates, sectionId: self.section) case let .spoofPreset(index, name, subtitle, editing, selected): - if editing { - return ItemListCheckboxItem(presentationData: presentationData, systemStyle: .glass, title: name, subtitle: subtitle, style: .left, checked: selected, zeroSeparatorInsets: false, sectionId: self.section, action: { + // Always an ItemListCheckboxItem so the row never swaps node *type* between display and + // editing. ItemList updates a row in place by stableId and cannot re-type a node (the cast + // in updateNode fails silently), which previously left the disclosure row in place in edit + // mode and made selection unresponsive. In display mode tapping applies the template; in + // edit mode it toggles selection. Bonus: templates now match the device-preset rows below. + return ItemListCheckboxItem(presentationData: presentationData, title: name, subtitle: subtitle, style: .left, checked: editing ? selected : false, zeroSeparatorInsets: false, sectionId: self.section, action: { + if editing { arguments.toggleSpoofTemplateSelected(index) - }) - } else { - return ItemListDisclosureItem(presentationData: presentationData, title: name, label: subtitle, labelStyle: .detailText, sectionId: self.section, style: .blocks, disclosureStyle: .none, action: { + } else { arguments.updateSettings { settings in var settings = settings if index < settings.spoofPresets.count { @@ -699,8 +693,8 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry { } return settings } - }) - } + } + }) case .spoofAddTemplate: return ItemListPeerActionItem(presentationData: presentationData, icon: PresentationResourcesItemList.plusIconImage(presentationData.theme), title: lang.WinterGram_AddTemplate, sectionId: self.section, height: .generic, color: .accent, editing: false, action: { arguments.addSpoofTemplate() @@ -787,7 +781,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti let lang = presentationData.strings var entries: [WinterGramSettingsEntry] = [] - // Appends an expandable single-select row with checkbox options shown inline. func appendDropdown(_ dropdown: WinterGramDropdown, _ title: String, _ section: WinterGramSettingsSection) { let options = winterGramDropdownOptions(dropdown, settings: settings).enumerated().map { index, option in ItemListExpandableSelectionItem.Option(id: "\(dropdown)_\(index)", title: wntOption(option.title, lang), isSelected: option.selected, index: index) @@ -797,7 +790,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti func appendGhost() { entries.append(.ghostHeader) - // Expandable Ghost Mode switch with checkbox sub-items for each suppressed signal. let subItems: [ItemListExpandableSwitchItem.SubItem] = [ .init(id: "readMessages", title: lang.WinterGram_DontReadMessages, isSelected: settings.suppressesReadReceipts, isEnabled: true), .init(id: "readStories", title: lang.WinterGram_DontReadStories, isSelected: settings.suppressesStoryViews, isEnabled: true), @@ -839,7 +831,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti entries.append(.stashMute(settings.stashMuteNotifications)) entries.append(.stashAutoRead(settings.stashAutoMarkRead)) entries.append(.stashPasscodeRow(settings.stashPasscode.isEmpty ? "None" : "••••")) - // All auto-privacy toggles combined into one expandable multi-select checkbox row. appendDropdown(.stashPrivacy, lang.WinterGram_AutoPrivacy, .stash) entries.append(.stashFooter) } @@ -868,7 +859,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti entries.append(.confirmStickers(settings.stickerConfirmation)) entries.append(.confirmGif(settings.gifConfirmation)) entries.append(.confirmVoice(settings.voiceConfirmation)) - // Footer last (stableId 67) so the section stays in ascending stableId order. entries.append(.chatFooter) } @@ -885,7 +875,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti func appendSpoofing() { entries.append(.spoofingHeader) - // Saved spoof templates at the top of the Spoofing section. entries.append(.spoofPresetsHeader) for (index, preset) in settings.spoofPresets.enumerated() { let subtitle = [preset.deviceModel, preset.appVersion].filter { !$0.isEmpty }.joined(separator: " · ") @@ -895,7 +884,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti if spoofTemplatesEditing && !selectedTemplates.isEmpty { entries.append(.spoofDeleteSelected) } - // Device model is chosen via the preset cards below (incl. "Real device") — no separate prompt row. for (i, preset) in winterGramDevicePresets.enumerated() { entries.append(.spoofingDevicePreset(i, preset.model == settings.spoofDeviceModel)) } @@ -950,8 +938,6 @@ public func winterGramSettingsController(context: AccountContext, category: Wint var pushControllerImpl: ((ViewController) -> Void)? var refreshDeletedCount: (() -> Void)? - // The combined super-categories (.ayugram/.other) are not standalone tabs in the legacy - // no-category segmented view. let sectionTabs: [WinterGramSettingsSection] = WinterGramSettingsSection.allCases.filter { $0 != .ayugram && $0 != .other } let selectedCategoryPromise = ValuePromise(category ?? .ghost, ignoreRepeated: true) @@ -968,13 +954,12 @@ public func winterGramSettingsController(context: AccountContext, category: Wint newSettings.customApiHash != initialSettings.customApiHash || newSettings.materialDesign != initialSettings.materialDesign || newSettings.customFont != initialSettings.customFont || - newSettings.monoFont != initialSettings.monoFont + newSettings.monoFont != initialSettings.monoFont || + newSettings.liquidGlass != initialSettings.liquidGlass requiresRestartPromise.set(needsRestart) }) } - // Applies a change to the stashed-peer privacy settings and re-syncs exceptions for every - // currently stashed peer when the rules change. let updateStashPrivacy: (@escaping (WinterGramStashPrivacySettings) -> WinterGramStashPrivacySettings) -> Void = { f in let previous = currentWinterGramSettings.stashPrivacy updateSettings { settings in @@ -991,12 +976,9 @@ public func winterGramSettingsController(context: AccountContext, category: Wint } } - // Which inline dropdown (if any) is currently expanded. Atomic mirror for synchronous reads in the - // toggle/select closures; promise drives the list rebuild. let expandedDropdownValue = Atomic(value: nil) let expandedDropdownPromise = ValuePromise(nil, ignoreRepeated: true) - // Whether the Ghost Mode expandable section in the Core menu is open. let ghostExpandedValue = Atomic(value: false) let ghostExpandedPromise = ValuePromise(false, ignoreRepeated: true) let spoofTemplatesEditingValue = Atomic(value: false) @@ -1025,7 +1007,6 @@ public func winterGramSettingsController(context: AccountContext, category: Wint updateSettings { options[index].apply($0) } } } - // Multi-select dropdowns stay open so the user can toggle more options. if dropdown != .stashPrivacy { let _ = expandedDropdownValue.swap(nil) expandedDropdownPromise.set(nil) @@ -1290,8 +1271,6 @@ public func winterGramSettingsController(context: AccountContext, category: Wint controller?.present(c, in: .window(.root), with: a) } pushControllerImpl = { [weak controller] c in - // Keep the restart-banner subscription alive for the controller's lifetime: this - // closure is retained by `arguments`, which the ItemListController holds via its state. let _ = restartBannerDisposable (controller?.navigationController as? NavigationController)?.pushViewController(c) } diff --git a/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift b/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift index 1f6b2e0832..87c55d28bc 100644 --- a/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift +++ b/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift @@ -902,7 +902,7 @@ func revalidateMediaResourceReference(accountPeerId: PeerId, postbox: Postbox, n } } // Rich-text messages (`RichTextMessageAttribute`) embed their media in the - // attribute's `InstantPage`, not in `message.media` — search there too so a + // attribute's `InstantPage`, not in `message.media`. Search there so a // stale instant-page audio/image file reference can revalidate. for attribute in message.attributes { if let attribute = attribute as? RichTextMessageAttribute { diff --git a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift index 840a50e907..3e32284476 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift @@ -4462,17 +4462,50 @@ func replayFinalState( } } case let .DeleteMessagesWithGlobalIds(ids): + // WinterGram: ordinary cloud deletions (private chats, basic groups) arrive here via + // updateDeleteMessages → deleteMessagesWithGlobalIds, NOT via .DeleteMessages (which only + // covers channels/supergroups). Resolve the global ids to message ids and preserve + mark + // them exactly like the .DeleteMessages path, otherwise these deletions stay unmarked. + var winterGramPreservedGlobalIds = false if currentWinterGramCoreSettings.saveDeletedMessages { - break + let resolvedIds = transaction.messageIdsForGlobalIds(ids) + var winterGramShouldSave = !resolvedIds.isEmpty + // When "save for bots" is off, skip preserving deletions in bot chats. + if winterGramShouldSave && !currentWinterGramCoreSettings.saveForBots { + if let firstPeerId = resolvedIds.first?.peerId, let peer = transaction.getPeer(firstPeerId) as? TelegramUser, peer.botInfo != nil { + winterGramShouldSave = false + } + } + if winterGramShouldSave { + let markDate = Int32(Date().timeIntervalSince1970) + winterGramRecordDeletedMessages(transaction: transaction, ids: resolvedIds) + for id in resolvedIds { + transaction.updateMessage(id, update: { currentMessage in + if currentMessage.attributes.contains(where: { $0 is WinterGramDeletedMessageAttribute }) { + return .skip + } + var storeForwardInfo: StoreMessageForwardInfo? + if let forwardInfo = currentMessage.forwardInfo { + storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags) + } + var attributes = currentMessage.attributes + attributes.append(WinterGramDeletedMessageAttribute(date: markDate)) + return .update(StoreMessage(id: currentMessage.id, customStableId: nil, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) + }) + } + winterGramPreservedGlobalIds = true + } } - var resourceIds: [MediaResourceId] = [] - transaction.deleteMessagesWithGlobalIds(ids, forEachMedia: { media in - addMessageMediaResourceIdsToRemove(media: media, resourceIds: &resourceIds) - }) - if !resourceIds.isEmpty { - let _ = mediaBox.removeCachedResources(Array(Set(resourceIds)), force: true).start() + if !winterGramPreservedGlobalIds { + var resourceIds: [MediaResourceId] = [] + transaction.deleteMessagesWithGlobalIds(ids, forEachMedia: { media in + addMessageMediaResourceIdsToRemove(media: media, resourceIds: &resourceIds) + }) + if !resourceIds.isEmpty { + let _ = mediaBox.removeCachedResources(Array(Set(resourceIds)), force: true).start() + } + deletedMessageIds.append(contentsOf: ids.map { .global($0) }) } - deletedMessageIds.append(contentsOf: ids.map { .global($0) }) case let .DeleteMessages(ids): var winterGramShouldSave = currentWinterGramCoreSettings.saveDeletedMessages // When "save for bots" is off, skip preserving deletions in bot chats. diff --git a/submodules/TelegramCore/Sources/WinterGram/WinterGramLocalEdit.swift b/submodules/TelegramCore/Sources/WinterGram/WinterGramLocalEdit.swift index a2a04942d6..bd0b51dbc5 100644 --- a/submodules/TelegramCore/Sources/WinterGram/WinterGramLocalEdit.swift +++ b/submodules/TelegramCore/Sources/WinterGram/WinterGramLocalEdit.swift @@ -2,7 +2,7 @@ import Foundation import Postbox import SwiftSignalKit -// Replaces a message's text locally only — no network edit is performed, so the +// Replaces a message's text locally without a network edit, so the // server never learns about the change and no "edited" mark is added. The change // persists in the local database until the message is re-fetched from the server. public func winterGramEditMessageLocally(postbox: Postbox, messageId: MessageId, text: String) -> Signal { diff --git a/submodules/TelegramCore/Sources/WinterGram/WinterGramRegistrationDate.swift b/submodules/TelegramCore/Sources/WinterGram/WinterGramRegistrationDate.swift index e55aa52df4..a52b6d63ae 100644 --- a/submodules/TelegramCore/Sources/WinterGram/WinterGramRegistrationDate.swift +++ b/submodules/TelegramCore/Sources/WinterGram/WinterGramRegistrationDate.swift @@ -3,7 +3,7 @@ import Foundation // Estimates the approximate registration date of a Telegram account from its user ID. // Telegram user IDs are allocated roughly monotonically over time, so a piecewise-linear // interpolation between known (id, date) anchor points yields a usable estimate. This is an -// approximation only — it is never exact and is meant for display with an "≈" prefix. +// approximation only and is meant for display with an "≈" prefix. public func winterGramEstimatedRegistrationDate(userId: Int64) -> Date? { guard userId > 0 else { return nil diff --git a/submodules/TelegramPresentationData/Sources/WinterGramBadge.swift b/submodules/TelegramPresentationData/Sources/WinterGramBadge.swift index c713fa15f4..f3ef2f725b 100644 --- a/submodules/TelegramPresentationData/Sources/WinterGramBadge.swift +++ b/submodules/TelegramPresentationData/Sources/WinterGramBadge.swift @@ -5,14 +5,6 @@ import AppBundle import TelegramCore import TelegramUIPreferences -// Runtime-composed WinterGram badge. -// -// Instead of shipping a fixed-colour PNG, the badge is composed on demand from two white-on-transparent -// shape assets (a scalloped backplate + a snowflake) so the backplate can follow the current theme -// colour. Per the design spec (1024² canvas): the backplate fills 1024@(0,0), the snowflake is 756² -// at (134,134). Results are cached per backplate colour; because every badge view re-renders when the -// presentation theme changes, this naturally recomposes the badge for the new theme. - private let winterGramBadgeCacheLock = NSLock() private var winterGramComposedBadgeCache: [UInt32: UIImage] = [:] @@ -23,8 +15,6 @@ private func winterGramColorKey(_ color: UIColor) -> UInt32 { return (channel(r) << 16) | (channel(g) << 8) | channel(b) } -/// Composes the badge image: a scalloped backplate filled with `backplateColor` and a white snowflake -/// on top, following the 1024 spec. Cached per colour. public func winterGramComposedBadge(backplateColor: UIColor) -> UIImage? { let key = winterGramColorKey(backplateColor) winterGramBadgeCacheLock.lock() @@ -51,16 +41,65 @@ public func winterGramComposedBadge(backplateColor: UIColor) -> UIImage? { return image } -/// The backplate colour for the current theme: the accent colour, slightly darkened. public func winterGramBadgeBackplateColor(theme: PresentationTheme) -> UIColor { return theme.list.itemAccentColor.withMultipliedBrightnessBy(0.82) } -/// The themed badge image for a peer, or nil if the peer carries no WinterGram badge. -/// Every official peer (developers and official channels) gets the composed backplate badge. +private var winterGramComposedBadgeCacheV2: [String: UIImage] = [:] + +private func winterGramLayerImage(source: String) -> UIImage? { + if let url = WinterGramBadgeManager.shared.localAssetFileURL(forSource: source), let image = UIImage(contentsOfFile: url.path) { + return image + } + return UIImage(bundleImageName: source) +} + +public func winterGramComposedBadge(for badge: WinterGramBadgeDef, canvas: Double, themeColor: UIColor, size: CGSize) -> UIImage? { + let canvasValue = canvas > 0.0 ? canvas : 1024.0 + let version = WinterGramBadgeManager.shared.manifest.version + let key = "\(badge.id)|\(version)|\(winterGramColorKey(themeColor))|\(Int(size.width))x\(Int(size.height))" + winterGramBadgeCacheLock.lock() + if let cached = winterGramComposedBadgeCacheV2[key] { + winterGramBadgeCacheLock.unlock() + return cached + } + winterGramBadgeCacheLock.unlock() + + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { _ in + for layer in badge.layers { + if layer.isLottie { + continue + } + guard let layerImage = winterGramLayerImage(source: layer.source) else { + continue + } + let rect = CGRect( + x: layer.x / canvasValue * size.width, + y: layer.y / canvasValue * size.height, + width: layer.width / canvasValue * size.width, + height: layer.height / canvasValue * size.height + ) + switch layer.tint { + case .theme: + layerImage.withTintColor(themeColor, renderingMode: .alwaysOriginal).draw(in: rect) + case let .hex(value): + layerImage.withTintColor(UIColor(rgb: value), renderingMode: .alwaysOriginal).draw(in: rect) + case .original: + layerImage.draw(in: rect) + } + } + } + winterGramBadgeCacheLock.lock() + winterGramComposedBadgeCacheV2[key] = image + winterGramBadgeCacheLock.unlock() + return image +} + public func winterGramBadgeImage(for peer: EnginePeer, theme: PresentationTheme) -> UIImage? { - guard isWinterGramOfficialPeer(peer) else { + guard let badge = WinterGramBadgeManager.shared.badge(for: peer) else { return nil } - return winterGramComposedBadge(backplateColor: winterGramBadgeBackplateColor(theme: theme)) + let manifest = WinterGramBadgeManager.shared.manifest + return winterGramComposedBadge(for: badge, canvas: manifest.canvas, themeColor: winterGramBadgeBackplateColor(theme: theme), size: CGSize(width: 36.0, height: 36.0)) } diff --git a/submodules/TelegramPresentationData/Sources/WinterGramStrings.swift b/submodules/TelegramPresentationData/Sources/WinterGramStrings.swift index 16a0da798a..f757022fad 100644 --- a/submodules/TelegramPresentationData/Sources/WinterGramStrings.swift +++ b/submodules/TelegramPresentationData/Sources/WinterGramStrings.swift @@ -139,6 +139,7 @@ private let winterGramRussianSeed: [String: String] = [ "WinterGram.ShowMessageSeconds": "Секунды в сообщениях", "WinterGram.ShowPeerID": "Показывать ID", "WinterGram.ShowRegistrationDate": "Показывать дату регистрации", + "WinterGram.RegistrationDate": "Дата регистрации", "WinterGram.SingleCornerRadius": "Одиночное скругление", "WinterGram.SomeSettingsWillTakeEffectAfterRestart": "Некоторые настройки вступят в силу после перезапуска.", "WinterGram.SpoofAppVersion": "Версия приложения", @@ -195,8 +196,8 @@ public func winterGramSeedStrings(languageCode: String) -> [String: String] { } /// Localizes a WinterGram option/section label chosen at runtime (dropdown selections, section -/// titles) by routing the known English value to its generated accessor. Unknown values — user -/// input such as custom fonts, reactions or spoofed identifiers — are returned unchanged. +/// titles) by routing the known English value to its generated accessor. Unknown user input +/// such as custom fonts, reactions, or spoofed identifiers is returned unchanged. public func wntOption(_ english: String, _ strings: PresentationStrings) -> String { switch english { case "Off": return strings.WinterGram_Off diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 485bb24547..ff696d82fe 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -3897,7 +3897,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI let targetAlpha: CGFloat = isDeleted ? 0.45 : 1.0 animation.animator.updateAlpha(layer: strongSelf.mainContextSourceNode.contentNode.layer, alpha: targetAlpha, completion: nil) - if hasDeletedMark, let winterGramDeletedAttribute = winterGramDeletedAttribute { + if hasDeletedMark { let deletedIconNode: ASImageNode if let current = strongSelf.winterGramDeletedIconNode { deletedIconNode = current @@ -3909,7 +3909,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.insertSubnode(newNode, belowSubnode: strongSelf.messageAccessibilityArea) deletedIconNode = newNode } - deletedIconNode.image = winterGramDeletedBadgeImage(emoji: currentWinterGramSettings.deletedMark, pillColor: item.presentationData.theme.theme.list.itemDestructiveColor, timeText: currentWinterGramSettings.showDeletedTime ? winterGramDeletedBadgeTimeText(winterGramDeletedAttribute.date) : nil) + // A small, unobtrusive mark (the chosen emoji, default 🧹) — NOT a coloured time pill. + // The deletion time lives in the long-press context menu instead (see ChatInterfaceStateContextMenus). + deletedIconNode.image = winterGramDeletedBadgeImage(emoji: currentWinterGramSettings.deletedMark, pillColor: item.presentationData.theme.theme.list.itemDestructiveColor, timeText: nil) let iconSize = deletedIconNode.image?.size ?? CGSize(width: 16.0, height: 16.0) let spacing: CGFloat = 5.0 let iconY = backgroundFrame.maxY - iconSize.height - 2.0 diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index 74839546bd..516084629b 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -22,17 +22,12 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode private let containerNode: ContainerNode public var statusNode: ChatMessageDateAndStatusNode? - // `init()` may run off the main thread; UIView construction must happen on the main thread. - // The page view is built lazily inside the apply closure (always main-thread) via ensurePageView(). + // `init()` may run off the main thread, so build the page view lazily on the main thread. private var pageView: InstantPageV2View? - // Tracks the message (id + stableVersion) baked into the current pageView's render context. - // The synthesized webpage uses a sentinel id (namespace 0, id 0) shared across all richText - // messages, so we key cache invalidation on the message itself. When the bubble is recycled - // with a different message we must discard pageView (render context is constructor-fixed). + // The synthesized webpage has a sentinel id shared across all richText messages, so key the + // page view on the message id and stableVersion and rebuild when the bubble is recycled. private var pageViewMessageKey: (id: EngineMessage.Id, stableVersion: UInt32, showMoreExpanded: Bool)? - // messageStableVersion is in the cache key because the synthesized instantPage content - // mutates between streamed AI message chunks (each chunk bumps stableVersion); without - // this, the cached layout would shadow newly-arrived content during streaming. + // Include stableVersion in the layout cache key so streamed chunks do not reuse stale layouts. private var currentPageLayout: (boundingWidth: CGFloat, presentationThemeIdentity: ObjectIdentifier, expandedDetails: [Int: Bool], @@ -40,9 +35,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode showMoreExpanded: Bool, layout: InstantPageV2Layout)? private var currentExpandedDetails: [Int: Bool] = [:] - // Intra-message anchor scroll that is waiting on a collapsed
to expand + relayout. + // Anchor scroll waiting on a collapsed
to expand and relayout. private var pendingScrollAnchor: String? - // Progress guard: the details index expanded on the previous pending pass. + // Details index expanded on the previous pending pass, used to avoid looping. private var lastExpandedPendingDetailsIndex: Int? private var linkProgressDisposable: Disposable? private var linkProgressRects: [CGRect]? @@ -54,16 +49,13 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode private var textRevealController: TextRevealController? private var textRevealLink: SharedDisplayLinkDriver.Link? private var currentRevealCostMap: InstantPageV2RevealCostMap? - // Cursor value pushed into pageView.applyReveal on the prior tick. The display-link tick - // compares the revealed prefix's height at this cursor vs the new cursor to decide when - // to request a full bubble re-layout (so the bubble grows with the reveal). + // Cursor from the previous display-link tick, used to detect when the revealed height + // changes and request a full bubble relayout. private var lastAppliedRevealedCount: Int = 0 private var displayContentsUnderSpoilers: Bool = false private var relativeDateTimer: (timer: SwiftSignalKit.Timer, period: Int32)? - // "Show more" affordance for partial rich messages (instantPage.isComplete == false). - // Managed inline, mirroring the statusNode pattern: a bubble-owned TextNode below the page - // content, with a TextLoadingEffectView shimmer while the full-text request is in flight. + // "Show more" link for partial rich messages, with a shimmer while the full text loads. private var showMoreTextNode: TextNode? private var showMoreLoadingView: TextLoadingEffectView? private var requestFullRichTextDisposable: Disposable? @@ -115,7 +107,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode self.addSubnode(self.containerNode) } - /// Builds (or reuses) the V2View. Same-message stableVersion bumps (streamed AI chunks) reuse + /// Builds or reuses the V2View. Same-message stableVersion bumps reuse /// the existing view, updating only the webpage content in place. The view is rebuilt only when /// the bubble is recycled with a different message/webpage (different message id). private func ensurePageView(item: ChatMessageBubbleContentItem, webpage: TelegramMediaWebpage, showMoreExpanded: Bool) -> InstantPageV2View { @@ -136,7 +128,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode self.pageView?.removeFromSuperview() self.pageView = nil - // Capture only the MessageReference (value type) — the closures are retained on the + // Capture only the value-type MessageReference. // render context which is owned by the V2View, so we must avoid making them retain // the bubble (`self`) or the message indirectly via `item`. let messageReference = MessageReference(item.message) @@ -402,7 +394,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode current.layout.formattedDateUpdatePeriod == nil { // Reuse the cached layout only when it has no relative `textDate`. A relative // date's formatted string ("N minutes ago") is baked into the laid-out text at - // layout time, and none of the cache-key inputs change as wall-clock advances — + // layout time, and the cache key does not change as wall-clock time advances, // so reusing it would freeze the date and defeat the refresh timer (which fires // `requestFullUpdate` precisely to re-run `layoutInstantPageV2` → `formatDate`). // Forcing a recompute for relative-date pages keeps the timer's tick visible. @@ -456,7 +448,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode if hasDraft { // The bubble's bottom inset is supplied by the `statusBottomEdge + 6.0` - // max() in the measure closure below — but that branch is gated by + // max() in the measure closure below, but that branch is gated by // `!hasDraft`, so during streaming the bubble has only its 1pt bottom rim // past `revealedContentSize.height` (= bounds.maxY + closingPad). Without // this, descenders of the last revealed line sit cramped against the @@ -591,7 +583,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode let frame = CGRect(origin: CGPoint(x: pageHorizontalInset, y: pageLayout.contentSize.height + showMoreTopSpacing), size: layout.0.size) showMoreLayoutResult = layout showMoreFramePageLocal = frame - // Date trails the link line (or wraps below it if it doesn't fit) — reuse the + // Date trails the link line or wraps below it. Reuse the // status machinery by substituting the link frame for the last-text-line frame. lastTextLineFrame = frame lastTextLineTrailingPadding = 0.0 @@ -608,11 +600,11 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode } // Measure trailing extent from the line's actual visible RIGHT EDGE (after - // alignment, in page coords) — not just its intrinsic width. A right-aligned + // alignment, in page coordinates), not just its intrinsic width. A right-aligned // or RTL last line has `lineWidth` worth of glyphs but sits all the way at // the right text inset (lineFrame.maxX == text.frame.minX + textItem.width). // Feeding the status node just `lineWidth` would let the trail/wrap decision - // place the date inline with the line — on top of it. `pageHorizontalInset` + // place the date inline with the line, on top of it. `pageHorizontalInset` // is the offset between page-coords and status-node-local coords (the status // node sits at x=pageHorizontalInset in self, and pageView sits at self-x 0). let trailingWidthToMeasure: CGFloat = lastTextLineFrame.map { $0.maxX - pageHorizontalInset } ?? 10000.0 @@ -657,7 +649,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode // lands at the right text inset rather than past the bubble's right edge. let statusSizeAndApply = statusSuggestedWidthAndContinue?.1(boundingWidth - pageHorizontalInset * 2.0) if let statusSizeAndApply, !hasDraft { - // Status node anchor Y in the content node's space — mirrors the apply + // Status node anchor Y in the content node's space mirrors the apply // closure below. let statusAnchorY: CGFloat if let lastTextLineFrame { @@ -698,7 +690,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode // On the collapse→expand transition (tapping "Show more"), grow the bubble // downward in screen space (inverted list offset direction) instead of pushing - // earlier messages up — matching the audio-transcription expand. The ListView + // earlier messages up, matching audio transcription expansion. The ListView // clamps this to what fits, so "if possible" is handled for us. Only fires on a // change, and never on the first apply (appliedShowMoreExpanded is nil). if let appliedShowMoreExpanded = self.appliedShowMoreExpanded, appliedShowMoreExpanded != showMoreExpanded { @@ -791,7 +783,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode // Continue an in-flight anchor scroll that is waiting on a
// expansion to re-lay-out. This runs on EVERY apply pass (not only the // expand-triggered one), but only does anything while a scroll is pending - // — and scrollToAnchor is idempotent: each invocation either resolves and + // scrollToAnchor is idempotent: each invocation resolves and // scrolls (clearing pending) or expands the next collapsed level, and the // progress guard guarantees termination. So an unrelated relayout (theme, // width, reactions) that lands mid-expand simply advances/no-ops the loop. @@ -839,7 +831,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode } if let formattedDateUpdatePeriod = pageLayout?.formattedDateUpdatePeriod { - // Recreate the timer only when the period changes — unlike the TextBubble + // Recreate the timer only when the period changes. // reference (ChatMessageTextBubbleContentNode), which rebuilds it every apply. // The timer fires `requestFullUpdate`, which relays out and re-enters here; at // a steady period this guard is false, so the running timer keeps its schedule @@ -862,7 +854,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode // 1. Compute / cache the cost map. // Reuse the cost map computed in the layout pass (the bubble's - // size depended on it) — don't recompute. Keep the previous map + // size depended on it). Keep the previous map // alive while a reveal/finalize is still in flight: on a post- // streaming pass (hasDraft && hadDraft both false) revealCostMap is // nil, and clobbering it would strand the display-link tick (whose @@ -882,8 +874,8 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode } // Seed the (possibly freshly rebuilt) V2 view to the reveal cursor's // current position so we don't flash full text. Use the live controller - // count rather than `previousAnimateGlyphCount`, which is nil — and would - // reset the reveal to 0 — on post-streaming finalize passes where the + // count rather than nil `previousAnimateGlyphCount`, which would + // reset the reveal to 0 on final passes where the // controller is still animating. let seedCount = self.textRevealController?.currentGlyphCount ?? previousAnimateGlyphCount ?? 0 self.pageView?.applyReveal(revealedCount: seedCount, @@ -1061,7 +1053,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode // "attribute substring with displayed range" API, so we cannot compare // displayed text to the resolved URL the way the chat text bubble does. // The chat URL handler will show a confirmation when concealed is true - // and the visible text differs from the destination — safer default. + // and the visible text differs from the destination. let concealed = true let url = ChatMessageBubbleContentTapAction.Url(url: urlHit.urlItem.url, concealed: concealed, allowInlineWebpageResolution: urlHit.urlItem.webpageId != nil) let rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint) @@ -1122,21 +1114,17 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode } else if let botCommand = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String { return .botCommand(botCommand) } else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag { - // Cashtags are carried as a Hashtag attribute (no dedicated cashtag key/tap-action exists); - // the leading "$" in the string distinguishes them, and the chat hashtag handler searches both. + // Cashtags use the hashtag attribute and retain their leading "$". return .hashtag(hashtag.peerName, hashtag.hashtag) } else if let bankCard = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BankCard)] as? String { return .bankCard(bankCard) } else if let date = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Date)] as? Int32 { - // The displayed string is unused downstream (ChatMessageBubbleItemNode matches `.date(date, _)`). return .date(date, "") } return nil } - /// Bridges an InstantPageUrlItem (used by the gallery's caption URL handler) to the - /// chat layer's URL handler. `concealed: true` matches `tapActionAtPoint` for the same - /// reason: V2 cannot reliably compare displayed link text to the resolved URL. + // Open gallery caption links through the chat URL handler. private func openInstantPageUrl(_ url: InstantPageUrlItem) { guard let item = self.item else { return } item.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl( @@ -1147,9 +1135,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode } private func computeHighlightRects(item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint) -> [CGRect] { - // Text item returns rects in its local coords; translate back into containerNode-local coords. - // containerNode is offset by (1, 1) from the bubble-content-node, but the highlight overlay lives - // *inside* containerNode, so we use layout-coords (= containerNode-local) for the rects. + // Translate text-local rects into container coordinates. let originX = parentOffset.x let originY = parentOffset.y return item.linkSelectionRects(at: localPoint).map { rect in @@ -1443,7 +1429,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor) return } - // 2. Not laid out — it may be buried in a collapsed
. Find the path and expand + // If hidden in collapsed details, find the path and expand // the first collapsed details on it, then retry after the relayout (post-relayout hook). let anchorExpanded = (self.showMoreExpanded?.messageId == item.message.id) ? (self.showMoreExpanded?.value ?? false) : false guard let instantPage = item.message.richText.map({ (anchorExpanded ? $0.fullInstantPage : nil) ?? $0.instantPage }), @@ -1482,7 +1468,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode if let state = self.showMoreExpanded, state.messageId == messageId, state.value { return } - // Full page already cached on the attribute — expand immediately, no network, no shimmer. + // Expand a cached full page immediately. if attribute.fullInstantPage != nil { self.showMoreExpanded = (messageId, true) item.controllerInteraction.requestMessageUpdate(messageId, false, nil) diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift index 0f656f4597..c4b5b14f2d 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift @@ -199,7 +199,7 @@ private final class PortalTransitionStaging { /// Tears down staging. Reparents contentNode into the requested destination, /// removes clone from its overlay host, removes wrapper from surface. /// All operations are explicit; we rely on Telegram's manual-animation policy - /// (no implicit CALayer actions) — no CATransaction wrapping needed. + /// without implicit CALayer actions. /// /// `presentationScale` re-application is intentionally not handled here: the /// portal path is gated on `contentNode.presentationScale == 1.0` in CCEPN's @@ -223,7 +223,7 @@ private final class PortalTransitionStaging { // ContentContainingNode/View added contentNode at init). Capturing // contentNode.supernode at staging-enter time was overengineered: during // animateOut staging that's offsetContainerNode (CCEPN's transient host), - // which gets torn down with CCEPN — leaving the bubble parentless. + // which gets torn down with CCEPN, leaving the bubble parentless. switch containingItem { case let .node(containingNode): containingNode.addSubnode(containingNode.contentNode) @@ -761,7 +761,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo contentNodeValue.sourceTransitionSurface = takeInfo.sourceTransitionSurface // Mirror any ancestor scale on the source (e.g. a sheet's container transform) onto the offset - // container so the extracted contents render at the same visual size as in-place — without this + // container so the extracted contents keep the same visual size. // they pop to 1:1 when reparented into the unscaled overlay window. let sourceView = takeInfo.containingItem.view let modeledWidth = sourceView.bounds.width @@ -1425,7 +1425,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo // Use ItemContentNode.frame (already includes contentVerticalOffset and // additionalVisibleOffsetY from the layout pass) plus // containingItem.contentRect.origin to derive the bubble's actual rest - // window rect. Using bare `contentRect` skips those offsets — when reactions + // window rect. Using bare `contentRect` skips those offsets, so reactions // are visible (additionalVisibleOffsetY = reactionContextNode.visibleExtensionDistance) // the wrapper would sit ~10pt above the bubble's true rest, visibly offsetting // the portal mirror until staging.settle reparents into offsetContainerNode. @@ -1451,7 +1451,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo // is visible at the chat-bubble slot; final (overlay clone, above // chrome) is visible at the menu position. Final fade-in begins // `crossfadeOverlap` seconds before source fade-out, so the final - // is already on screen before the source starts dropping out — no + // is already on screen before the source starts dropping out. // single-frame seam at the handoff. Each fade runs for a fixed 0.1s. if let wrapper = staging.wrapper, let clone = staging.clone { let sourceFadeDelay = 0.12 * duration @@ -1759,7 +1759,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo // Mid-dismiss crossfade: clone (overlay portal target, above chrome) // is visible at the menu position; final (chat-tree wrapper, behind // chrome) is visible once the bubble settles at the chat-bubble - // slot. Late handoff — clone carries the bubble through most of the + // slot. The clone carries the bubble through most of the // dismiss; final picks up only as the bubble lands. Source fade-out // is anchored at 80% of the spring duration; final fade-in starts // `crossfadeOverlap` seconds earlier. Each fade runs for 0.1s. @@ -2138,4 +2138,3 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo } } - diff --git a/submodules/TelegramUI/Components/EmojiStatusComponent/Sources/EmojiStatusComponent.swift b/submodules/TelegramUI/Components/EmojiStatusComponent/Sources/EmojiStatusComponent.swift index 10509a6085..132b647e13 100644 --- a/submodules/TelegramUI/Components/EmojiStatusComponent/Sources/EmojiStatusComponent.swift +++ b/submodules/TelegramUI/Components/EmojiStatusComponent/Sources/EmojiStatusComponent.swift @@ -16,9 +16,8 @@ import GZip import HierarchyTrackingLayer import TelegramUIPreferences -// WinterGram: bakes a white-on-transparent shape asset, tinted to `color`, into a CGImage usable as -// layer contents. Drawing into a renderer context is required because `UIImage.withTintColor(...).cgImage` -// returns the original (untinted) pixels. Cached per (shape, colour). +// WinterGram: bake the tinted shape into a CGImage; `UIImage.withTintColor(...).cgImage` returns the +// original (untinted) pixels, so drawing into a renderer context is required. Cached per (shape, color). private var winterGramTintedShapeCache: [String: CGImage] = [:] func winterGramBakeTintedShape(_ name: String, color: UIColor) -> CGImage? { var r: CGFloat = 0.0, g: CGFloat = 0.0, b: CGFloat = 0.0, a: CGFloat = 0.0 @@ -266,9 +265,8 @@ public final class EmojiStatusComponent: Component { private var winterGramBackplateLayer: SimpleLayer? private var winterGramSnowflakeLayer: SimpleLayer? - // Whether the badge backplate should be spinning. Tracked separately so the rotation can be - // re-applied when the layer re-enters the hierarchy (Core Animation drops detached animations - // when a layer leaves the window — e.g. cell reuse / scrolling / backgrounding). + // Track whether the badge backplate should spin so the animation can be restored when the + // layer re-enters the hierarchy. private var winterGramBadgeAnimating: Bool = false private var animationLayer: InlineStickerItemLayer? @@ -300,9 +298,7 @@ public final class EmojiStatusComponent: Component { } } - // (Re)applies the infinite backplate-rotation animation for the WinterGram badge. Safe to call - // repeatedly: it removes any existing animation first and only re-adds it when animation is - // enabled, so re-entering the hierarchy resumes the spin instead of leaving it frozen. + // Restore or update the infinite backplate rotation for the WinterGram badge. private func applyWinterGramBadgeRotation() { guard let backplateLayer = self.winterGramBackplateLayer else { return @@ -605,8 +601,7 @@ public final class EmojiStatusComponent: Component { if case let .winterGramBadge(backplateColor) = component.content { size = availableSize - // The tint must be BAKED into the bitmap: `UIImage.withTintColor(...).cgImage` returns the - // original (white) pixels, which would render the backplate white instead of the theme colour. + // Bake the tint into the bitmap; `UIImage.withTintColor(...).cgImage` would keep the white pixels. let backplateImage = winterGramBakeTintedShape("WntGramBackplateShape", color: backplateColor) let snowflakeImage = winterGramBakeTintedShape("WntGramSnowflakeShape", color: .white) diff --git a/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift b/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift index 75006283d1..13962d423c 100644 --- a/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift +++ b/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift @@ -2917,8 +2917,7 @@ final class TextContentItemLayer: SimpleLayer { effectiveCharacterDrawCount = maxCharacterDrawCount } else { if self.previousMaxCharacterDrawCount > 0 || !self.animatingSnippetLayers.isEmpty { - // Reveal finished — compute total character count so mask and snippets - // can continue updating until all snippet animations complete + // Keep mask active until all snippet animations finish. var totalCharCount = 0 for line in lines { if let characterRects = line.characterRects { @@ -2932,7 +2931,7 @@ final class TextContentItemLayer: SimpleLayer { } effectiveCharacterDrawCount = totalCharCount } else { - // Nothing left to animate — remove the mask + // Remove mask when nothing remains to animate. if let _ = self.revealMaskLayer { self.renderNodeContainer.mask = nil self.revealMaskLayer = nil @@ -3036,8 +3035,7 @@ final class TextContentItemLayer: SimpleLayer { } } - // Build mask rects — use the lowest animating snippet index as the mask limit - // so the mask never extends past any character still being animated + // Limit mask to the lowest animating snippet index. let maskCharacterLimit: Int if let lowestAnimating = self.animatingSnippetLayers.min(by: { $0.characterIndex < $1.characterIndex })?.characterIndex { maskCharacterLimit = lowestAnimating diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift index 5ad64b0bbb..63f3a6fcaa 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift @@ -1693,7 +1693,6 @@ public final class MessageInputPanelComponent: Component { } } - // AI Button do { let isTallPanel = textFieldSize.height >= 70.0 let textLength = self.textFieldExternalState.textLength diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent/Sources/PeerInfoGiftsCoverComponent.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent/Sources/PeerInfoGiftsCoverComponent.swift index b3dc98d22a..a665281e0d 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent/Sources/PeerInfoGiftsCoverComponent.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent/Sources/PeerInfoGiftsCoverComponent.swift @@ -195,9 +195,7 @@ public final class PeerInfoGiftsCoverComponent: Component { } private func recomputeGifts() { - // WinterGram: visual (fake NFT) gifts are merged via `additionalGifts`, so we must render - // them even before the real gifts state has loaded (e.g. a profile with no pinned gifts). - // Don't bail when `currentGiftsState` is nil — just treat the pinned set as empty. + // WinterGram: merge visual gifts from `additionalGifts` even before the real gifts state loads. let giftStatusId = self.currentGiftStatusId let pinnedGifts = (self.currentGiftsState?.gifts ?? []).filter { gift in if gift.pinnedToTop { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift index 51987bc4af..0a4ae2b24a 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift @@ -1642,8 +1642,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { } } - // WinterGram: render the snowflake/developer badge last, to the right of the premium emoji - // status, the verified mark and any credibility icon — mirroring the chat-list ordering. + // WinterGram: render the snowflake/developer badge last, mirroring the chat-list ordering. if let winterGramIconSize = self.winterGramIconSize, let titleExpandedWinterGramIconSize = self.titleExpandedWinterGramIconSize, winterGramIconSize.width > 0.0 { // Extra leading gap (vs the 4pt used between other icons) so the badge sits ~symmetrically // after the premium emoji status, which carries its own internal transparent padding. diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift index 70c02362ca..abf39a2ee1 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift @@ -171,7 +171,7 @@ func infoItems( } var displayId = "\(rawId)" if let winterGramDcId = winterGramDcId { - displayId += " (DC: \(winterGramDcId))" + displayId += " (dc: \(winterGramDcId))" } let copyId: () -> Void = { [weak interaction] in UIPasteboard.general.string = "\(rawId)" @@ -179,7 +179,7 @@ func infoItems( controller.present(UndoOverlayController(presentationData: presentationData, content: .copy(text: presentationData.strings.Conversation_TextCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current) } } - items[.winterGramInfo]!.append(PeerInfoScreenLabeledValueItem(id: 3099, label: "ID", text: displayId, textColor: .primary, action: { _, _ in + items[.winterGramInfo]!.append(PeerInfoScreenLabeledValueItem(id: 3099, label: "id", text: displayId, textColor: .primary, action: { _, _ in copyId() }, longTapAction: nil, contextAction: { _, gesture, _ in copyId() @@ -192,9 +192,9 @@ func infoItems( if currentWinterGramSettings.showRegistrationDate, !user.isDeleted, let date = winterGramEstimatedRegistrationDate(userId: user.id.id._internalGetInt64Value()) { let formatter = DateFormatter() formatter.locale = Locale(identifier: presentationData.strings.baseLanguageCode) - formatter.dateFormat = "MMMM yyyy" - let dateText = "≈ " + formatter.string(from: date) - items[.winterGramInfo]!.append(PeerInfoScreenLabeledValueItem(id: 3098, label: "Registered", text: dateText, textColor: .primary, action: nil, longTapAction: nil, contextAction: { _, gesture, _ in + formatter.setLocalizedDateFormatFromTemplate("MMMM yyyy") + let dateText = formatter.string(from: date) + items[.winterGramInfo]!.append(PeerInfoScreenLabeledValueItem(id: 3098, label: presentationData.strings.WinterGram_RegistrationDate, text: dateText, textColor: .primary, action: nil, longTapAction: nil, contextAction: { _, gesture, _ in UIPasteboard.general.string = dateText gesture?.cancel() }, requestLayout: { animated in diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/WinterGramGiftPickerScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/WinterGramGiftPickerScreen.swift index f6b2c4f5f9..938bc2acbf 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/WinterGramGiftPickerScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/WinterGramGiftPickerScreen.swift @@ -9,8 +9,7 @@ import AccountContext import TelegramPresentationData import GiftItemComponent -// WinterGram: a simple grid picker of all regular (generic) star gifts — including sold-out ones still -// present in the cached catalog — used to add a NON-unique gift "visually" to the profile. +// WinterGram: grid picker of regular star gifts for adding a non-unique gift to the profile. public final class WinterGramGiftPickerScreen: ViewController { private final class Node: ViewControllerTracingNode { private weak var controller: WinterGramGiftPickerScreen? @@ -36,8 +35,7 @@ public final class WinterGramGiftPickerScreen: ViewController { self.scrollNode.view.alwaysBounceVertical = true self.addSubnode(self.scrollNode) - // Make sure the gift catalog is fetched/cached — otherwise the picker would be empty if the - // user never opened a gift screen before. + // Ensure the gift catalog is cached so the picker is not empty on first open. self.keepUpdatedDisposable = context.engine.payments.keepStarGiftsUpdated().startStrict() self.disposable = (context.engine.payments.cachedStarGifts() diff --git a/submodules/TelegramUI/Components/TabBarComponent/Sources/TabBarComponent.swift b/submodules/TelegramUI/Components/TabBarComponent/Sources/TabBarComponent.swift index d2ad61f9a6..d0e9e40d86 100644 --- a/submodules/TelegramUI/Components/TabBarComponent/Sources/TabBarComponent.swift +++ b/submodules/TelegramUI/Components/TabBarComponent/Sources/TabBarComponent.swift @@ -707,11 +707,11 @@ public final class TabBarComponent: Component { let equalWidth = floorToScreenPixels(availableItemsWidth / CGFloat(component.items.count)) if unboundItemWidths.allSatisfy({ $0 <= equalWidth }) { - // All items fit in equal width — use equal widths for optical alignment + // Use equal widths when all items fit. itemWidths = Array(repeating: equalWidth, count: component.items.count) totalItemsWidth = equalWidth * CGFloat(component.items.count) } else { - // Some items need more space — use weighted fit + // Use weighted widths when some items need more space. let itemWeightNorm: CGFloat = availableItemsWidth / unboundItemWidthSum var widths: [CGFloat] = [] var total: CGFloat = 0.0 diff --git a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift index 591b0f708c..dda853877f 100644 --- a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift +++ b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift @@ -17,7 +17,8 @@ import TextInputMenu import ObjCRuntimeUtils import MultilineTextComponent -public final class EmptyInputView: UIView, UIInputViewAudioFeedback { +// Internal to avoid collision with ChatEntityKeyboardInputNode.EmptyInputView. +final class EmptyInputView: UIView, UIInputViewAudioFeedback { public var enableInputClicksWhenVisible: Bool { return true } diff --git a/submodules/TelegramUI/Images.xcassets/IntroLogoDark.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/IntroLogoDark.imageset/Contents.json new file mode 100644 index 0000000000..babd065c89 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/IntroLogoDark.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "IntroLogoDark@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "IntroLogoDark@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/submodules/TelegramUI/Images.xcassets/IntroLogoDark.imageset/IntroLogoDark@2x.png b/submodules/TelegramUI/Images.xcassets/IntroLogoDark.imageset/IntroLogoDark@2x.png new file mode 100644 index 0000000000..e0a7e8b901 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/IntroLogoDark.imageset/IntroLogoDark@2x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/IntroLogoDark.imageset/IntroLogoDark@3x.png b/submodules/TelegramUI/Images.xcassets/IntroLogoDark.imageset/IntroLogoDark@3x.png new file mode 100644 index 0000000000..2aeed63a71 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/IntroLogoDark.imageset/IntroLogoDark@3x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/IntroLogoLight.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/IntroLogoLight.imageset/Contents.json new file mode 100644 index 0000000000..a2ff2b49d0 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/IntroLogoLight.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "IntroLogoLight@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "IntroLogoLight@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/submodules/TelegramUI/Images.xcassets/IntroLogoLight.imageset/IntroLogoLight@2x.png b/submodules/TelegramUI/Images.xcassets/IntroLogoLight.imageset/IntroLogoLight@2x.png new file mode 100644 index 0000000000..a776761ff4 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/IntroLogoLight.imageset/IntroLogoLight@2x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/IntroLogoLight.imageset/IntroLogoLight@3x.png b/submodules/TelegramUI/Images.xcassets/IntroLogoLight.imageset/IntroLogoLight@3x.png new file mode 100644 index 0000000000..2cecd79754 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/IntroLogoLight.imageset/IntroLogoLight@3x.png differ diff --git a/submodules/TelegramUI/Resources/Readability/Readability.js b/submodules/TelegramUI/Resources/Readability/Readability.js index 535afffd4d..066c5a90bc 100644 --- a/submodules/TelegramUI/Resources/Readability/Readability.js +++ b/submodules/TelegramUI/Resources/Readability/Readability.js @@ -1724,7 +1724,7 @@ Readability.prototype = { /** * Attempts to get excerpt and byline metadata for the article. * - * @param {Object} jsonld — object containing any metadata that + * @param {Object} jsonld object containing any metadata that * could be extracted from JSON-LD object. * * @return Object with optional "excerpt" and "byline" properties diff --git a/submodules/TelegramUI/Resources/WinterGramBadgesManifest.json b/submodules/TelegramUI/Resources/WinterGramBadgesManifest.json new file mode 100644 index 0000000000..a27bcbf41c --- /dev/null +++ b/submodules/TelegramUI/Resources/WinterGramBadgesManifest.json @@ -0,0 +1,50 @@ +{ + "version": 0, + "canvas": 1024, + "badges": [ + { + "id": "developer", + "description": "WinterGram developer", + "peers": { "users": [885166226, 5665997196], "channels": [] }, + "priority": 100, + "layers": [ + { + "name": "backplate", + "source": "WntGramBackplateShape", + "x": 0, "y": 0, "width": 1024, "height": 1024, + "tint": "theme", + "animation": { "type": "rotate", "duration": 8.0, "direction": "cw", "loop": true } + }, + { + "name": "icon", + "source": "WntGramSnowflakeShape", + "x": 134, "y": 134, "width": 756, "height": 756, + "tint": "#FFFFFF", + "animation": { "type": "none" } + } + ] + }, + { + "id": "official", + "description": "Official WinterGram channel", + "peers": { "users": [], "channels": [3943351959, 4316373875, 3999337820, 4348385636] }, + "priority": 50, + "layers": [ + { + "name": "backplate", + "source": "WntGramBackplateShape", + "x": 0, "y": 0, "width": 1024, "height": 1024, + "tint": "theme", + "animation": { "type": "rotate", "duration": 8.0, "direction": "cw", "loop": true } + }, + { + "name": "icon", + "source": "WntGramSnowflakeShape", + "x": 134, "y": 134, "width": 756, "height": 756, + "tint": "#FFFFFF", + "animation": { "type": "none" } + } + ] + } + ] +} diff --git a/submodules/TelegramUI/Sources/AppDelegate.swift b/submodules/TelegramUI/Sources/AppDelegate.swift index e5aa783716..411674616b 100644 --- a/submodules/TelegramUI/Sources/AppDelegate.swift +++ b/submodules/TelegramUI/Sources/AppDelegate.swift @@ -1224,6 +1224,7 @@ private func extractAccountManagerState(records: AccountRecordsView deliverOnMainQueue).start(next: { settings in let glass = settings.liquidGlass diff --git a/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift b/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift index 4a12867f5a..4e602cbdb9 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift @@ -140,9 +140,7 @@ final class ChatSendMessageRichTextPreview: ChatSendMessageContextScreenRichText fitToWidth: true ) self.pageView.update(layout: layout, theme: pageTheme, animation: .None) - // The parent (MessageItemView) owns and sets `pageView`'s frame; `update` only - // rebuilds content and reports the size. Rendering is static (.None) — the screen - // drives the size/crossfade transition. + // The parent owns the page frame and transition. self.cachedBoundingWidth = boundingWidth self.cachedThemeIdentity = themeIdentity diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index fd2ef6f449..0fe8a60d11 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -5494,7 +5494,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } // Find the bubble for this message (it must be at least partially - // visible — we got here from a tap inside it) and compute the + // visible because the tap came from it) and compute the // anchor's y in item-local coords. .bottom(anchorY) places the item // so the anchor lands at the visual top of the rotated chat list's // content area; .center(.custom) is bypassed for short items, so it diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index 049bd55562..59846965ba 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -4051,7 +4051,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { // In overlay chat mode (self.containerNode != nil) historyNodeContainer is // reparented out of contentContainerNode (see line ~1299), making the // `aboveSubview: historyNodeContainer.view` insertion invalid. Return nil - // so callers fall back to CCEPN's clipping path — portal-style transitions + // so callers fall back to CCEPN's clipping path. Portal-style transitions // are not supported in overlay mode. guard self.containerNode == nil else { return nil } let parent = self.contentContainerNode.contentNode.view diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index 6400b57974..85bf785d87 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -1434,6 +1434,18 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }))) } + if let deletedAttribute = message.attributes.first(where: { $0 is WinterGramDeletedMessageAttribute }) as? WinterGramDeletedMessageAttribute { + // WinterGram: surface WHEN a preserved message was deleted, in the long-press menu, + // instead of stamping a coloured time pill onto the bubble. + let deletedDateText = humanReadableStringForTimestamp(strings: chatPresentationInterfaceState.strings, dateTimeFormat: chatPresentationInterfaceState.dateTimeFormat, timestamp: deletedAttribute.date, alwaysShowTime: true, allowYesterday: true, format: nil).string + let deletedMark = currentWinterGramSettings.deletedMark.isEmpty ? "🧹" : currentWinterGramSettings.deletedMark + actions.append(.action(ContextMenuActionItem(text: "\(deletedMark) \(deletedDateText)", textColor: .primary, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + f(.default) + }))) + } + if currentWinterGramSettings.showPeerId != .hidden { actions.append(.action(ContextMenuActionItem(text: "Copy Message ID", icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.actionSheet.primaryTextColor) diff --git a/submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift b/submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift index 2074c849b6..b4cbd0d330 100644 --- a/submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift @@ -64,8 +64,8 @@ private func pendingAdditiveSublayerTranslation(_ layer: CALayer) -> CGPoint { /// `convert(_:to:)` into the child. Applying the correction at the right level /// (rather than flat-summing the translations in the destination space) lets /// `CALayer.convert` propagate each correction through any remaining -/// transforms — child `transform`, further ancestors' own model -/// `sublayerTransform`, etc. — so the result is correct even when the chain +/// transforms, including child `transform` and ancestor model +/// `sublayerTransform`, so the result is correct even when the chain /// contains non-translation transforms (rotations, scales). private func convertAnimatingSourceRectFromWindow(_ windowRect: CGRect, toView: UIView) -> CGRect { var chain: [CALayer] = [] diff --git a/submodules/TelegramUI/Sources/MusicListenTracker.swift b/submodules/TelegramUI/Sources/MusicListenTracker.swift index e1786cec62..e5d68fec6d 100644 --- a/submodules/TelegramUI/Sources/MusicListenTracker.swift +++ b/submodules/TelegramUI/Sources/MusicListenTracker.swift @@ -43,7 +43,7 @@ final class MusicListenTracker { func update(with stateAndType: (Account, SharedMediaPlayerItemPlaybackStateOrLoading, MediaManagerPlayerType)?) { assert(Queue.mainQueue().isCurrent()) guard let (_, stateOrLoading, type) = stateAndType, type == .music else { - // Player closed or switched to non-music — report and clear + // Report when the player closes or switches away from music. self.reportAndReset() return } @@ -135,7 +135,7 @@ final class MusicListenTracker { self.pauseTimer?.invalidate() self.pauseTimer = nil } else if !nowPlaying && wasPlaying { - // Just paused — start pause timer + // Start the pause timer. self.startPauseTimer() } @@ -154,7 +154,7 @@ final class MusicListenTracker { } private func pauseTimerFired() { - // Paused > 60s — report current session + // Report after a 60-second pause. self.reportAndReset() } diff --git a/submodules/TelegramUI/Sources/OpenUrl.swift b/submodules/TelegramUI/Sources/OpenUrl.swift index f0c199859d..25aee11084 100644 --- a/submodules/TelegramUI/Sources/OpenUrl.swift +++ b/submodules/TelegramUI/Sources/OpenUrl.swift @@ -443,8 +443,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur navigationController?.pushViewController(controller) return } - // WinterGram: wnt://profile opens the current account's own profile; wnt://profile?id= - // opens that user's profile (resolved from the local cache — works for known peers). + // Open the current or requested cached profile. if host == "profile" { let targetPeerId: EnginePeer.Id if let idString = parsedUrl.query.flatMap({ QueryParameters($0)?["id"] }), let idValue = Int64(idString) { diff --git a/submodules/TelegramUI/Sources/WinterGramTopBanner.swift b/submodules/TelegramUI/Sources/WinterGramTopBanner.swift index 39b6b2fef8..1f457e6464 100644 --- a/submodules/TelegramUI/Sources/WinterGramTopBanner.swift +++ b/submodules/TelegramUI/Sources/WinterGramTopBanner.swift @@ -3,7 +3,7 @@ import Display import TelegramUIPreferences // WinterGram: a persistent branding banner shown at the very top, centred in the Dynamic Island / -// notch band. It is a purely decorative overlay added to the key window — `isUserInteractionEnabled` +// notch band. It is a decorative overlay added to the key window. `isUserInteractionEnabled` // is false so it never intercepts touches. For now there is a single banner type (the bundled // `WntGramBanner` image); `WinterGramTopBannerStyle.off` hides it, any other value shows it. public final class WinterGramTopBannerView: UIView { diff --git a/submodules/TelegramUIPreferences/Sources/WinterGramBadgeManager.swift b/submodules/TelegramUIPreferences/Sources/WinterGramBadgeManager.swift new file mode 100644 index 0000000000..09b180115b --- /dev/null +++ b/submodules/TelegramUIPreferences/Sources/WinterGramBadgeManager.swift @@ -0,0 +1,196 @@ +import Foundation +import TelegramCore + +public let winterGramBadgeManifestBaseURL = "https://raw.githubusercontent.com/reekeer/WinterGram/master/.wintergram/icons/" + +public extension Notification.Name { + static let winterGramBadgesChanged = Notification.Name("winterGramBadgesChanged") +} + +public final class WinterGramBadgeManager { + public static let shared = WinterGramBadgeManager() + + private let queue = DispatchQueue(label: "org.wintergram.badges", qos: .utility) + private let lock = NSLock() + private var _manifest: WinterGramBadgeManifest + + private let cacheDirectory: URL + private let manifestCacheURL: URL + private let refreshInterval: TimeInterval = 6.0 * 60.0 * 60.0 + private let requestTimeout: TimeInterval = 15.0 + private var timer: Timer? + private var isRefreshing = false + private var didStart = false + + private init() { + let caches = (FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first) ?? URL(fileURLWithPath: NSTemporaryDirectory()) + let directory = caches.appendingPathComponent("wintergram-badges", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + self.cacheDirectory = directory + self.manifestCacheURL = directory.appendingPathComponent("manifest.json") + + if let data = try? Data(contentsOf: self.manifestCacheURL), let manifest = WinterGramBadgeManifest.decode(from: data) { + self._manifest = manifest + } else if let bundled = WinterGramBadgeManager.loadBundledManifest() { + self._manifest = bundled + } else { + self._manifest = .empty + } + } + + private static func loadBundledManifest() -> WinterGramBadgeManifest? { + guard let url = Bundle.main.url(forResource: "WinterGramBadgesManifest", withExtension: "json"), let data = try? Data(contentsOf: url) else { + return nil + } + return WinterGramBadgeManifest.decode(from: data) + } + + public var manifest: WinterGramBadgeManifest { + self.lock.lock() + defer { self.lock.unlock() } + return self._manifest + } + + public func badge(for peer: EnginePeer) -> WinterGramBadgeDef? { + let rawId = peer.id.id._internalGetInt64Value() + let manifest = self.manifest + var best: WinterGramBadgeDef? + for badge in manifest.badges { + let matches: Bool + switch peer { + case .user: + matches = badge.peers.users.contains(rawId) + case .channel: + matches = badge.peers.channels.contains(rawId) + default: + matches = false + } + if matches && (best == nil || badge.priority > best!.priority) { + best = badge + } + } + return best + } + + public func localAssetFileURL(forSource source: String) -> URL? { + let url = self.cacheDirectory.appendingPathComponent(WinterGramBadgeManager.sanitize(source)) + if FileManager.default.fileExists(atPath: url.path) { + return url + } + return nil + } + + private static func sanitize(_ source: String) -> String { + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.") + let mapped = String(source.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }) + return mapped.replacingOccurrences(of: "..", with: "_") + } + + public func start() { + self.queue.async { + if self.didStart { + return + } + self.didStart = true + self.refreshLocked() + } + DispatchQueue.main.async { + if self.timer == nil { + self.timer = Timer.scheduledTimer(withTimeInterval: self.refreshInterval, repeats: true, block: { [weak self] _ in + self?.refresh() + }) + } + } + } + + public func refresh() { + self.queue.async { + self.refreshLocked() + } + } + + private func refreshLocked() { + if self.isRefreshing { + return + } + guard let manifestURL = URL(string: winterGramBadgeManifestBaseURL + "manifest.json"), manifestURL.scheme?.lowercased() == "https" else { + return + } + self.isRefreshing = true + + var request = URLRequest(url: manifestURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: self.requestTimeout) + if let etag = UserDefaults.standard.string(forKey: "wnt_badge_etag") { + request.setValue(etag, forHTTPHeaderField: "If-None-Match") + } + + URLSession.shared.dataTask(with: request) { [weak self] data, response, _ in + guard let strongSelf = self else { + return + } + strongSelf.queue.async { + defer { strongSelf.isRefreshing = false } + + guard let http = response as? HTTPURLResponse else { + return + } + if http.statusCode == 304 { + return + } + guard http.statusCode == 200, let data = data, data.count <= WinterGramBadgeLimits.maxAssetBytes, let newManifest = WinterGramBadgeManifest.decode(from: data) else { + return + } + + if newManifest.version <= strongSelf.manifest.version && newManifest == strongSelf.manifest { + return + } + + strongSelf.downloadAssets(for: newManifest) { + try? data.write(to: strongSelf.manifestCacheURL, options: .atomic) + if let etag = http.value(forHTTPHeaderField: "Etag") { + UserDefaults.standard.set(etag, forKey: "wnt_badge_etag") + } + strongSelf.apply(newManifest) + } + } + }.resume() + } + + private func downloadAssets(for manifest: WinterGramBadgeManifest, completion: @escaping () -> Void) { + let sources = manifest.assetSources.filter { self.localAssetFileURL(forSource: $0) == nil } + guard !sources.isEmpty else { + completion() + return + } + let group = DispatchGroup() + for source in sources { + guard let url = URL(string: winterGramBadgeManifestBaseURL + source), url.scheme?.lowercased() == "https" else { + continue + } + group.enter() + let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: self.requestTimeout) + URLSession.shared.dataTask(with: request) { [weak self] data, response, _ in + defer { group.leave() } + guard let strongSelf = self else { + return + } + guard let http = response as? HTTPURLResponse, http.statusCode == 200, let data = data, data.count <= WinterGramBadgeLimits.maxAssetBytes else { + return + } + let destination = strongSelf.cacheDirectory.appendingPathComponent(WinterGramBadgeManager.sanitize(source)) + try? data.write(to: destination, options: .atomic) + }.resume() + } + group.notify(queue: self.queue) { + completion() + } + } + + private func apply(_ manifest: WinterGramBadgeManifest) { + self.lock.lock() + self._manifest = manifest + self.lock.unlock() + DispatchQueue.main.async { + NotificationCenter.default.post(name: .winterGramBadgesChanged, object: nil) + } + } +} diff --git a/submodules/TelegramUIPreferences/Sources/WinterGramBadgeManifest.swift b/submodules/TelegramUIPreferences/Sources/WinterGramBadgeManifest.swift new file mode 100644 index 0000000000..e589662e7f --- /dev/null +++ b/submodules/TelegramUIPreferences/Sources/WinterGramBadgeManifest.swift @@ -0,0 +1,220 @@ +import Foundation + +public enum WinterGramBadgeLimits { + public static let maxBadges = 64 + public static let maxLayers = 16 + public static let maxAssetBytes = 512 * 1024 +} + +public enum WinterGramBadgeTint: Equatable { + case theme + case hex(UInt32) + case original + + public init(parsing raw: String?) { + guard var s = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !s.isEmpty else { + self = .theme + return + } + if s == "theme" { + self = .theme + } else if s == "none" || s == "original" { + self = .original + } else { + if s.hasPrefix("#") { + s.removeFirst() + } + if s.count == 6, let value = UInt32(s, radix: 16) { + self = .hex(value) + } else { + self = .theme + } + } + } +} + +public enum WinterGramBadgeAnimationType: String, Equatable { + case none + case rotate + case blink + case pulse + case bounce + case shake + case lottie + + public init(parsing raw: String?) { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), let value = WinterGramBadgeAnimationType(rawValue: raw) else { + self = .none + return + } + self = value + } +} + +public struct WinterGramBadgeAnimation: Equatable, Decodable { + public let type: WinterGramBadgeAnimationType + public let duration: Double + public let loop: Bool + public let directionClockwise: Bool + public let amplitude: Double + + public static let none = WinterGramBadgeAnimation(type: .none, duration: 0.0, loop: false, directionClockwise: true, amplitude: 0.0) + + public init(type: WinterGramBadgeAnimationType, duration: Double, loop: Bool, directionClockwise: Bool, amplitude: Double) { + self.type = type + self.duration = duration + self.loop = loop + self.directionClockwise = directionClockwise + self.amplitude = amplitude + } + + private enum CodingKeys: String, CodingKey { + case type, duration, loop, direction, amplitude + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = WinterGramBadgeAnimationType(parsing: try container.decodeIfPresent(String.self, forKey: .type)) + self.duration = (try container.decodeIfPresent(Double.self, forKey: .duration)) ?? 1.0 + self.loop = (try container.decodeIfPresent(Bool.self, forKey: .loop)) ?? true + let direction = (try container.decodeIfPresent(String.self, forKey: .direction))?.lowercased() + self.directionClockwise = (direction != "ccw") + self.amplitude = (try container.decodeIfPresent(Double.self, forKey: .amplitude)) ?? 0.1 + } +} + +public struct WinterGramBadgeLayer: Equatable, Decodable { + public let name: String + public let source: String + public let x: Double + public let y: Double + public let width: Double + public let height: Double + public let tint: WinterGramBadgeTint + public let animation: WinterGramBadgeAnimation + + public var isLottie: Bool { + let lowered = self.source.lowercased() + return lowered.hasSuffix(".tgs") || lowered.hasSuffix(".json") + } + + public init(name: String, source: String, x: Double, y: Double, width: Double, height: Double, tint: WinterGramBadgeTint, animation: WinterGramBadgeAnimation) { + self.name = name + self.source = source + self.x = x + self.y = y + self.width = width + self.height = height + self.tint = tint + self.animation = animation + } + + private enum CodingKeys: String, CodingKey { + case name, source, x, y, width, height, tint, animation + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.name = (try container.decodeIfPresent(String.self, forKey: .name)) ?? "" + self.source = (try container.decodeIfPresent(String.self, forKey: .source)) ?? "" + self.x = (try container.decodeIfPresent(Double.self, forKey: .x)) ?? 0.0 + self.y = (try container.decodeIfPresent(Double.self, forKey: .y)) ?? 0.0 + self.width = (try container.decodeIfPresent(Double.self, forKey: .width)) ?? 0.0 + self.height = (try container.decodeIfPresent(Double.self, forKey: .height)) ?? 0.0 + self.tint = WinterGramBadgeTint(parsing: try container.decodeIfPresent(String.self, forKey: .tint)) + self.animation = (try container.decodeIfPresent(WinterGramBadgeAnimation.self, forKey: .animation)) ?? .none + } +} + +public struct WinterGramBadgePeers: Equatable, Decodable { + public let users: [Int64] + public let channels: [Int64] + + public init(users: [Int64], channels: [Int64]) { + self.users = users + self.channels = channels + } + + private enum CodingKeys: String, CodingKey { + case users, channels + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.users = (try container.decodeIfPresent([Int64].self, forKey: .users)) ?? [] + self.channels = (try container.decodeIfPresent([Int64].self, forKey: .channels)) ?? [] + } +} + +public struct WinterGramBadgeDef: Equatable, Decodable { + public let id: String + public let peers: WinterGramBadgePeers + public let priority: Int + public let layers: [WinterGramBadgeLayer] + public let description: String + + public init(id: String, peers: WinterGramBadgePeers, priority: Int, layers: [WinterGramBadgeLayer], description: String) { + self.id = id + self.peers = peers + self.priority = priority + self.layers = layers + self.description = description + } + + private enum CodingKeys: String, CodingKey { + case id, peers, priority, layers, description + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = (try container.decodeIfPresent(String.self, forKey: .id)) ?? "" + self.peers = (try container.decodeIfPresent(WinterGramBadgePeers.self, forKey: .peers)) ?? WinterGramBadgePeers(users: [], channels: []) + self.priority = (try container.decodeIfPresent(Int.self, forKey: .priority)) ?? 0 + let rawLayers = (try container.decodeIfPresent([WinterGramBadgeLayer].self, forKey: .layers)) ?? [] + self.layers = Array(rawLayers.prefix(WinterGramBadgeLimits.maxLayers)) + self.description = (try container.decodeIfPresent(String.self, forKey: .description)) ?? "" + } +} + +public struct WinterGramBadgeManifest: Equatable, Decodable { + public let version: Int + public let canvas: Double + public let badges: [WinterGramBadgeDef] + + public init(version: Int, canvas: Double, badges: [WinterGramBadgeDef]) { + self.version = version + self.canvas = canvas + self.badges = badges + } + + private enum CodingKeys: String, CodingKey { + case version, canvas, badges + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.version = (try container.decodeIfPresent(Int.self, forKey: .version)) ?? 0 + self.canvas = (try container.decodeIfPresent(Double.self, forKey: .canvas)) ?? 1024.0 + let rawBadges = (try container.decodeIfPresent([WinterGramBadgeDef].self, forKey: .badges)) ?? [] + self.badges = Array(rawBadges.prefix(WinterGramBadgeLimits.maxBadges)) + } + + public static let empty = WinterGramBadgeManifest(version: 0, canvas: 1024.0, badges: []) + + public static func decode(from data: Data) -> WinterGramBadgeManifest? { + return try? JSONDecoder().decode(WinterGramBadgeManifest.self, from: data) + } + + public var assetSources: [String] { + var seen = Set() + var result: [String] = [] + for badge in self.badges { + for layer in badge.layers where !layer.source.isEmpty { + if seen.insert(layer.source).inserted { + result.append(layer.source) + } + } + } + return result + } +} diff --git a/submodules/TelegramUIPreferences/Sources/WinterGramSettings.swift b/submodules/TelegramUIPreferences/Sources/WinterGramSettings.swift index 1b83bdb7c4..0e8f53d72c 100644 --- a/submodules/TelegramUIPreferences/Sources/WinterGramSettings.swift +++ b/submodules/TelegramUIPreferences/Sources/WinterGramSettings.swift @@ -197,8 +197,8 @@ public struct WinterGramVisualGift: Codable, Equatable { let container = try decoder.container(keyedBy: CodingKeys.self) self.id = try container.decode(String.self, forKey: .id) self.gift = try container.decode(StarGift.self, forKey: .gift) - // `EnginePeer` isn't `Codable` and can't be rebuilt without a postbox, so — like - // TelegramCore's own `StarGift` — only the peer id is persisted and `fromPeer` is left + // `EnginePeer` isn't `Codable` and can't be rebuilt without a postbox. + // Like TelegramCore's `StarGift`, only the peer id is persisted and `fromPeer` is left // nil on decode, to be resolved lazily by the consumer when a peer is actually needed. self.fromPeer = nil } @@ -816,40 +816,16 @@ public func observeWinterGramSettings(accountManager: AccountManager Bool { - let peerIdValue = peer.id.id._internalGetInt64Value() - switch peer { - case .user: - return peerIdValue == 885166226 || peerIdValue == 5665997196 - case .channel: - // Raw channel ids (the part after the -100 marker): @wntgram/@wntbeta plus -1003999337820 / -1004348385636. - return peerIdValue == 3943351959 || peerIdValue == 4316373875 || peerIdValue == 3999337820 || peerIdValue == 4348385636 - default: - return false - } + return WinterGramBadgeManager.shared.badge(for: peer) != nil } -// Developer accounts get a distinct backplated badge; other official peers keep the plain snowflake. public func isWinterGramDeveloperPeer(_ peer: EnginePeer) -> Bool { - let peerIdValue = peer.id.id._internalGetInt64Value() - switch peer { - case .user: - return peerIdValue == 885166226 || peerIdValue == 5665997196 - default: - return false - } + return WinterGramBadgeManager.shared.badge(for: peer)?.id == "developer" } -// Name of the bundled badge image for a peer, or nil if the peer carries no WinterGram badge. -// Developers and official channels get the backplated badge; other official peers get the plain snowflake. public func winterGramBadgeImageName(for peer: EnginePeer) -> String? { - if isWinterGramDeveloperPeer(peer) { - return "WntGramDeveloperBadge" + guard let badge = WinterGramBadgeManager.shared.badge(for: peer) else { + return nil } - if isWinterGramOfficialPeer(peer) { - if case .channel = peer { - return "WntGramDeveloperBadge" - } - return "WinterGramSnowflake" - } - return nil + return badge.id == "developer" ? "WntGramDeveloperBadge" : "WinterGramSnowflake" } diff --git a/submodules/TextFormat/Sources/CustomEmojiMarkdownMarker.swift b/submodules/TextFormat/Sources/CustomEmojiMarkdownMarker.swift index 18b0800c30..07efd33961 100644 --- a/submodules/TextFormat/Sources/CustomEmojiMarkdownMarker.swift +++ b/submodules/TextFormat/Sources/CustomEmojiMarkdownMarker.swift @@ -10,7 +10,7 @@ public func customEmojiMarkdownURL(fileId: Int64) -> String { /// Backslash-escapes only the characters that would break a marker link's /// display text (the `alt`): backslash, the link-text brackets, and newline. -/// Minimal by design — the forward CommonMark parser unescapes these, so the +/// The CommonMark parser unescapes these, so the /// alt round-trips. Shared by every site that emits a `[](tg://emoji?id=…)` /// marker so the escaping cannot drift between encoders. public func escapeCustomEmojiMarkdownAlt(_ string: String) -> String { @@ -53,7 +53,7 @@ private let customEmojiMarkerRegex = try? NSRegularExpression( /// markdown text. Used to populate the edit compose field so it shows the /// animated emoji; on re-save the forward path reads the attribute's fileId back. /// -/// `file` is left nil — the renderer resolves the emoji lazily from `fileId`, +/// `file` is nil because the renderer resolves the emoji lazily from `fileId`, /// and the send path only needs the fileId. Known limitation: an alt containing /// a literal `]` is not matched (emoji alts do not contain brackets). public func chatInputTextWithReattachedCustomEmoji(_ markdown: String) -> NSAttributedString { diff --git a/submodules/Utils/RangeSet/Sources/RangeSet.swift b/submodules/Utils/RangeSet/Sources/RangeSet.swift index 1456b139d8..2905ac3989 100644 --- a/submodules/Utils/RangeSet/Sources/RangeSet.swift +++ b/submodules/Utils/RangeSet/Sources/RangeSet.swift @@ -59,7 +59,7 @@ public struct RangeSet { /// The ranges stored by a range set are never empty, never overlap, /// and are always stored in ascending order when comparing their lower /// or upper bounds. In addition to not overlapping, no two consecutive - /// ranges share an upper and lower bound — `[0..<5, 5..<10]` is ill-formed, + /// ranges share an upper and lower bound. `[0..<5, 5..<10]` is ill-formed, /// and would instead be represented as `[0..<10]`. internal func _checkInvariants() { for (a, b) in zip(ranges, ranges.dropFirst()) { diff --git a/submodules/WebUI/Sources/WebAppUtils.swift b/submodules/WebUI/Sources/WebAppUtils.swift index 38ddd92cf6..368a0b7fc2 100644 --- a/submodules/WebUI/Sources/WebAppUtils.swift +++ b/submodules/WebUI/Sources/WebAppUtils.swift @@ -51,12 +51,12 @@ func isAllowedBotMediaUrl(_ urlString: String) -> Bool { // Strict canonical dotted-decimal IPv4 (4 octets, no leading zeros, each 0-255). // Do NOT use inet_pton here: Darwin's inet_pton accepts "0177.0.0.1" as // decimal 177.0.0.1, but getaddrinfo (used by URLSession) interprets the - // same string as octal 127.0.0.1 — the divergence is a loopback bypass. + // same string as octal 127.0.0.1. The divergence is a loopback bypass. if let v4Bytes = parseCanonicalIPv4(host) { return isPublicIPv4(v4Bytes) } - // IPv6 only — host must contain ":" so we don't accidentally hand a + // IPv6 only. Require ":" to avoid handing a // numeric-looking hostname to inet_pton. if host.contains(":") { var v6 = in6_addr() @@ -70,7 +70,7 @@ func isAllowedBotMediaUrl(_ urlString: String) -> Bool { } // Strict DNS-name validation. Anything that doesn't look like a real - // FQDN is rejected — this catches non-canonical numeric IP forms + // FQDN is rejected. This catches non-canonical numeric IP forms // (decimal-32 like "2130706433", octal like "0177.0.0.1", hex like // "0x7f.0.0.1", short forms like "127.1") that the OS resolver may // still treat as 127.0.0.1 even when inet_pton would accept them as diff --git a/third-party/dav1d/build-dav1d-bazel.sh b/third-party/dav1d/build-dav1d-bazel.sh index 7bdb31cb5a..f3d237d745 100644 --- a/third-party/dav1d/build-dav1d-bazel.sh +++ b/third-party/dav1d/build-dav1d-bazel.sh @@ -10,7 +10,15 @@ MESON_OPTIONS="--buildtype=release --default-library=static -Denable_tools=false CROSSFILE="" if [ "$ARCH" = "arm64" ]; then - CROSSFILE="../package/crossfiles/arm64-iPhoneOS.meson" + # Rewrite the hardcoded /Applications/Xcode.app sysroot to the active developer dir + # (e.g. Xcode-beta.app), mirroring the sim_arm64 branch — otherwise the meson cross + # build can't find the iPhoneOS SDK headers when Xcode lives at a non-default path. + TARGET_CROSSFILE="$BUILD_DIR/dav1d/package/crossfiles/arm64-iPhoneOS-custom.meson" + rm -f "$TARGET_CROSSFILE" + cp "$BUILD_DIR/dav1d/package/crossfiles/arm64-iPhoneOS.meson" "$TARGET_CROSSFILE" + custom_xcode_path="$(xcode-select -p)/" + sed -i '' "s|/Applications/Xcode.app/Contents/Developer/|$custom_xcode_path|g" "$TARGET_CROSSFILE" + CROSSFILE="../package/crossfiles/arm64-iPhoneOS-custom.meson" elif [ "$ARCH" = "sim_arm64" ]; then rm -f "arm64-iPhoneSimulator-custom.meson" TARGET_CROSSFILE="$BUILD_DIR/dav1d/package/crossfiles/arm64-iPhoneSimulator-custom.meson" diff --git a/versions.json b/versions.json index 555ec09321..c711d6795a 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "app": "1.1", + "app": "1.2", "xcode": "26.5", "deploy_xcode": "26.5", "bazel": "8.4.2:45e9388abf21d1107e146ea366ad080eb93cb6a5f3a4a3b048f78de0bc3faffa",