chore: bump version to 1.2

This commit is contained in:
IMDelewer 2026-06-30 15:01:22 +03:00
parent f6bb33d193
commit 83f209e44a
101 changed files with 2419 additions and 608 deletions

View file

@ -1,4 +1,3 @@
build --action_env=ZERO_AR_DATE=1
build --enable_platform_specific_config build --enable_platform_specific_config
@ -39,3 +38,8 @@ common:index_build --noannounce_rc
common:index_build --noshow_timestamps common:index_build --noshow_timestamps
common:index_build --curses=no common:index_build --curses=no
common:index_build --color=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

4
.gitignore vendored
View file

@ -53,6 +53,7 @@ xcuserdata
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
build-system/wintergram-development-configuration.json build-system/wintergram-development-configuration.json
build-system/fake-codesigning-wintergram/ build-system/fake-codesigning-wintergram/
build-system/fake-codesigning-generated/
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Editor / AI assistant / local tooling # Editor / AI assistant / local tooling
@ -121,3 +122,6 @@ AppBinary.xcworkspace/*
Project.xcodeproj/* Project.xcodeproj/*
Watch/Watch.xcodeproj/* Watch/Watch.xcodeproj/*
AppBundle.xcworkspace/* AppBundle.xcworkspace/*
# wintergram badge preview gifs
.wintergram/icons/preview_*.gif

View file

@ -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.
- `<badge>/...` — 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 <id>`, `--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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

View file

@ -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" }
}
]
}
]
}

View file

@ -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)." }
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

163
AGENTS.md Normal file
View file

@ -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 <your-codesigning-repo> \
--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.<Area>` 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`

View file

@ -160,7 +160,7 @@ genrule(
], ],
) )
minimum_os_version = "13.0" minimum_os_version = "26.5"
notificationServiceExtensionVersion = "v1" notificationServiceExtensionVersion = "v1"
@ -1850,7 +1850,7 @@ ios_application(
xcodeproj( xcodeproj(
name = "Telegram_xcodeproj", name = "Telegram_xcodeproj",
bazel_path = telegram_bazel_path, bazel_path = telegram_bazel_path,
project_name = "Telegram", project_name = "WinterGram",
tags = ["manual"], tags = ["manual"],
top_level_targets = [ top_level_targets = [
top_level_target(":Telegram", target_environments = ["device", "simulator"]), top_level_target(":Telegram", target_environments = ["device", "simulator"]),

View file

@ -2612,60 +2612,36 @@ typealias CMJImage = UIImage
// MARK: - Public Namespace // MARK: - Public Namespace
/// A lightweight utility for turning ordinary bitmap images into ``NSAdaptiveImageGlyph`` (Apple Genmoji) objects. /// Utility for converting bitmaps into ``NSAdaptiveImageGlyph`` (Genmoji).
///
/// ## 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:)``
///
public struct Customoji { public struct Customoji {
// MARK: Library-scoped Errors // MARK: Library-scoped Errors
/// Errors that can be thrown by ``Customoji``. /// Errors thrown by ``Customoji``.
public enum Error: Swift.Error, LocalizedError { 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 case nonSquare
/// The longest side of the input image exceeds ``maxSide`` pixels (defaults to 4096). /// Input image exceeds ``maxSide`` pixels.
case imageTooLarge case imageTooLarge
/// Failed to create an in-memory HEIC container via *Image I/O*. /// Failed to create the in-memory HEIC container.
case heicDestinationCreationFailed case heicDestinationCreationFailed
/// Failed to finalise the HEIC container after writing all tiles. /// Failed to finalize the HEIC container.
case heicFinalizeFailed case heicFinalizeFailed
/// Internal resize operation failed when generating a tile. /// Resizing a tile failed.
case imageScaleFailed case imageScaleFailed
/// A `CGImage` representation could not be extracted from the source image. /// Could not extract a `CGImage` from the source image.
case cgImageUnavailable 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 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 case noTilesGenerated
/// The current platform is neither UIKit- nor AppKit-based (unlikely in practice). /// Unsupported platform.
case unsupportedPlatform case unsupportedPlatform
/// The `heicQuality` parameter was outside the 0.0 1.0 range. /// `heicQuality` was outside the 0.0...1.0 range.
case invalidHeicQuality case invalidHeicQuality
/// Cropping or scaling to a square failed. /// Cropping or scaling to a square failed.
case squareCropFailed case squareCropFailed
/// Textual description suitable for presenting to users. /// Localized error description.
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
case .nonSquare: return "Input image must be square." 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] 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 private static let maxSide = 4_096
// MARK: - Synchronous Builder // MARK: - Synchronous Builder
#if swift(>=5.10) #if swift(>=5.10)
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *) @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *)
/// Generates an ``NSAdaptiveImageGlyph`` synchronously on the **current** thread. /// Generate an ``NSAdaptiveImageGlyph`` from a bitmap.
///
/// - 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.
static func makeGlyph( static func makeGlyph(
from image: CMJImage, from image: CMJImage,
description: String, description: String,
@ -2739,19 +2705,7 @@ public struct Customoji {
// MARK: - AttributedString Utilities // MARK: - AttributedString Utilities
#if swift(>=5.10) #if swift(>=5.10)
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *) @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *)
/// Breaks an ``NSAttributedString`` that may contain adaptive-image glyphs into three parts. /// Split an attributed string into plain text, glyph ranges, and HEIC data.
///
/// 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.
@inlinable @inlinable
public static func decompose(_ attr: NSAttributedString) -> ( public static func decompose(_ attr: NSAttributedString) -> (
plain: String, ranges: [(NSRange, String)], blobs: [String: Data] 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, *) @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *)
/// Reassembles an attributed string previously produced by ``decompose(_:)``. /// Reassemble an attributed string from ``decompose(_:)`` output.
///
/// - 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.
public static func recompose( public static func recompose(
plain: String, plain: String,
ranges: [(NSRange, String)], ranges: [(NSRange, String)],
@ -2805,8 +2753,7 @@ public struct Customoji {
#endif #endif
// MARK: - Helper: Pixel-Side Validation // MARK: - Helper: Pixel-Side Validation
/// Ensures that the images **longer edge** does not exceed ``maxSide`` pixels, guarding against /// Reject images whose longer edge exceeds ``maxSide``.
/// excessive memory usage during HEIC encoding.
private static func validatePixelSide(of image: CMJImage) throws { private static func validatePixelSide(of image: CMJImage) throws {
#if canImport(UIKit) #if canImport(UIKit)
let scale = image.scale == 0 ? 1 : image.scale 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 // MARK: - Core Builder
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *) @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *)
extension Customoji { extension Customoji {

View file

@ -353,6 +353,8 @@
<string>remote-notification</string> <string>remote-notification</string>
<string>voip</string> <string>voip</string>
</array> </array>
<key>UIDesignRequiresCompatibility</key>
<true/>
<key>UIDeviceFamily</key> <key>UIDeviceFamily</key>
<array> <array>
<integer>1</integer> <integer>1</integer>

View file

@ -402,22 +402,22 @@
// Tour // Tour
"Tour.Title1" = "WinterGram"; "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.Title2" = "Telegram Fork";
"Tour.Text2" = "**WinterGram** delivers messages\nfaster than any other application."; "Tour.Text2" = "Fully compatible with **Telegram**.\nYour chats, channels and accounts.";
"Tour.Title3" = "Powerful"; "Tour.Title3" = "Power Tools";
"Tour.Text3" = "**WinterGram** has no limits on\nthe size of your media and chats."; "Tour.Text3" = "Saved **deleted messages**, full **edit\nhistory** and a **hidden archive**.";
"Tour.Title4" = "Secure"; "Tour.Title4" = "Make It Yours";
"Tour.Text4" = "**WinterGram** keeps your messages\nsafe from hacker attacks."; "Tour.Text4" = "Deep **customization**, **Liquid Glass**,\nlocal **Premium** and a device **spoofer**.";
"Tour.Title5" = "Cloud-Based"; "Tour.Title5" = "Open Source";
"Tour.Text5" = "**WinterGram** lets you access your\nmessages from multiple devices."; "Tour.Text5" = "Free and fully **open-source**.\nInspect and build it yourself.";
"Tour.Title6" = "Free"; "Tour.Title6" = "Ghost Mode";
"Tour.Text6" = "**WinterGram** provides free unlimited\ncloud storage for chats and media."; "Tour.Text6" = "Read messages without sending\nread receipts, online or typing status.";
"Tour.StartButton" = "Start Messaging"; "Tour.StartButton" = "Start Messaging";
@ -16509,6 +16509,7 @@ Error: %8$@";
"WinterGram.ShowMessageSeconds" = "Show Message Seconds"; "WinterGram.ShowMessageSeconds" = "Show Message Seconds";
"WinterGram.ShowPeerID" = "Show Peer ID"; "WinterGram.ShowPeerID" = "Show Peer ID";
"WinterGram.ShowRegistrationDate" = "Show Registration Date"; "WinterGram.ShowRegistrationDate" = "Show Registration Date";
"WinterGram.RegistrationDate" = "Registration date";
"WinterGram.SingleCornerRadius" = "Single Corner Radius"; "WinterGram.SingleCornerRadius" = "Single Corner Radius";
"WinterGram.SomeSettingsWillTakeEffectAfterRestart" = "Some settings will take effect after restart."; "WinterGram.SomeSettingsWillTakeEffectAfterRestart" = "Some settings will take effect after restart.";
"WinterGram.SpoofAppVersion" = "App Version"; "WinterGram.SpoofAppVersion" = "App Version";

View file

@ -34,12 +34,12 @@ class UITests: XCTestCase {
deleteTestAccount(phone: "9996625296") deleteTestAccount(phone: "9996625296")
app.launch() app.launch()
// Welcome screen tap Start Messaging // Start messaging.
let startButton = app.buttons["Auth.Welcome.StartButton"] let startButton = app.buttons["Auth.Welcome.StartButton"]
XCTAssert(startButton.waitForExistence(timeout: 5.0)) XCTAssert(startButton.waitForExistence(timeout: 5.0))
startButton.tap() startButton.tap()
// Phone entry screen enter test phone number // Enter the test phone number.
let countryCodeField = app.textFields["Auth.PhoneEntry.CountryCodeField"] let countryCodeField = app.textFields["Auth.PhoneEntry.CountryCodeField"]
XCTAssert(countryCodeField.waitForExistence(timeout: 10.0)) XCTAssert(countryCodeField.waitForExistence(timeout: 10.0))
countryCodeField.tap() countryCodeField.tap()
@ -57,12 +57,12 @@ class UITests: XCTestCase {
XCTAssert(continueButton.isEnabled) XCTAssert(continueButton.isEnabled)
continueButton.tap() continueButton.tap()
// Confirmation dialog tap Continue // Confirm.
let confirmButton = app.buttons["Auth.PhoneConfirm.ContinueButton"] let confirmButton = app.buttons["Auth.PhoneConfirm.ContinueButton"]
XCTAssert(confirmButton.waitForExistence(timeout: 5.0)) XCTAssert(confirmButton.waitForExistence(timeout: 5.0))
confirmButton.tap() confirmButton.tap()
// Code entry screen enter verification code // Enter the verification code.
let codeEntryTitle = app.staticTexts["Auth.CodeEntry.Title"] let codeEntryTitle = app.staticTexts["Auth.CodeEntry.Title"]
XCTAssert(codeEntryTitle.waitForExistence(timeout: 15.0)) XCTAssert(codeEntryTitle.waitForExistence(timeout: 15.0))
@ -70,7 +70,7 @@ class UITests: XCTestCase {
XCTAssert(codeField.waitForExistence(timeout: 3.0)) XCTAssert(codeField.waitForExistence(timeout: 3.0))
codeField.typeText("22222") codeField.typeText("22222")
// Set name screen enter name and submit // Set the name.
let firstNameField = app.textFields["Auth.SetName.FirstNameField"] let firstNameField = app.textFields["Auth.SetName.FirstNameField"]
XCTAssert(firstNameField.waitForExistence(timeout: 15.0)) XCTAssert(firstNameField.waitForExistence(timeout: 15.0))
firstNameField.tap() firstNameField.tap()

View file

@ -4,8 +4,8 @@
against an exported tgwatch source tree, optionally codesigns the result, and exposes against an exported tgwatch source tree, optionally codesigns the result, and exposes
it through the providers that `ios_application(watch_application = ...)` consumes: it through the providers that `ios_application(watch_application = ...)` consumes:
* AppleBundleInfo bundle metadata (the host reads only `.product_type`). * AppleBundleInfo: bundle metadata (the host reads only `.product_type`).
* AppleEmbeddableInfo `watch_bundles` (the zipped .app placed under Watch/). * AppleEmbeddableInfo: `watch_bundles` (the zipped .app placed under Watch/).
The watch source tree is the committed in-repo snapshot at `Telegram/WatchApp/` (tracked 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 inputs). To update it, re-sync from the standalone tgwatch repo via

View file

@ -2,7 +2,7 @@
# Worker for the apple_prebuilt_watchos_application Bazel rule. # Worker for the apple_prebuilt_watchos_application Bazel rule.
# #
# Builds the tgwatch watch app via xcodebuild (device, Release, UNSIGNED), then # 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, # frameworks with the watchkitapp provisioning profile and a matching identity,
# and finally zips the .app into the rule's output archive. # and finally zips the .app into the rule's output archive.
# #
@ -45,7 +45,7 @@ DD="$(mktemp -d)"
trap 'rm -rf "$DD"' EXIT trap 'rm -rf "$DD"' EXIT
# Build from a writable copy so xcodebuild/SwiftPM never write into the (possibly # 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. # The tree is small (~12M); a plain cp on each (uncached) build is acceptable.
WORKSRC="$DD/src" WORKSRC="$DD/src"
mkdir -p "$WORKSRC" mkdir -p "$WORKSRC"
@ -84,7 +84,7 @@ fi
# Sign the watch app whenever a provisioning profile is available. When no explicit # 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 # 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 # 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 # every nested bundle must carry the Apple submission certificate. Without a profile
# the app is left unsigned (the host does not re-sign it). # the app is left unsigned (the host does not re-sign it).
if [ -n "$PROFILE" ]; then if [ -n "$PROFILE" ]; then

View file

@ -14,7 +14,9 @@ def is_apple_silicon():
def get_clean_env(use_clean_env=True): def get_clean_env(use_clean_env=True):
clean_env = os.environ.copy() clean_env = os.environ.copy()
if use_clean_env: 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 return clean_env

View file

@ -533,13 +533,10 @@ def resolve_configuration(base_path, bazel_command_line: BazelCommandLine, argum
) )
aps_environment = codesigning_data.aps_environment aps_environment = codesigning_data.aps_environment
if aps_environment is None: if aps_environment is None:
# WinterGram: for unsigned sideload builds (provisioning disabled), the fake profiles # Unsigned builds use a placeholder aps-environment instead of failing.
# don't match the custom bundle id, so no aps-environment is found. Push won't work if getattr(arguments, 'disableProvisioningProfiles', False) or getattr(arguments, 'disableExtensions', False):
# until AltStore / SideStore / LiveContainer re-signs with the user's profile, so we print('No aps-environment found; disabling APNs entitlement for unsigned sideload build.')
# fall back to a placeholder instead of failing the build. aps_environment = ''
if getattr(arguments, 'disableProvisioningProfiles', False):
print('No aps-environment found; defaulting to "development" for unsigned sideload build.')
aps_environment = 'development'
else: else:
print('Could not find a valid aps-environment entitlement in the provided provisioning profiles') print('Could not find a valid aps-environment entitlement in the provided provisioning profiles')
sys.exit(1) 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 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 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 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 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. 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 ' '--embedWatchApp is set for a distribution build (--gitCodesigningType={ct}), but no watchkitapp '
'provisioning profile resolved (looked for {p}).\n' 'provisioning profile resolved (looked for {p}).\n'
'The {ct} codesigning material does not contain a `.watchkitapp` profile, so the embedded watch app ' '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' '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 ' '--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( 'watchkitapp provisioning profile, or pass an explicit --watchProvisioningProfile.'.format(
ct=arguments.gitCodesigningType, p=resolved_watch_profile ct=arguments.gitCodesigningType, p=resolved_watch_profile
) )

View file

@ -49,7 +49,10 @@ def generate_xcodeproj(build_environment: BuildEnvironment, disable_extensions,
call_executable(bazel_generate_arguments) 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 return xcodeproj_path

View file

@ -207,8 +207,8 @@ enum CodeGenerator {
let structName = "\(apiPrefix)\(layerNumber)" let structName = "\(apiPrefix)\(layerNumber)"
let filename = "\(apiPrefix)Layer\(layerNumber).swift" let filename = "\(apiPrefix)Layer\(layerNumber).swift"
// All nested type refs (inside the struct) use `structName` as their prefix // Nested type refs use `structName` as their prefix.
// `apiPrefix` is used only to compute `structName` and `filename`. Helpers like // `apiPrefix` only computes `structName` and `filename`. Helpers like
// typeReferenceRepresentation and generateFieldParsing get `structName`, not // typeReferenceRepresentation and generateFieldParsing get `structName`, not
// `apiPrefix`, so e.g. fields render as `media: SecretApi8.DecryptedMessageMedia`. // `apiPrefix`, so e.g. fields render as `media: SecretApi8.DecryptedMessageMedia`.

View file

@ -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 "$@"

View file

@ -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() CONTROLLER_NAME = "v1" # set by --algo flag in main()
@ -130,7 +130,7 @@ class TextRevealControllerV2:
FRAME_DT_CAP = 0.05 FRAME_DT_CAP = 0.05
INITIAL_INPUT_RATE = 40.0 # fallback velocity for the first chunk INITIAL_INPUT_RATE = 40.0 # fallback velocity for the first chunk
# When predicted_next_arrival has passed (stream stalled), don't speed up # 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 STALL_FLOOR = 0.10
def __init__(self, initial_revealed_count: int, initial_length: int) -> None: def __init__(self, initial_revealed_count: int, initial_length: int) -> None:

View file

@ -1,26 +1,9 @@
#!/bin/bash #!/bin/bash
# WinterGram build wrapper — produces WinterGram-named IPAs for every install target. # WinterGram build wrapper.
# 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
set -euo pipefail set -euo pipefail
# Resolve to the repo root regardless of where the script is invoked from (it lives in scripts/).
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
REPO="$(pwd)" REPO="$(pwd)"
source ~/.zshrc 2>/dev/null || true
OUT_DIR="build" OUT_DIR="build"
SIDELOAD_NAME="WinterGram.ipa" SIDELOAD_NAME="WinterGram.ipa"
@ -29,12 +12,19 @@ SIM_NAME="WinterGram-Simulator.ipa"
WNT_BUNDLE_ID="dev.reekeer.wintergram" WNT_BUNDLE_ID="dev.reekeer.wintergram"
BAZEL="./build-input/bazel-8.4.2-darwin-arm64" BAZEL="./build-input/bazel-8.4.2-darwin-arm64"
DEVICE_SRC="bazel-bin/Telegram/Telegram.ipa" 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" MODE="all"
INSTALL_REQUESTED=0
INSTALL_DEVICE=0
INSTALL_SIM=0 INSTALL_SIM=0
RUN_SIM=0 RUN_APP=0
CLEAN=0 CLEAN=0
OPEN_BUILD_DIR=0 OPEN_BUILD_DIR=0
DEVICE_SELECTOR=""
usage() { usage() {
cat <<EOF cat <<EOF
@ -45,19 +35,25 @@ Usage:
Modes: Modes:
all Build all deliverables. Default. all Build all deliverables. Default.
sideload, device Build device sideload IPA -> build/$SIDELOAD_NAME sideload, device,
ios Build device sideload IPA -> build/$SIDELOAD_NAME
livecontainer, lc Build unsigned LiveContainer IPA -> build/$LC_NAME livecontainer, lc Build unsigned LiveContainer IPA -> build/$LC_NAME
sim, simulator Build simulator IPA -> build/$SIM_NAME sim, simulator Build simulator IPA -> build/$SIM_NAME
Options: Options:
--install Build the simulator IPA and install it into the active booted Simulator. --install Install after build. With ios/device/sideload mode, installs on a connected
This forces "sim" mode (install only makes sense for the simulator). iPhone/iPad via xcrun devicectl. With sim mode, installs into the active
booted Simulator. With no explicit mode, keeps the old simulator shortcut.
--device <id|name> Device selector for devicectl. Optional if exactly one iPhone/iPad is connected.
--run Launch the app after --install (implies --install). --run Launch the app after --install (implies --install).
--clean Remove ./build before building. --clean Remove ./build before building.
--open-build-dir Open ./build in Finder after build. --open-build-dir Open ./build in Finder after build.
-h, --help Show this help. -h, --help Show this help.
Examples: Examples:
$0 ios --install
$0 ios --install --run
$0 ios --install --device "Del's iPhone"
$0 --install $0 --install
$0 --install --run $0 --install --run
$0 sideload --clean $0 sideload --clean
@ -83,16 +79,21 @@ MODE_WAS_EXPLICIT=0
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case "$1" in case "$1" in
all|sideload|device|livecontainer|lc|sim|simulator) all|sideload|device|ios|livecontainer|lc|sim|simulator)
MODE="$1" MODE="$1"
MODE_WAS_EXPLICIT=1 MODE_WAS_EXPLICIT=1
;; ;;
--install) --install)
INSTALL_SIM=1 INSTALL_REQUESTED=1
;; ;;
--run) --run)
RUN_SIM=1 RUN_APP=1
INSTALL_SIM=1 INSTALL_REQUESTED=1
;;
--device)
shift
[ "$#" -gt 0 ] || die "--device requires a value"
DEVICE_SELECTOR="$1"
;; ;;
--clean) --clean)
CLEAN=1 CLEAN=1
@ -111,13 +112,26 @@ while [ "$#" -gt 0 ]; do
shift shift
done done
# --install is simulator-only: installing a device/livecontainer IPA into a Simulator makes no if [ "$INSTALL_REQUESTED" -eq 1 ]; then
# sense, so --install always forces "sim" mode (warning if a conflicting mode was given). case "$MODE" in
if [ "$INSTALL_SIM" -eq 1 ]; then sim|simulator)
if [ "$MODE_WAS_EXPLICIT" -eq 1 ] && [ "$MODE" != "sim" ] && [ "$MODE" != "simulator" ]; then INSTALL_SIM=1
echo "==> --install is simulator-only; ignoring mode '$MODE' and building 'sim'." >&2 ;;
fi sideload|device|ios)
MODE="sim" 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 fi
if [ "$CLEAN" -eq 1 ]; then if [ "$CLEAN" -eq 1 ]; then
@ -135,8 +149,10 @@ build_sim() {
--cacheDir "$HOME/telegram-bazel-cache" \ --cacheDir "$HOME/telegram-bazel-cache" \
build \ build \
--configurationPath build-system/wintergram-development-configuration.json \ --configurationPath build-system/wintergram-development-configuration.json \
--codesigningInformationPath build-system/fake-codesigning-wintergram \ --codesigningInformationPath build-system/fake-codesigning \
--disableProvisioningProfiles \
--disableExtensions \ --disableExtensions \
--bazelArguments="$BAZEL_XCODE_ACTION_ENV $BAZEL_SDK_COMPAT_ARGS" \
--buildNumber=1 --configuration=debug_sim_arm64 --buildNumber=1 --configuration=debug_sim_arm64
[ -f "$DEVICE_SRC" ] || die "simulator artifact not found at $DEVICE_SRC" [ -f "$DEVICE_SRC" ] || die "simulator artifact not found at $DEVICE_SRC"
@ -181,7 +197,7 @@ install_sim() {
echo "==> [Simulator] installed: $(basename "$APP_PATH")" echo "==> [Simulator] installed: $(basename "$APP_PATH")"
if [ "$RUN_SIM" -eq 1 ]; then if [ "$RUN_APP" -eq 1 ]; then
[ -n "$INSTALLED_BUNDLE_ID" ] || { [ -n "$INSTALLED_BUNDLE_ID" ] || {
rm -rf "$TMP_DIR" rm -rf "$TMP_DIR"
die "could not read CFBundleIdentifier from app Info.plist" die "could not read CFBundleIdentifier from app Info.plist"
@ -194,6 +210,135 @@ install_sim() {
rm -rf "$TMP_DIR" 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 ----------------------- # --- device build shared by sideload + livecontainer -----------------------
ensure_cert() { ensure_cert() {
@ -207,13 +352,17 @@ ensure_cert() {
build_device() { build_device() {
ensure_cert 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) ..." echo "==> [Device] build (debug_arm64, ${WNT_BUNDLE_ID}, extensions disabled) ..."
python3 build-system/Make/Make.py --overrideXcodeVersion \ python3 build-system/Make/Make.py --overrideXcodeVersion \
--cacheDir "$HOME/telegram-bazel-cache" \ --cacheDir "$HOME/telegram-bazel-cache" \
build \ build \
--configurationPath build-system/wintergram-development-configuration.json \ --configurationPath build-system/wintergram-development-configuration.json \
--codesigningInformationPath build-system/fake-codesigning-wintergram \ --codesigningInformationPath build-system/fake-codesigning-generated \
--disableExtensions \ --disableExtensions \
--bazelArguments="$BAZEL_XCODE_ACTION_ENV $BAZEL_SDK_COMPAT_ARGS" \
--buildNumber=1 --configuration=debug_arm64 --buildNumber=1 --configuration=debug_arm64
[ -f "$DEVICE_SRC" ] || die "device artifact not found at $DEVICE_SRC" [ -f "$DEVICE_SRC" ] || die "device artifact not found at $DEVICE_SRC"
@ -265,7 +414,7 @@ case "$MODE" in
sim|simulator) sim|simulator)
build_sim build_sim
;; ;;
sideload|device) sideload|device|ios)
build_device build_device
make_sideload make_sideload
;; ;;
@ -274,8 +423,7 @@ case "$MODE" in
make_livecontainer make_livecontainer
;; ;;
all) all)
# One device build feeds BOTH device deliverables; derive them before the sim build # Derive device deliverables before the simulator build overwrites the artifact.
# overwrites bazel-bin/Telegram/Telegram.ipa with the simulator artifact.
build_device build_device
make_sideload make_sideload
make_livecontainer make_livecontainer
@ -287,14 +435,17 @@ case "$MODE" in
esac esac
if [ "$INSTALL_SIM" -eq 1 ]; then if [ "$INSTALL_SIM" -eq 1 ]; then
# --install forced MODE=sim above, so the simulator IPA was just built — install it.
install_sim install_sim
fi fi
if [ "$INSTALL_DEVICE" -eq 1 ]; then
install_device
fi
echo echo
echo "==> WinterGram deliverables in ./$OUT_DIR/:" echo "==> WinterGram deliverables in ./$OUT_DIR/:"
ls -1 "$OUT_DIR"/WinterGram*.ipa 2>/dev/null | sed 's#^# #' || true ls -1 "$OUT_DIR"/WinterGram*.ipa 2>/dev/null | sed 's#^# #' || true
if [ "$OPEN_BUILD_DIR" -eq 1 ]; then if [ "$OPEN_BUILD_DIR" -eq 1 ]; then
open "$OUT_DIR" open "$OUT_DIR"
fi fi

View file

@ -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 <old_team>.<old_bundle><suffix>
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()

View file

@ -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 <<EOF
Generate WinterGram.xcodeproj with your signing settings.
Usage:
$0 --team-id TEAMID [--bundle-id com.reekeer.wintergram] [--api-id ID --api-hash HASH]
Environment variables are also supported:
WINTERGRAM_TEAM_ID, WINTERGRAM_BUNDLE_ID, WINTERGRAM_API_ID, WINTERGRAM_API_HASH
Example:
$0 --team-id ABCDE12345 --bundle-id com.reekeer.wintergram
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--team-id)
TEAM_ID="${2:-}"
shift 2
;;
--bundle-id)
BUNDLE_ID="${2:-}"
shift 2
;;
--api-id)
API_ID="${2:-}"
shift 2
;;
--api-hash)
API_HASH="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "ERROR: unknown argument: $1" >&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"

394
scripts/wintergram-badge-tool.py Executable file
View file

@ -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())

View file

@ -31,6 +31,7 @@ swift_library(
"//submodules/SolidRoundedButtonNode:SolidRoundedButtonNode", "//submodules/SolidRoundedButtonNode:SolidRoundedButtonNode",
"//submodules/ImageCompression:ImageCompression", "//submodules/ImageCompression:ImageCompression",
"//submodules/RMIntro:RMIntro", "//submodules/RMIntro:RMIntro",
"//submodules/AppBundle:AppBundle",
"//submodules/QrCode:QrCode", "//submodules/QrCode:QrCode",
"//submodules/PhoneInputNode:PhoneInputNode", "//submodules/PhoneInputNode:PhoneInputNode",
"//submodules/CodeInputView:CodeInputView", "//submodules/CodeInputView:CodeInputView",

View file

@ -94,7 +94,8 @@ public final class AuthorizationSequencePhoneEntryController: ViewController, MF
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(self.cancelPressed)) 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) AuthorizationSequenceCountrySelectionController.setupCountryCodes(countries: countriesConfiguration.countries, codesByPrefix: countriesConfiguration.countriesByPrefix)
} }
} }

View file

@ -9,6 +9,7 @@ import TelegramPresentationData
import LegacyComponents import LegacyComponents
import SolidRoundedButtonNode import SolidRoundedButtonNode
import RMIntro import RMIntro
import AppBundle
public final class AuthorizationSequenceSplashController: ViewController { public final class AuthorizationSequenceSplashController: ViewController {
private var controllerNode: AuthorizationSequenceSplashControllerNode { private var controllerNode: AuthorizationSequenceSplashControllerNode {
@ -78,7 +79,11 @@ public final class AuthorizationSequenceSplashController: ViewController {
super.init(navigationBarPresentationData: nil) super.init(navigationBarPresentationData: nil)
self._hasGlassStyle = true 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.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait)
self.statusBar.statusBarStyle = theme.intro.statusBarStyle.style self.statusBar.statusBarStyle = theme.intro.statusBarStyle.style

View file

@ -1173,7 +1173,7 @@ public func richMarkdownAttributeIfNeeded(context: AccountContext, attributedTex
// .textCustomEmoji in markdownInlineContent. A link is entity-expressible, // .textCustomEmoji in markdownInlineContent. A link is entity-expressible,
// so an emoji-only message still classifies as not-rich (and falls through // so an emoji-only message still classifies as not-rich (and falls through
// to the entity path, where its untouched attribute makes a .CustomEmoji // 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) let text = markdownSourceInjectingCustomEmojiMarkers(attributedText)
guard markdownMightNeedRichLayout(text) else { guard markdownMightNeedRichLayout(text) else {
return nil return nil

View file

@ -16,6 +16,7 @@ swift_library(
"//submodules/TelegramCore:TelegramCore", "//submodules/TelegramCore:TelegramCore",
"//submodules/TelegramPresentationData:TelegramPresentationData", "//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/TelegramStringFormatting:TelegramStringFormatting", "//submodules/TelegramStringFormatting:TelegramStringFormatting",
"//submodules/libphonenumber:libphonenumber",
"//submodules/SearchBarNode:SearchBarNode", "//submodules/SearchBarNode:SearchBarNode",
"//submodules/AppBundle:AppBundle", "//submodules/AppBundle:AppBundle",
"//submodules/ComponentFlow", "//submodules/ComponentFlow",

View file

@ -11,6 +11,9 @@ import TelegramCore
import ComponentFlow import ComponentFlow
import BundleIconComponent import BundleIconComponent
import GlassBarButtonComponent import GlassBarButtonComponent
import libphonenumber
private let countryPhoneNumberUtil = NBPhoneNumberUtil()
private func loadCountryCodes() -> [Country] { private func loadCountryCodes() -> [Country] {
guard let filePath = getAppBundle().path(forResource: "PhoneCountries", ofType: "txt") else { guard let filePath = getAppBundle().path(forResource: "PhoneCountries", ofType: "txt") else {
@ -56,10 +59,38 @@ private func loadCountryCodes() -> [Country] {
let countryName = locale.localizedString(forIdentifier: countryId) ?? "" let countryName = locale.localizedString(forIdentifier: countryId) ?? ""
if let _ = Int(countryCode) { 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) let country = Country(id: countryId, name: countryName, localizedName: nil, countryCodes: [code], hidden: false)
result.append(country) 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 { if let maybeNameRange = maybeNameRange {
@ -74,28 +105,36 @@ private func loadCountryCodes() -> [Country] {
return result return result
} }
private var countryCodes: [Country] = loadCountryCodes()
private var countryCodesByPrefix: [String: (Country, Country.CountryCode)] = [:] 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<TelegramAccountManagerTypes>, engine: TelegramEngineUnauthorized, completion: @escaping () -> Void) { public func loadServerCountryCodes(accountManager: AccountManager<TelegramAccountManagerTypes>, engine: TelegramEngineUnauthorized, completion: @escaping () -> Void) {
let _ = (engine.localization.getCountriesList(accountManager: accountManager, langCode: nil) let _ = (engine.localization.getCountriesList(accountManager: accountManager, langCode: nil)
|> deliverOnMainQueue).start(next: { countries in |> 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 { Queue.mainQueue().async {
completion() completion()
} }
@ -105,21 +144,8 @@ public func loadServerCountryCodes(accountManager: AccountManager<TelegramAccoun
public func loadServerCountryCodes(accountManager: AccountManager<TelegramAccountManagerTypes>, engine: TelegramEngine, completion: @escaping () -> Void) { public func loadServerCountryCodes(accountManager: AccountManager<TelegramAccountManagerTypes>, engine: TelegramEngine, completion: @escaping () -> Void) {
let _ = (engine.localization.getCountriesList(accountManager: accountManager, langCode: nil) let _ = (engine.localization.getCountriesList(accountManager: accountManager, langCode: nil)
|> deliverOnMainQueue).start(next: { countries in |> 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 { Queue.mainQueue().async {
completion() completion()
} }
@ -204,6 +230,9 @@ public final class AuthorizationSequenceCountrySelectionController: ViewControll
} }
public static func setupCountryCodes(countries: [Country], codesByPrefix: [String: (Country, Country.CountryCode)]) { public static func setupCountryCodes(countries: [Country], codesByPrefix: [String: (Country, Country.CountryCode)]) {
guard !countries.isEmpty else {
return
}
countryCodes = countries countryCodes = countries
countryCodesByPrefix = codesByPrefix countryCodesByPrefix = codesByPrefix
} }
@ -229,6 +258,7 @@ public final class AuthorizationSequenceCountrySelectionController: ViewControll
public static func lookupCountryIdByNumber(_ number: String, preferredCountries: [String: String]) -> (Country, Country.CountryCode)? { public static func lookupCountryIdByNumber(_ number: String, preferredCountries: [String: String]) -> (Country, Country.CountryCode)? {
let number = removePlus(number) let number = removePlus(number)
var results: [(Country, Country.CountryCode)]? = nil 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 { if number.count == 1, let preferredCountryId = preferredCountries[number], let country = lookupCountryById(preferredCountryId), let code = country.countryCodes.first {
return (country, code) return (country, code)
} }
@ -244,10 +274,19 @@ public final class AuthorizationSequenceCountrySelectionController: ViewControll
} }
} else { } else {
results = [country] results = [country]
matchedPrefixLength = prefix.count
} }
} }
} }
if let results = results { 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 !preferredCountries.isEmpty, let (_, code) = results.first {
if let preferredCountry = preferredCountries[code.code] { if let preferredCountry = preferredCountries[code.code] {
for (country, code) in results { for (country, code) in results {

View file

@ -62,8 +62,8 @@ private func drawRectsImageContent(size: CGSize, context: CGContext, color: UICo
} else { } else {
// The path-stitching loop below assumes consecutive rects in a group // The path-stitching loop below assumes consecutive rects in a group
// are adjacent (overlapping or sharing an edge). When they're disjoint // are adjacent (overlapping or sharing an edge). When they're disjoint
// vertical gap, vertical inversion, or horizontal gap on the same // with a vertical gap, vertical inversion, or horizontal gap on the same
// line it bridges them with a polygon perimeter that renders as a // line, it bridges them with a polygon perimeter that renders as a
// diagonal bridge across empty space. Split groups whenever the next // diagonal bridge across empty space. Split groups whenever the next
// rect doesn't intersect the last one (1pt slop in both axes matches // rect doesn't intersect the last one (1pt slop in both axes matches
// the snap loop's adjacency test). // the snap loop's adjacency test).

View file

@ -4,9 +4,9 @@ import TelegramCore
/// Recurses the `InstantPage` block tree to locate the anchor `name`, returning the /// Recurses the `InstantPage` block tree to locate the anchor `name`, returning the
/// details-sibling-ordinal path of enclosing `<details>` blocks (outermost first). /// details-sibling-ordinal path of enclosing `<details>` blocks (outermost first).
/// ///
/// - `nil` the anchor exists nowhere. /// - `nil`: the anchor does not exist.
/// - `[]` the anchor exists outside any `<details>` (top level, a table cell, a quote, etc.). /// - `[]`: the anchor is outside any `<details>`.
/// - `[2,0]` inside the 3rd top-level `<details>`, then that details' 1st nested `<details>`. /// - `[2, 0]`: the third top-level `<details>`, then its first nested `<details>`.
/// ///
/// Ordinals (not layout indices) because the layout's details index counter is /// Ordinals (not layout indices) because the layout's details index counter is
/// expansion-dependent. Consumers map ordinals to live indices via /// expansion-dependent. Consumers map ordinals to live indices via
@ -71,23 +71,7 @@ private func instantPageAnchorPathSearch(
} }
} }
default: default:
// .unsupported/.divider/.formula/.image/.video/.audio/.webEmbed/.channelBanner/.map // Keep recursion aligned with the V2 block layout.
// 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 <details> 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 <details> 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.
break break
} }
} }

View file

@ -206,7 +206,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
// button. An ASControl's `.touchUpInside` is cancelled by the chat ListView's gesture // 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 // 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 // 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 // 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 // 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.) // 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)) self.scrubbingNode.frame = CGRect(origin: CGPoint(x: leftScrubberInset, y: 26.0 + topOffset), size: CGSize(width: size.width - leftScrubberInset - rightScrubberInset, height: 15.0))
} }
} }

View file

@ -14,7 +14,7 @@ import LocationUI
/// - `.webpage(webPage)` cover single-entry `InstantPageGalleryController`. /// - `.webpage(webPage)` cover single-entry `InstantPageGalleryController`.
/// - `.file(file)` with `isAnimated` single-entry gallery in "playing video" mode. /// - `.file(file)` with `isAnimated` single-entry gallery in "playing video" mode.
/// - Default multi-entry gallery built from `allMedias` (filtered to `.image` and non-audio /// - 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. /// video" mode is on.
/// ///
/// Behavior matches V1's `InstantPageControllerNode.openMedia(_:)` bit-for-bit. /// 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 /// - 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 /// 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 /// 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 /// - 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. /// 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 /// - hiddenMediaCallback: invoked while the gallery is foregrounded so callers can hide the

View file

@ -181,7 +181,7 @@ public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol
// A blockquote is exploded by the layout into one text item per child line, // 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 // 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 // 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. // quote line would become its own block and be separated by a blank line.
if depth > 0 { if depth > 0 {
var lines: [String] = [] var lines: [String] = []

View file

@ -21,7 +21,7 @@ import ShimmeringMask
/// Stable identity for an `InstantPageV2LaidOutItem` across `update()` calls. The renderer /// 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 /// 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. /// signals + image content on every chat-bubble re-apply.
/// ///
/// Media items use their `media.index` (already unique within a page and used as the /// 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 // MARK: - Render context
/// Bundle of render-time dependencies required to display real media inside an InstantPage V2 /// 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 /// (typically because the bubble was recycled with a different webpage), the caller must
/// rebuild the V2View with a fresh render context. /// 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. /// streaming bubble to reuse one V2View across `stableVersion` bumps instead of rebuilding.
/// Only `webpage` changes across chunks; the `imageReference`/`fileReference` closures keep /// Only `webpage` changes across chunks; the `imageReference`/`fileReference` closures keep
/// their construction-time `MessageReference` snapshot, which is acceptable because the message /// 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) { public func updateContent(webpage: TelegramMediaWebpage) {
self.webpage = webpage self.webpage = webpage
} }
@ -152,7 +152,7 @@ public final class InstantPageV2View: UIView {
private var emojiEnableLooping: Bool = true private var emojiEnableLooping: Bool = true
/// Scroll-visibility rect in this view's coordinate space; gates emoji animation looping. /// 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`. /// propagated down the nested tree (details/table) by `propagateVisibilityRect`.
public var visibilityRect: CGRect? { public var visibilityRect: CGRect? {
didSet { didSet {
@ -179,7 +179,7 @@ public final class InstantPageV2View: UIView {
/// Walks the `rootMediaRegistryHost` chain transitively until it finds a self-referencing /// 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 /// 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. /// `propagateRegistryHost(to:)` only walks one hop, so the chain must be followed at lookup.
var trueRegistryRoot: InstantPageV2View { var trueRegistryRoot: InstantPageV2View {
var host: InstantPageV2View = self var host: InstantPageV2View = self
@ -251,7 +251,7 @@ public final class InstantPageV2View: UIView {
let newFrame = InstantPageV2View.actualFrame(forItem: item) // parent positions child let newFrame = InstantPageV2View.actualFrame(forItem: item) // parent positions child
if animation.isAnimated && reusedView.frame != newFrame { if animation.isAnimated && reusedView.frame != newFrame {
// A collapsing details view keeps its body alive; remove it once this // 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 let detailsView = reusedView as? InstantPageV2DetailsView
animation.animator.updateFrame(layer: reusedView.layer, frame: newFrame, completion: { [weak detailsView] _ in animation.animator.updateFrame(layer: reusedView.layer, frame: newFrame, completion: { [weak detailsView] _ in
detailsView?.finalizePendingCollapse() detailsView?.finalizePendingCollapse()
@ -316,7 +316,7 @@ public final class InstantPageV2View: UIView {
for view in self.itemViews { for view in self.itemViews {
// Top-level `.text` items host their emoji directly. The thinking block hosts emoji on // 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 // 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. // sub-layouts (details bodies, table cells) instead run their own updateInlineEmoji.
let textView: InstantPageV2TextView 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 // 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` // 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. // root assignment fans out across the whole tree.
private func propagateVisibilityRect() { private func propagateVisibilityRect() {
for view in self.itemViews { 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 /// For most item types this is `item.frame`. `InstantPageV2TextView` widens its backing store
/// by `v2TextViewClippingInset` on every side to accommodate glyph overhang and underline /// 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 /// rendering past the text's logical `maxY`. The reuse path applies the same expansion so re-layout
/// constructing the view. The reuse path must apply the same expansion so that re-layout
/// (theme change, bubble resize, etc.) does not clip italic glyphs or underlines. /// (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. /// 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 maxCharacterDrawCount: Int?
private var previousMaxCharacterDrawCount: Int = 0 private var previousMaxCharacterDrawCount: Int = 0
private var revealMaskLayer: SimpleLayer? private var revealMaskLayer: SimpleLayer?
@ -962,7 +961,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
self.item = item self.item = item
// Lay every container out from the item's own (clipping-inset-expanded) frame rather than // 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 // 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. // renderContainer/renderView too, which the old update path skipped.
let containerBounds = CGRect(origin: .zero, size: item.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset).size) let containerBounds = CGRect(origin: .zero, size: item.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset).size)
self.renderContainer.frame = containerBounds self.renderContainer.frame = containerBounds
@ -1165,7 +1164,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
let currentLineInfos = self.computeRevealedLines(characterLimit: effectiveCharacterDrawCount) let currentLineInfos = self.computeRevealedLines(characterLimit: effectiveCharacterDrawCount)
// Snippet spawn pass animate newly-revealed characters. // Animate newly revealed characters.
if self.previousMaxCharacterDrawCount < effectiveCharacterDrawCount, if self.previousMaxCharacterDrawCount < effectiveCharacterDrawCount,
let contents = self.renderView.layer.contents, let contents = self.renderView.layer.contents,
animateNewSegments { 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 // (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 // 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. // view, which would hide every char until the next tick.
let maskCharacterLimit: Int let maskCharacterLimit: Int
@ -1257,7 +1256,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
// Per-glyph rect captures descenders, italic overhang, accents exactly. Per // Per-glyph rect captures descenders, italic overhang, accents exactly. Per
// line we accumulate the union of revealed glyphs into one mask rect (one // line we accumulate the union of revealed glyphs into one mask rect (one
// CALayer sublayer per line), and consecutive fully-revealed lines collapse // 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. // sublayer regardless of line count.
// //
// Lines without per-character data (computeRevealCharacterRects == false on // Lines without per-character data (computeRevealCharacterRects == false on
@ -1298,7 +1297,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
lineUnion = union lineUnion = union
remainingChars -= characterRects.count remainingChars -= characterRects.count
} else { } else {
// No per-character data expose the whole line. // Expose the whole line without character data.
lineUnion = line.range.length > 0 ? renderLocalLineFrame : nil lineUnion = line.range.length > 0 ? renderLocalLineFrame : nil
isFullLine = remainingChars >= line.range.length isFullLine = remainingChars >= line.range.length
remainingChars -= line.range.length remainingChars -= line.range.length
@ -1705,10 +1704,10 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
var bodyView: InstantPageV2View? var bodyView: InstantPageV2View?
private let titleHitView: UIView 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) 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 // 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. // (InstantPageV2View.update) calls from the completion of the frame-shrink (clip) animation.
private var bodyPendingRemoval = false private var bodyPendingRemoval = false
@ -1744,7 +1743,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
super.init(frame: item.frame) super.init(frame: item.frame)
self.backgroundColor = .clear // structural 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.titleTextView)
self.addSubview(self.chevronView) self.addSubview(self.chevronView)
@ -1755,7 +1754,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
self.titleHitView.addGestureRecognizer(tap) self.titleHitView.addGestureRecognizer(tap)
// All content (title, chevron tint/position, separator, titleHit frame, body) flows through // 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) self.update(item: item, theme: theme, renderContext: renderContext, animation: .None)
} }
@ -1786,7 +1785,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
self.titleHitView.frame = item.titleFrame self.titleHitView.frame = item.titleFrame
// Body lifecycle. The reveal/hide of the body is produced by the parent animating this // 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 // InstantPageV2View.update. The body's internal layout is forwarded `animation` so a
// *nested* details block inside the body can also animate its own toggle. // *nested* details block inside the body can also animate its own toggle.
let blockHeight: CGFloat let blockHeight: CGFloat
@ -1842,7 +1841,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
height: UIScreenPixel height: UIScreenPixel
), completion: nil) ), 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. // finalizePendingCollapse(), which the parent calls from the frame-shrink (clip) animation.
let targetTransform = item.isExpanded ? InstantPageV2DetailsView.expandedChevronTransform : CATransform3DIdentity let targetTransform = item.isExpanded ? InstantPageV2DetailsView.expandedChevronTransform : CATransform3DIdentity
animation.animator.updateTransform(layer: self.chevronView.layer, transform: targetTransform, completion: nil) 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) /// 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 /// 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 /// 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() { func finalizePendingCollapse() {
if !self.item.isExpanded, self.bodyPendingRemoval { if !self.item.isExpanded, self.bodyPendingRemoval {
self.bodyView?.removeFromSuperview() 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 /// 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 /// 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 /// `(-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 /// ascenders, descenders, and the last line's underline). The inner text view fills the shimmer;
/// bare line box. The inner text view fills the shimmer; its `+inset` render translate lands the /// its `+inset` render translate lands the
/// glyphs back at self's origin, so the text position is unchanged. Mirrors how a `.text` view's /// 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`). /// frame is inset-expanded (`actualFrame` / `InstantPageV2TextView.init`).
private func layoutContents() { private func layoutContents() {
@ -2026,7 +2025,7 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
// every frame / colour / sub-layout below. Insertion order matches the original interleaved // 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 // 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 // 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 { if item.titleSubLayout != nil {
let v = InstantPageV2View(renderContext: renderContext) let v = InstantPageV2View(renderContext: renderContext)
self.contentView.addSubview(v) 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 effectiveBorderWidth = item.bordered ? v2TableBorderWidth : 0.0
let gridHeight = item.contentSize.height - gridOffsetY let gridHeight = item.contentSize.height - gridOffsetY
var stripeIndex = 0 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.) // 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. // Frames are set here (not in init) so reuse with a different grid re-positions the lines.
let lineRects = item.horizontalLines + item.verticalLines 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.cornerRadius = v2TableCornerRadius
self.contentView.layer.borderColor = item.borderColor.cgColor self.contentView.layer.borderColor = item.borderColor.cgColor
self.contentView.layer.borderWidth = item.bordered ? v2TableBorderWidth : 0.0 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. /// 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 `<details>` /// Returns nil if the anchor is missing or inside collapsed `<details>`
/// (whose inner blocks aren't laid out) or doesn't exist. Mirrors `findTextItem`. /// (whose inner blocks aren't laid out) or doesn't exist. Mirrors `findTextItem`.
func anchorFrame(name: String) -> CGRect? { func anchorFrame(name: String) -> CGRect? {
guard let layout = self.currentLayout else { return nil } 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 /// Given a details-sibling-ordinal path (from `instantPageAnchorPath`), walk the live layout
/// and return the `currentExpandedDetails` index of the FIRST not-yet-expanded `<details>` on /// and return the `currentExpandedDetails` index of the FIRST not-yet-expanded `<details>` on
/// the path. Returns nil if every details on the path is already expanded, or the path doesn't /// 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? { func firstCollapsedDetails(forOrdinalPath path: [Int]) -> Int? {
guard let layout = self.currentLayout else { return nil } guard let layout = self.currentLayout else { return nil }
var currentItems = layout.items var currentItems = layout.items
@ -2403,7 +2402,7 @@ private func collectSelectableTextItems(
/// For block formulas wider than the bubble's available width, the layout sets /// 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` /// `isScrollable = true`; this view then wraps the image in a horizontal `UIScrollView`
/// matching V1's `InstantPageScrollableNode` (no bounce on non-overflowing content, /// 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 { final class InstantPageV2FormulaView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2FormulaItem private(set) var item: InstantPageV2FormulaItem
var itemFrame: CGRect { return self.item.frame } var itemFrame: CGRect { return self.item.frame }

View file

@ -364,8 +364,7 @@ public final class InstantPageTextItem: InstantPageItem {
} }
public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? { 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 // Clamp taps to the character under the finger.
// strict, clamping behavior.
if !orNearest { if !orNearest {
return self.attributesAtPoint(point) return self.attributesAtPoint(point)
} }
@ -402,8 +401,7 @@ public final class InstantPageTextItem: InstantPageItem {
} else if point.x >= lineFrame.maxX { } else if point.x >= lineFrame.maxX {
// Trailing edge: return the line's upper bound (one past its last character) so a // 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 // 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 // upper bound is exclusive, so use the line end. Mirrors Display.TextNode.
// path does would always leave it unselected. Mirrors Display.TextNode.
index = lineRange.location + lineRange.length index = lineRange.location + lineRange.length
} else { } else {
index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: point.x - lineFrame.minX, y: 0.0)) 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 // 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 // body font (scaled proportionally). Must match the V2 layout's emoji cell size
// (InstantPageV2Layout.swift). The run delegate still reports the font's own // (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 itemSize = font.ascender - font.descender + 4.0 * font.pointSize / 17.0
let extentBuffer = UnsafeMutablePointer<RunStruct>.allocate(capacity: 1) let extentBuffer = UnsafeMutablePointer<RunStruct>.allocate(capacity: 1)
extentBuffer.initialize(to: RunStruct(ascent: font.ascender, descent: font.descender, width: itemSize)) extentBuffer.initialize(to: RunStruct(ascent: font.ascender, descent: font.descender, width: itemSize))

View file

@ -195,8 +195,7 @@ final class InstantPageV2AudioContentNode: ASDisplayNode {
self.descriptionAttributedString = InstantPageV2AudioContentNode.descriptionString(file: self.file, incoming: incoming, presentationData: presentationData) self.descriptionAttributedString = InstantPageV2AudioContentNode.descriptionString(file: self.file, incoming: incoming, presentationData: presentationData)
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
self.statusNode.backgroundNodeColor = messageTheme.mediaActiveControlColor self.statusNode.backgroundNodeColor = messageTheme.mediaActiveControlColor
// foreground/overlay also depend on incoming + theme (set at construction) refresh them // Refresh colors after an in-place theme or direction change.
// too so the play glyph isn't miscolored after an in-place theme/direction change.
self.statusNode.foregroundNodeColor = (incoming && messageTheme.mediaActiveControlColor.rgb != 0xffffff) ? .white : .clear self.statusNode.foregroundNodeColor = (incoming && messageTheme.mediaActiveControlColor.rgb != 0xffffff) ? .white : .clear
self.statusNode.overlayForegroundNodeColor = presentationData.theme.chat.message.mediaOverlayControlColors.foregroundColor self.statusNode.overlayForegroundNodeColor = presentationData.theme.chat.message.mediaOverlayControlColors.foregroundColor

View file

@ -9,14 +9,14 @@ import AccountContext
import PhotoResources import PhotoResources
import MediaResources 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. /// to render a single `RichText.image` cell inside a text view.
/// ///
/// Owned by `InstantPageV2View` (not an `InstantPageItemView` conformer; not in /// Owned by `InstantPageV2View` (not an `InstantPageItemView` conformer; not in
/// the view-factory switch). Hosted inside the parent text view's /// the view-factory switch). Hosted inside the parent text view's
/// `imageContainerView` (sibling of `renderContainer`, above the reveal mask, /// `imageContainerView` (sibling of `renderContainer`, above the reveal mask,
/// below `emojiContainerView`), so the streaming reveal can wipe text glyphs /// 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(...))` /// to the text view, so a URL-wrapping `RichText.url(text: .image(...))`
/// continues to route taps to the URL handler. /// continues to route taps to the URL handler.
final class InstantPageV2InlineImageView: UIView { final class InstantPageV2InlineImageView: UIView {

View file

@ -20,7 +20,7 @@ public struct InstantPageV2Layout {
/// `line.imageItems` MediaIds at update time. Nested layouts (details body, /// `line.imageItems` MediaIds at update time. Nested layouts (details body,
/// table cells, table title) carry the parent's same map. /// table cells, table title) carry the parent's same map.
public let media: [EngineMedia.Id: EngineMedia] 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 /// form the `WebpageReference` for `ImageMediaReference.webPage(...)`. May
/// be nil for non-webpage-anchored layouts; in that case the lookup proceeds /// 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). /// 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`. /// 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. /// expressed in their parent's local coordinates.
public func offsetBy(_ delta: CGPoint) -> InstantPageV2LaidOutItem { public func offsetBy(_ delta: CGPoint) -> InstantPageV2LaidOutItem {
switch self { switch self {
@ -470,7 +470,7 @@ public func lastTextLineFrame(in layout: InstantPageV2Layout) -> CGRect? {
} }
case let .table(table): case let .table(table):
// Walk cells in reverse row-major order (last cell of last row first). // 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 let gridOffsetY = table.titleFrame?.height ?? 0.0
for cell in table.cells.reversed() { for cell in table.cells.reversed() {
if let subLayout = cell.subLayout, let frame = lastTextLineFrame(in: subLayout) { 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 /// 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, /// 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 /// 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)? { public func lastTextLineFrameIfLastItemIsText(in layout: InstantPageV2Layout) -> (frame: CGRect, trailingBottomPadding: CGFloat)? {
guard let bottomItem = layout.items.max(by: { $0.frame.maxY < $1.frame.maxY }), guard let bottomItem = layout.items.max(by: { $0.frame.maxY < $1.frame.maxY }),
case let .text(text) = bottomItem, case let .text(text) = bottomItem,
@ -505,11 +505,9 @@ public func lastTextLineFrameIfLastItemIsText(in layout: InstantPageV2Layout) ->
else { else {
return nil return nil
} }
// The stored line frame always has minX = 0 alignment (center / right / natural-RTL) is // Alignment is applied at render time by `v2FrameForLine`. Apply the same correction here so the returned
// 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 // 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 // at the text item's left. Without this, a right-aligned or RTL last line would feed the status
// edge sits at `textItem.width`, all the way at the right text inset would feed the status
// node a `contentWidth` equal to just `lineWidth`. The trail/wrap decision would then think // 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 // 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. // 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) var contentSize = CGSize(width: boundingWidth, height: contentHeight)
if context.fitToWidth { if context.fitToWidth {
// Match V1 InstantPageLayout.swift:1114 include `+ horizontalInset` so the contentSize // Match V1 and include `horizontalInset` in the content size. This
// reserves a right margin equal to the left inset. Without this, the longest text item's // 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 // right edge equals contentSize.width, and the bubble's containerNode (sized to
// boundingSize.width - 2) clips the last 2pt of text. // boundingSize.width - 2) clips the last 2pt of text.
var maxX: CGFloat = 0.0 var maxX: CGFloat = 0.0
@ -755,7 +753,7 @@ private func layoutBlock(
context: &context context: &context
) )
} else { } 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 // behavior, which returns an empty layout for unknown image IDs (V1
// InstantPageLayout.swift:623). The existing layoutMediaWithCaption would emit a // InstantPageLayout.swift:623). The existing layoutMediaWithCaption would emit a
// grey rectangle; matching V1 instead. // grey rectangle; matching V1 instead.
@ -923,10 +921,8 @@ private func layoutBlock(
horizontalInset: horizontalInset, context: &context) horizontalInset: horizontalInset, context: &context)
case let .map(latitude, longitude, zoom, dimensions, caption): case let .map(latitude, longitude, zoom, dimensions, caption):
// AI/server-sent `.map` blocks can arrive with zero `dimensions` (the wire `w`/`h` are // `.map` blocks can arrive with zero dimensions. A zero `naturalSize.height` collapses the media
// required, but the sender may put 0). A zero `naturalSize.height` collapses the media // frame to height 0 (`instantPageV2MediaFrame`'s else branch). A zero-sized
// 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
// `MapSnapshotMediaResource` makes `MKMapSnapshotter` render nothing. Substitute a sensible // `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 // 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 // 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 // `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 // 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). // 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. // 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 // The `cornerRadius` argument is ignored when `flush == true`; flush media is unrounded.
// un-rounded; callers may still pass their legacy radius, it has no effect.)
// //
// `flush == false`: DEAD as of the V2 audio port audio was its last caller and now has its // `flush == false` is unused after the V2 audio port.
// own `layoutAudio` arm (in `layoutBlock`), so this branch is currently unreachable (follow-up: // Legacy behavior inset media by `horizontalInset` with the caller-supplied corner radius.
// drop the `flush` parameter and this branch). Legacy behavior was: inset by `horizontalInset`
// on each side with the caller-supplied corner radius.
// //
// Returns the frame, the un-bled scaled content size (the caption is offset by // 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. // `scaledSize.height`), and the effective corner radius to stamp on the item.
@ -1862,7 +1855,7 @@ private func layoutCollage(
horizontalInset: CGFloat, horizontalInset: CGFloat,
context: inout LayoutContext context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] { ) -> [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] = [] var itemSizes: [CGSize] = []
for block in innerBlocks { for block in innerBlocks {
switch block { 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) let (mosaic, mosaicSize) = chatMessageBubbleMosaicLayout(maxSize: CGSize(width: boundingWidth, height: boundingWidth), itemSizes: itemSizes)
// 3. One typed media item per resolvable cell, at its mosaic frame. // 3. One typed media item per resolvable cell, at its mosaic frame.
@ -2009,7 +2002,7 @@ private func layoutMediaWithCaption(
) )
result.append(contentsOf: captionItems) 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 // rendered (matches V1 lines 204-206: `contentSize.height > 0 && isCover`). For an
// empty-caption cover image no padding is added. // empty-caption cover image no padding is added.
// Implemented by extending the last text item's frame rather than emitting an invisible shape // 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 <details>) code blocks span the full boundingWidth flush (x=0), matching V1 // Top-level (and <details>) 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 // (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. // 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 blockHeight = textSize.height + backgroundInset * 2.0
let isNestedInQuote = horizontalInset > context.pageHorizontalInset let isNestedInQuote = horizontalInset > context.pageHorizontalInset
// Inset (quote-nested) code blocks get an 8pt rounded background; flush (top-level / details) // 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 cornerRadius: CGFloat = isNestedInQuote ? 8.0 : 0.0
let blockFrame = CGRect( let blockFrame = CGRect(
x: isNestedInQuote ? horizontalInset : 0.0, x: isNestedInQuote ? horizontalInset : 0.0,
@ -2364,9 +2357,9 @@ private func layoutBlockQuote(
attributedCaption, attributedCaption,
boundingWidth: innerBoundingWidth, boundingWidth: innerBoundingWidth,
alignment: context.rtl ? .right : .natural, 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], // 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), offset: CGPoint(x: innerHorizontalInset + (context.rtl ? -lineInset : 0.0), y: contentHeight),
fitToWidth: context.fitToWidth, fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects computeRevealCharacterRects: context.computeRevealCharacterRects
@ -2477,7 +2470,7 @@ private func layoutQuoteText(
kind: .line(thickness: 1.0), kind: .line(thickness: 1.0),
color: context.theme.textCategories.caption.color 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)) result.append(.shape(bottomLine))
} else { } else {
// blockQuote: vertical bar on the leading edge (V1 lines 547549). // blockQuote: vertical bar on the leading edge (V1 lines 547549).
@ -2494,7 +2487,7 @@ private func layoutQuoteText(
// Tag this quote's produced text items at quote depth 1 so the markdown // 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 // 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 // converter renders both flavors as `> `. Nested quotes are lifted further
// by the outer multi-block path's own bumpQuoteDepth(result) call. // by the outer multi-block path's own bumpQuoteDepth(result) call.
bumpQuoteDepth(result) bumpQuoteDepth(result)
@ -2514,12 +2507,10 @@ private func layoutList(
) -> [InstantPageV2LaidOutItem] { ) -> [InstantPageV2LaidOutItem] {
// Determine marker characteristics. // Determine marker characteristics.
var maxIndexWidth: CGFloat = 0.0 var maxIndexWidth: CGFloat = 0.0
// hasNums: at least one ordered item carries an explicit `num` in which case items // hasNums means at least one ordered item carries an explicit `num`. Items
// without one fall back to a blank " " (preserves the source's numbering gaps) rather // without one fall back to a blank " " rather
// than auto-generated `(i + 1).`. Unordered lists never auto-generate numbers, so this // 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 // flag is only meaningful when `ordered` is true.
// the uniform 8pt gap below replaced the per-list `indexSpacing` ternary that consumed
// it; column right-alignment handles mixed bullet/checkbox lists without flagging.)
var hasNums = false var hasNums = false
if ordered { if ordered {
for item in listItems { for item in listItems {
@ -2542,10 +2533,8 @@ private func layoutList(
stroke: context.theme.pageBackgroundColor, stroke: context.theme.pageBackgroundColor,
border: context.theme.controlColor border: context.theme.controlColor
) )
// Track maxIndexWidth for ALL marker kinds (ordered + unordered, all three shapes), not // Track every marker kind and align them to one shared column edge.
// just ordered as V1/older V2 did. With every kind contributing to the marker column width // Bullets and checkboxes align to the same x, and the same uniform
// 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
// gap separates them from the text. The column width simply equals the widest marker; for // 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 // 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` // identical to the pre-change formula), and for a pure checkbox list `maxIndexWidth == 18`
@ -2594,12 +2583,8 @@ private func layoutList(
// Uniform 8pt markertext gap across all four cases (ordered/unordered × bullet/number/ // Uniform 8pt markertext gap across all four cases (ordered/unordered × bullet/number/
// checkbox). With markers right-aligned to a shared column of width `maxIndexWidth`, text // checkbox). With markers right-aligned to a shared column of width `maxIndexWidth`, text
// starts at `horizontalInset + maxIndexWidth + indexSpacing` so `indexSpacing` IS the // 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, regardless of marker shape.
// 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 (148), numbers (128) and ordered-checkbox (168), and only
// loosens unordered-checkbox very slightly (68) so all four kinds match.
let indexSpacing: CGFloat = 8.0 let indexSpacing: CGFloat = 8.0
// Layout each item. // Layout each item.
@ -2722,7 +2707,7 @@ private func layoutList(
// Nil-guard in stampMarkdownContext preserves any richer kind (e.g. .heading) // 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 // 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. // a documented best-effort case for markdown reconstruction.
stampMarkdownContext(translatedItems, kind: .listItem(ordered: ordered, marker: markdownMarker, checked: markdownChecked)) stampMarkdownContext(translatedItems, kind: .listItem(ordered: ordered, marker: markdownMarker, checked: markdownChecked))
result.append(contentsOf: translatedItems) result.append(contentsOf: translatedItems)
@ -2732,7 +2717,7 @@ private func layoutList(
// Mirror the .text case above (and what .checklist already does here): use the // 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 // 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 // `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 // 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 // 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` /// 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 /// 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 /// 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( private func markerFrameFor(
kind: InstantPageV2ListMarkerKind, kind: InstantPageV2ListMarkerKind,
naturalWidth: CGFloat, 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) 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 /// 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. /// 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 // 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 // `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 // 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 // ~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 // 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 // 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 // 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` // line box and allowed to bleed above/below (mirroring V1 `layoutTextItemWithString`
// and the chat `InteractiveTextComponent`). Their run delegates already report the // 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 // 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, // 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. // 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 // Caret advance width is too tight for italics,
// CTFontGetBoundingRectsForGlyphs caret-position advance-width
// math (CTLineGetOffsetForStringIndex) is too tight for italics,
// accented marks, and any glyph with side bearings, which causes // accented marks, and any glyph with side bearings, which causes
// the reveal mask to visibly clip the glyph edges. Mirrors // the reveal mask to visibly clip the glyph edges. Mirrors
// InteractiveTextComponent.computeCharacterRectsForLine. // InteractiveTextComponent.computeCharacterRectsForLine.
@ -3406,7 +3389,7 @@ func layoutTextItem(
// Image cell is centered on the font line box (see frame loop). Baseline-relative // 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 // 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 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) rects[localIndex] = CGRect(x: x, y: fontLineHeight / 2.0 - image.size.height / 2.0, width: image.size.width, height: image.size.height)
} }
} }

View file

@ -9,17 +9,14 @@ import TelegramPresentationData
import GalleryUI import GalleryUI
import UniversalMediaPlayer import UniversalMediaPlayer
// Mutable weak box: lets a wrapper hand its `openMedia` closure a back-reference to itself, // Mutable weak reference filled after `super.init`.
// filled in after `super.init` (when `self` becomes usable). SwiftSignalKit's `Weak<T>` requires
// a non-optional value at init time, so it can't be used here.
private final class WrapperRef { private final class WrapperRef {
weak var view: UIView? weak var view: UIView?
} }
// MARK: - Shared media node factory // MARK: - Shared media node factory
// Hosts a V1 `InstantPageImageNode` inside a V2 UIView wrapper. The caller sizes its own // Creates an image node for a V2 media wrapper.
// frame from `item.frame` and adds the returned node's view as a subview.
func makeMediaWrapper( func makeMediaWrapper(
frame: CGRect, frame: CGRect,
media: InstantPageMedia, media: InstantPageMedia,
@ -52,7 +49,7 @@ func makeMediaWrapper(
return imageNode 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? { private func findEnclosingV2View(from start: UIView?) -> InstantPageV2View? {
var view: UIView? = start var view: UIView? = start
while view != nil { while view != nil {
@ -64,18 +61,13 @@ private func findEnclosingV2View(from start: UIView?) -> InstantPageV2View? {
return nil return nil
} }
// Registers `wrapper` in the root V2View's `mediaRegistry` under `mediaIndex`. The root is // Register media at the root V2 view.
// 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.
func registerInRootRegistry(wrapper: UIView, mediaIndex: Int) { func registerInRootRegistry(wrapper: UIView, mediaIndex: Int) {
guard let v2 = findEnclosingV2View(from: wrapper.superview) else { return } guard let v2 = findEnclosingV2View(from: wrapper.superview) else { return }
v2.trueRegistryRoot.mediaRegistry[mediaIndex] = Weak(wrapper) v2.trueRegistryRoot.mediaRegistry[mediaIndex] = Weak(wrapper)
} }
// Routes a tap on `tapped` through `openInstantPageMedia`, sourcing sibling medias from the // Open media with siblings from the root layout.
// root V2View's `currentLayout`. No-op if the wrapper isn't currently in a V2View tree.
func handleOpenMediaTap( func handleOpenMediaTap(
tapped: InstantPageMedia, tapped: InstantPageMedia,
wrapper: UIView, wrapper: UIView,
@ -446,9 +438,7 @@ final class InstantPageV2MediaAudioView: UIView, InstantPageItemView {
let fetchMedia = item.media let fetchMedia = item.media
self.audioNode.fetch = { self.audioNode.fetch = {
guard case let .file(file) = fetchMedia.media, let message = fetchMessage, let messageId = message.id else { return } 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 // Use the fetch manager so status reports `.Fetching`.
// messageMediaFileStatus signal which keys progress off the fetch manager's
// `hasEntry` surfaces .Fetching, letting the overlay show the animated ring.
let _ = messageMediaFileInteractiveFetched(fetchManager: fetchContext.fetchManager, messageId: messageId, messageReference: message, file: file, userInitiated: true, priority: .userInitiated).startStandalone() let _ = messageMediaFileInteractiveFetched(fetchManager: fetchContext.fetchManager, messageId: messageId, messageReference: message, file: file, userInitiated: true, priority: .userInitiated).startStandalone()
} }

View file

@ -28,7 +28,7 @@ extension InstantPageV2RevealCostMap {
// Reveal cost is in width units (points along the reading direction). The unit is uniform // 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 // 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 // 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 // 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 /// * `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). /// (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(...)`. /// mirroring TextBubble's `clippedGlyphCountLayout = textLayout.layoutForCharacterCount(...)`.
func revealedContentSize(revealedCount: Int, layout: InstantPageV2Layout) -> CGSize { func revealedContentSize(revealedCount: Int, layout: InstantPageV2Layout) -> CGSize {
let bounds = computeRevealedBounds(items: layout.items, entries: self.topLevelEntries, revealedCount: revealedCount) 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 // The full layout reserves a closing spacing after the last top-level block (see
// `closingSpacing` in layoutBlockSequence). Mirror that so a partially-revealed // `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. // revealed line sits flush against the bubble's bottom edge.
let lastItemMaxY = layout.items.map { $0.frame.maxY }.max() ?? 0.0 let lastItemMaxY = layout.items.map { $0.frame.maxY }.max() ?? 0.0
let closingPad = max(0.0, layout.contentSize.height - lastItemMaxY) 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 /// 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 /// 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). /// not inside it (where unrevealed items would otherwise draw).
func revealedItemsMaxY(revealedCount: Int, layout: InstantPageV2Layout) -> CGFloat { func revealedItemsMaxY(revealedCount: Int, layout: InstantPageV2Layout) -> CGFloat {
let bounds = computeRevealedBounds(items: layout.items, entries: self.topLevelEntries, revealedCount: revealedCount) 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, // 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 // 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 // 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. // table of "1"/"2"/"3" costs ~30pt while occupying ~200pt visually.
// Text inside a cell still char-reveals against its own ink widths; the // 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 // "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)) entries.append(.table(start: start, end: cursor, rows: rows, title: titleMap))
case .thinking: 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/ // positions are identical whether or not thinking blocks are present, so adding/
// removing a thinking block never jumps the answer's reveal position. // removing a thinking block never jumps the answer's reveal position.
entries.append(.thinking(start: cursor)) entries.append(.thinking(start: cursor))
@ -432,7 +432,7 @@ private func applyRevealEntry(view: InstantPageItemView, entry: InstantPageV2Rev
let _ = start let _ = start
case let .thinking(start): case let .thinking(start):
// Whole-block 0.12s alpha fade-in at the index position; inner text is drawn fully // 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 let visible = revealedCount >= start
applyVisibility(view: view, visible: visible, animated: animated) 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 let rowMaxY = cellsInRow.map { $0.frame.maxY }.max() ?? 0.0
maskHeight = gridOffsetY + rowMaxY maskHeight = gridOffsetY + rowMaxY
} else { } 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 maskHeight = (title != nil) ? gridOffsetY : 0.0
} }

View file

@ -6,11 +6,7 @@ import AccountContext
import TelegramCore import TelegramCore
import TelegramPresentationData import TelegramPresentationData
// A paged carousel for an `InstantPageBlock.slideshow`. Ports V1's InstantPageSlideshowNode / // Paged slideshow backed by `InstantPageImageNode`.
// 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).
final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollViewDelegate { final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollViewDelegate {
private(set) var item: InstantPageV2SlideshowItem private(set) var item: InstantPageV2SlideshowItem
var itemFrame: CGRect { return self.item.frame } var itemFrame: CGRect { return self.item.frame }
@ -21,9 +17,7 @@ final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollVie
private let scrollView: UIScrollView private let scrollView: UIScrollView
private let pageControlNode: PageControlNode private let pageControlNode: PageControlNode
// One wrapper view per media (so page count stays aligned with the page control). `pageImageNodes` // Keep one page per media so the page control stays aligned.
// 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.
private var pageViews: [UIView] = [] private var pageViews: [UIView] = []
private var pageImageNodes: [InstantPageImageNode] = [] private var pageImageNodes: [InstantPageImageNode] = []
@ -68,7 +62,7 @@ final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollVie
self.pageImageNodes = [] self.pageImageNodes = []
let renderContext = self.renderContext 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 let openMedia: (InstantPageMedia) -> Void = { [weak self] tapped in
guard let self else { return } guard let self else { return }
handleOpenMediaTap(tapped: tapped, wrapper: self, renderContext: renderContext) handleOpenMediaTap(tapped: tapped, wrapper: self, renderContext: renderContext)
@ -91,23 +85,21 @@ final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollVie
pageView.addSubview(node.view) pageView.addSubview(node.view)
self.pageImageNodes.append(node) self.pageImageNodes.append(node)
} }
// Non-image medias (none in practice layoutSlideshow filters to images) get an empty page // Keep an empty page for unexpected non-image media.
// to keep page indices aligned with the page control.
self.scrollView.addSubview(pageView) self.scrollView.addSubview(pageView)
self.pageViews.append(pageView) self.pageViews.append(pageView)
} }
self.pageControlNode.pagesCount = self.item.medias.count self.pageControlNode.pagesCount = self.item.medias.count
self.pageControlNode.setPage(0.0) self.pageControlNode.setPage(0.0)
// Re-register media indices when rebuilding while already on-window (positional reuse with // Re-register reused pages already attached to a window.
// changed content); no-ops before the view is attached, where didMoveToWindow handles it.
self.registerMedias() self.registerMedias()
self.setNeedsLayout() self.setNeedsLayout()
} }
private func registerMedias() { private func registerMedias() {
guard self.window != nil else { return } 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 { for media in self.item.medias {
registerInRootRegistry(wrapper: self, mediaIndex: media.index) registerInRootRegistry(wrapper: self, mediaIndex: media.index)
} }

View file

@ -248,8 +248,8 @@ public final class FFMpegMediaVideoFrameDecoder: MediaTrackFrameDecoder {
guard format == .YUV || format == .YUVA else { guard format == .YUV || format == .YUVA else {
return false return false
} }
// Reject bottom-up / negative-stride frames before any row-copy loop ffmpeg // Reject bottom-up and negative-stride frames before copying rows.
// rawdec flips linesize[0] for codec_tag WRAW/cyuv/BottomUp, and other decoders // ffmpeg rawdec flips linesize[0] for WRAW, cyuv, and BottomUp.
// (dxtory, mimic, mjpegdec, scpr) flip the chroma planes too. // (dxtory, mimic, mjpegdec, scpr) flip the chroma planes too.
if frame.lineSize[0] < 0 || frame.lineSize[1] < 0 || frame.lineSize[2] < 0 { if frame.lineSize[0] < 0 || frame.lineSize[1] < 0 || frame.lineSize[2] < 0 {
return false return false

View file

@ -460,7 +460,7 @@ static bool executeGenerationCodeRecursive(NSString *code, HelloParseState *stat
break; break;
} }
case HelloGenerationCommandEndAlternative: { case HelloGenerationCommandEndAlternative: {
// signals end of current alternative return to caller // End the current alternative.
return true; return true;
} }
case HelloGenerationCommandBeginAlternative: case HelloGenerationCommandBeginAlternative:

View file

@ -62,6 +62,9 @@
@property (nonatomic) CGRect defaultFrame; @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; - (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); @property (nonatomic, copy) void (^startMessaging)(void);

View file

@ -107,6 +107,8 @@ typedef enum {
UIView *_wrapperView; UIView *_wrapperView;
UIView *_startButton; UIView *_startButton;
UIImageView *_firstPageIconView;
bool _loadedView; bool _loadedView;
} }
@end @end
@ -379,6 +381,14 @@ typedef enum {
p.clearsContextBeforeDrawing = false; p.clearsContextBeforeDrawing = false;
[_pageViews addObject:p]; [_pageViews addObject:p];
[_pageScrollView addSubview: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]; [_pageScrollView setPage:0];
@ -574,6 +584,17 @@ typedef enum {
[_pageViews enumerateObjectsUsingBlock:^(UIView *pageView, NSUInteger index, __unused BOOL *stop) { [_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); 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 - (void)viewWillAppear:(BOOL)animated

View file

@ -56,6 +56,7 @@ swift_library(
"//submodules/PhoneInputNode:PhoneInputNode", "//submodules/PhoneInputNode:PhoneInputNode",
"//submodules/PhotoResources:PhotoResources", "//submodules/PhotoResources:PhotoResources",
"//submodules/StickerResources:StickerResources", "//submodules/StickerResources:StickerResources",
"//submodules/SolidRoundedButtonNode:SolidRoundedButtonNode",
"//submodules/ProgressNavigationButtonNode:ProgressNavigationButtonNode", "//submodules/ProgressNavigationButtonNode:ProgressNavigationButtonNode",
"//submodules/RadialStatusNode:RadialStatusNode", "//submodules/RadialStatusNode:RadialStatusNode",
"//submodules/SearchBarNode:SearchBarNode", "//submodules/SearchBarNode:SearchBarNode",

View file

@ -393,7 +393,9 @@ private enum WinterGramDeletedMessagesControllerEntry: ItemListNodeEntry {
case let .topChat(_, peer, count, size, _): case let .topChat(_, peer, count, size, _):
let sizeFormatting = DataSizeStringFormatting(strings: presentationData.strings, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator) let sizeFormatting = DataSizeStringFormatting(strings: presentationData.strings, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator)
let subtitle = "\(count)\(dataSizeString(size, formatting: sizeFormatting))" 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): 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 ? { 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() arguments.deleteSelected()
@ -414,11 +416,13 @@ private final class WinterGramDeletedMessagesControllerArguments {
let context: AccountContext let context: AccountContext
let toggleCategory: (WinterGramDeletedMessageCategory) -> Void let toggleCategory: (WinterGramDeletedMessageCategory) -> Void
let deleteSelected: () -> Void let deleteSelected: () -> Void
let openChat: (EnginePeer) -> Void
init(context: AccountContext, toggleCategory: @escaping (WinterGramDeletedMessageCategory) -> Void, deleteSelected: @escaping () -> Void) {
init(context: AccountContext, toggleCategory: @escaping (WinterGramDeletedMessageCategory) -> Void, deleteSelected: @escaping () -> Void, openChat: @escaping (EnginePeer) -> Void) {
self.context = context self.context = context
self.toggleCategory = toggleCategory self.toggleCategory = toggleCategory
self.deleteSelected = deleteSelected self.deleteSelected = deleteSelected
self.openChat = openChat
} }
} }
@ -426,10 +430,19 @@ private func winterGramDeletedMessagesControllerEntries(stats: WinterGramDeleted
let sizeFormatting = DataSizeStringFormatting(strings: strings, decimalSeparator: dateTimeFormat.decimalSeparator) let sizeFormatting = DataSizeStringFormatting(strings: strings, decimalSeparator: dateTimeFormat.decimalSeparator)
var entries: [WinterGramDeletedMessagesControllerEntry] = [] var entries: [WinterGramDeletedMessagesControllerEntry] = []
entries.append(.overviewHeader(theme, strings.WinterGram_DeletedMessages_Title.uppercased())) 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))")) 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) } let chartTotal = stats.categories.reduce(Int64(0)) { $0 + max(0, $1.size) }
var chartItems: [PieChartComponent.ChartData.Item] = [] var chartItems: [PieChartComponent.ChartData.Item] = []
for stat in stats.categories where stat.size > 0 { 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 return entries
} }
@ -517,8 +527,12 @@ public func winterGramDeletedMessagesController(context: AccountContext) -> View
}) })
])]) ])])
presentControllerImpl?(actionSheet, nil) 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 statsSignal = winterGramDeletedMessagesStats(postbox: context.account.postbox)
let topPeersSignal = statsSignal let topPeersSignal = statsSignal
|> map { stats -> [EnginePeer.Id] in |> map { stats -> [EnginePeer.Id] in
@ -545,7 +559,22 @@ public func winterGramDeletedMessagesController(context: AccountContext) -> View
) )
|> map { presentationData, stats, selectedCategories, topPeers -> (ItemListControllerState, (ItemListNodeState, Any)) in |> 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 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)) return (controllerState, (listState, arguments))
} }

View file

@ -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)
}
}

View file

@ -276,10 +276,17 @@ class ThemeSettingsChatPreviewItemNode: ListViewItemNode {
} }
node.updateFrame(CGRect(origin: CGPoint(x: 0.0, y: topOffset), size: node.frame.size), within: layoutSize) 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 { 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( avatarFrame = CGRect(
x: params.leftInset + 9.0, x: params.leftInset + 9.0,
y: topOffset + max(6.0, node.frame.height - avatarSize - 8.0), y: visualBottom - avatarSize - tailBottomInset,
width: avatarSize, width: avatarSize,
height: 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 let avatarRadius = min(avatarFrame.width, avatarFrame.height) * CGFloat(max(0, min(50, item.avatarCornerRadius))) / 100.0
strongSelf.avatarNode.layer.cornerRadius = avatarRadius strongSelf.avatarNode.layer.cornerRadius = avatarRadius
strongSelf.avatarNode.layer.masksToBounds = true 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) strongSelf.avatarNode.updateSize(size: avatarFrame.size)
} else { } else {
strongSelf.avatarNode.isHidden = true strongSelf.avatarNode.isHidden = true

View file

@ -7,8 +7,6 @@ import TelegramPresentationData
import ItemListUI import ItemListUI
import PresentationDataUtils 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 { private func winterGramBannerIcon(size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size) let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { rendererContext in 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 { private func winterGramBannerRoundedIcon(image: UIImage, size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size) let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in return renderer.image { _ in
@ -137,7 +134,6 @@ class WinterGramBannerItemNode: ListViewItemNode {
strongSelf.item = item strongSelf.item = item
let width = params.width let width = params.width
// No grey backplate behind the banner it sits flat on the grouped background.
strongSelf.backgroundNode.backgroundColor = .clear strongSelf.backgroundNode.backgroundColor = .clear
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: contentHeight)) strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: contentHeight))

View file

@ -35,28 +35,29 @@ private enum WinterGramMainSettingsSection: Int32 {
case links case links
} }
// A link row shown in the "Links" section: SF Symbol, label, accent-coloured value and target URL.
private struct WinterGramLink { private struct WinterGramLink {
let symbol: String let symbol: String
let imageName: String? let imageName: String?
let title: String let title: String
let value: String let value: String
let url: 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.symbol = symbol
self.imageName = imageName self.imageName = imageName
self.title = title self.title = title
self.value = value self.value = value
self.url = url self.url = url
self.color = color
} }
} }
private let winterGramLinks: [WinterGramLink] = [ private let winterGramLinks: [WinterGramLink] = [
WinterGramLink(symbol: "paperplane.fill", title: "Channel", value: "@wntgram", url: "https://t.me/wntgram"), 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"), 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"), 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"), 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") 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): case let .link(_, link):
return ItemListDisclosureItem( return ItemListDisclosureItem(
presentationData: presentationData, 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), title: wntOption(link.title, presentationData.strings),
label: link.value, label: link.value,
labelStyle: .coloredText(accent), labelStyle: .coloredText(accent),
@ -162,15 +163,15 @@ private enum WinterGramMainSettingsEntry: ItemListNodeEntry {
} }
) )
case .ayugram: 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: case .features:
category = .antiFeatures; title = "Features"; iconName = "sparkles"; iconColor = UIColor(rgb: 0xFF9500) category = .antiFeatures; title = "Features"; iconName = "sparkles"; iconColor = UIColor(rgb: 0xFF9F0A)
case .other: case .other:
category = .other; title = "Other"; iconName = "ellipsis.circle"; iconColor = UIColor(rgb: 0x8E8E93) category = .other; title = "Other"; iconName = "ellipsis.circle"; iconColor = UIColor(rgb: 0x8E8E93)
case .spoofing: 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: 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( return ItemListDisclosureItem(
presentationData: presentationData, 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? { private func winterGramCategoryIcon(_ symbolName: String, _ backgroundColor: UIColor) -> UIImage? {
return winterGramCategoryIcon(symbolName: symbolName, imageName: nil, backgroundColor: backgroundColor) return winterGramCategoryIcon(symbolName: symbolName, imageName: nil, backgroundColor: backgroundColor)
} }
private func winterGramCategoryIcon(symbolName: String, imageName: String?, backgroundColor: UIColor) -> UIImage? { 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) let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in return renderer.image { _ in
let bounds = CGRect(origin: .zero, size: size) let bounds = CGRect(origin: .zero, size: size)
// Smaller colored backplate with a transparent margin so the white icon dominates. // ~0.225 of the side gives the iOS app-icon "squircle" feel at this size.
let backplateInset: CGFloat = 5.0 let backplate = UIBezierPath(roundedRect: bounds, cornerRadius: 7.5)
let backplateRect = bounds.insetBy(dx: backplateInset, dy: backplateInset)
let backplate = UIBezierPath(roundedRect: backplateRect, cornerRadius: 9.0)
backgroundColor.setFill() backgroundColor.setFill()
backplate.fill() backplate.fill()
let icon: UIImage? let icon: UIImage?
if let imageName = imageName { if let imageName = imageName {
icon = UIImage(bundleImageName: imageName)?.withRenderingMode(.alwaysTemplate) icon = UIImage(bundleImageName: imageName)?.withRenderingMode(.alwaysTemplate)
} else { } 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 { guard let symbol = icon?.withTintColor(.white, renderingMode: .alwaysOriginal) else {
return 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 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) let symbolSize = CGSize(width: symbol.size.width * symbolScale, height: symbol.size.height * symbolScale)
symbol.draw(in: CGRect( symbol.draw(in: CGRect(
x: floor((size.width - symbolSize.width) / 2.0), x: (size.width - symbolSize.width) / 2.0,
y: floor((size.height - symbolSize.height) / 2.0), y: (size.height - symbolSize.height) / 2.0,
width: symbolSize.width, width: symbolSize.width,
height: symbolSize.height height: symbolSize.height
)) ))
@ -263,7 +264,6 @@ public func winterGramMainSettingsController(context: AccountContext) -> ViewCon
pushControllerImpl?(winterGramSettingsController(context: context, category: category)) pushControllerImpl?(winterGramSettingsController(context: context, category: category))
}, },
openUrl: { url in 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 } let presentationData = context.sharedContext.currentPresentationData.with { $0 }
context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: url, forceExternal: false, presentationData: presentationData, navigationController: getNavigationControllerImpl?(), dismissInput: {}) context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: url, forceExternal: false, presentationData: presentationData, navigationController: getNavigationControllerImpl?(), dismissInput: {})
}, },

View file

@ -13,9 +13,6 @@ enum WinterGramRadiusPreviewKind {
case bubble 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 { private func winterGramRadiusPreviewImage(kind: WinterGramRadiusPreviewKind, value: Int32, minValue: Int32, maxValue: Int32, size: CGSize, theme: PresentationTheme, avatarImage: UIImage? = nil) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size) let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { rendererContext in return renderer.image { rendererContext in
@ -148,8 +145,6 @@ class WinterGramRadiusItemNode: ListViewItemNode {
self.addSubnode(self.titleNode) self.addSubnode(self.titleNode)
self.addSubnode(self.valueNode) 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 { deinit {

View file

@ -76,9 +76,8 @@ public enum WinterGramSettingsSection: Int32, CaseIterable {
case chat case chat
case liquidGlass case liquidGlass
case spoofing case spoofing
// Combined "super-categories" shown in the redesigned main menu. case ayugram
case ayugram // Ghost Mode + History + Hidden Archive case other
case other // Chat tweaks + Spoofing (show id, registration date, spoofer, )
public var title: String { public var title: String {
switch self { switch self {
@ -106,7 +105,6 @@ public enum WinterGramSettingsSection: Int32, CaseIterable {
} }
public var iconName: String { public var iconName: String {
// SF Symbols used for the main menu cells.
switch self { switch self {
case .banner: case .banner:
return "" return ""
@ -131,7 +129,6 @@ public enum WinterGramSettingsSection: Int32, CaseIterable {
} }
} }
// Maps a deep-link path/section name (wnt://wintergram/<name>) to a settings subtab.
public init?(deepLinkName: String) { public init?(deepLinkName: String) {
switch deepLinkName.lowercased() { switch deepLinkName.lowercased() {
case "ghost", "ghostmode": self = .ghost case "ghost", "ghostmode": self = .ghost
@ -156,8 +153,6 @@ private enum WinterGramDropdown: Equatable {
case stashPrivacy 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)] { private func winterGramDropdownOptions(_ dropdown: WinterGramDropdown, settings: WinterGramSettings) -> [(title: String, selected: Bool, apply: (WinterGramSettings) -> WinterGramSettings)] {
switch dropdown { switch dropdown {
case .stashPrivacy: 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?)] = [ private let winterGramDevicePresets: [(name: String, subtitle: String, model: String?)] = [
("Real device", "Report this device's real model", nil), ("Real device", "Report this device's real model", nil),
("iPhone 16 Pro Max", "iPhone17,2 · A18 Pro", "iPhone 16 Pro Max"), ("iPhone 16 Pro Max", "iPhone17,2 · A18 Pro", "iPhone 16 Pro Max"),
@ -309,7 +302,6 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry {
case spoofingApiHash(String) case spoofingApiHash(String)
case spoofingFooter case spoofingFooter
// Expandable single-select row: dropdown key, title, expanded flag, options.
case expandableSelection(WinterGramDropdown, String, Bool, [ItemListExpandableSelectionItem.Option]) case expandableSelection(WinterGramDropdown, String, Bool, [ItemListExpandableSelectionItem.Option])
var section: ItemListSectionId { var section: ItemListSectionId {
@ -349,7 +341,6 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry {
} }
var stableId: Int32 { var stableId: Int32 {
// Device preset cards nest between spoofingDevice (30000) and WebView platform (40000).
if case let .spoofingDevicePreset(index, _) = self { if case let .spoofingDevicePreset(index, _) = self {
return 30100 + Int32(index) return 30100 + Int32(index)
} }
@ -684,12 +675,15 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry {
case .spoofPresetsHeader: case .spoofPresetsHeader:
return ItemListSectionHeaderItem(presentationData: presentationData, text: lang.WinterGram_Templates, sectionId: self.section) return ItemListSectionHeaderItem(presentationData: presentationData, text: lang.WinterGram_Templates, sectionId: self.section)
case let .spoofPreset(index, name, subtitle, editing, selected): case let .spoofPreset(index, name, subtitle, editing, selected):
if editing { // Always an ItemListCheckboxItem so the row never swaps node *type* between display and
return ItemListCheckboxItem(presentationData: presentationData, systemStyle: .glass, title: name, subtitle: subtitle, style: .left, checked: selected, zeroSeparatorInsets: false, sectionId: self.section, action: { // 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) arguments.toggleSpoofTemplateSelected(index)
}) } else {
} else {
return ItemListDisclosureItem(presentationData: presentationData, title: name, label: subtitle, labelStyle: .detailText, sectionId: self.section, style: .blocks, disclosureStyle: .none, action: {
arguments.updateSettings { settings in arguments.updateSettings { settings in
var settings = settings var settings = settings
if index < settings.spoofPresets.count { if index < settings.spoofPresets.count {
@ -699,8 +693,8 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry {
} }
return settings return settings
} }
}) }
} })
case .spoofAddTemplate: 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: { 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() arguments.addSpoofTemplate()
@ -787,7 +781,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti
let lang = presentationData.strings let lang = presentationData.strings
var entries: [WinterGramSettingsEntry] = [] var entries: [WinterGramSettingsEntry] = []
// Appends an expandable single-select row with checkbox options shown inline.
func appendDropdown(_ dropdown: WinterGramDropdown, _ title: String, _ section: WinterGramSettingsSection) { func appendDropdown(_ dropdown: WinterGramDropdown, _ title: String, _ section: WinterGramSettingsSection) {
let options = winterGramDropdownOptions(dropdown, settings: settings).enumerated().map { index, option in 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) 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() { func appendGhost() {
entries.append(.ghostHeader) entries.append(.ghostHeader)
// Expandable Ghost Mode switch with checkbox sub-items for each suppressed signal.
let subItems: [ItemListExpandableSwitchItem.SubItem] = [ let subItems: [ItemListExpandableSwitchItem.SubItem] = [
.init(id: "readMessages", title: lang.WinterGram_DontReadMessages, isSelected: settings.suppressesReadReceipts, isEnabled: true), .init(id: "readMessages", title: lang.WinterGram_DontReadMessages, isSelected: settings.suppressesReadReceipts, isEnabled: true),
.init(id: "readStories", title: lang.WinterGram_DontReadStories, isSelected: settings.suppressesStoryViews, 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(.stashMute(settings.stashMuteNotifications))
entries.append(.stashAutoRead(settings.stashAutoMarkRead)) entries.append(.stashAutoRead(settings.stashAutoMarkRead))
entries.append(.stashPasscodeRow(settings.stashPasscode.isEmpty ? "None" : "••••")) 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) appendDropdown(.stashPrivacy, lang.WinterGram_AutoPrivacy, .stash)
entries.append(.stashFooter) entries.append(.stashFooter)
} }
@ -868,7 +859,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti
entries.append(.confirmStickers(settings.stickerConfirmation)) entries.append(.confirmStickers(settings.stickerConfirmation))
entries.append(.confirmGif(settings.gifConfirmation)) entries.append(.confirmGif(settings.gifConfirmation))
entries.append(.confirmVoice(settings.voiceConfirmation)) entries.append(.confirmVoice(settings.voiceConfirmation))
// Footer last (stableId 67) so the section stays in ascending stableId order.
entries.append(.chatFooter) entries.append(.chatFooter)
} }
@ -885,7 +875,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti
func appendSpoofing() { func appendSpoofing() {
entries.append(.spoofingHeader) entries.append(.spoofingHeader)
// Saved spoof templates at the top of the Spoofing section.
entries.append(.spoofPresetsHeader) entries.append(.spoofPresetsHeader)
for (index, preset) in settings.spoofPresets.enumerated() { for (index, preset) in settings.spoofPresets.enumerated() {
let subtitle = [preset.deviceModel, preset.appVersion].filter { !$0.isEmpty }.joined(separator: " · ") 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 { if spoofTemplatesEditing && !selectedTemplates.isEmpty {
entries.append(.spoofDeleteSelected) 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() { for (i, preset) in winterGramDevicePresets.enumerated() {
entries.append(.spoofingDevicePreset(i, preset.model == settings.spoofDeviceModel)) entries.append(.spoofingDevicePreset(i, preset.model == settings.spoofDeviceModel))
} }
@ -950,8 +938,6 @@ public func winterGramSettingsController(context: AccountContext, category: Wint
var pushControllerImpl: ((ViewController) -> Void)? var pushControllerImpl: ((ViewController) -> Void)?
var refreshDeletedCount: (() -> 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 sectionTabs: [WinterGramSettingsSection] = WinterGramSettingsSection.allCases.filter { $0 != .ayugram && $0 != .other }
let selectedCategoryPromise = ValuePromise<WinterGramSettingsSection>(category ?? .ghost, ignoreRepeated: true) let selectedCategoryPromise = ValuePromise<WinterGramSettingsSection>(category ?? .ghost, ignoreRepeated: true)
@ -968,13 +954,12 @@ public func winterGramSettingsController(context: AccountContext, category: Wint
newSettings.customApiHash != initialSettings.customApiHash || newSettings.customApiHash != initialSettings.customApiHash ||
newSettings.materialDesign != initialSettings.materialDesign || newSettings.materialDesign != initialSettings.materialDesign ||
newSettings.customFont != initialSettings.customFont || newSettings.customFont != initialSettings.customFont ||
newSettings.monoFont != initialSettings.monoFont newSettings.monoFont != initialSettings.monoFont ||
newSettings.liquidGlass != initialSettings.liquidGlass
requiresRestartPromise.set(needsRestart) 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 updateStashPrivacy: (@escaping (WinterGramStashPrivacySettings) -> WinterGramStashPrivacySettings) -> Void = { f in
let previous = currentWinterGramSettings.stashPrivacy let previous = currentWinterGramSettings.stashPrivacy
updateSettings { settings in 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<WinterGramDropdown?>(value: nil) let expandedDropdownValue = Atomic<WinterGramDropdown?>(value: nil)
let expandedDropdownPromise = ValuePromise<WinterGramDropdown?>(nil, ignoreRepeated: true) let expandedDropdownPromise = ValuePromise<WinterGramDropdown?>(nil, ignoreRepeated: true)
// Whether the Ghost Mode expandable section in the Core menu is open.
let ghostExpandedValue = Atomic<Bool>(value: false) let ghostExpandedValue = Atomic<Bool>(value: false)
let ghostExpandedPromise = ValuePromise<Bool>(false, ignoreRepeated: true) let ghostExpandedPromise = ValuePromise<Bool>(false, ignoreRepeated: true)
let spoofTemplatesEditingValue = Atomic<Bool>(value: false) let spoofTemplatesEditingValue = Atomic<Bool>(value: false)
@ -1025,7 +1007,6 @@ public func winterGramSettingsController(context: AccountContext, category: Wint
updateSettings { options[index].apply($0) } updateSettings { options[index].apply($0) }
} }
} }
// Multi-select dropdowns stay open so the user can toggle more options.
if dropdown != .stashPrivacy { if dropdown != .stashPrivacy {
let _ = expandedDropdownValue.swap(nil) let _ = expandedDropdownValue.swap(nil)
expandedDropdownPromise.set(nil) expandedDropdownPromise.set(nil)
@ -1290,8 +1271,6 @@ public func winterGramSettingsController(context: AccountContext, category: Wint
controller?.present(c, in: .window(.root), with: a) controller?.present(c, in: .window(.root), with: a)
} }
pushControllerImpl = { [weak controller] c in 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 let _ = restartBannerDisposable
(controller?.navigationController as? NavigationController)?.pushViewController(c) (controller?.navigationController as? NavigationController)?.pushViewController(c)
} }

View file

@ -902,7 +902,7 @@ func revalidateMediaResourceReference(accountPeerId: PeerId, postbox: Postbox, n
} }
} }
// Rich-text messages (`RichTextMessageAttribute`) embed their media in the // 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. // stale instant-page audio/image file reference can revalidate.
for attribute in message.attributes { for attribute in message.attributes {
if let attribute = attribute as? RichTextMessageAttribute { if let attribute = attribute as? RichTextMessageAttribute {

View file

@ -4462,17 +4462,50 @@ func replayFinalState(
} }
} }
case let .DeleteMessagesWithGlobalIds(ids): 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 { 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] = [] if !winterGramPreservedGlobalIds {
transaction.deleteMessagesWithGlobalIds(ids, forEachMedia: { media in var resourceIds: [MediaResourceId] = []
addMessageMediaResourceIdsToRemove(media: media, resourceIds: &resourceIds) transaction.deleteMessagesWithGlobalIds(ids, forEachMedia: { media in
}) addMessageMediaResourceIdsToRemove(media: media, resourceIds: &resourceIds)
if !resourceIds.isEmpty { })
let _ = mediaBox.removeCachedResources(Array(Set(resourceIds)), force: true).start() 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): case let .DeleteMessages(ids):
var winterGramShouldSave = currentWinterGramCoreSettings.saveDeletedMessages var winterGramShouldSave = currentWinterGramCoreSettings.saveDeletedMessages
// When "save for bots" is off, skip preserving deletions in bot chats. // When "save for bots" is off, skip preserving deletions in bot chats.

View file

@ -2,7 +2,7 @@ import Foundation
import Postbox import Postbox
import SwiftSignalKit 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 // 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. // persists in the local database until the message is re-fetched from the server.
public func winterGramEditMessageLocally(postbox: Postbox, messageId: MessageId, text: String) -> Signal<Void, NoError> { public func winterGramEditMessageLocally(postbox: Postbox, messageId: MessageId, text: String) -> Signal<Void, NoError> {

View file

@ -3,7 +3,7 @@ import Foundation
// Estimates the approximate registration date of a Telegram account from its user ID. // 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 // 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 // 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? { public func winterGramEstimatedRegistrationDate(userId: Int64) -> Date? {
guard userId > 0 else { guard userId > 0 else {
return nil return nil

View file

@ -5,14 +5,6 @@ import AppBundle
import TelegramCore import TelegramCore
import TelegramUIPreferences 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 let winterGramBadgeCacheLock = NSLock()
private var winterGramComposedBadgeCache: [UInt32: UIImage] = [:] private var winterGramComposedBadgeCache: [UInt32: UIImage] = [:]
@ -23,8 +15,6 @@ private func winterGramColorKey(_ color: UIColor) -> UInt32 {
return (channel(r) << 16) | (channel(g) << 8) | channel(b) 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? { public func winterGramComposedBadge(backplateColor: UIColor) -> UIImage? {
let key = winterGramColorKey(backplateColor) let key = winterGramColorKey(backplateColor)
winterGramBadgeCacheLock.lock() winterGramBadgeCacheLock.lock()
@ -51,16 +41,65 @@ public func winterGramComposedBadge(backplateColor: UIColor) -> UIImage? {
return image return image
} }
/// The backplate colour for the current theme: the accent colour, slightly darkened.
public func winterGramBadgeBackplateColor(theme: PresentationTheme) -> UIColor { public func winterGramBadgeBackplateColor(theme: PresentationTheme) -> UIColor {
return theme.list.itemAccentColor.withMultipliedBrightnessBy(0.82) return theme.list.itemAccentColor.withMultipliedBrightnessBy(0.82)
} }
/// The themed badge image for a peer, or nil if the peer carries no WinterGram badge. private var winterGramComposedBadgeCacheV2: [String: UIImage] = [:]
/// Every official peer (developers and official channels) gets the composed backplate badge.
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? { 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 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))
} }

View file

@ -139,6 +139,7 @@ private let winterGramRussianSeed: [String: String] = [
"WinterGram.ShowMessageSeconds": "Секунды в сообщениях", "WinterGram.ShowMessageSeconds": "Секунды в сообщениях",
"WinterGram.ShowPeerID": "Показывать ID", "WinterGram.ShowPeerID": "Показывать ID",
"WinterGram.ShowRegistrationDate": "Показывать дату регистрации", "WinterGram.ShowRegistrationDate": "Показывать дату регистрации",
"WinterGram.RegistrationDate": "Дата регистрации",
"WinterGram.SingleCornerRadius": "Одиночное скругление", "WinterGram.SingleCornerRadius": "Одиночное скругление",
"WinterGram.SomeSettingsWillTakeEffectAfterRestart": "Некоторые настройки вступят в силу после перезапуска.", "WinterGram.SomeSettingsWillTakeEffectAfterRestart": "Некоторые настройки вступят в силу после перезапуска.",
"WinterGram.SpoofAppVersion": "Версия приложения", "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 /// 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 /// titles) by routing the known English value to its generated accessor. Unknown user input
/// input such as custom fonts, reactions or spoofed identifiers are returned unchanged. /// such as custom fonts, reactions, or spoofed identifiers is returned unchanged.
public func wntOption(_ english: String, _ strings: PresentationStrings) -> String { public func wntOption(_ english: String, _ strings: PresentationStrings) -> String {
switch english { switch english {
case "Off": return strings.WinterGram_Off case "Off": return strings.WinterGram_Off

View file

@ -3897,7 +3897,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
let targetAlpha: CGFloat = isDeleted ? 0.45 : 1.0 let targetAlpha: CGFloat = isDeleted ? 0.45 : 1.0
animation.animator.updateAlpha(layer: strongSelf.mainContextSourceNode.contentNode.layer, alpha: targetAlpha, completion: nil) animation.animator.updateAlpha(layer: strongSelf.mainContextSourceNode.contentNode.layer, alpha: targetAlpha, completion: nil)
if hasDeletedMark, let winterGramDeletedAttribute = winterGramDeletedAttribute { if hasDeletedMark {
let deletedIconNode: ASImageNode let deletedIconNode: ASImageNode
if let current = strongSelf.winterGramDeletedIconNode { if let current = strongSelf.winterGramDeletedIconNode {
deletedIconNode = current deletedIconNode = current
@ -3909,7 +3909,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
strongSelf.insertSubnode(newNode, belowSubnode: strongSelf.messageAccessibilityArea) strongSelf.insertSubnode(newNode, belowSubnode: strongSelf.messageAccessibilityArea)
deletedIconNode = newNode 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 iconSize = deletedIconNode.image?.size ?? CGSize(width: 16.0, height: 16.0)
let spacing: CGFloat = 5.0 let spacing: CGFloat = 5.0
let iconY = backgroundFrame.maxY - iconSize.height - 2.0 let iconY = backgroundFrame.maxY - iconSize.height - 2.0

View file

@ -22,17 +22,12 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
private let containerNode: ContainerNode private let containerNode: ContainerNode
public var statusNode: ChatMessageDateAndStatusNode? public var statusNode: ChatMessageDateAndStatusNode?
// `init()` may run off the main thread; UIView construction must happen on the main thread. // `init()` may run off the main thread, so build the page view lazily on the main thread.
// The page view is built lazily inside the apply closure (always main-thread) via ensurePageView().
private var pageView: InstantPageV2View? private var pageView: InstantPageV2View?
// Tracks the message (id + stableVersion) baked into the current pageView's render context. // The synthesized webpage has a sentinel id shared across all richText messages, so key the
// The synthesized webpage uses a sentinel id (namespace 0, id 0) shared across all richText // page view on the message id and stableVersion and rebuild when the bubble is recycled.
// 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).
private var pageViewMessageKey: (id: EngineMessage.Id, stableVersion: UInt32, showMoreExpanded: Bool)? private var pageViewMessageKey: (id: EngineMessage.Id, stableVersion: UInt32, showMoreExpanded: Bool)?
// messageStableVersion is in the cache key because the synthesized instantPage content // Include stableVersion in the layout cache key so streamed chunks do not reuse stale layouts.
// mutates between streamed AI message chunks (each chunk bumps stableVersion); without
// this, the cached layout would shadow newly-arrived content during streaming.
private var currentPageLayout: (boundingWidth: CGFloat, private var currentPageLayout: (boundingWidth: CGFloat,
presentationThemeIdentity: ObjectIdentifier, presentationThemeIdentity: ObjectIdentifier,
expandedDetails: [Int: Bool], expandedDetails: [Int: Bool],
@ -40,9 +35,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
showMoreExpanded: Bool, showMoreExpanded: Bool,
layout: InstantPageV2Layout)? layout: InstantPageV2Layout)?
private var currentExpandedDetails: [Int: Bool] = [:] private var currentExpandedDetails: [Int: Bool] = [:]
// Intra-message anchor scroll that is waiting on a collapsed <details> to expand + relayout. // Anchor scroll waiting on a collapsed <details> to expand and relayout.
private var pendingScrollAnchor: String? 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 lastExpandedPendingDetailsIndex: Int?
private var linkProgressDisposable: Disposable? private var linkProgressDisposable: Disposable?
private var linkProgressRects: [CGRect]? private var linkProgressRects: [CGRect]?
@ -54,16 +49,13 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
private var textRevealController: TextRevealController? private var textRevealController: TextRevealController?
private var textRevealLink: SharedDisplayLinkDriver.Link? private var textRevealLink: SharedDisplayLinkDriver.Link?
private var currentRevealCostMap: InstantPageV2RevealCostMap? private var currentRevealCostMap: InstantPageV2RevealCostMap?
// Cursor value pushed into pageView.applyReveal on the prior tick. The display-link tick // Cursor from the previous display-link tick, used to detect when the revealed height
// compares the revealed prefix's height at this cursor vs the new cursor to decide when // changes and request a full bubble relayout.
// to request a full bubble re-layout (so the bubble grows with the reveal).
private var lastAppliedRevealedCount: Int = 0 private var lastAppliedRevealedCount: Int = 0
private var displayContentsUnderSpoilers: Bool = false private var displayContentsUnderSpoilers: Bool = false
private var relativeDateTimer: (timer: SwiftSignalKit.Timer, period: Int32)? private var relativeDateTimer: (timer: SwiftSignalKit.Timer, period: Int32)?
// "Show more" affordance for partial rich messages (instantPage.isComplete == false). // "Show more" link for partial rich messages, with a shimmer while the full text loads.
// 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.
private var showMoreTextNode: TextNode? private var showMoreTextNode: TextNode?
private var showMoreLoadingView: TextLoadingEffectView? private var showMoreLoadingView: TextLoadingEffectView?
private var requestFullRichTextDisposable: Disposable? private var requestFullRichTextDisposable: Disposable?
@ -115,7 +107,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
self.addSubnode(self.containerNode) 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 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). /// the bubble is recycled with a different message/webpage (different message id).
private func ensurePageView(item: ChatMessageBubbleContentItem, webpage: TelegramMediaWebpage, showMoreExpanded: Bool) -> InstantPageV2View { private func ensurePageView(item: ChatMessageBubbleContentItem, webpage: TelegramMediaWebpage, showMoreExpanded: Bool) -> InstantPageV2View {
@ -136,7 +128,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
self.pageView?.removeFromSuperview() self.pageView?.removeFromSuperview()
self.pageView = nil 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 // render context which is owned by the V2View, so we must avoid making them retain
// the bubble (`self`) or the message indirectly via `item`. // the bubble (`self`) or the message indirectly via `item`.
let messageReference = MessageReference(item.message) let messageReference = MessageReference(item.message)
@ -402,7 +394,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
current.layout.formattedDateUpdatePeriod == nil { current.layout.formattedDateUpdatePeriod == nil {
// Reuse the cached layout only when it has no relative `textDate`. A relative // 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 // 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 // so reusing it would freeze the date and defeat the refresh timer (which fires
// `requestFullUpdate` precisely to re-run `layoutInstantPageV2` `formatDate`). // `requestFullUpdate` precisely to re-run `layoutInstantPageV2` `formatDate`).
// Forcing a recompute for relative-date pages keeps the timer's tick visible. // Forcing a recompute for relative-date pages keeps the timer's tick visible.
@ -456,7 +448,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
if hasDraft { if hasDraft {
// The bubble's bottom inset is supplied by the `statusBottomEdge + 6.0` // 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 // `!hasDraft`, so during streaming the bubble has only its 1pt bottom rim
// past `revealedContentSize.height` (= bounds.maxY + closingPad). Without // past `revealedContentSize.height` (= bounds.maxY + closingPad). Without
// this, descenders of the last revealed line sit cramped against the // 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) let frame = CGRect(origin: CGPoint(x: pageHorizontalInset, y: pageLayout.contentSize.height + showMoreTopSpacing), size: layout.0.size)
showMoreLayoutResult = layout showMoreLayoutResult = layout
showMoreFramePageLocal = frame 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. // status machinery by substituting the link frame for the last-text-line frame.
lastTextLineFrame = frame lastTextLineFrame = frame
lastTextLineTrailingPadding = 0.0 lastTextLineTrailingPadding = 0.0
@ -608,11 +600,11 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
} }
// Measure trailing extent from the line's actual visible RIGHT EDGE (after // 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 // 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). // the right text inset (lineFrame.maxX == text.frame.minX + textItem.width).
// Feeding the status node just `lineWidth` would let the trail/wrap decision // 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 // 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). // node sits at x=pageHorizontalInset in self, and pageView sits at self-x 0).
let trailingWidthToMeasure: CGFloat = lastTextLineFrame.map { $0.maxX - pageHorizontalInset } ?? 10000.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. // lands at the right text inset rather than past the bubble's right edge.
let statusSizeAndApply = statusSuggestedWidthAndContinue?.1(boundingWidth - pageHorizontalInset * 2.0) let statusSizeAndApply = statusSuggestedWidthAndContinue?.1(boundingWidth - pageHorizontalInset * 2.0)
if let statusSizeAndApply, !hasDraft { 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. // closure below.
let statusAnchorY: CGFloat let statusAnchorY: CGFloat
if let lastTextLineFrame { if let lastTextLineFrame {
@ -698,7 +690,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
// On the collapseexpand transition (tapping "Show more"), grow the bubble // On the collapseexpand transition (tapping "Show more"), grow the bubble
// downward in screen space (inverted list offset direction) instead of pushing // 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 // 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). // change, and never on the first apply (appliedShowMoreExpanded is nil).
if let appliedShowMoreExpanded = self.appliedShowMoreExpanded, appliedShowMoreExpanded != showMoreExpanded { 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 <details> // Continue an in-flight anchor scroll that is waiting on a <details>
// expansion to re-lay-out. This runs on EVERY apply pass (not only the // 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 // 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 // scrolls (clearing pending) or expands the next collapsed level, and the
// progress guard guarantees termination. So an unrelated relayout (theme, // progress guard guarantees termination. So an unrelated relayout (theme,
// width, reactions) that lands mid-expand simply advances/no-ops the loop. // 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 { 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. // reference (ChatMessageTextBubbleContentNode), which rebuilds it every apply.
// The timer fires `requestFullUpdate`, which relays out and re-enters here; at // 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 // 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. // 1. Compute / cache the cost map.
// Reuse the cost map computed in the layout pass (the bubble's // 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- // alive while a reveal/finalize is still in flight: on a post-
// streaming pass (hasDraft && hadDraft both false) revealCostMap is // streaming pass (hasDraft && hadDraft both false) revealCostMap is
// nil, and clobbering it would strand the display-link tick (whose // 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 // 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 // current position so we don't flash full text. Use the live controller
// count rather than `previousAnimateGlyphCount`, which is nil and would // count rather than nil `previousAnimateGlyphCount`, which would
// reset the reveal to 0 on post-streaming finalize passes where the // reset the reveal to 0 on final passes where the
// controller is still animating. // controller is still animating.
let seedCount = self.textRevealController?.currentGlyphCount ?? previousAnimateGlyphCount ?? 0 let seedCount = self.textRevealController?.currentGlyphCount ?? previousAnimateGlyphCount ?? 0
self.pageView?.applyReveal(revealedCount: seedCount, self.pageView?.applyReveal(revealedCount: seedCount,
@ -1061,7 +1053,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
// "attribute substring with displayed range" API, so we cannot compare // "attribute substring with displayed range" API, so we cannot compare
// displayed text to the resolved URL the way the chat text bubble does. // 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 // 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 concealed = true
let url = ChatMessageBubbleContentTapAction.Url(url: urlHit.urlItem.url, concealed: concealed, allowInlineWebpageResolution: urlHit.urlItem.webpageId != nil) 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) 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 { } else if let botCommand = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String {
return .botCommand(botCommand) return .botCommand(botCommand)
} else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag { } 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); // Cashtags use the hashtag attribute and retain their leading "$".
// the leading "$" in the string distinguishes them, and the chat hashtag handler searches both.
return .hashtag(hashtag.peerName, hashtag.hashtag) return .hashtag(hashtag.peerName, hashtag.hashtag)
} else if let bankCard = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BankCard)] as? String { } else if let bankCard = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BankCard)] as? String {
return .bankCard(bankCard) return .bankCard(bankCard)
} else if let date = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Date)] as? Int32 { } 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 .date(date, "")
} }
return nil return nil
} }
/// Bridges an InstantPageUrlItem (used by the gallery's caption URL handler) to the // Open gallery caption links through the chat URL handler.
/// chat layer's URL handler. `concealed: true` matches `tapActionAtPoint` for the same
/// reason: V2 cannot reliably compare displayed link text to the resolved URL.
private func openInstantPageUrl(_ url: InstantPageUrlItem) { private func openInstantPageUrl(_ url: InstantPageUrlItem) {
guard let item = self.item else { return } guard let item = self.item else { return }
item.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl( item.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl(
@ -1147,9 +1135,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
} }
private func computeHighlightRects(item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint) -> [CGRect] { private func computeHighlightRects(item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint) -> [CGRect] {
// Text item returns rects in its local coords; translate back into containerNode-local coords. // Translate text-local rects into container coordinates.
// 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.
let originX = parentOffset.x let originX = parentOffset.x
let originY = parentOffset.y let originY = parentOffset.y
return item.linkSelectionRects(at: localPoint).map { rect in return item.linkSelectionRects(at: localPoint).map { rect in
@ -1443,7 +1429,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor) item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor)
return return
} }
// 2. Not laid out it may be buried in a collapsed <details>. 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). // 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 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 }), 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 { if let state = self.showMoreExpanded, state.messageId == messageId, state.value {
return return
} }
// Full page already cached on the attribute expand immediately, no network, no shimmer. // Expand a cached full page immediately.
if attribute.fullInstantPage != nil { if attribute.fullInstantPage != nil {
self.showMoreExpanded = (messageId, true) self.showMoreExpanded = (messageId, true)
item.controllerInteraction.requestMessageUpdate(messageId, false, nil) item.controllerInteraction.requestMessageUpdate(messageId, false, nil)

View file

@ -199,7 +199,7 @@ private final class PortalTransitionStaging {
/// Tears down staging. Reparents contentNode into the requested destination, /// Tears down staging. Reparents contentNode into the requested destination,
/// removes clone from its overlay host, removes wrapper from surface. /// removes clone from its overlay host, removes wrapper from surface.
/// All operations are explicit; we rely on Telegram's manual-animation policy /// 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 /// `presentationScale` re-application is intentionally not handled here: the
/// portal path is gated on `contentNode.presentationScale == 1.0` in CCEPN's /// 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 // ContentContainingNode/View added contentNode at init). Capturing
// contentNode.supernode at staging-enter time was overengineered: during // contentNode.supernode at staging-enter time was overengineered: during
// animateOut staging that's offsetContainerNode (CCEPN's transient host), // 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 { switch containingItem {
case let .node(containingNode): case let .node(containingNode):
containingNode.addSubnode(containingNode.contentNode) containingNode.addSubnode(containingNode.contentNode)
@ -761,7 +761,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo
contentNodeValue.sourceTransitionSurface = takeInfo.sourceTransitionSurface contentNodeValue.sourceTransitionSurface = takeInfo.sourceTransitionSurface
// Mirror any ancestor scale on the source (e.g. a sheet's container transform) onto the offset // 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. // they pop to 1:1 when reparented into the unscaled overlay window.
let sourceView = takeInfo.containingItem.view let sourceView = takeInfo.containingItem.view
let modeledWidth = sourceView.bounds.width let modeledWidth = sourceView.bounds.width
@ -1425,7 +1425,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo
// Use ItemContentNode.frame (already includes contentVerticalOffset and // Use ItemContentNode.frame (already includes contentVerticalOffset and
// additionalVisibleOffsetY from the layout pass) plus // additionalVisibleOffsetY from the layout pass) plus
// containingItem.contentRect.origin to derive the bubble's actual rest // 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) // are visible (additionalVisibleOffsetY = reactionContextNode.visibleExtensionDistance)
// the wrapper would sit ~10pt above the bubble's true rest, visibly offsetting // the wrapper would sit ~10pt above the bubble's true rest, visibly offsetting
// the portal mirror until staging.settle reparents into offsetContainerNode. // 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 // is visible at the chat-bubble slot; final (overlay clone, above
// chrome) is visible at the menu position. Final fade-in begins // chrome) is visible at the menu position. Final fade-in begins
// `crossfadeOverlap` seconds before source fade-out, so the final // `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. // single-frame seam at the handoff. Each fade runs for a fixed 0.1s.
if let wrapper = staging.wrapper, let clone = staging.clone { if let wrapper = staging.wrapper, let clone = staging.clone {
let sourceFadeDelay = 0.12 * duration let sourceFadeDelay = 0.12 * duration
@ -1759,7 +1759,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo
// Mid-dismiss crossfade: clone (overlay portal target, above chrome) // Mid-dismiss crossfade: clone (overlay portal target, above chrome)
// is visible at the menu position; final (chat-tree wrapper, behind // is visible at the menu position; final (chat-tree wrapper, behind
// chrome) is visible once the bubble settles at the chat-bubble // 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 // dismiss; final picks up only as the bubble lands. Source fade-out
// is anchored at 80% of the spring duration; final fade-in starts // is anchored at 80% of the spring duration; final fade-in starts
// `crossfadeOverlap` seconds earlier. Each fade runs for 0.1s. // `crossfadeOverlap` seconds earlier. Each fade runs for 0.1s.
@ -2138,4 +2138,3 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo
} }
} }

View file

@ -16,9 +16,8 @@ import GZip
import HierarchyTrackingLayer import HierarchyTrackingLayer
import TelegramUIPreferences import TelegramUIPreferences
// WinterGram: bakes a white-on-transparent shape asset, tinted to `color`, into a CGImage usable as // WinterGram: bake the tinted shape into a CGImage; `UIImage.withTintColor(...).cgImage` returns the
// layer contents. Drawing into a renderer context is required because `UIImage.withTintColor(...).cgImage` // original (untinted) pixels, so drawing into a renderer context is required. Cached per (shape, color).
// returns the original (untinted) pixels. Cached per (shape, colour).
private var winterGramTintedShapeCache: [String: CGImage] = [:] private var winterGramTintedShapeCache: [String: CGImage] = [:]
func winterGramBakeTintedShape(_ name: String, color: UIColor) -> 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 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 winterGramBackplateLayer: SimpleLayer?
private var winterGramSnowflakeLayer: SimpleLayer? private var winterGramSnowflakeLayer: SimpleLayer?
// Whether the badge backplate should be spinning. Tracked separately so the rotation can be // Track whether the badge backplate should spin so the animation can be restored when the
// re-applied when the layer re-enters the hierarchy (Core Animation drops detached animations // layer re-enters the hierarchy.
// when a layer leaves the window e.g. cell reuse / scrolling / backgrounding).
private var winterGramBadgeAnimating: Bool = false private var winterGramBadgeAnimating: Bool = false
private var animationLayer: InlineStickerItemLayer? 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 // Restore or update the infinite backplate rotation for the WinterGram badge.
// 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.
private func applyWinterGramBadgeRotation() { private func applyWinterGramBadgeRotation() {
guard let backplateLayer = self.winterGramBackplateLayer else { guard let backplateLayer = self.winterGramBackplateLayer else {
return return
@ -605,8 +601,7 @@ public final class EmojiStatusComponent: Component {
if case let .winterGramBadge(backplateColor) = component.content { if case let .winterGramBadge(backplateColor) = component.content {
size = availableSize size = availableSize
// The tint must be BAKED into the bitmap: `UIImage.withTintColor(...).cgImage` returns the // Bake the tint into the bitmap; `UIImage.withTintColor(...).cgImage` would keep the white pixels.
// original (white) pixels, which would render the backplate white instead of the theme colour.
let backplateImage = winterGramBakeTintedShape("WntGramBackplateShape", color: backplateColor) let backplateImage = winterGramBakeTintedShape("WntGramBackplateShape", color: backplateColor)
let snowflakeImage = winterGramBakeTintedShape("WntGramSnowflakeShape", color: .white) let snowflakeImage = winterGramBakeTintedShape("WntGramSnowflakeShape", color: .white)

View file

@ -2917,8 +2917,7 @@ final class TextContentItemLayer: SimpleLayer {
effectiveCharacterDrawCount = maxCharacterDrawCount effectiveCharacterDrawCount = maxCharacterDrawCount
} else { } else {
if self.previousMaxCharacterDrawCount > 0 || !self.animatingSnippetLayers.isEmpty { if self.previousMaxCharacterDrawCount > 0 || !self.animatingSnippetLayers.isEmpty {
// Reveal finished compute total character count so mask and snippets // Keep mask active until all snippet animations finish.
// can continue updating until all snippet animations complete
var totalCharCount = 0 var totalCharCount = 0
for line in lines { for line in lines {
if let characterRects = line.characterRects { if let characterRects = line.characterRects {
@ -2932,7 +2931,7 @@ final class TextContentItemLayer: SimpleLayer {
} }
effectiveCharacterDrawCount = totalCharCount effectiveCharacterDrawCount = totalCharCount
} else { } else {
// Nothing left to animate remove the mask // Remove mask when nothing remains to animate.
if let _ = self.revealMaskLayer { if let _ = self.revealMaskLayer {
self.renderNodeContainer.mask = nil self.renderNodeContainer.mask = nil
self.revealMaskLayer = 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 // Limit mask to the lowest animating snippet index.
// so the mask never extends past any character still being animated
let maskCharacterLimit: Int let maskCharacterLimit: Int
if let lowestAnimating = self.animatingSnippetLayers.min(by: { $0.characterIndex < $1.characterIndex })?.characterIndex { if let lowestAnimating = self.animatingSnippetLayers.min(by: { $0.characterIndex < $1.characterIndex })?.characterIndex {
maskCharacterLimit = lowestAnimating maskCharacterLimit = lowestAnimating

View file

@ -1693,7 +1693,6 @@ public final class MessageInputPanelComponent: Component {
} }
} }
// AI Button
do { do {
let isTallPanel = textFieldSize.height >= 70.0 let isTallPanel = textFieldSize.height >= 70.0
let textLength = self.textFieldExternalState.textLength let textLength = self.textFieldExternalState.textLength

View file

@ -195,9 +195,7 @@ public final class PeerInfoGiftsCoverComponent: Component {
} }
private func recomputeGifts() { private func recomputeGifts() {
// WinterGram: visual (fake NFT) gifts are merged via `additionalGifts`, so we must render // WinterGram: merge visual gifts from `additionalGifts` even before the real gifts state loads.
// 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.
let giftStatusId = self.currentGiftStatusId let giftStatusId = self.currentGiftStatusId
let pinnedGifts = (self.currentGiftsState?.gifts ?? []).filter { gift in let pinnedGifts = (self.currentGiftsState?.gifts ?? []).filter { gift in
if gift.pinnedToTop { if gift.pinnedToTop {

View file

@ -1642,8 +1642,7 @@ final class PeerInfoHeaderNode: ASDisplayNode {
} }
} }
// WinterGram: render the snowflake/developer badge last, to the right of the premium emoji // WinterGram: render the snowflake/developer badge last, mirroring the chat-list ordering.
// status, the verified mark and any credibility icon mirroring the chat-list ordering.
if let winterGramIconSize = self.winterGramIconSize, let titleExpandedWinterGramIconSize = self.titleExpandedWinterGramIconSize, winterGramIconSize.width > 0.0 { 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 // 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. // after the premium emoji status, which carries its own internal transparent padding.

View file

@ -171,7 +171,7 @@ func infoItems(
} }
var displayId = "\(rawId)" var displayId = "\(rawId)"
if let winterGramDcId = winterGramDcId { if let winterGramDcId = winterGramDcId {
displayId += " (DC: \(winterGramDcId))" displayId += " (dc: \(winterGramDcId))"
} }
let copyId: () -> Void = { [weak interaction] in let copyId: () -> Void = { [weak interaction] in
UIPasteboard.general.string = "\(rawId)" 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) 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() copyId()
}, longTapAction: nil, contextAction: { _, gesture, _ in }, longTapAction: nil, contextAction: { _, gesture, _ in
copyId() copyId()
@ -192,9 +192,9 @@ func infoItems(
if currentWinterGramSettings.showRegistrationDate, !user.isDeleted, let date = winterGramEstimatedRegistrationDate(userId: user.id.id._internalGetInt64Value()) { if currentWinterGramSettings.showRegistrationDate, !user.isDeleted, let date = winterGramEstimatedRegistrationDate(userId: user.id.id._internalGetInt64Value()) {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.locale = Locale(identifier: presentationData.strings.baseLanguageCode) formatter.locale = Locale(identifier: presentationData.strings.baseLanguageCode)
formatter.dateFormat = "MMMM yyyy" formatter.setLocalizedDateFormatFromTemplate("MMMM yyyy")
let dateText = "" + formatter.string(from: date) 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 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 UIPasteboard.general.string = dateText
gesture?.cancel() gesture?.cancel()
}, requestLayout: { animated in }, requestLayout: { animated in

View file

@ -9,8 +9,7 @@ import AccountContext
import TelegramPresentationData import TelegramPresentationData
import GiftItemComponent import GiftItemComponent
// WinterGram: a simple grid picker of all regular (generic) star gifts including sold-out ones still // WinterGram: grid picker of regular star gifts for adding a non-unique gift to the profile.
// present in the cached catalog used to add a NON-unique gift "visually" to the profile.
public final class WinterGramGiftPickerScreen: ViewController { public final class WinterGramGiftPickerScreen: ViewController {
private final class Node: ViewControllerTracingNode { private final class Node: ViewControllerTracingNode {
private weak var controller: WinterGramGiftPickerScreen? private weak var controller: WinterGramGiftPickerScreen?
@ -36,8 +35,7 @@ public final class WinterGramGiftPickerScreen: ViewController {
self.scrollNode.view.alwaysBounceVertical = true self.scrollNode.view.alwaysBounceVertical = true
self.addSubnode(self.scrollNode) self.addSubnode(self.scrollNode)
// Make sure the gift catalog is fetched/cached otherwise the picker would be empty if the // Ensure the gift catalog is cached so the picker is not empty on first open.
// user never opened a gift screen before.
self.keepUpdatedDisposable = context.engine.payments.keepStarGiftsUpdated().startStrict() self.keepUpdatedDisposable = context.engine.payments.keepStarGiftsUpdated().startStrict()
self.disposable = (context.engine.payments.cachedStarGifts() self.disposable = (context.engine.payments.cachedStarGifts()

View file

@ -707,11 +707,11 @@ public final class TabBarComponent: Component {
let equalWidth = floorToScreenPixels(availableItemsWidth / CGFloat(component.items.count)) let equalWidth = floorToScreenPixels(availableItemsWidth / CGFloat(component.items.count))
if unboundItemWidths.allSatisfy({ $0 <= equalWidth }) { 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) itemWidths = Array(repeating: equalWidth, count: component.items.count)
totalItemsWidth = equalWidth * CGFloat(component.items.count) totalItemsWidth = equalWidth * CGFloat(component.items.count)
} else { } else {
// Some items need more space use weighted fit // Use weighted widths when some items need more space.
let itemWeightNorm: CGFloat = availableItemsWidth / unboundItemWidthSum let itemWeightNorm: CGFloat = availableItemsWidth / unboundItemWidthSum
var widths: [CGFloat] = [] var widths: [CGFloat] = []
var total: CGFloat = 0.0 var total: CGFloat = 0.0

View file

@ -17,7 +17,8 @@ import TextInputMenu
import ObjCRuntimeUtils import ObjCRuntimeUtils
import MultilineTextComponent import MultilineTextComponent
public final class EmptyInputView: UIView, UIInputViewAudioFeedback { // Internal to avoid collision with ChatEntityKeyboardInputNode.EmptyInputView.
final class EmptyInputView: UIView, UIInputViewAudioFeedback {
public var enableInputClicksWhenVisible: Bool { public var enableInputClicksWhenVisible: Bool {
return true return true
} }

View file

@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View file

@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

View file

@ -1724,7 +1724,7 @@ Readability.prototype = {
/** /**
* Attempts to get excerpt and byline metadata for the article. * 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. * could be extracted from JSON-LD object.
* *
* @return Object with optional "excerpt" and "byline" properties * @return Object with optional "excerpt" and "byline" properties

View file

@ -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" }
}
]
}
]
}

View file

@ -1224,6 +1224,7 @@ private func extractAccountManagerState(records: AccountRecordsView<TelegramAcco
let sharedApplicationContext = SharedApplicationContext(sharedContext: sharedContext, notificationManager: notificationManager, wakeupManager: wakeupManager) let sharedApplicationContext = SharedApplicationContext(sharedContext: sharedContext, notificationManager: notificationManager, wakeupManager: wakeupManager)
sharedApplicationContext.sharedContext.mediaManager.overlayMediaManager.attachOverlayMediaController(sharedApplicationContext.overlayMediaController) sharedApplicationContext.sharedContext.mediaManager.overlayMediaManager.attachOverlayMediaController(sharedApplicationContext.overlayMediaController)
self.winterGramSettingsDisposable.set(observeWinterGramSettings(accountManager: accountManager)) self.winterGramSettingsDisposable.set(observeWinterGramSettings(accountManager: accountManager))
WinterGramBadgeManager.shared.start()
self.winterGramGlassDisposable.set((winterGramSettings(accountManager: accountManager) self.winterGramGlassDisposable.set((winterGramSettings(accountManager: accountManager)
|> deliverOnMainQueue).start(next: { settings in |> deliverOnMainQueue).start(next: { settings in
let glass = settings.liquidGlass let glass = settings.liquidGlass

View file

@ -140,9 +140,7 @@ final class ChatSendMessageRichTextPreview: ChatSendMessageContextScreenRichText
fitToWidth: true fitToWidth: true
) )
self.pageView.update(layout: layout, theme: pageTheme, animation: .None) self.pageView.update(layout: layout, theme: pageTheme, animation: .None)
// The parent (MessageItemView) owns and sets `pageView`'s frame; `update` only // The parent owns the page frame and transition.
// rebuilds content and reports the size. Rendering is static (.None) the screen
// drives the size/crossfade transition.
self.cachedBoundingWidth = boundingWidth self.cachedBoundingWidth = boundingWidth
self.cachedThemeIdentity = themeIdentity self.cachedThemeIdentity = themeIdentity

View file

@ -5494,7 +5494,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
return return
} }
// Find the bubble for this message (it must be at least partially // 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 // 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 // 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 // content area; .center(.custom) is bypassed for short items, so it

View file

@ -4051,7 +4051,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate {
// In overlay chat mode (self.containerNode != nil) historyNodeContainer is // In overlay chat mode (self.containerNode != nil) historyNodeContainer is
// reparented out of contentContainerNode (see line ~1299), making the // reparented out of contentContainerNode (see line ~1299), making the
// `aboveSubview: historyNodeContainer.view` insertion invalid. Return nil // `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. // are not supported in overlay mode.
guard self.containerNode == nil else { return nil } guard self.containerNode == nil else { return nil }
let parent = self.contentContainerNode.contentNode.view let parent = self.contentContainerNode.contentNode.view

View file

@ -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 { if currentWinterGramSettings.showPeerId != .hidden {
actions.append(.action(ContextMenuActionItem(text: "Copy Message ID", icon: { theme in actions.append(.action(ContextMenuActionItem(text: "Copy Message ID", icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.actionSheet.primaryTextColor) return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.actionSheet.primaryTextColor)

View file

@ -64,8 +64,8 @@ private func pendingAdditiveSublayerTranslation(_ layer: CALayer) -> CGPoint {
/// `convert(_:to:)` into the child. Applying the correction at the right level /// `convert(_:to:)` into the child. Applying the correction at the right level
/// (rather than flat-summing the translations in the destination space) lets /// (rather than flat-summing the translations in the destination space) lets
/// `CALayer.convert` propagate each correction through any remaining /// `CALayer.convert` propagate each correction through any remaining
/// transforms child `transform`, further ancestors' own model /// transforms, including child `transform` and ancestor model
/// `sublayerTransform`, etc. so the result is correct even when the chain /// `sublayerTransform`, so the result is correct even when the chain
/// contains non-translation transforms (rotations, scales). /// contains non-translation transforms (rotations, scales).
private func convertAnimatingSourceRectFromWindow(_ windowRect: CGRect, toView: UIView) -> CGRect { private func convertAnimatingSourceRectFromWindow(_ windowRect: CGRect, toView: UIView) -> CGRect {
var chain: [CALayer] = [] var chain: [CALayer] = []

View file

@ -43,7 +43,7 @@ final class MusicListenTracker {
func update(with stateAndType: (Account, SharedMediaPlayerItemPlaybackStateOrLoading, MediaManagerPlayerType)?) { func update(with stateAndType: (Account, SharedMediaPlayerItemPlaybackStateOrLoading, MediaManagerPlayerType)?) {
assert(Queue.mainQueue().isCurrent()) assert(Queue.mainQueue().isCurrent())
guard let (_, stateOrLoading, type) = stateAndType, type == .music else { 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() self.reportAndReset()
return return
} }
@ -135,7 +135,7 @@ final class MusicListenTracker {
self.pauseTimer?.invalidate() self.pauseTimer?.invalidate()
self.pauseTimer = nil self.pauseTimer = nil
} else if !nowPlaying && wasPlaying { } else if !nowPlaying && wasPlaying {
// Just paused start pause timer // Start the pause timer.
self.startPauseTimer() self.startPauseTimer()
} }
@ -154,7 +154,7 @@ final class MusicListenTracker {
} }
private func pauseTimerFired() { private func pauseTimerFired() {
// Paused > 60s report current session // Report after a 60-second pause.
self.reportAndReset() self.reportAndReset()
} }

View file

@ -443,8 +443,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur
navigationController?.pushViewController(controller) navigationController?.pushViewController(controller)
return return
} }
// WinterGram: wnt://profile opens the current account's own profile; wnt://profile?id=<id> // Open the current or requested cached profile.
// opens that user's profile (resolved from the local cache works for known peers).
if host == "profile" { if host == "profile" {
let targetPeerId: EnginePeer.Id let targetPeerId: EnginePeer.Id
if let idString = parsedUrl.query.flatMap({ QueryParameters($0)?["id"] }), let idValue = Int64(idString) { if let idString = parsedUrl.query.flatMap({ QueryParameters($0)?["id"] }), let idValue = Int64(idString) {

View file

@ -3,7 +3,7 @@ import Display
import TelegramUIPreferences import TelegramUIPreferences
// WinterGram: a persistent branding banner shown at the very top, centred in the Dynamic Island / // 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 // 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. // `WntGramBanner` image); `WinterGramTopBannerStyle.off` hides it, any other value shows it.
public final class WinterGramTopBannerView: UIView { public final class WinterGramTopBannerView: UIView {

View file

@ -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)
}
}
}

View file

@ -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<String>()
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
}
}

View file

@ -197,8 +197,8 @@ public struct WinterGramVisualGift: Codable, Equatable {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id) self.id = try container.decode(String.self, forKey: .id)
self.gift = try container.decode(StarGift.self, forKey: .gift) self.gift = try container.decode(StarGift.self, forKey: .gift)
// `EnginePeer` isn't `Codable` and can't be rebuilt without a postbox, so like // `EnginePeer` isn't `Codable` and can't be rebuilt without a postbox.
// TelegramCore's own `StarGift` only the peer id is persisted and `fromPeer` is left // 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. // nil on decode, to be resolved lazily by the consumer when a peer is actually needed.
self.fromPeer = nil self.fromPeer = nil
} }
@ -816,40 +816,16 @@ public func observeWinterGramSettings(accountManager: AccountManager<TelegramAcc
} }
public func isWinterGramOfficialPeer(_ peer: EnginePeer) -> Bool { public func isWinterGramOfficialPeer(_ peer: EnginePeer) -> Bool {
let peerIdValue = peer.id.id._internalGetInt64Value() return WinterGramBadgeManager.shared.badge(for: peer) != nil
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
}
} }
// Developer accounts get a distinct backplated badge; other official peers keep the plain snowflake.
public func isWinterGramDeveloperPeer(_ peer: EnginePeer) -> Bool { public func isWinterGramDeveloperPeer(_ peer: EnginePeer) -> Bool {
let peerIdValue = peer.id.id._internalGetInt64Value() return WinterGramBadgeManager.shared.badge(for: peer)?.id == "developer"
switch peer {
case .user:
return peerIdValue == 885166226 || peerIdValue == 5665997196
default:
return false
}
} }
// 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? { public func winterGramBadgeImageName(for peer: EnginePeer) -> String? {
if isWinterGramDeveloperPeer(peer) { guard let badge = WinterGramBadgeManager.shared.badge(for: peer) else {
return "WntGramDeveloperBadge" return nil
} }
if isWinterGramOfficialPeer(peer) { return badge.id == "developer" ? "WntGramDeveloperBadge" : "WinterGramSnowflake"
if case .channel = peer {
return "WntGramDeveloperBadge"
}
return "WinterGramSnowflake"
}
return nil
} }

View file

@ -10,7 +10,7 @@ public func customEmojiMarkdownURL(fileId: Int64) -> String {
/// Backslash-escapes only the characters that would break a marker link's /// Backslash-escapes only the characters that would break a marker link's
/// display text (the `alt`): backslash, the link-text brackets, and newline. /// 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 `[<alt>](tg://emoji?id=)` /// alt round-trips. Shared by every site that emits a `[<alt>](tg://emoji?id=)`
/// marker so the escaping cannot drift between encoders. /// marker so the escaping cannot drift between encoders.
public func escapeCustomEmojiMarkdownAlt(_ string: String) -> String { 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 /// 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. /// 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 /// and the send path only needs the fileId. Known limitation: an alt containing
/// a literal `]` is not matched (emoji alts do not contain brackets). /// a literal `]` is not matched (emoji alts do not contain brackets).
public func chatInputTextWithReattachedCustomEmoji(_ markdown: String) -> NSAttributedString { public func chatInputTextWithReattachedCustomEmoji(_ markdown: String) -> NSAttributedString {

View file

@ -59,7 +59,7 @@ public struct RangeSet<Bound: Comparable> {
/// The ranges stored by a range set are never empty, never overlap, /// The ranges stored by a range set are never empty, never overlap,
/// and are always stored in ascending order when comparing their lower /// and are always stored in ascending order when comparing their lower
/// or upper bounds. In addition to not overlapping, no two consecutive /// 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]`. /// and would instead be represented as `[0..<10]`.
internal func _checkInvariants() { internal func _checkInvariants() {
for (a, b) in zip(ranges, ranges.dropFirst()) { for (a, b) in zip(ranges, ranges.dropFirst()) {

View file

@ -51,12 +51,12 @@ func isAllowedBotMediaUrl(_ urlString: String) -> Bool {
// Strict canonical dotted-decimal IPv4 (4 octets, no leading zeros, each 0-255). // 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 // 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 // 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) { if let v4Bytes = parseCanonicalIPv4(host) {
return isPublicIPv4(v4Bytes) 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. // numeric-looking hostname to inet_pton.
if host.contains(":") { if host.contains(":") {
var v6 = in6_addr() 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 // 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 // (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 // "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 // still treat as 127.0.0.1 even when inet_pton would accept them as

View file

@ -10,7 +10,15 @@ MESON_OPTIONS="--buildtype=release --default-library=static -Denable_tools=false
CROSSFILE="" CROSSFILE=""
if [ "$ARCH" = "arm64" ]; then 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 elif [ "$ARCH" = "sim_arm64" ]; then
rm -f "arm64-iPhoneSimulator-custom.meson" rm -f "arm64-iPhoneSimulator-custom.meson"
TARGET_CROSSFILE="$BUILD_DIR/dav1d/package/crossfiles/arm64-iPhoneSimulator-custom.meson" TARGET_CROSSFILE="$BUILD_DIR/dav1d/package/crossfiles/arm64-iPhoneSimulator-custom.meson"

Some files were not shown because too many files have changed in this diff Show more