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
@ -39,3 +38,8 @@ common:index_build --noannounce_rc
common:index_build --noshow_timestamps
common:index_build --curses=no
common:index_build --color=no
# Demote new Xcode 27 / iOS 27 deprecation and implicit-capture diagnostics to warnings.
build --features=-treat_warnings_as_errors
build --@build_bazel_rules_swift//swift:copt=-no-warnings-as-errors
build --copt=-Wno-deprecated-declarations

4
.gitignore vendored
View file

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

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"
@ -1850,7 +1850,7 @@ ios_application(
xcodeproj(
name = "Telegram_xcodeproj",
bazel_path = telegram_bazel_path,
project_name = "Telegram",
project_name = "WinterGram",
tags = ["manual"],
top_level_targets = [
top_level_target(":Telegram", target_environments = ["device", "simulator"]),

View file

@ -2612,60 +2612,36 @@ typealias CMJImage = UIImage
// MARK: - Public Namespace
/// A lightweight utility for turning ordinary bitmap images into ``NSAdaptiveImageGlyph`` (Apple Genmoji) objects.
///
/// ## Overview
/// The public API is minimal a single call to generate a glyph and helper utilities for
/// de/serialising attributed strings.
///
/// ```swift
/// // Synchronous usage
/// let glyph = try Customoji.makeGlyph(
/// from: image,
/// description: "alt text",
/// cropToSquare: true
/// )
/// let attr = NSAttributedString(adaptiveImageGlyph: glyph, attributes: [:])
/// ```
///
/// ## Requirements
/// * iOS 18 / macOS 15
/// * Swift 5.10+
///
/// ## Topics
/// - Generating glyphs: ``makeGlyph(from:description:identifier:tileSizes:cropToSquare:heicQuality:)``
/// - Generating glyphs asynchronously: ``makeGlyphAsync(from:description:identifier:tileSizes:cropToSquare:heicQuality:)``
/// - Serialising: ``decompose(_:)`` / ``recompose(plain:ranges:blobs:)``
///
/// Utility for converting bitmaps into ``NSAdaptiveImageGlyph`` (Genmoji).
public struct Customoji {
// MARK: Library-scoped Errors
/// Errors that can be thrown by ``Customoji``.
/// Errors thrown by ``Customoji``.
public enum Error: Swift.Error, LocalizedError {
/// The input image is not square and ``makeGlyph(from:description:identifier:tileSizes:cropToSquare:heicQuality:)`` was called with `cropToSquare == false`.
/// Input image is not square and cropping is disabled.
case nonSquare
/// The longest side of the input image exceeds ``maxSide`` pixels (defaults to 4096).
/// Input image exceeds ``maxSide`` pixels.
case imageTooLarge
/// Failed to create an in-memory HEIC container via *Image I/O*.
/// Failed to create the in-memory HEIC container.
case heicDestinationCreationFailed
/// Failed to finalise the HEIC container after writing all tiles.
/// Failed to finalize the HEIC container.
case heicFinalizeFailed
/// Internal resize operation failed when generating a tile.
/// Resizing a tile failed.
case imageScaleFailed
/// A `CGImage` representation could not be extracted from the source image.
/// Could not extract a `CGImage` from the source image.
case cgImageUnavailable
/// The `tileSizes` parameter was empty, not strictly ascending, contained duplicates, or exceeded the source size.
/// `tileSizes` was empty, not strictly ascending, contained duplicates, or exceeded the source size.
case invalidTileSizes
/// No tiles were generated normally unreachable unless all requested sizes were invalid.
/// No tiles were generated; normally unreachable unless all requested sizes were invalid.
case noTilesGenerated
/// The current platform is neither UIKit- nor AppKit-based (unlikely in practice).
/// Unsupported platform.
case unsupportedPlatform
/// The `heicQuality` parameter was outside the 0.0 1.0 range.
/// `heicQuality` was outside the 0.0...1.0 range.
case invalidHeicQuality
/// Cropping or scaling to a square failed.
case squareCropFailed
/// Textual description suitable for presenting to users.
/// Localized error description.
public var errorDescription: String? {
switch self {
case .nonSquare: return "Input image must be square."
@ -2694,26 +2670,16 @@ public struct Customoji {
}
}
/// Default tile sizes (in pixels) officially used by Apple for Genmoji.
/// Default Genmoji tile sizes.
public static let defaultTileSizes = [40, 64, 96, 160, 320]
/// Maximum allowed side length (in pixels) to guard against excessive memory consumption.
/// Maximum allowed side length, in pixels.
private static let maxSide = 4_096
// MARK: - Synchronous Builder
#if swift(>=5.10)
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *)
/// Generates an ``NSAdaptiveImageGlyph`` synchronously on the **current** thread.
///
/// - Parameters:
/// - image: Source image. Can be rectangular; see `cropToSquare`.
/// - description: A human-readable accessibility description (VoiceOver/Narrator).
/// - identifier: A globally unique content identifier. Defaults to a fresh UUID.
/// - tileSizes: Desired tile sizes, in pixels. Must be strictly ascending. Defaults to ``defaultTileSizes``.
/// - cropToSquare: When `true`, the central square area is cropped if the input image is not square.
/// - heicQuality: Lossy compression quality between `0.0` and `1.0`. Defaults to `0.8`.
/// - Throws: ``Customoji/Error`` if validation or encoding fails.
/// - Returns: A fully-formed glyph ready for attribution.
/// Generate an ``NSAdaptiveImageGlyph`` from a bitmap.
static func makeGlyph(
from image: CMJImage,
description: String,
@ -2739,19 +2705,7 @@ public struct Customoji {
// MARK: - AttributedString Utilities
#if swift(>=5.10)
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *)
/// Breaks an ``NSAttributedString`` that may contain adaptive-image glyphs into three parts.
///
/// This is handy when you need to **serialise** rich text for network transport or persistent
/// storage, because `NSAdaptiveImageGlyph` itself is not `Codable`.
///
/// ```swift
/// let (plain, ranges, blobs) = Customoji.decompose(attrString)
/// ```
///
/// - Returns: A tuple where:
/// - plain: UTF-16 plain text.
/// - ranges: An array mapping text ranges to glyph identifiers.
/// - blobs: A dictionary mapping identifier raw HEIC data.
/// Split an attributed string into plain text, glyph ranges, and HEIC data.
@inlinable
public static func decompose(_ attr: NSAttributedString) -> (
plain: String, ranges: [(NSRange, String)], blobs: [String: Data]
@ -2773,13 +2727,7 @@ public struct Customoji {
}
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *)
/// Reassembles an attributed string previously produced by ``decompose(_:)``.
///
/// - Parameters:
/// - plain: The plain UTF-16 text.
/// - ranges: An array of `(NSRange, identifier)` pairs.
/// - blobs: A dictionary of identifier HEIC data. *Must* contain every identifier referenced by `ranges`.
/// - Returns: A fully restored `NSAttributedString` with adaptive-image glyphs reinstated.
/// Reassemble an attributed string from ``decompose(_:)`` output.
public static func recompose(
plain: String,
ranges: [(NSRange, String)],
@ -2805,8 +2753,7 @@ public struct Customoji {
#endif
// MARK: - Helper: Pixel-Side Validation
/// Ensures that the images **longer edge** does not exceed ``maxSide`` pixels, guarding against
/// excessive memory usage during HEIC encoding.
/// Reject images whose longer edge exceeds ``maxSide``.
private static func validatePixelSide(of image: CMJImage) throws {
#if canImport(UIKit)
let scale = image.scale == 0 ? 1 : image.scale
@ -2821,11 +2768,6 @@ public struct Customoji {
}
}
// =============================================================
// Below this line lies the internal implementation.
// Public-facing symbols have already been documented above.
// =============================================================
// MARK: - Core Builder
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *)
extension Customoji {

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
# Worker for the apple_prebuilt_watchos_application Bazel rule.
#
# Builds the tgwatch watch app via xcodebuild (device, Release, UNSIGNED), then
# — if a provisioning profile is supplied — codesigns the app and its nested
# If a provisioning profile is supplied, codesigns the app and its nested
# frameworks with the watchkitapp provisioning profile and a matching identity,
# and finally zips the .app into the rule's output archive.
#
@ -45,7 +45,7 @@ DD="$(mktemp -d)"
trap 'rm -rf "$DD"' EXIT
# Build from a writable copy so xcodebuild/SwiftPM never write into the (possibly
# in-repo, read-only) source tree — e.g. SwiftPM's Package.resolved or the workspace.
# in-repo, read-only) source tree, such as SwiftPM's Package.resolved or the workspace.
# The tree is small (~12M); a plain cp on each (uncached) build is acceptable.
WORKSRC="$DD/src"
mkdir -p "$WORKSRC"
@ -84,7 +84,7 @@ fi
# Sign the watch app whenever a provisioning profile is available. When no explicit
# identity is supplied, derive it from the certificate embedded in that profile, so
# the watch app is signed with the same distribution/development identity as the host
# app (resolved from the shared codesigning material) required for App Store, where
# app (resolved from the shared codesigning material), as required for App Store, where
# every nested bundle must carry the Apple submission certificate. Without a profile
# the app is left unsigned (the host does not re-sign it).
if [ -n "$PROFILE" ]; then

View file

@ -14,7 +14,9 @@ def is_apple_silicon():
def get_clean_env(use_clean_env=True):
clean_env = os.environ.copy()
if use_clean_env:
clean_env['PATH'] = '/usr/bin:/bin:/usr/sbin:/sbin'
# Force xcode-select to return the Xcode-beta path instead of CommandLineTools.
wrapper_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'build-system', 'xcode-wrapper')
clean_env['PATH'] = wrapper_dir + ':/usr/bin:/bin:/usr/sbin:/sbin'
return clean_env

View file

@ -533,13 +533,10 @@ def resolve_configuration(base_path, bazel_command_line: BazelCommandLine, argum
)
aps_environment = codesigning_data.aps_environment
if aps_environment is None:
# WinterGram: for unsigned sideload builds (provisioning disabled), the fake profiles
# don't match the custom bundle id, so no aps-environment is found. Push won't work
# until AltStore / SideStore / LiveContainer re-signs with the user's profile, so we
# fall back to a placeholder instead of failing the build.
if getattr(arguments, 'disableProvisioningProfiles', False):
print('No aps-environment found; defaulting to "development" for unsigned sideload build.')
aps_environment = 'development'
# Unsigned builds use a placeholder aps-environment instead of failing.
if getattr(arguments, 'disableProvisioningProfiles', False) or getattr(arguments, 'disableExtensions', False):
print('No aps-environment found; disabling APNs entitlement for unsigned sideload build.')
aps_environment = ''
else:
print('Could not find a valid aps-environment entitlement in the provided provisioning profiles')
sys.exit(1)
@ -636,7 +633,7 @@ def resolve_watch_provisioning_profile(arguments, base_path):
Returns the absolute path of the profile to sign the embedded watch app with, or None
to build the watch app UNSIGNED. None is only returned (with a warning) for
non-distribution codesigning a distribution build (appstore/adhoc/enterprise) raises
non-distribution codesigning. A distribution build (appstore/adhoc/enterprise) raises
instead, because the host ios_application does NOT re-sign the embedded watch app, so an
unsigned Watch/ payload ships as-is and is silently rejected at install time. Failing
here turns that silent "won't install" into a build error that names the missing profile.
@ -664,11 +661,11 @@ def resolve_watch_provisioning_profile(arguments, base_path):
'--embedWatchApp is set for a distribution build (--gitCodesigningType={ct}), but no watchkitapp '
'provisioning profile resolved (looked for {p}).\n'
'The {ct} codesigning material does not contain a `.watchkitapp` profile, so the embedded watch app '
'would be UNSIGNED — the host app is not re-signed over it, so it ships unsigned and is silently '
'would be UNSIGNED. The host app is not re-signed over it, so it ships unsigned and is silently '
'rejected when installing on a watch.\n'
'Fix: fetch the latest codesigning material so the watchkitapp profile is present — drop '
'Fix: fetch the latest codesigning material so the watchkitapp profile is present. Drop '
'--gitCodesigningUseCurrent (it skips the fetch), or run '
'`git -C build-input/configuration-repository-workdir/encrypted pull` or create/register the {ct} '
'`git -C build-input/configuration-repository-workdir/encrypted pull`, or create/register the {ct} '
'watchkitapp provisioning profile, or pass an explicit --watchProvisioningProfile.'.format(
ct=arguments.gitCodesigningType, p=resolved_watch_profile
)

View file

@ -49,7 +49,10 @@ def generate_xcodeproj(build_environment: BuildEnvironment, disable_extensions,
call_executable(bazel_generate_arguments)
xcodeproj_path = '{}.xcodeproj'.format(app_target_spec.replace(':', '/'))
if target_name == 'Telegram':
xcodeproj_path = 'Telegram/WinterGram.xcodeproj'
else:
xcodeproj_path = '{}.xcodeproj'.format(app_target_spec.replace(':', '/'))
return xcodeproj_path

View file

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

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()
@ -130,7 +130,7 @@ class TextRevealControllerV2:
FRAME_DT_CAP = 0.05
INITIAL_INPUT_RATE = 40.0 # fallback velocity for the first chunk
# When predicted_next_arrival has passed (stream stalled), don't speed up
# further — clamp time_to_next at this minimum.
# Clamp time_to_next at this minimum.
STALL_FLOOR = 0.10
def __init__(self, initial_revealed_count: int, initial_length: int) -> None:

View file

@ -1,26 +1,9 @@
#!/bin/bash
# WinterGram build wrapper — produces WinterGram-named IPAs for every install target.
# Lives in scripts/; all paths are resolved relative to the repo root (the script cd's there).
#
# Usage:
# ./scripts/build-wintergram.sh
# ./scripts/build-wintergram.sh all
# ./scripts/build-wintergram.sh sideload
# ./scripts/build-wintergram.sh livecontainer
# ./scripts/build-wintergram.sh sim
#
# Convenience:
# ./scripts/build-wintergram.sh --install # build the simulator IPA and install it into the active Simulator (sim mode only)
# ./scripts/build-wintergram.sh --install --run # also launch the app in the active Simulator
# ./scripts/build-wintergram.sh --clean # remove ./build before building
# ./scripts/build-wintergram.sh --open-build-dir # open ./build in Finder after build
# ./scripts/build-wintergram.sh --help
# WinterGram build wrapper.
set -euo pipefail
# Resolve to the repo root regardless of where the script is invoked from (it lives in scripts/).
cd "$(dirname "$0")/.."
REPO="$(pwd)"
source ~/.zshrc 2>/dev/null || true
OUT_DIR="build"
SIDELOAD_NAME="WinterGram.ipa"
@ -29,12 +12,19 @@ SIM_NAME="WinterGram-Simulator.ipa"
WNT_BUNDLE_ID="dev.reekeer.wintergram"
BAZEL="./build-input/bazel-8.4.2-darwin-arm64"
DEVICE_SRC="bazel-bin/Telegram/Telegram.ipa"
_XCODE_DEV_DIR="${DEVELOPER_DIR:-$(xcode-select -p 2>/dev/null)}"
BAZEL_XCODE_ACTION_ENV="--action_env=DEVELOPER_DIR=${_XCODE_DEV_DIR} --host_action_env=DEVELOPER_DIR=${_XCODE_DEV_DIR}"
# Xcode 27 compatibility
BAZEL_SDK_COMPAT_ARGS="--features=-treat_warnings_as_errors --@build_bazel_rules_swift//swift:copt=-no-warnings-as-errors --copt=-Wno-deprecated-declarations"
MODE="all"
INSTALL_REQUESTED=0
INSTALL_DEVICE=0
INSTALL_SIM=0
RUN_SIM=0
RUN_APP=0
CLEAN=0
OPEN_BUILD_DIR=0
DEVICE_SELECTOR=""
usage() {
cat <<EOF
@ -45,19 +35,25 @@ Usage:
Modes:
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
sim, simulator Build simulator IPA -> build/$SIM_NAME
Options:
--install Build the simulator IPA and install it into the active booted Simulator.
This forces "sim" mode (install only makes sense for the simulator).
--install Install after build. With ios/device/sideload mode, installs on a connected
iPhone/iPad via xcrun devicectl. With sim mode, installs into the active
booted Simulator. With no explicit mode, keeps the old simulator shortcut.
--device <id|name> Device selector for devicectl. Optional if exactly one iPhone/iPad is connected.
--run Launch the app after --install (implies --install).
--clean Remove ./build before building.
--open-build-dir Open ./build in Finder after build.
-h, --help Show this help.
Examples:
$0 ios --install
$0 ios --install --run
$0 ios --install --device "Del's iPhone"
$0 --install
$0 --install --run
$0 sideload --clean
@ -83,16 +79,21 @@ MODE_WAS_EXPLICIT=0
while [ "$#" -gt 0 ]; do
case "$1" in
all|sideload|device|livecontainer|lc|sim|simulator)
all|sideload|device|ios|livecontainer|lc|sim|simulator)
MODE="$1"
MODE_WAS_EXPLICIT=1
;;
--install)
INSTALL_SIM=1
INSTALL_REQUESTED=1
;;
--run)
RUN_SIM=1
INSTALL_SIM=1
RUN_APP=1
INSTALL_REQUESTED=1
;;
--device)
shift
[ "$#" -gt 0 ] || die "--device requires a value"
DEVICE_SELECTOR="$1"
;;
--clean)
CLEAN=1
@ -111,13 +112,26 @@ while [ "$#" -gt 0 ]; do
shift
done
# --install is simulator-only: installing a device/livecontainer IPA into a Simulator makes no
# sense, so --install always forces "sim" mode (warning if a conflicting mode was given).
if [ "$INSTALL_SIM" -eq 1 ]; then
if [ "$MODE_WAS_EXPLICIT" -eq 1 ] && [ "$MODE" != "sim" ] && [ "$MODE" != "simulator" ]; then
echo "==> --install is simulator-only; ignoring mode '$MODE' and building 'sim'." >&2
fi
MODE="sim"
if [ "$INSTALL_REQUESTED" -eq 1 ]; then
case "$MODE" in
sim|simulator)
INSTALL_SIM=1
;;
sideload|device|ios)
INSTALL_DEVICE=1
;;
all)
if [ "$MODE_WAS_EXPLICIT" -eq 1 ]; then
die "--install with 'all' is ambiguous. Use 'ios --install' for a device or 'sim --install' for Simulator."
fi
MODE="sim"
INSTALL_SIM=1
;;
livecontainer|lc)
die "--install does not support LiveContainer IPA. Use 'ios --install' for direct device install."
;;
esac
fi
if [ "$CLEAN" -eq 1 ]; then
@ -135,8 +149,10 @@ build_sim() {
--cacheDir "$HOME/telegram-bazel-cache" \
build \
--configurationPath build-system/wintergram-development-configuration.json \
--codesigningInformationPath build-system/fake-codesigning-wintergram \
--codesigningInformationPath build-system/fake-codesigning \
--disableProvisioningProfiles \
--disableExtensions \
--bazelArguments="$BAZEL_XCODE_ACTION_ENV $BAZEL_SDK_COMPAT_ARGS" \
--buildNumber=1 --configuration=debug_sim_arm64
[ -f "$DEVICE_SRC" ] || die "simulator artifact not found at $DEVICE_SRC"
@ -181,7 +197,7 @@ install_sim() {
echo "==> [Simulator] installed: $(basename "$APP_PATH")"
if [ "$RUN_SIM" -eq 1 ]; then
if [ "$RUN_APP" -eq 1 ]; then
[ -n "$INSTALLED_BUNDLE_ID" ] || {
rm -rf "$TMP_DIR"
die "could not read CFBundleIdentifier from app Info.plist"
@ -194,6 +210,135 @@ install_sim() {
rm -rf "$TMP_DIR"
}
select_ios_device() {
require_cmd xcrun
if [ -n "$DEVICE_SELECTOR" ]; then
echo "$DEVICE_SELECTOR"
return 0
fi
local JSON_PATH
JSON_PATH="$(mktemp)"
if ! xcrun devicectl list devices --timeout 20 --json-output "$JSON_PATH" >/dev/null; then
rm -f "$JSON_PATH"
die "could not list connected devices via xcrun devicectl. Unlock the phone, trust this Mac, and try again."
fi
local SELECTED
if ! SELECTED="$(python3 - "$JSON_PATH" 2>&1 <<'PY'
import json
import sys
path = sys.argv[1]
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
devices = data.get("result", {}).get("devices", [])
def get(obj, dotted, default=""):
current = obj
for part in dotted.split("."):
if not isinstance(current, dict):
return default
current = current.get(part)
return current if current is not None else default
candidates = []
for device in devices:
platform = str(get(device, "hardwareProperties.platform")).lower()
if platform not in ("ios", "ipados"):
continue
identifier = (
device.get("identifier")
or get(device, "hardwareProperties.udid")
or get(device, "hardwareProperties.serialNumber")
)
if not identifier:
continue
name = (
get(device, "deviceProperties.name")
or device.get("name")
or get(device, "hardwareProperties.marketingName")
or identifier
)
transport = get(device, "connectionProperties.transportType")
pair_state = get(device, "connectionProperties.pairingState")
candidates.append((str(identifier), str(name), str(transport), str(pair_state)))
if len(candidates) == 1:
print(candidates[0][0])
sys.exit(0)
if not candidates:
print("no connected iPhone/iPad found by devicectl", file=sys.stderr)
else:
print("multiple iPhone/iPad devices found; pass --device with one of these:", file=sys.stderr)
for identifier, name, transport, pair_state in candidates:
details = ", ".join(part for part in (transport, pair_state) if part)
suffix = f" ({details})" if details else ""
print(f" {identifier} {name}{suffix}", file=sys.stderr)
sys.exit(2)
PY
)"; then
rm -f "$JSON_PATH"
die "$SELECTED"
fi
rm -f "$JSON_PATH"
echo "$SELECTED"
}
install_device() {
require_cmd xcrun
local IPA="$OUT_DIR/$SIDELOAD_NAME"
[ -f "$IPA" ] || die "device IPA not found at $IPA"
echo "==> [Device] preparing app for install ..."
local TMP_DIR
TMP_DIR="$(mktemp -d)"
unzip -q "$IPA" -d "$TMP_DIR"
local APP_PATH
APP_PATH="$(find "$TMP_DIR/Payload" -maxdepth 1 -type d -name "*.app" | head -n 1)"
[ -n "${APP_PATH:-}" ] || {
rm -rf "$TMP_DIR"
die "no .app found inside $IPA"
}
local INSTALLED_BUNDLE_ID
INSTALLED_BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$APP_PATH/Info.plist" 2>/dev/null || true)"
local DEVICE
DEVICE="$(select_ios_device)"
echo "==> [Device] installing $(basename "$APP_PATH") on $DEVICE ..."
xcrun devicectl device install app --device "$DEVICE" "$APP_PATH"
echo "==> [Device] installed: $(basename "$APP_PATH")"
if [ "$RUN_APP" -eq 1 ]; then
[ -n "$INSTALLED_BUNDLE_ID" ] || {
rm -rf "$TMP_DIR"
die "could not read CFBundleIdentifier from app Info.plist"
}
echo "==> [Device] launching $INSTALLED_BUNDLE_ID on $DEVICE ..."
xcrun devicectl device process launch --device "$DEVICE" --terminate-existing "$INSTALLED_BUNDLE_ID" || true
fi
rm -rf "$TMP_DIR"
}
# --- device build shared by sideload + livecontainer -----------------------
ensure_cert() {
@ -207,13 +352,17 @@ ensure_cert() {
build_device() {
ensure_cert
echo "==> [Device] generating fake provisioning profiles for ${WNT_BUNDLE_ID} ..."
python3 scripts/generate-fake-profiles.py build-system/fake-codesigning-generated
echo "==> [Device] build (debug_arm64, ${WNT_BUNDLE_ID}, extensions disabled) ..."
python3 build-system/Make/Make.py --overrideXcodeVersion \
--cacheDir "$HOME/telegram-bazel-cache" \
build \
--configurationPath build-system/wintergram-development-configuration.json \
--codesigningInformationPath build-system/fake-codesigning-wintergram \
--codesigningInformationPath build-system/fake-codesigning-generated \
--disableExtensions \
--bazelArguments="$BAZEL_XCODE_ACTION_ENV $BAZEL_SDK_COMPAT_ARGS" \
--buildNumber=1 --configuration=debug_arm64
[ -f "$DEVICE_SRC" ] || die "device artifact not found at $DEVICE_SRC"
@ -265,7 +414,7 @@ case "$MODE" in
sim|simulator)
build_sim
;;
sideload|device)
sideload|device|ios)
build_device
make_sideload
;;
@ -274,8 +423,7 @@ case "$MODE" in
make_livecontainer
;;
all)
# One device build feeds BOTH device deliverables; derive them before the sim build
# overwrites bazel-bin/Telegram/Telegram.ipa with the simulator artifact.
# Derive device deliverables before the simulator build overwrites the artifact.
build_device
make_sideload
make_livecontainer
@ -287,14 +435,17 @@ case "$MODE" in
esac
if [ "$INSTALL_SIM" -eq 1 ]; then
# --install forced MODE=sim above, so the simulator IPA was just built — install it.
install_sim
fi
if [ "$INSTALL_DEVICE" -eq 1 ]; then
install_device
fi
echo
echo "==> WinterGram deliverables in ./$OUT_DIR/:"
ls -1 "$OUT_DIR"/WinterGram*.ipa 2>/dev/null | sed 's#^# #' || true
if [ "$OPEN_BUILD_DIR" -eq 1 ]; then
open "$OUT_DIR"
fi
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/ImageCompression:ImageCompression",
"//submodules/RMIntro:RMIntro",
"//submodules/AppBundle:AppBundle",
"//submodules/QrCode:QrCode",
"//submodules/PhoneInputNode:PhoneInputNode",
"//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))
}
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)
}
}

View file

@ -9,6 +9,7 @@ import TelegramPresentationData
import LegacyComponents
import SolidRoundedButtonNode
import RMIntro
import AppBundle
public final class AuthorizationSequenceSplashController: ViewController {
private var controllerNode: AuthorizationSequenceSplashControllerNode {
@ -78,7 +79,11 @@ public final class AuthorizationSequenceSplashController: ViewController {
super.init(navigationBarPresentationData: nil)
self._hasGlassStyle = true
// Brand icon for the first intro page, matching the theme.
let iconName = theme.overallDarkAppearance ? "IntroLogoDark" : "IntroLogoLight"
self.controller.firstPageIcon = UIImage(bundleImageName: iconName)
self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait)
self.statusBar.statusBarStyle = theme.intro.statusBarStyle.style

View file

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

View file

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

View file

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

View file

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

View file

@ -4,9 +4,9 @@ import TelegramCore
/// Recurses the `InstantPage` block tree to locate the anchor `name`, returning the
/// details-sibling-ordinal path of enclosing `<details>` blocks (outermost first).
///
/// - `nil` the anchor exists nowhere.
/// - `[]` the anchor exists outside any `<details>` (top level, a table cell, a quote, etc.).
/// - `[2,0]` inside the 3rd top-level `<details>`, then that details' 1st nested `<details>`.
/// - `nil`: the anchor does not exist.
/// - `[]`: the anchor is outside any `<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
/// expansion-dependent. Consumers map ordinals to live indices via
@ -71,23 +71,7 @@ private func instantPageAnchorPathSearch(
}
}
default:
// .unsupported/.divider/.formula/.image/.video/.audio/.webEmbed/.channelBanner/.map
// leaf/media blocks with no anchor-bearing text. (.relatedArticles also lands here: the
// V2 layout discards its title and lays out only the article media, so its title text is
// never rendered.)
//
// CRITICAL the recursion set here must match the containers the V2 layout recurses
// through layoutBlock (and thus counts <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.
// Keep recursion aligned with the V2 block layout.
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
// system (the control highlights on touch-down, but the action never fires), so an
// embedded audio control in a rich-message bubble could never start playback. A gesture
// recognizer coordinates with the list's gestures and fires reliably matching the V2
// recognizer coordinates with the list's gestures and fires reliably, matching the V2
// image node, the details-title hit view, and the regular file/music message. The plain
// view sits above `statusNode` and is positioned over the icon in `layout()`. (Works in
// V1's full-page Instant View too; gesture recognizers fire inside its scroll view.)
@ -281,4 +281,3 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
self.scrubbingNode.frame = CGRect(origin: CGPoint(x: leftScrubberInset, y: 26.0 + topOffset), size: CGSize(width: size.width - leftScrubberInset - rightScrubberInset, height: 15.0))
}
}

View file

@ -14,7 +14,7 @@ import LocationUI
/// - `.webpage(webPage)` cover single-entry `InstantPageGalleryController`.
/// - `.file(file)` with `isAnimated` single-entry gallery in "playing video" mode.
/// - Default multi-entry gallery built from `allMedias` (filtered to `.image` and non-audio
/// `.file` audio/music siblings are excluded), centered on the tapped media; "playing
/// `.file`; audio and music siblings are excluded), centered on the tapped media; "playing
/// video" mode is on.
///
/// Behavior matches V1's `InstantPageControllerNode.openMedia(_:)` bit-for-bit.
@ -23,7 +23,7 @@ import LocationUI
/// - allMedias: every laid-out media on the page, in laid-out order. Used to build sibling
/// entries when the gallery needs them. Callers may pass `[]` for paths that don't need
/// siblings (e.g. webpage-cover single-entry gallery), but it's safer to always pass the
/// full list the helper filters/uses it only on the default branch.
/// full list. The helper filters it only on the default branch.
/// - transitionArgsForMedia: invoked by the gallery presentation to find the source rect for
/// the swipe-back animation; return `nil` if the source view is not on screen.
/// - hiddenMediaCallback: invoked while the gallery is foregrounded so callers can hide the

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,
// each stamped with quoteDepth > 0. Re-coalesce a run of consecutive quoted
// segments into a single block whose lines are joined by `\n` (each carrying
// its own `> ` depth), matching the whole-message converter otherwise every
// its own `> ` depth), matching the whole-message converter. Otherwise every
// quote line would become its own block and be separated by a blank line.
if depth > 0 {
var lines: [String] = []

View file

@ -21,7 +21,7 @@ import ShimmeringMask
/// Stable identity for an `InstantPageV2LaidOutItem` across `update()` calls. The renderer
/// uses this to harvest existing item views and reuse them when the new layout still has
/// an item with the same id preventing the media wrappers from torching their fetch
/// an item with the same id, preserving media fetch
/// signals + image content on every chat-bubble re-apply.
///
/// Media items use their `media.index` (already unique within a page and used as the
@ -42,7 +42,7 @@ public enum InstantPageV2ItemKind: Hashable {
// MARK: - Render context
/// Bundle of render-time dependencies required to display real media inside an InstantPage V2
/// view. Tied to an `InstantPageV2View` for the view's lifetime if any field would change
/// view. Tied to an `InstantPageV2View` for the view's lifetime. If any field would change
/// (typically because the bubble was recycled with a different webpage), the caller must
/// rebuild the V2View with a fresh render context.
///
@ -93,7 +93,7 @@ public final class InstantPageV2RenderContext {
/// streaming bubble to reuse one V2View across `stableVersion` bumps instead of rebuilding.
/// Only `webpage` changes across chunks; the `imageReference`/`fileReference` closures keep
/// their construction-time `MessageReference` snapshot, which is acceptable because the message
/// id is stable across chunks (media resolves by id) and streamed AI content carries no media.
/// id is stable across chunks and streamed content carries no media.
public func updateContent(webpage: TelegramMediaWebpage) {
self.webpage = webpage
}
@ -152,7 +152,7 @@ public final class InstantPageV2View: UIView {
private var emojiEnableLooping: Bool = true
/// Scroll-visibility rect in this view's coordinate space; gates emoji animation looping.
/// `nil` means "not visible" emoji don't animate. The root rect is set by the bubble and
/// `nil` means not visible, so emoji do not animate. The root rect is set by the bubble and
/// propagated down the nested tree (details/table) by `propagateVisibilityRect`.
public var visibilityRect: CGRect? {
didSet {
@ -179,7 +179,7 @@ public final class InstantPageV2View: UIView {
/// Walks the `rootMediaRegistryHost` chain transitively until it finds a self-referencing
/// host (the true root). Necessary because nested details blocks can leave an inner body's
/// `rootMediaRegistryHost` pointing at an intermediate body rather than the outer root
/// `rootMediaRegistryHost` pointing at an intermediate body rather than the outer root.
/// `propagateRegistryHost(to:)` only walks one hop, so the chain must be followed at lookup.
var trueRegistryRoot: InstantPageV2View {
var host: InstantPageV2View = self
@ -251,7 +251,7 @@ public final class InstantPageV2View: UIView {
let newFrame = InstantPageV2View.actualFrame(forItem: item) // parent positions child
if animation.isAnimated && reusedView.frame != newFrame {
// A collapsing details view keeps its body alive; remove it once this
// frame-shrink (the clip that hides it) finishes see finalizePendingCollapse().
// frame shrink finishes. See finalizePendingCollapse().
let detailsView = reusedView as? InstantPageV2DetailsView
animation.animator.updateFrame(layer: reusedView.layer, frame: newFrame, completion: { [weak detailsView] _ in
detailsView?.finalizePendingCollapse()
@ -316,7 +316,7 @@ public final class InstantPageV2View: UIView {
for view in self.itemViews {
// Top-level `.text` items host their emoji directly. The thinking block hosts emoji on
// its shimmer-wrapped inner text view, which the page never sees as a top-level item
// its shimmer-wrapped inner text view, which is not a top-level item,
// so without this it is skipped and the emoji never get layers (invisible). Nested V2
// sub-layouts (details bodies, table cells) instead run their own updateInlineEmoji.
let textView: InstantPageV2TextView
@ -574,7 +574,7 @@ public final class InstantPageV2View: UIView {
// Pushes this view's `visibilityRect` down into every nested V2 view (details body, table
// title + cells), converted into each child's coordinate space. Each child's `visibilityRect`
// didSet re-runs `updateEmojiVisibility`, which propagates one level further so a single
// didSet re-runs `updateEmojiVisibility`, which propagates one level further, so a single
// root assignment fans out across the whole tree.
private func propagateVisibilityRect() {
for view in self.itemViews {
@ -829,8 +829,7 @@ public final class InstantPageV2View: UIView {
///
/// For most item types this is `item.frame`. `InstantPageV2TextView` widens its backing store
/// by `v2TextViewClippingInset` on every side to accommodate glyph overhang and underline
/// rendering past the text's logical `maxY` the same inset its `init` applies when
/// constructing the view. The reuse path must apply the same expansion so that re-layout
/// rendering past the text's logical `maxY`. The reuse path applies the same expansion so re-layout
/// (theme change, bubble resize, etc.) does not clip italic glyphs or underlines.
///
/// Keep this helper aligned with each view class's init-time frame computation.
@ -924,7 +923,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
}
}
// Reveal mask state populated in Task 5.
// Reveal mask state.
private var maxCharacterDrawCount: Int?
private var previousMaxCharacterDrawCount: Int = 0
private var revealMaskLayer: SimpleLayer?
@ -962,7 +961,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
self.item = item
// Lay every container out from the item's own (clipping-inset-expanded) frame rather than
// self.bounds, so the single path is correct regardless of when the parent assigns our
// frame and so a reused text view that changed size (e.g. AI streaming) re-frames its
// frame. A reused text view that changes size also updates its
// renderContainer/renderView too, which the old update path skipped.
let containerBounds = CGRect(origin: .zero, size: item.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset).size)
self.renderContainer.frame = containerBounds
@ -1165,7 +1164,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
let currentLineInfos = self.computeRevealedLines(characterLimit: effectiveCharacterDrawCount)
// Snippet spawn pass animate newly-revealed characters.
// Animate newly revealed characters.
if self.previousMaxCharacterDrawCount < effectiveCharacterDrawCount,
let contents = self.renderView.layer.contents,
animateNewSegments {
@ -1235,9 +1234,9 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
}
}
// Mask rebuild when snippets are in flight, clamp to the lowest animating one
// Clamp the mask to the lowest animating snippet
// (so the mask never exposes a char a snippet is still flying for). With no animations
// in flight, snap directly to the current target `previousMaxCharacterDrawCount`
// in flight, snap directly to the current target. `previousMaxCharacterDrawCount`
// would lag by one call (it's updated at the end of this function) and is 0 on a fresh
// view, which would hide every char until the next tick.
let maskCharacterLimit: Int
@ -1257,7 +1256,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
// Per-glyph rect captures descenders, italic overhang, accents exactly. Per
// line we accumulate the union of revealed glyphs into one mask rect (one
// CALayer sublayer per line), and consecutive fully-revealed lines collapse
// further into a single rect so a fully-revealed prefix is always one
// further into a single rect, so a fully revealed prefix is always one
// sublayer regardless of line count.
//
// Lines without per-character data (computeRevealCharacterRects == false on
@ -1298,7 +1297,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
lineUnion = union
remainingChars -= characterRects.count
} else {
// No per-character data expose the whole line.
// Expose the whole line without character data.
lineUnion = line.range.length > 0 ? renderLocalLineFrame : nil
isFullLine = remainingChars >= line.range.length
remainingChars -= line.range.length
@ -1705,10 +1704,10 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
var bodyView: InstantPageV2View?
private let titleHitView: UIView
// The expanded chevron is the collapsed one rotated 180° (down up).
// Rotate the collapsed chevron for the expanded state.
private static let expandedChevronTransform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0)
// On an animated collapse the body is kept until the toggle animation finishes (so the
// shrinking clip can hide it), then removed in finalizePendingCollapse() which the parent
// shrinking clip can hide it), then removed in finalizePendingCollapse(). The parent
// (InstantPageV2View.update) calls from the completion of the frame-shrink (clip) animation.
private var bodyPendingRemoval = false
@ -1744,7 +1743,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
super.init(frame: item.frame)
self.backgroundColor = .clear // structural
self.clipsToBounds = true // structural the parent's frame-height animation clips the body
self.clipsToBounds = true
self.addSubview(self.titleTextView)
self.addSubview(self.chevronView)
@ -1755,7 +1754,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
self.titleHitView.addGestureRecognizer(tap)
// All content (title, chevron tint/position, separator, titleHit frame, body) flows through
// update its expanded branch lazily creates the body, so init no longer builds it itself.
// update. Its expanded branch creates the body lazily.
self.update(item: item, theme: theme, renderContext: renderContext, animation: .None)
}
@ -1786,7 +1785,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
self.titleHitView.frame = item.titleFrame
// Body lifecycle. The reveal/hide of the body is produced by the parent animating this
// view's own frame height (clipsToBounds = true), not by the body itself see
// view's own frame height (clipsToBounds = true), not by the body itself. See
// InstantPageV2View.update. The body's internal layout is forwarded `animation` so a
// *nested* details block inside the body can also animate its own toggle.
let blockHeight: CGFloat
@ -1842,7 +1841,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
height: UIScreenPixel
), completion: nil)
// Chevron rotation. The body teardown on collapse is NOT tied to this completion see
// Body teardown is not tied to chevron rotation. See
// finalizePendingCollapse(), which the parent calls from the frame-shrink (clip) animation.
let targetTransform = item.isExpanded ? InstantPageV2DetailsView.expandedChevronTransform : CATransform3DIdentity
animation.animator.updateTransform(layer: self.chevronView.layer, transform: targetTransform, completion: nil)
@ -1851,7 +1850,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
/// Removes the body kept alive across an animated collapse. The parent (InstantPageV2View.update)
/// calls this from the completion of the frame-shrink animation that clips the body away, so the
/// body is torn down exactly when it finishes being hidden. The guard makes a re-expand that
/// interrupts the collapse safe the re-expand clears `bodyPendingRemoval` first.
/// interrupts the collapse safely because re-expanding clears `bodyPendingRemoval`.
func finalizePendingCollapse() {
if !self.item.isExpanded, self.bodyPendingRemoval {
self.bodyView?.removeFromSuperview()
@ -1938,8 +1937,8 @@ final class InstantPageV2ThinkingView: UIView, InstantPageItemView {
/// Parent positions self at the item frame (the bare line box). The shimmer and its gradient
/// mask are sized to the text view's clipping-inset-EXPANDED frame and shifted to
/// `(-inset, -inset)`, so the mask doesn't crop the glyph overhang the inset reserves (tall
/// ascenders, descenders, the last line's underline) the symptom of sizing the mask to the
/// bare line box. The inner text view fills the shimmer; its `+inset` render translate lands the
/// ascenders, descenders, and the last line's underline). The inner text view fills the shimmer;
/// its `+inset` render translate lands the
/// glyphs back at self's origin, so the text position is unchanged. Mirrors how a `.text` view's
/// frame is inset-expanded (`actualFrame` / `InstantPageV2TextView.init`).
private func layoutContents() {
@ -2026,7 +2025,7 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
// every frame / colour / sub-layout below. Insertion order matches the original interleaved
// build so the layer/subview z-order is unchanged (stripes at the bottom, then the title and
// cell sub-views, then the inner grid lines). Cell-count changes on later reuse are not
// reconciled here (pre-existing limitation) update's index-guarded loops refresh in place.
// reconciled here; update's index-guarded loops refresh in place.
if item.titleSubLayout != nil {
let v = InstantPageV2View(renderContext: renderContext)
self.contentView.addSubview(v)
@ -2090,7 +2089,7 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
}
}
// Stripe layers (cell backgrounds) update color + frame + corner rounding in original order.
// Update cell background layers in their original order.
let effectiveBorderWidth = item.bordered ? v2TableBorderWidth : 0.0
let gridHeight = item.contentSize.height - gridOffsetY
var stripeIndex = 0
@ -2111,7 +2110,7 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
}
}
// Inner line layers refresh colour AND frame in place. (`lineLayers` holds only inner grid
// Refresh inner grid line colors and frames.
// lines; the outer border is the contentView layer's own rounded border, refreshed below.)
// Frames are set here (not in init) so reuse with a different grid re-positions the lines.
let lineRects = item.horizontalLines + item.verticalLines
@ -2122,7 +2121,7 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
}
}
// Rounded outer border refresh radius/color/width (theme or `bordered` flag may change).
// Refresh the rounded outer border.
self.contentView.layer.cornerRadius = v2TableCornerRadius
self.contentView.layer.borderColor = item.borderColor.cgColor
self.contentView.layer.borderWidth = item.bordered ? v2TableBorderWidth : 0.0
@ -2168,7 +2167,7 @@ public extension InstantPageV2View {
}
/// The frame (pageView-space) of the anchor `name` in the *currently laid-out* layout.
/// Returns nil if the anchor isn't present e.g. it's inside a collapsed `<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`.
func anchorFrame(name: String) -> CGRect? {
guard let layout = self.currentLayout else { return nil }
@ -2178,7 +2177,7 @@ public extension InstantPageV2View {
/// Given a details-sibling-ordinal path (from `instantPageAnchorPath`), walk the live layout
/// and return the `currentExpandedDetails` index of the FIRST not-yet-expanded `<details>` on
/// the path. Returns nil if every details on the path is already expanded, or the path doesn't
/// match the live layout. Reads indices from the laid-out items never reproduces them.
/// match the live layout. Reads indices from the laid-out items.
func firstCollapsedDetails(forOrdinalPath path: [Int]) -> Int? {
guard let layout = self.currentLayout else { return nil }
var currentItems = layout.items
@ -2403,7 +2402,7 @@ private func collectSelectableTextItems(
/// For block formulas wider than the bubble's available width, the layout sets
/// `isScrollable = true`; this view then wraps the image in a horizontal `UIScrollView`
/// matching V1's `InstantPageScrollableNode` (no bounce on non-overflowing content,
/// scroll indicator hidden appropriate for content embedded inside a chat bubble).
/// scroll indicator hidden, for content embedded inside a chat bubble).
final class InstantPageV2FormulaView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2FormulaItem
var itemFrame: CGRect { return self.item.frame }

View file

@ -364,8 +364,7 @@ public final class InstantPageTextItem: InstantPageItem {
}
public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? {
// Hit-testing (taps on links/entities) wants the character under the finger keep the
// strict, clamping behavior.
// Clamp taps to the character under the finger.
if !orNearest {
return self.attributesAtPoint(point)
}
@ -402,8 +401,7 @@ public final class InstantPageTextItem: InstantPageItem {
} else if point.x >= lineFrame.maxX {
// Trailing edge: return the line's upper bound (one past its last character) so a
// right-handle drag can include the last character/item of the line. The selection
// upper bound is exclusive, so clamping to the last character's index as the strict
// path does would always leave it unselected. Mirrors Display.TextNode.
// upper bound is exclusive, so use the line end. Mirrors Display.TextNode.
index = lineRange.location + lineRange.length
} else {
index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: point.x - lineFrame.minX, y: 0.0))
@ -964,7 +962,7 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt
// Size the inline emoji to the font's line height (A + D) plus a 4pt bump at the 17pt
// body font (scaled proportionally). Must match the V2 layout's emoji cell size
// (InstantPageV2Layout.swift). The run delegate still reports the font's own
// ascent/descent (below), so the line height is unchanged only the emoji width changes.
// ascent/descent, so only the emoji width changes.
let itemSize = font.ascender - font.descender + 4.0 * font.pointSize / 17.0
let extentBuffer = UnsafeMutablePointer<RunStruct>.allocate(capacity: 1)
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)
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
self.statusNode.backgroundNodeColor = messageTheme.mediaActiveControlColor
// foreground/overlay also depend on incoming + theme (set at construction) refresh them
// too so the play glyph isn't miscolored after an in-place theme/direction change.
// Refresh colors after an in-place theme or direction change.
self.statusNode.foregroundNodeColor = (incoming && messageTheme.mediaActiveControlColor.rgb != 0xffffff) ? .white : .clear
self.statusNode.overlayForegroundNodeColor = presentationData.theme.chat.message.mediaOverlayControlColors.foregroundColor

View file

@ -9,14 +9,14 @@ import AccountContext
import PhotoResources
import MediaResources
/// Lightweight inline image view for InstantPage V2 wraps a `TransformImageNode`
/// Inline InstantPage V2 image backed by `TransformImageNode`.
/// to render a single `RichText.image` cell inside a text view.
///
/// Owned by `InstantPageV2View` (not an `InstantPageItemView` conformer; not in
/// the view-factory switch). Hosted inside the parent text view's
/// `imageContainerView` (sibling of `renderContainer`, above the reveal mask,
/// below `emojiContainerView`), so the streaming reveal can wipe text glyphs
/// while the image pops in independently. Non-interactive taps pass through
/// while the image pops in independently. Taps pass through
/// to the text view, so a URL-wrapping `RichText.url(text: .image(...))`
/// continues to route taps to the URL handler.
final class InstantPageV2InlineImageView: UIView {

View file

@ -20,7 +20,7 @@ public struct InstantPageV2Layout {
/// `line.imageItems` MediaIds at update time. Nested layouts (details body,
/// table cells, table title) carry the parent's same map.
public let media: [EngineMedia.Id: EngineMedia]
/// Webpage carried for the same reason `updateInlineImages()` needs it to
/// Webpage carried because `updateInlineImages()` needs it to
/// form the `WebpageReference` for `ImageMediaReference.webPage(...)`. May
/// be nil for non-webpage-anchored layouts; in that case the lookup proceeds
/// but no fetch signal can be bound (image view simply isn't created).
@ -121,7 +121,7 @@ public enum InstantPageV2LaidOutItem {
}
/// Returns a copy of `self` with its top-level frame translated by `delta`.
/// Sub-layouts inside details/table cells are not re-translated they're already
/// Sub-layouts inside details and table cells are already
/// expressed in their parent's local coordinates.
public func offsetBy(_ delta: CGPoint) -> InstantPageV2LaidOutItem {
switch self {
@ -470,7 +470,7 @@ public func lastTextLineFrame(in layout: InstantPageV2Layout) -> CGRect? {
}
case let .table(table):
// Walk cells in reverse row-major order (last cell of last row first).
// The renderer shifts cells down by gridOffsetY (title height) match that here.
// Match the renderer's title offset.
let gridOffsetY = table.titleFrame?.height ?? 0.0
for cell in table.cells.reversed() {
if let subLayout = cell.subLayout, let frame = lastTextLineFrame(in: subLayout) {
@ -497,7 +497,7 @@ public func lastTextLineFrame(in layout: InstantPageV2Layout) -> CGRect? {
/// anchor at `maxY + trailingBottomPadding` to align with where the text actually renders. The pad
/// is 0 when the line is taller than its font line height (a tall inline attachment, e.g. a formula,
/// already pushes maxY down to the right spot). Callers should NOT apply the pad when the status
/// wraps onto its own line below the text there it should sit at the bare maxY.
/// wraps onto its own line below the text, where it sits at the bare maxY.
public func lastTextLineFrameIfLastItemIsText(in layout: InstantPageV2Layout) -> (frame: CGRect, trailingBottomPadding: CGFloat)? {
guard let bottomItem = layout.items.max(by: { $0.frame.maxY < $1.frame.maxY }),
case let .text(text) = bottomItem,
@ -505,11 +505,9 @@ public func lastTextLineFrameIfLastItemIsText(in layout: InstantPageV2Layout) ->
else {
return nil
}
// The stored line frame always has minX = 0 alignment (center / right / natural-RTL) is
// applied at render time by `v2FrameForLine`. Apply the same correction here so the returned
// Alignment is applied at render time by `v2FrameForLine`. Apply the same correction here so the returned
// frame's `maxX` reflects the line's actual on-screen right edge, not just its width anchored
// at the textItem's left. Without this, a right-aligned or RTL last line whose visible right
// edge sits at `textItem.width`, all the way at the right text inset would feed the status
// at the text item's left. Without this, a right-aligned or RTL last line would feed the status
// node a `contentWidth` equal to just `lineWidth`. The trail/wrap decision would then think
// the date fits trailing the line, and place it directly on top of the line at the right text
// inset where the line itself ends. The width is unchanged; only `origin.x` shifts.
@ -602,8 +600,8 @@ private func layoutBlockSequence(
var contentSize = CGSize(width: boundingWidth, height: contentHeight)
if context.fitToWidth {
// Match V1 InstantPageLayout.swift:1114 include `+ horizontalInset` so the contentSize
// reserves a right margin equal to the left inset. Without this, the longest text item's
// Match V1 and include `horizontalInset` in the content size. This
// reserves a right margin equal to the left inset. Without it, the longest text item's
// right edge equals contentSize.width, and the bubble's containerNode (sized to
// boundingSize.width - 2) clips the last 2pt of text.
var maxX: CGFloat = 0.0
@ -755,7 +753,7 @@ private func layoutBlock(
context: &context
)
} else {
// Fallback when the image is not present in the page's media dict preserve V1
// Preserve the V1 fallback when the image is missing.
// behavior, which returns an empty layout for unknown image IDs (V1
// InstantPageLayout.swift:623). The existing layoutMediaWithCaption would emit a
// grey rectangle; matching V1 instead.
@ -923,10 +921,8 @@ private func layoutBlock(
horizontalInset: horizontalInset, context: &context)
case let .map(latitude, longitude, zoom, dimensions, caption):
// AI/server-sent `.map` blocks can arrive with zero `dimensions` (the wire `w`/`h` are
// required, but the sender may put 0). A zero `naturalSize.height` collapses the media
// frame to height 0 (`instantPageV2MediaFrame`'s else branch) the map takes no space,
// the caption slides up into it, and the pin floats over the caption and a zero-sized
// `.map` blocks can arrive with zero dimensions. A zero `naturalSize.height` collapses the media
// frame to height 0 (`instantPageV2MediaFrame`'s else branch). A zero-sized
// `MapSnapshotMediaResource` makes `MKMapSnapshotter` render nothing. Substitute a sensible
// default (a 2:1 map strip) for BOTH the layout size and the snapshot resource. Real web
// articles (the V1 renderer) always carry real dimensions, so only the rich-message path
@ -1757,15 +1753,12 @@ private let instantPageV2MediaEdgeBleed: CGFloat = 4.0
// `boundingWidth`) with corner radius forced to 0, relying on the bubble's rounded clipping
// container to round media that meets the bubble's top/bottom edge. A media item that fills the
// full width is widened by `instantPageV2MediaEdgeBleed` on the trailing edge (see the constant).
// A media item narrower than the full width (a small image NOT upscaled, the `min(_, 1.0)`
// A media item narrower than the full width (a small image that is not upscaled; the `min(_, 1.0)`
// scale cap is kept) stays at its natural size, flush-left at x = 0, with no bleed.
// (The `cornerRadius` argument is ignored when `flush == true` flush media is always
// un-rounded; callers may still pass their legacy radius, it has no effect.)
// The `cornerRadius` argument is ignored when `flush == true`; flush media is unrounded.
//
// `flush == false`: DEAD as of the V2 audio port audio was its last caller and now has its
// own `layoutAudio` arm (in `layoutBlock`), so this branch is currently unreachable (follow-up:
// drop the `flush` parameter and this branch). Legacy behavior was: inset by `horizontalInset`
// on each side with the caller-supplied corner radius.
// `flush == false` is unused after the V2 audio port.
// Legacy behavior inset media by `horizontalInset` with the caller-supplied corner radius.
//
// Returns the frame, the un-bled scaled content size (the caption is offset by
// `scaledSize.height`), and the effective corner radius to stamp on the item.
@ -1862,7 +1855,7 @@ private func layoutCollage(
horizontalInset: CGFloat,
context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] {
// 1. One size per inner block (zero for unresolved V1 still reserves a mosaic slot).
// One size per inner block; V1 reserves a slot for unresolved media.
var itemSizes: [CGSize] = []
for block in innerBlocks {
switch block {
@ -1883,7 +1876,7 @@ private func layoutCollage(
}
}
// 2. Mosaic geometry the same engine V1 uses.
// Use the V1 mosaic geometry.
let (mosaic, mosaicSize) = chatMessageBubbleMosaicLayout(maxSize: CGSize(width: boundingWidth, height: boundingWidth), itemSizes: itemSizes)
// 3. One typed media item per resolvable cell, at its mosaic frame.
@ -2009,7 +2002,7 @@ private func layoutMediaWithCaption(
)
result.append(contentsOf: captionItems)
// isCover adds extra 14pt bottom padding but only when caption/credit text was actually
// Covers add 14pt bottom padding when caption or credit text was
// rendered (matches V1 lines 204-206: `contentSize.height > 0 && isCover`). For an
// empty-caption cover image no padding is added.
// Implemented by extending the last text item's frame rather than emitting an invisible shape
@ -2236,12 +2229,12 @@ private func layoutCodeBlock(
// Top-level (and <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
// lineInset), so honor it here otherwise the full-width background bleeds out under the
// lineInset), so honor it here. Otherwise the full-width background bleeds under the
// quote bar instead of insetting to the quote's content gutter like the quote's text does.
let blockHeight = textSize.height + backgroundInset * 2.0
let isNestedInQuote = horizontalInset > context.pageHorizontalInset
// Inset (quote-nested) code blocks get an 8pt rounded background; flush (top-level / details)
// ones stay square the bubble's own rounded clip handles their edges.
// ones stay square. The bubble clip handles their edges.
let cornerRadius: CGFloat = isNestedInQuote ? 8.0 : 0.0
let blockFrame = CGRect(
x: isNestedInQuote ? horizontalInset : 0.0,
@ -2364,9 +2357,9 @@ private func layoutBlockQuote(
attributedCaption,
boundingWidth: innerBoundingWidth,
alignment: context.rtl ? .right : .natural,
// The caption is single-inset (band [H+lineInset, B-H]), unlike the double-inset
// The caption uses a single inset, unlike the double-inset
// child band, so it needs its own RTL mirror delta of -lineInset ( [H, B-H-lineInset],
// tucked under the trailing bar) NOT the children's bandOffsetX.
// tucked under the trailing bar), not the children's bandOffsetX.
offset: CGPoint(x: innerHorizontalInset + (context.rtl ? -lineInset : 0.0), y: contentHeight),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
@ -2477,7 +2470,7 @@ private func layoutQuoteText(
kind: .line(thickness: 1.0),
color: context.theme.textCategories.caption.color
)
// Insert bottom rule before caption trailing space is consumed append after final verticalInset.
// Insert the bottom rule before caption trailing space.
result.append(.shape(bottomLine))
} else {
// blockQuote: vertical bar on the leading edge (V1 lines 547549).
@ -2494,7 +2487,7 @@ private func layoutQuoteText(
// Tag this quote's produced text items at quote depth 1 so the markdown
// converter renders them with a `> ` prefix. Applies to BOTH block quotes
// (single-paragraph fast path) and pull quotes the whole-message markdown
// (single-paragraph fast path) and pull quotes. The whole-message markdown
// converter renders both flavors as `> `. Nested quotes are lifted further
// by the outer multi-block path's own bumpQuoteDepth(result) call.
bumpQuoteDepth(result)
@ -2514,12 +2507,10 @@ private func layoutList(
) -> [InstantPageV2LaidOutItem] {
// Determine marker characteristics.
var maxIndexWidth: CGFloat = 0.0
// hasNums: at least one ordered item carries an explicit `num` in which case items
// without one fall back to a blank " " (preserves the source's numbering gaps) rather
// hasNums means at least one ordered item carries an explicit `num`. Items
// without one fall back to a blank " " rather
// than auto-generated `(i + 1).`. Unordered lists never auto-generate numbers, so this
// flag is only meaningful when `ordered` is true. (`hasTaskMarkers` is no longer derived
// the uniform 8pt gap below replaced the per-list `indexSpacing` ternary that consumed
// it; column right-alignment handles mixed bullet/checkbox lists without flagging.)
// flag is only meaningful when `ordered` is true.
var hasNums = false
if ordered {
for item in listItems {
@ -2542,10 +2533,8 @@ private func layoutList(
stroke: context.theme.pageBackgroundColor,
border: context.theme.controlColor
)
// Track maxIndexWidth for ALL marker kinds (ordered + unordered, all three shapes), not
// just ordered as V1/older V2 did. With every kind contributing to the marker column width
// we can right-align every marker to a single shared column edge so in a mixed unordered
// list (bullets + checkboxes) both right-align flush to the same x, and the same uniform
// Track every marker kind and align them to one shared column edge.
// Bullets and checkboxes align to the same x, and the same uniform
// gap separates them from the text. The column width simply equals the widest marker; for
// a pure bullet list `maxIndexWidth == 6` and the bullet sits at `horizontalInset` (visually
// identical to the pre-change formula), and for a pure checkbox list `maxIndexWidth == 18`
@ -2594,12 +2583,8 @@ private func layoutList(
// Uniform 8pt markertext gap across all four cases (ordered/unordered × bullet/number/
// checkbox). With markers right-aligned to a shared column of width `maxIndexWidth`, text
// starts at `horizontalInset + maxIndexWidth + indexSpacing` so `indexSpacing` IS the
// gap, regardless of marker shape. V1 used 12/16/20/24 (a mix of marker-area-width and
// gap-after-marker, depending on alignment); the four gaps came out to 12/16/14/6 far
// from uniform, and a 14pt bullet gap looked especially loose. 8pt is a standard iOS list
// gap; it tightens bullets (148), numbers (128) and ordered-checkbox (168), and only
// loosens unordered-checkbox very slightly (68) so all four kinds match.
// starts at `horizontalInset + maxIndexWidth + indexSpacing`, so `indexSpacing` is the
// gap, regardless of marker shape.
let indexSpacing: CGFloat = 8.0
// Layout each item.
@ -2722,7 +2707,7 @@ private func layoutList(
// Nil-guard in stampMarkdownContext preserves any richer kind (e.g. .heading)
// already stamped by a child block's own layout. So a heading nested inside a
// .blocks list item keeps .heading, not .listItem multi-block list items are
// Multi-block list items keep `.heading`.
// a documented best-effort case for markdown reconstruction.
stampMarkdownContext(translatedItems, kind: .listItem(ordered: ordered, marker: markdownMarker, checked: markdownChecked))
result.append(contentsOf: translatedItems)
@ -2732,7 +2717,7 @@ private func layoutList(
// Mirror the .text case above (and what .checklist already does here): use the
// first text line's midY for centering. `originY` is the sub-block's TOP, NOT a
// line midpoint `markerFrameFor` then subtracts `size.height / 2`, so feeding
// line midpoint. `markerFrameFor` subtracts `size.height / 2`, so feeding
// `originY` placed the marker straddling the sub-block boundary, ½·marker-height
// ABOVE the first text line. V1 hid the same arithmetic under a 6×12 shape with a
// 3pt internal offset (matching ½·fontLineHeight for 17pt paragraph text), which
@ -2773,7 +2758,7 @@ private func layoutList(
/// For a pure-kind list `maxIndexWidth == markerWidth`, so the marker lands at `horizontalInset`
/// exactly as before; for mixed unordered lists, bullets and checkboxes align flush at the
/// column's inner edge. Column right-alignment is the single rule across every marker shape
/// no `ordered` / `indexSpacing` split which is why those parameters dropped.
/// without separate `ordered` or `indexSpacing` parameters.
private func markerFrameFor(
kind: InstantPageV2ListMarkerKind,
naturalWidth: CGFloat,
@ -2802,7 +2787,7 @@ private func markerFrameFor(
return CGRect(x: x, y: floorToScreenPixels(lineMidY - size.height / 2.0), width: size.width, height: size.height)
}
/// Leading/trailing geometry helpers the single source of truth for "which side is the
/// Leading and trailing geometry helpers.
/// block gutter on", gated on the page's explicit `rtl` flag. The `rtl == false` branch returns
/// the pre-existing literal so non-RTL pages are byte-identical.
@ -2969,7 +2954,7 @@ func v2FrameForLine(_ line: InstantPageTextLine, boundingWidth: CGFloat, alignme
// Returns the leading-edge x offset (line-origin-relative) for an inline-attachment's string
// `range`, correct for both LTR and RTL runs. `CTLineGetOffsetForStringIndex` at the start index
// gives the glyph's LEFT edge in LTR text, but its RIGHT edge in RTL text (increasing string index
// moves leftward) so using the start-index offset alone as the left edge shoves an RTL attachment
// moves leftward), so using the start-index offset alone shifts an RTL attachment
// ~one advance too far right. Taking the min of the start- and end-index offsets yields the true
// leading (left) edge in both directions. Mirrors `Display.TextNode`'s `addEmbeddedItem`, including
// the directional-boundary secondary-offset handling. For a pure-LTR line this returns exactly the
@ -3195,7 +3180,7 @@ func layoutTextItem(
// Inline emoji and images do NOT inflate the line: they are centered on the font
// line box and allowed to bleed above/below (mirroring V1 `layoutTextItemWithString`
// and the chat `InteractiveTextComponent`). Their run delegates already report the
// font's own ascent/descent, so CoreText lays the line out at the normal height the
// font's own ascent/descent, so CoreText keeps the normal line height.
// old `lineAscent = emoji.size` inflation both doubled the line height and (because the
// baseline sits at the bottom of the box) shoved the text baseline down. Only formulas,
// which carry their own typographic metrics, are allowed to grow the line.
@ -3335,9 +3320,7 @@ func layoutTextItem(
}
}
}
// Per-character rects use each glyph's actual ink bounds via
// CTFontGetBoundingRectsForGlyphs caret-position advance-width
// math (CTLineGetOffsetForStringIndex) is too tight for italics,
// Caret advance width is too tight for italics,
// accented marks, and any glyph with side bearings, which causes
// the reveal mask to visibly clip the glyph edges. Mirrors
// InteractiveTextComponent.computeCharacterRectsForLine.
@ -3406,7 +3389,7 @@ func layoutTextItem(
// Image cell is centered on the font line box (see frame loop). Baseline-relative
// cell spans [fontLineHeight/2 height/2, fontLineHeight/2 + height/2]; the full
// width feeds the reveal cost map so the streaming cursor is charged the image's
// width when crossing it same as an emoji cell.
// width when crossing it, like an emoji cell.
rects[localIndex] = CGRect(x: x, y: fontLineHeight / 2.0 - image.size.height / 2.0, width: image.size.width, height: image.size.height)
}
}

View file

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

View file

@ -28,7 +28,7 @@ extension InstantPageV2RevealCostMap {
// Reveal cost is in width units (points along the reading direction). The unit is uniform
// across all item kinds, so the streaming reveal pace ("points per second") is visually
// consistent wide tables and media take proportionally longer than narrow inline text.
// consistent. Wide tables and media take longer than narrow inline text.
//
// For text items, the cost is the sum of glyph ink widths across all lines (the total ink
// extent in reading direction). For non-text items, the cost is `item.frame.width`. Zero-width
@ -112,7 +112,7 @@ public extension InstantPageV2RevealCostMap {
/// * `details` / `codeBlock`: the whole container appears as soon as revealedCount > entry.start
/// (background and chrome pop in atomically, then inner text reveals char-by-char).
///
/// Used by the rich-data bubble to size itself to the revealed prefix during AI streaming,
/// Used by the rich-data bubble to size itself to the revealed prefix during streaming,
/// mirroring TextBubble's `clippedGlyphCountLayout = textLayout.layoutForCharacterCount(...)`.
func revealedContentSize(revealedCount: Int, layout: InstantPageV2Layout) -> CGSize {
let bounds = computeRevealedBounds(items: layout.items, entries: self.topLevelEntries, revealedCount: revealedCount)
@ -121,7 +121,7 @@ public extension InstantPageV2RevealCostMap {
}
// The full layout reserves a closing spacing after the last top-level block (see
// `closingSpacing` in layoutBlockSequence). Mirror that so a partially-revealed
// bubble has the same bottom padding as a fully-revealed one otherwise the last
// bubble has the same bottom padding as a fully revealed one. Otherwise the last
// revealed line sits flush against the bubble's bottom edge.
let lastItemMaxY = layout.items.map { $0.frame.maxY }.max() ?? 0.0
let closingPad = max(0.0, layout.contentSize.height - lastItemMaxY)
@ -130,7 +130,7 @@ public extension InstantPageV2RevealCostMap {
/// Returns the maxY of revealed items in `layout` coords (no closing pad). Use this to
/// size the InstantPageV2View itself so its content never overflows past the revealed
/// extent the bubble's closing pad sits in containerNode space *outside* the pageView,
/// extent. The bubble's closing pad sits in containerNode space outside the pageView,
/// not inside it (where unrevealed items would otherwise draw).
func revealedItemsMaxY(revealedCount: Int, layout: InstantPageV2Layout) -> CGFloat {
let bounds = computeRevealedBounds(items: layout.items, entries: self.topLevelEntries, revealedCount: revealedCount)
@ -308,7 +308,7 @@ private func computeEntries(items: [InstantPageV2LaidOutItem], cursor: inout Int
// Each cell consumes at least its frame.width worth of cursor advance,
// even if its inner text ink width is smaller (or it has no subLayout
// at all). Without this floor, narrow- or empty-cell tables ran through
// the cursor much faster than their visual width warrants a 3-column
// the cursor much faster than their visual width warrants. A 3-column
// table of "1"/"2"/"3" costs ~30pt while occupying ~200pt visually.
// Text inside a cell still char-reveals against its own ink widths; the
// "extra" cost (cell width inner ink) is filler time during which the
@ -330,7 +330,7 @@ private func computeEntries(items: [InstantPageV2LaidOutItem], cursor: inout Int
}
entries.append(.table(start: start, end: cursor, rows: rows, title: titleMap))
case .thinking:
// Zero cost: do NOT advance the cursor. This is the linchpin answer-content cursor
// Do not advance the cursor for thinking blocks.
// positions are identical whether or not thinking blocks are present, so adding/
// removing a thinking block never jumps the answer's reveal position.
entries.append(.thinking(start: cursor))
@ -432,7 +432,7 @@ private func applyRevealEntry(view: InstantPageItemView, entry: InstantPageV2Rev
let _ = start
case let .thinking(start):
// Whole-block 0.12s alpha fade-in at the index position; inner text is drawn fully
// (never char-reveal-masked) the shimmer is the only ongoing animation.
// (never character-masked). The shimmer is the only ongoing animation.
let visible = revealedCount >= start
applyVisibility(view: view, visible: visible, animated: animated)
}
@ -520,7 +520,7 @@ private func applyTableReveal(tableView: InstantPageV2TableView, start: Int, end
let rowMaxY = cellsInRow.map { $0.frame.maxY }.max() ?? 0.0
maskHeight = gridOffsetY + rowMaxY
} else {
// No rows revealed yet but the title (if any) is still visible above the grid.
// Keep the title visible before the first row.
maskHeight = (title != nil) ? gridOffsetY : 0.0
}

View file

@ -6,11 +6,7 @@ import AccountContext
import TelegramCore
import TelegramPresentationData
// A paged carousel for an `InstantPageBlock.slideshow`. Ports V1's InstantPageSlideshowNode /
// InstantPageSlideshowPagerNode (InstantPageSlideshowItemNode.swift), simplified to create all pages
// eagerly (slideshows are short; this avoids V1's central±1 index bookkeeping and makes the gallery
// transition source available for every page). Each image page hosts an `InstantPageImageNode` exactly
// like the static media views; non-image medias render an empty page (matches V1).
// Paged slideshow backed by `InstantPageImageNode`.
final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollViewDelegate {
private(set) var item: InstantPageV2SlideshowItem
var itemFrame: CGRect { return self.item.frame }
@ -21,9 +17,7 @@ final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollVie
private let scrollView: UIScrollView
private let pageControlNode: PageControlNode
// One wrapper view per media (so page count stays aligned with the page control). `pageImageNodes`
// holds only the real image nodes; it may be shorter than `pageViews` if a non-image media appears
// (which `layoutSlideshow` currently filters out). Nothing relies on positional correspondence.
// Keep one page per media so the page control stays aligned.
private var pageViews: [UIView] = []
private var pageImageNodes: [InstantPageImageNode] = []
@ -68,7 +62,7 @@ final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollVie
self.pageImageNodes = []
let renderContext = self.renderContext
// The image node owns this closure, and is owned (transitively) by self capture weakly.
// The image node retains this closure.
let openMedia: (InstantPageMedia) -> Void = { [weak self] tapped in
guard let self else { return }
handleOpenMediaTap(tapped: tapped, wrapper: self, renderContext: renderContext)
@ -91,23 +85,21 @@ final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollVie
pageView.addSubview(node.view)
self.pageImageNodes.append(node)
}
// Non-image medias (none in practice layoutSlideshow filters to images) get an empty page
// to keep page indices aligned with the page control.
// Keep an empty page for unexpected non-image media.
self.scrollView.addSubview(pageView)
self.pageViews.append(pageView)
}
self.pageControlNode.pagesCount = self.item.medias.count
self.pageControlNode.setPage(0.0)
// Re-register media indices when rebuilding while already on-window (positional reuse with
// changed content); no-ops before the view is attached, where didMoveToWindow handles it.
// Re-register reused pages already attached to a window.
self.registerMedias()
self.setNeedsLayout()
}
private func registerMedias() {
guard self.window != nil else { return }
// Register under every contained media index so transitionArgsFor(media) can find this view.
// Register every media index for gallery transitions.
for media in self.item.medias {
registerInRootRegistry(wrapper: self, mediaIndex: media.index)
}

View file

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

View file

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

View file

@ -62,6 +62,9 @@
@property (nonatomic) CGRect defaultFrame;
// Brand icon overlaid on the first intro page animation.
@property (nonatomic, strong) UIImage *firstPageIcon;
- (instancetype)initWithBackgroundColor:(UIColor *)backgroundColor primaryColor:(UIColor *)primaryColor buttonColor:(UIColor *)buttonColor accentColor:(UIColor *)accentColor regularDotColor:(UIColor *)regularDotColor highlightedDotColor:(UIColor *)highlightedDotColor suggestedLocalizationSignal:(SSignal *)suggestedLocalizationSignal;
@property (nonatomic, copy) void (^startMessaging)(void);

View file

@ -107,6 +107,8 @@ typedef enum {
UIView *_wrapperView;
UIView *_startButton;
UIImageView *_firstPageIconView;
bool _loadedView;
}
@end
@ -379,6 +381,14 @@ typedef enum {
p.clearsContextBeforeDrawing = false;
[_pageViews addObject:p];
[_pageScrollView addSubview:p];
// Brand icon on the first intro page; frame is set in -updateLayout.
if (i == 0 && _firstPageIcon != nil) {
p.clipsToBounds = false;
_firstPageIconView = [[UIImageView alloc] initWithImage:_firstPageIcon];
_firstPageIconView.contentMode = UIViewContentModeScaleAspectFit;
[p addSubview:_firstPageIconView];
}
}
[_pageScrollView setPage:0];
@ -574,6 +584,17 @@ typedef enum {
[_pageViews enumerateObjectsUsingBlock:^(UIView *pageView, NSUInteger index, __unused BOOL *stop) {
pageView.frame = CGRectMake(index * self.view.bounds.size.width, (pageY - statusBarHeight), self.view.bounds.size.width, 150);
}];
if (_firstPageIconView != nil) {
bool isIpad = ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad);
CGFloat glSize = isIpad ? 200.0f * 1.2f : 200.0f;
CGFloat iconSize = floor(glSize * 0.6f);
// Center the icon on the GL animation square, in page-local coordinates.
CGFloat glCenterY = (_glkView.frame.origin.y + glSize / 2.0f);
CGFloat pageTopAbsolute = _pageScrollView.frame.origin.y + (pageY - statusBarHeight);
CGFloat iconCenterYLocal = glCenterY - pageTopAbsolute;
_firstPageIconView.frame = CGRectMake(floor((self.view.bounds.size.width - iconSize) / 2.0f), iconCenterYLocal - iconSize / 2.0f, iconSize, iconSize);
}
}
- (void)viewWillAppear:(BOOL)animated

View file

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

View file

@ -393,7 +393,9 @@ private enum WinterGramDeletedMessagesControllerEntry: ItemListNodeEntry {
case let .topChat(_, peer, count, size, _):
let sizeFormatting = DataSizeStringFormatting(strings: presentationData.strings, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator)
let subtitle = "\(count)\(dataSizeString(size, formatting: sizeFormatting))"
return ItemListPeerItem(presentationData: presentationData, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, context: arguments.context, peer: peer, presence: nil, text: .text(subtitle, .secondary), label: .none, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: nil), switchValue: nil, enabled: true, selectable: false, sectionId: self.section, action: nil, setPeerIdWithRevealedOptions: { _, _ in }, removePeer: { _ in })
return ItemListPeerItem(presentationData: presentationData, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, context: arguments.context, peer: peer, presence: nil, text: .text(subtitle, .secondary), label: .none, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: nil), switchValue: nil, enabled: true, selectable: true, sectionId: self.section, action: {
arguments.openChat(peer)
}, setPeerIdWithRevealedOptions: { _, _ in }, removePeer: { _ in })
case let .delete(_, text, enabled):
return ItemListPeerActionItem(presentationData: presentationData, style: .blocks, icon: PresentationResourcesItemList.deleteIconImage(presentationData.theme), title: text, sectionId: self.section, color: enabled ? .destructive : .disabled, editing: false, action: enabled ? {
arguments.deleteSelected()
@ -414,11 +416,13 @@ private final class WinterGramDeletedMessagesControllerArguments {
let context: AccountContext
let toggleCategory: (WinterGramDeletedMessageCategory) -> Void
let deleteSelected: () -> Void
init(context: AccountContext, toggleCategory: @escaping (WinterGramDeletedMessageCategory) -> Void, deleteSelected: @escaping () -> Void) {
let openChat: (EnginePeer) -> Void
init(context: AccountContext, toggleCategory: @escaping (WinterGramDeletedMessageCategory) -> Void, deleteSelected: @escaping () -> Void, openChat: @escaping (EnginePeer) -> Void) {
self.context = context
self.toggleCategory = toggleCategory
self.deleteSelected = deleteSelected
self.openChat = openChat
}
}
@ -426,10 +430,19 @@ private func winterGramDeletedMessagesControllerEntries(stats: WinterGramDeleted
let sizeFormatting = DataSizeStringFormatting(strings: strings, decimalSeparator: dateTimeFormat.decimalSeparator)
var entries: [WinterGramDeletedMessagesControllerEntry] = []
entries.append(.overviewHeader(theme, strings.WinterGram_DeletedMessages_Title.uppercased()))
// Empty state: when nothing has been saved yet, drop the (otherwise empty) pie chart, the category
// checklist and the delete action they read as a "cheap" empty ring and just explain where saved
// deletions come from, matching the calm empty states of the native Storage Usage screen.
if stats.totalCount == 0 {
entries.append(.overviewInfo(theme, strings.WinterGram_DeletedAndEditedMessagesAreKeptLocallyOnThisDeviceOnly))
return entries
}
entries.append(.overviewInfo(theme, "\(strings.WinterGram_DeletedMessages_Total): \(stats.totalCount)\(dataSizeString(stats.totalSize, formatting: sizeFormatting))"))
let chartTotal = stats.categories.reduce(Int64(0)) { $0 + max(0, $1.size) }
var chartItems: [PieChartComponent.ChartData.Item] = []
for stat in stats.categories where stat.size > 0 {
@ -468,9 +481,6 @@ private func winterGramDeletedMessagesControllerEntries(stats: WinterGramDeleted
}
}
let canDelete = !selectedCategories.isEmpty
entries.append(.delete(theme, strings.WinterGram_DeletedMessages_DeleteSelected, canDelete))
return entries
}
@ -517,8 +527,12 @@ public func winterGramDeletedMessagesController(context: AccountContext) -> View
})
])])
presentControllerImpl?(actionSheet, nil)
}, openChat: { peer in
if let navigationController = context.sharedContext.mainWindow?.viewController as? NavigationController {
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer)))
}
})
let statsSignal = winterGramDeletedMessagesStats(postbox: context.account.postbox)
let topPeersSignal = statsSignal
|> map { stats -> [EnginePeer.Id] in
@ -545,7 +559,22 @@ public func winterGramDeletedMessagesController(context: AccountContext) -> View
)
|> map { presentationData, stats, selectedCategories, topPeers -> (ItemListControllerState, (ItemListNodeState, Any)) in
let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(presentationData.strings.WinterGram_DeletedMessages_Title), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back))
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: winterGramDeletedMessagesControllerEntries(stats: stats, topPeers: topPeers, selectedCategories: selectedCategories, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, theme: presentationData.theme), style: .blocks, animateChanges: true)
let canDelete = !selectedCategories.isEmpty
let sizeFormatting = DataSizeStringFormatting(strings: presentationData.strings, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator)
let selectedSize = stats.categories.reduce(Int64(0)) { partial, stat in
return selectedCategories.contains(stat.category) ? partial + max(0, stat.size) : partial
}
var footerTitle = presentationData.strings.WinterGram_DeletedMessages_DeleteSelected
if canDelete && selectedSize > 0 {
footerTitle += " " + dataSizeString(selectedSize, formatting: sizeFormatting)
}
// No saved deletions no destructive footer (matches the empty state from the entries builder).
let footerItem: WinterGramDeletedMessagesFooterItem? = stats.totalCount == 0 ? nil : WinterGramDeletedMessagesFooterItem(theme: presentationData.theme, title: footerTitle, isEnabled: canDelete, action: {
arguments.deleteSelected()
})
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: winterGramDeletedMessagesControllerEntries(stats: stats, topPeers: topPeers, selectedCategories: selectedCategories, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, theme: presentationData.theme), style: .blocks, footerItem: footerItem, animateChanges: true)
return (controllerState, (listState, arguments))
}

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)
if avatarFrame == nil, nodeIndex < displayedMessageItems.count, !displayedMessageItems[nodeIndex].outgoing {
let avatarSize: CGFloat = 40.0
// The message nodes live in `containerNode`, which is flipped 180° (π about Z),
// so a node placed at container-space top `topOffset` is drawn with its visual
// bottom at `contentSize.height - topOffset`. The avatar is a direct subnode of
// `self` (un-flipped), so compute its position in screen space and snap its
// bottom to the incoming bubble's tail matching a real Telegram message row.
let avatarSize: CGFloat = 37.0
let tailBottomInset: CGFloat = -2.0
let visualBottom = contentSize.height - topOffset
avatarFrame = CGRect(
x: params.leftInset + 9.0,
y: topOffset + max(6.0, node.frame.height - avatarSize - 8.0),
y: visualBottom - avatarSize - tailBottomInset,
width: avatarSize,
height: avatarSize
)
@ -292,7 +299,11 @@ class ThemeSettingsChatPreviewItemNode: ListViewItemNode {
let avatarRadius = min(avatarFrame.width, avatarFrame.height) * CGFloat(max(0, min(50, item.avatarCornerRadius))) / 100.0
strongSelf.avatarNode.layer.cornerRadius = avatarRadius
strongSelf.avatarNode.layer.masksToBounds = true
strongSelf.avatarNode.setPeer(context: item.context, theme: item.componentTheme, peer: avatarPeer, synchronousLoad: false, displayDimensions: avatarFrame.size)
// Generate a SQUARE avatar image (clipStyle .none) and let this node's own
// cornerRadius define the shape. Otherwise AvatarNode bakes the *global*
// avatarCornerRadius into the image and early-returns on re-`setPeer`, so the
// live preview value (dragged slider) never takes effect.
strongSelf.avatarNode.setPeer(context: item.context, theme: item.componentTheme, peer: avatarPeer, clipStyle: .none, synchronousLoad: false, displayDimensions: avatarFrame.size)
strongSelf.avatarNode.updateSize(size: avatarFrame.size)
} else {
strongSelf.avatarNode.isHidden = true

View file

@ -7,8 +7,6 @@ import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
// A hero banner shown at the very top of the WinterGram settings menu, just under the navigation
// bar / Dynamic Island: a snowflake app-tile, the WinterGram name and a short tagline.
private func winterGramBannerIcon(size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { rendererContext in
@ -28,7 +26,6 @@ private func winterGramBannerIcon(size: CGSize) -> UIImage {
}
}
// Renders the user's current app icon as a rounded-rect tile for the banner.
private func winterGramBannerRoundedIcon(image: UIImage, size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
@ -137,7 +134,6 @@ class WinterGramBannerItemNode: ListViewItemNode {
strongSelf.item = item
let width = params.width
// No grey backplate behind the banner it sits flat on the grouped background.
strongSelf.backgroundNode.backgroundColor = .clear
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: contentHeight))

View file

@ -35,28 +35,29 @@ private enum WinterGramMainSettingsSection: Int32 {
case links
}
// A link row shown in the "Links" section: SF Symbol, label, accent-coloured value and target URL.
private struct WinterGramLink {
let symbol: String
let imageName: String?
let title: String
let value: String
let url: String
let color: UIColor
init(symbol: String, imageName: String? = nil, title: String, value: String, url: String) {
init(symbol: String, imageName: String? = nil, title: String, value: String, url: String, color: UIColor = UIColor(rgb: 0x8E8E93)) {
self.symbol = symbol
self.imageName = imageName
self.title = title
self.value = value
self.url = url
self.color = color
}
}
private let winterGramLinks: [WinterGramLink] = [
WinterGramLink(symbol: "paperplane.fill", title: "Channel", value: "@wntgram", url: "https://t.me/wntgram"),
WinterGramLink(symbol: "sparkles", title: "Beta", value: "@wntbeta", url: "https://t.me/wntbeta"),
WinterGramLink(symbol: "bubble.left.and.bubble.right.fill", title: "Chat", value: "@wntForum", url: "https://t.me/wntForum"),
WinterGramLink(symbol: "puzzlepiece.extension.fill", title: "Plugins", value: "@wntPlugins", url: "https://t.me/wntPlugins"),
WinterGramLink(symbol: "paperplane.fill", title: "Channel", value: "@wntgram", url: "https://t.me/wntgram", color: UIColor(rgb: 0x2AABEE)),
WinterGramLink(symbol: "sparkles", title: "Beta", value: "@wntbeta", url: "https://t.me/wntbeta", color: UIColor(rgb: 0xFF9F0A)),
WinterGramLink(symbol: "bubble.left.and.bubble.right.fill", title: "Chat", value: "@wntForum", url: "https://t.me/wntForum", color: UIColor(rgb: 0x34C759)),
WinterGramLink(symbol: "puzzlepiece.extension.fill", title: "Plugins", value: "@wntPlugins", url: "https://t.me/wntPlugins", color: UIColor(rgb: 0xBF5AF2)),
WinterGramLink(symbol: "link", imageName: "Item List/Icons/GitHub", title: "GitHub", value: "reekeer/WinterGram", url: "https://github.com/reekeer/WinterGram")
]
@ -150,7 +151,7 @@ private enum WinterGramMainSettingsEntry: ItemListNodeEntry {
case let .link(_, link):
return ItemListDisclosureItem(
presentationData: presentationData,
icon: winterGramCategoryIcon(symbolName: link.symbol, imageName: link.imageName, backgroundColor: UIColor(rgb: 0x8E8E93)),
icon: winterGramCategoryIcon(symbolName: link.symbol, imageName: link.imageName, backgroundColor: link.color),
title: wntOption(link.title, presentationData.strings),
label: link.value,
labelStyle: .coloredText(accent),
@ -162,15 +163,15 @@ private enum WinterGramMainSettingsEntry: ItemListNodeEntry {
}
)
case .ayugram:
category = .ayugram; title = "Core"; iconName = "shield.fill"; iconColor = UIColor(rgb: 0x5856D6)
category = .ayugram; title = "Core"; iconName = "shield.fill"; iconColor = UIColor(rgb: 0x0A84FF)
case .features:
category = .antiFeatures; title = "Features"; iconName = "sparkles"; iconColor = UIColor(rgb: 0xFF9500)
category = .antiFeatures; title = "Features"; iconName = "sparkles"; iconColor = UIColor(rgb: 0xFF9F0A)
case .other:
category = .other; title = "Other"; iconName = "ellipsis.circle"; iconColor = UIColor(rgb: 0x8E8E93)
case .spoofing:
category = .spoofing; title = "Spoofing"; iconName = "theatermasks"; iconColor = UIColor(rgb: 0xFF3B30)
category = .spoofing; title = "Spoofing"; iconName = "theatermasks.fill"; iconColor = UIColor(rgb: 0xBF5AF2)
case .hiddenArchive:
category = .stash; title = "Hidden Archive"; iconName = "tray.full.fill"; iconColor = UIColor(rgb: 0x34C759)
category = .stash; title = "Hidden Archive"; iconName = "tray.full.fill"; iconColor = UIColor(rgb: 0x30D158)
}
return ItemListDisclosureItem(
presentationData: presentationData,
@ -187,38 +188,38 @@ private enum WinterGramMainSettingsEntry: ItemListNodeEntry {
}
}
/// Renders a rounded-rect backplate filled with `backgroundColor` and a white SF Symbol centred on
/// top a clean settings tile look.
private func winterGramCategoryIcon(_ symbolName: String, _ backgroundColor: UIColor) -> UIImage? {
return winterGramCategoryIcon(symbolName: symbolName, imageName: nil, backgroundColor: backgroundColor)
}
private func winterGramCategoryIcon(symbolName: String, imageName: String?, backgroundColor: UIColor) -> UIImage? {
let size = CGSize(width: 44.0, height: 44.0)
// Match the iOS-Settings-style colored icons Telegram uses for settings rows: the image *is* the
// rounded colored backplate (~30pt), centered by ItemListDisclosureItem in its icon column, with a
// white glyph that has comfortable padding. The previous 44pt image held a small 34pt backplate, so
// icons looked oversized/off-centre next to standard rows and the backplate looked too small.
let size = CGSize(width: 32.0, height: 32.0)
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
let bounds = CGRect(origin: .zero, size: size)
// Smaller colored backplate with a transparent margin so the white icon dominates.
let backplateInset: CGFloat = 5.0
let backplateRect = bounds.insetBy(dx: backplateInset, dy: backplateInset)
let backplate = UIBezierPath(roundedRect: backplateRect, cornerRadius: 9.0)
// ~0.225 of the side gives the iOS app-icon "squircle" feel at this size.
let backplate = UIBezierPath(roundedRect: bounds, cornerRadius: 7.5)
backgroundColor.setFill()
backplate.fill()
let icon: UIImage?
if let imageName = imageName {
icon = UIImage(bundleImageName: imageName)?.withRenderingMode(.alwaysTemplate)
} else {
icon = UIImage(systemName: symbolName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 28.0, weight: .medium))?.withRenderingMode(.alwaysTemplate)
icon = UIImage(systemName: symbolName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 20.0, weight: .bold))?.withRenderingMode(.alwaysTemplate)
}
guard let symbol = icon?.withTintColor(.white, renderingMode: .alwaysOriginal) else {
return
}
let maxIconSide: CGFloat = 30.0
let maxIconSide: CGFloat = 21.0
let symbolScale = min(maxIconSide / max(symbol.size.width, 1.0), maxIconSide / max(symbol.size.height, 1.0), 1.0)
let symbolSize = CGSize(width: symbol.size.width * symbolScale, height: symbol.size.height * symbolScale)
symbol.draw(in: CGRect(
x: floor((size.width - symbolSize.width) / 2.0),
y: floor((size.height - symbolSize.height) / 2.0),
x: (size.width - symbolSize.width) / 2.0,
y: (size.height - symbolSize.height) / 2.0,
width: symbolSize.width,
height: symbolSize.height
))
@ -263,7 +264,6 @@ public func winterGramMainSettingsController(context: AccountContext) -> ViewCon
pushControllerImpl?(winterGramSettingsController(context: context, category: category))
},
openUrl: { url in
// Open WinterGram channels in-app (resolve t.me links) rather than bouncing to the browser.
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: url, forceExternal: false, presentationData: presentationData, navigationController: getNavigationControllerImpl?(), dismissInput: {})
},

View file

@ -13,9 +13,6 @@ enum WinterGramRadiusPreviewKind {
case bubble
}
// Draws a small live preview that reflects the chosen corner radius: a sample avatar
// (the user's actual avatar when available, otherwise a gradient rounded square with a person glyph)
// or a sample chat bubble.
private func winterGramRadiusPreviewImage(kind: WinterGramRadiusPreviewKind, value: Int32, minValue: Int32, maxValue: Int32, size: CGSize, theme: PresentationTheme, avatarImage: UIImage? = nil) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { rendererContext in
@ -148,8 +145,6 @@ class WinterGramRadiusItemNode: ListViewItemNode {
self.addSubnode(self.titleNode)
self.addSubnode(self.valueNode)
// WinterGram: the inline radius preview next to the slider is intentionally NOT shown the
// rounding is previewed in the chat preview instead. The slider spans the full width.
}
deinit {

View file

@ -76,9 +76,8 @@ public enum WinterGramSettingsSection: Int32, CaseIterable {
case chat
case liquidGlass
case spoofing
// Combined "super-categories" shown in the redesigned main menu.
case ayugram // Ghost Mode + History + Hidden Archive
case other // Chat tweaks + Spoofing (show id, registration date, spoofer, )
case ayugram
case other
public var title: String {
switch self {
@ -106,7 +105,6 @@ public enum WinterGramSettingsSection: Int32, CaseIterable {
}
public var iconName: String {
// SF Symbols used for the main menu cells.
switch self {
case .banner:
return ""
@ -131,7 +129,6 @@ public enum WinterGramSettingsSection: Int32, CaseIterable {
}
}
// Maps a deep-link path/section name (wnt://wintergram/<name>) to a settings subtab.
public init?(deepLinkName: String) {
switch deepLinkName.lowercased() {
case "ghost", "ghostmode": self = .ghost
@ -156,8 +153,6 @@ private enum WinterGramDropdown: Equatable {
case stashPrivacy
}
// Single source of truth for each inline dropdown's options: display title (English; localized at
// render time), whether it is the current selection, and how to apply it.
private func winterGramDropdownOptions(_ dropdown: WinterGramDropdown, settings: WinterGramSettings) -> [(title: String, selected: Bool, apply: (WinterGramSettings) -> WinterGramSettings)] {
switch dropdown {
case .stashPrivacy:
@ -209,8 +204,6 @@ private func winterGramDropdownOptions(_ dropdown: WinterGramDropdown, settings:
}
}
// Built-in device-model spoof presets shown as tappable cards in the Spoofing section.
// `model` is the string reported to Telegram (nil = the device's real model). `subtitle` describes it.
private let winterGramDevicePresets: [(name: String, subtitle: String, model: String?)] = [
("Real device", "Report this device's real model", nil),
("iPhone 16 Pro Max", "iPhone17,2 · A18 Pro", "iPhone 16 Pro Max"),
@ -309,7 +302,6 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry {
case spoofingApiHash(String)
case spoofingFooter
// Expandable single-select row: dropdown key, title, expanded flag, options.
case expandableSelection(WinterGramDropdown, String, Bool, [ItemListExpandableSelectionItem.Option])
var section: ItemListSectionId {
@ -349,7 +341,6 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry {
}
var stableId: Int32 {
// Device preset cards nest between spoofingDevice (30000) and WebView platform (40000).
if case let .spoofingDevicePreset(index, _) = self {
return 30100 + Int32(index)
}
@ -684,12 +675,15 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry {
case .spoofPresetsHeader:
return ItemListSectionHeaderItem(presentationData: presentationData, text: lang.WinterGram_Templates, sectionId: self.section)
case let .spoofPreset(index, name, subtitle, editing, selected):
if editing {
return ItemListCheckboxItem(presentationData: presentationData, systemStyle: .glass, title: name, subtitle: subtitle, style: .left, checked: selected, zeroSeparatorInsets: false, sectionId: self.section, action: {
// Always an ItemListCheckboxItem so the row never swaps node *type* between display and
// editing. ItemList updates a row in place by stableId and cannot re-type a node (the cast
// in updateNode fails silently), which previously left the disclosure row in place in edit
// mode and made selection unresponsive. In display mode tapping applies the template; in
// edit mode it toggles selection. Bonus: templates now match the device-preset rows below.
return ItemListCheckboxItem(presentationData: presentationData, title: name, subtitle: subtitle, style: .left, checked: editing ? selected : false, zeroSeparatorInsets: false, sectionId: self.section, action: {
if editing {
arguments.toggleSpoofTemplateSelected(index)
})
} else {
return ItemListDisclosureItem(presentationData: presentationData, title: name, label: subtitle, labelStyle: .detailText, sectionId: self.section, style: .blocks, disclosureStyle: .none, action: {
} else {
arguments.updateSettings { settings in
var settings = settings
if index < settings.spoofPresets.count {
@ -699,8 +693,8 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry {
}
return settings
}
})
}
}
})
case .spoofAddTemplate:
return ItemListPeerActionItem(presentationData: presentationData, icon: PresentationResourcesItemList.plusIconImage(presentationData.theme), title: lang.WinterGram_AddTemplate, sectionId: self.section, height: .generic, color: .accent, editing: false, action: {
arguments.addSpoofTemplate()
@ -787,7 +781,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti
let lang = presentationData.strings
var entries: [WinterGramSettingsEntry] = []
// Appends an expandable single-select row with checkbox options shown inline.
func appendDropdown(_ dropdown: WinterGramDropdown, _ title: String, _ section: WinterGramSettingsSection) {
let options = winterGramDropdownOptions(dropdown, settings: settings).enumerated().map { index, option in
ItemListExpandableSelectionItem.Option(id: "\(dropdown)_\(index)", title: wntOption(option.title, lang), isSelected: option.selected, index: index)
@ -797,7 +790,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti
func appendGhost() {
entries.append(.ghostHeader)
// Expandable Ghost Mode switch with checkbox sub-items for each suppressed signal.
let subItems: [ItemListExpandableSwitchItem.SubItem] = [
.init(id: "readMessages", title: lang.WinterGram_DontReadMessages, isSelected: settings.suppressesReadReceipts, isEnabled: true),
.init(id: "readStories", title: lang.WinterGram_DontReadStories, isSelected: settings.suppressesStoryViews, isEnabled: true),
@ -839,7 +831,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti
entries.append(.stashMute(settings.stashMuteNotifications))
entries.append(.stashAutoRead(settings.stashAutoMarkRead))
entries.append(.stashPasscodeRow(settings.stashPasscode.isEmpty ? "None" : "••••"))
// All auto-privacy toggles combined into one expandable multi-select checkbox row.
appendDropdown(.stashPrivacy, lang.WinterGram_AutoPrivacy, .stash)
entries.append(.stashFooter)
}
@ -868,7 +859,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti
entries.append(.confirmStickers(settings.stickerConfirmation))
entries.append(.confirmGif(settings.gifConfirmation))
entries.append(.confirmVoice(settings.voiceConfirmation))
// Footer last (stableId 67) so the section stays in ascending stableId order.
entries.append(.chatFooter)
}
@ -885,7 +875,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti
func appendSpoofing() {
entries.append(.spoofingHeader)
// Saved spoof templates at the top of the Spoofing section.
entries.append(.spoofPresetsHeader)
for (index, preset) in settings.spoofPresets.enumerated() {
let subtitle = [preset.deviceModel, preset.appVersion].filter { !$0.isEmpty }.joined(separator: " · ")
@ -895,7 +884,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti
if spoofTemplatesEditing && !selectedTemplates.isEmpty {
entries.append(.spoofDeleteSelected)
}
// Device model is chosen via the preset cards below (incl. "Real device") no separate prompt row.
for (i, preset) in winterGramDevicePresets.enumerated() {
entries.append(.spoofingDevicePreset(i, preset.model == settings.spoofDeviceModel))
}
@ -950,8 +938,6 @@ public func winterGramSettingsController(context: AccountContext, category: Wint
var pushControllerImpl: ((ViewController) -> Void)?
var refreshDeletedCount: (() -> Void)?
// The combined super-categories (.ayugram/.other) are not standalone tabs in the legacy
// no-category segmented view.
let sectionTabs: [WinterGramSettingsSection] = WinterGramSettingsSection.allCases.filter { $0 != .ayugram && $0 != .other }
let selectedCategoryPromise = ValuePromise<WinterGramSettingsSection>(category ?? .ghost, ignoreRepeated: true)
@ -968,13 +954,12 @@ public func winterGramSettingsController(context: AccountContext, category: Wint
newSettings.customApiHash != initialSettings.customApiHash ||
newSettings.materialDesign != initialSettings.materialDesign ||
newSettings.customFont != initialSettings.customFont ||
newSettings.monoFont != initialSettings.monoFont
newSettings.monoFont != initialSettings.monoFont ||
newSettings.liquidGlass != initialSettings.liquidGlass
requiresRestartPromise.set(needsRestart)
})
}
// Applies a change to the stashed-peer privacy settings and re-syncs exceptions for every
// currently stashed peer when the rules change.
let updateStashPrivacy: (@escaping (WinterGramStashPrivacySettings) -> WinterGramStashPrivacySettings) -> Void = { f in
let previous = currentWinterGramSettings.stashPrivacy
updateSettings { settings in
@ -991,12 +976,9 @@ public func winterGramSettingsController(context: AccountContext, category: Wint
}
}
// Which inline dropdown (if any) is currently expanded. Atomic mirror for synchronous reads in the
// toggle/select closures; promise drives the list rebuild.
let expandedDropdownValue = Atomic<WinterGramDropdown?>(value: nil)
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 ghostExpandedPromise = ValuePromise<Bool>(false, ignoreRepeated: true)
let spoofTemplatesEditingValue = Atomic<Bool>(value: false)
@ -1025,7 +1007,6 @@ public func winterGramSettingsController(context: AccountContext, category: Wint
updateSettings { options[index].apply($0) }
}
}
// Multi-select dropdowns stay open so the user can toggle more options.
if dropdown != .stashPrivacy {
let _ = expandedDropdownValue.swap(nil)
expandedDropdownPromise.set(nil)
@ -1290,8 +1271,6 @@ public func winterGramSettingsController(context: AccountContext, category: Wint
controller?.present(c, in: .window(.root), with: a)
}
pushControllerImpl = { [weak controller] c in
// Keep the restart-banner subscription alive for the controller's lifetime: this
// closure is retained by `arguments`, which the ItemListController holds via its state.
let _ = restartBannerDisposable
(controller?.navigationController as? NavigationController)?.pushViewController(c)
}

View file

@ -902,7 +902,7 @@ func revalidateMediaResourceReference(accountPeerId: PeerId, postbox: Postbox, n
}
}
// Rich-text messages (`RichTextMessageAttribute`) embed their media in the
// attribute's `InstantPage`, not in `message.media` search there too so a
// attribute's `InstantPage`, not in `message.media`. Search there so a
// stale instant-page audio/image file reference can revalidate.
for attribute in message.attributes {
if let attribute = attribute as? RichTextMessageAttribute {

View file

@ -4462,17 +4462,50 @@ func replayFinalState(
}
}
case let .DeleteMessagesWithGlobalIds(ids):
// WinterGram: ordinary cloud deletions (private chats, basic groups) arrive here via
// updateDeleteMessages deleteMessagesWithGlobalIds, NOT via .DeleteMessages (which only
// covers channels/supergroups). Resolve the global ids to message ids and preserve + mark
// them exactly like the .DeleteMessages path, otherwise these deletions stay unmarked.
var winterGramPreservedGlobalIds = false
if currentWinterGramCoreSettings.saveDeletedMessages {
break
let resolvedIds = transaction.messageIdsForGlobalIds(ids)
var winterGramShouldSave = !resolvedIds.isEmpty
// When "save for bots" is off, skip preserving deletions in bot chats.
if winterGramShouldSave && !currentWinterGramCoreSettings.saveForBots {
if let firstPeerId = resolvedIds.first?.peerId, let peer = transaction.getPeer(firstPeerId) as? TelegramUser, peer.botInfo != nil {
winterGramShouldSave = false
}
}
if winterGramShouldSave {
let markDate = Int32(Date().timeIntervalSince1970)
winterGramRecordDeletedMessages(transaction: transaction, ids: resolvedIds)
for id in resolvedIds {
transaction.updateMessage(id, update: { currentMessage in
if currentMessage.attributes.contains(where: { $0 is WinterGramDeletedMessageAttribute }) {
return .skip
}
var storeForwardInfo: StoreMessageForwardInfo?
if let forwardInfo = currentMessage.forwardInfo {
storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags)
}
var attributes = currentMessage.attributes
attributes.append(WinterGramDeletedMessageAttribute(date: markDate))
return .update(StoreMessage(id: currentMessage.id, customStableId: nil, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media))
})
}
winterGramPreservedGlobalIds = true
}
}
var resourceIds: [MediaResourceId] = []
transaction.deleteMessagesWithGlobalIds(ids, forEachMedia: { media in
addMessageMediaResourceIdsToRemove(media: media, resourceIds: &resourceIds)
})
if !resourceIds.isEmpty {
let _ = mediaBox.removeCachedResources(Array(Set(resourceIds)), force: true).start()
if !winterGramPreservedGlobalIds {
var resourceIds: [MediaResourceId] = []
transaction.deleteMessagesWithGlobalIds(ids, forEachMedia: { media in
addMessageMediaResourceIdsToRemove(media: media, resourceIds: &resourceIds)
})
if !resourceIds.isEmpty {
let _ = mediaBox.removeCachedResources(Array(Set(resourceIds)), force: true).start()
}
deletedMessageIds.append(contentsOf: ids.map { .global($0) })
}
deletedMessageIds.append(contentsOf: ids.map { .global($0) })
case let .DeleteMessages(ids):
var winterGramShouldSave = currentWinterGramCoreSettings.saveDeletedMessages
// When "save for bots" is off, skip preserving deletions in bot chats.

View file

@ -2,7 +2,7 @@ import Foundation
import Postbox
import SwiftSignalKit
// Replaces a message's text locally only no network edit is performed, so the
// Replaces a message's text locally without a network edit, so the
// server never learns about the change and no "edited" mark is added. The change
// persists in the local database until the message is re-fetched from the server.
public func winterGramEditMessageLocally(postbox: Postbox, messageId: MessageId, text: String) -> Signal<Void, NoError> {

View file

@ -3,7 +3,7 @@ import Foundation
// Estimates the approximate registration date of a Telegram account from its user ID.
// Telegram user IDs are allocated roughly monotonically over time, so a piecewise-linear
// interpolation between known (id, date) anchor points yields a usable estimate. This is an
// approximation only it is never exact and is meant for display with an "" prefix.
// approximation only and is meant for display with an "" prefix.
public func winterGramEstimatedRegistrationDate(userId: Int64) -> Date? {
guard userId > 0 else {
return nil

View file

@ -5,14 +5,6 @@ import AppBundle
import TelegramCore
import TelegramUIPreferences
// Runtime-composed WinterGram badge.
//
// Instead of shipping a fixed-colour PNG, the badge is composed on demand from two white-on-transparent
// shape assets (a scalloped backplate + a snowflake) so the backplate can follow the current theme
// colour. Per the design spec (1024² canvas): the backplate fills 1024@(0,0), the snowflake is 756²
// at (134,134). Results are cached per backplate colour; because every badge view re-renders when the
// presentation theme changes, this naturally recomposes the badge for the new theme.
private let winterGramBadgeCacheLock = NSLock()
private var winterGramComposedBadgeCache: [UInt32: UIImage] = [:]
@ -23,8 +15,6 @@ private func winterGramColorKey(_ color: UIColor) -> UInt32 {
return (channel(r) << 16) | (channel(g) << 8) | channel(b)
}
/// Composes the badge image: a scalloped backplate filled with `backplateColor` and a white snowflake
/// on top, following the 1024 spec. Cached per colour.
public func winterGramComposedBadge(backplateColor: UIColor) -> UIImage? {
let key = winterGramColorKey(backplateColor)
winterGramBadgeCacheLock.lock()
@ -51,16 +41,65 @@ public func winterGramComposedBadge(backplateColor: UIColor) -> UIImage? {
return image
}
/// The backplate colour for the current theme: the accent colour, slightly darkened.
public func winterGramBadgeBackplateColor(theme: PresentationTheme) -> UIColor {
return theme.list.itemAccentColor.withMultipliedBrightnessBy(0.82)
}
/// The themed badge image for a peer, or nil if the peer carries no WinterGram badge.
/// Every official peer (developers and official channels) gets the composed backplate badge.
private var winterGramComposedBadgeCacheV2: [String: UIImage] = [:]
private func winterGramLayerImage(source: String) -> UIImage? {
if let url = WinterGramBadgeManager.shared.localAssetFileURL(forSource: source), let image = UIImage(contentsOfFile: url.path) {
return image
}
return UIImage(bundleImageName: source)
}
public func winterGramComposedBadge(for badge: WinterGramBadgeDef, canvas: Double, themeColor: UIColor, size: CGSize) -> UIImage? {
let canvasValue = canvas > 0.0 ? canvas : 1024.0
let version = WinterGramBadgeManager.shared.manifest.version
let key = "\(badge.id)|\(version)|\(winterGramColorKey(themeColor))|\(Int(size.width))x\(Int(size.height))"
winterGramBadgeCacheLock.lock()
if let cached = winterGramComposedBadgeCacheV2[key] {
winterGramBadgeCacheLock.unlock()
return cached
}
winterGramBadgeCacheLock.unlock()
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { _ in
for layer in badge.layers {
if layer.isLottie {
continue
}
guard let layerImage = winterGramLayerImage(source: layer.source) else {
continue
}
let rect = CGRect(
x: layer.x / canvasValue * size.width,
y: layer.y / canvasValue * size.height,
width: layer.width / canvasValue * size.width,
height: layer.height / canvasValue * size.height
)
switch layer.tint {
case .theme:
layerImage.withTintColor(themeColor, renderingMode: .alwaysOriginal).draw(in: rect)
case let .hex(value):
layerImage.withTintColor(UIColor(rgb: value), renderingMode: .alwaysOriginal).draw(in: rect)
case .original:
layerImage.draw(in: rect)
}
}
}
winterGramBadgeCacheLock.lock()
winterGramComposedBadgeCacheV2[key] = image
winterGramBadgeCacheLock.unlock()
return image
}
public func winterGramBadgeImage(for peer: EnginePeer, theme: PresentationTheme) -> UIImage? {
guard isWinterGramOfficialPeer(peer) else {
guard let badge = WinterGramBadgeManager.shared.badge(for: peer) else {
return nil
}
return winterGramComposedBadge(backplateColor: winterGramBadgeBackplateColor(theme: theme))
let manifest = WinterGramBadgeManager.shared.manifest
return winterGramComposedBadge(for: badge, canvas: manifest.canvas, themeColor: winterGramBadgeBackplateColor(theme: theme), size: CGSize(width: 36.0, height: 36.0))
}

View file

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

View file

@ -3897,7 +3897,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
let targetAlpha: CGFloat = isDeleted ? 0.45 : 1.0
animation.animator.updateAlpha(layer: strongSelf.mainContextSourceNode.contentNode.layer, alpha: targetAlpha, completion: nil)
if hasDeletedMark, let winterGramDeletedAttribute = winterGramDeletedAttribute {
if hasDeletedMark {
let deletedIconNode: ASImageNode
if let current = strongSelf.winterGramDeletedIconNode {
deletedIconNode = current
@ -3909,7 +3909,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
strongSelf.insertSubnode(newNode, belowSubnode: strongSelf.messageAccessibilityArea)
deletedIconNode = newNode
}
deletedIconNode.image = winterGramDeletedBadgeImage(emoji: currentWinterGramSettings.deletedMark, pillColor: item.presentationData.theme.theme.list.itemDestructiveColor, timeText: currentWinterGramSettings.showDeletedTime ? winterGramDeletedBadgeTimeText(winterGramDeletedAttribute.date) : nil)
// A small, unobtrusive mark (the chosen emoji, default 🧹) NOT a coloured time pill.
// The deletion time lives in the long-press context menu instead (see ChatInterfaceStateContextMenus).
deletedIconNode.image = winterGramDeletedBadgeImage(emoji: currentWinterGramSettings.deletedMark, pillColor: item.presentationData.theme.theme.list.itemDestructiveColor, timeText: nil)
let iconSize = deletedIconNode.image?.size ?? CGSize(width: 16.0, height: 16.0)
let spacing: CGFloat = 5.0
let iconY = backgroundFrame.maxY - iconSize.height - 2.0

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -195,9 +195,7 @@ public final class PeerInfoGiftsCoverComponent: Component {
}
private func recomputeGifts() {
// WinterGram: visual (fake NFT) gifts are merged via `additionalGifts`, so we must render
// them even before the real gifts state has loaded (e.g. a profile with no pinned gifts).
// Don't bail when `currentGiftsState` is nil just treat the pinned set as empty.
// WinterGram: merge visual gifts from `additionalGifts` even before the real gifts state loads.
let giftStatusId = self.currentGiftStatusId
let pinnedGifts = (self.currentGiftsState?.gifts ?? []).filter { gift in
if gift.pinnedToTop {

View file

@ -1642,8 +1642,7 @@ final class PeerInfoHeaderNode: ASDisplayNode {
}
}
// WinterGram: render the snowflake/developer badge last, to the right of the premium emoji
// status, the verified mark and any credibility icon mirroring the chat-list ordering.
// WinterGram: render the snowflake/developer badge last, mirroring the chat-list ordering.
if let winterGramIconSize = self.winterGramIconSize, let titleExpandedWinterGramIconSize = self.titleExpandedWinterGramIconSize, winterGramIconSize.width > 0.0 {
// Extra leading gap (vs the 4pt used between other icons) so the badge sits ~symmetrically
// after the premium emoji status, which carries its own internal transparent padding.

View file

@ -171,7 +171,7 @@ func infoItems(
}
var displayId = "\(rawId)"
if let winterGramDcId = winterGramDcId {
displayId += " (DC: \(winterGramDcId))"
displayId += " (dc: \(winterGramDcId))"
}
let copyId: () -> Void = { [weak interaction] in
UIPasteboard.general.string = "\(rawId)"
@ -179,7 +179,7 @@ func infoItems(
controller.present(UndoOverlayController(presentationData: presentationData, content: .copy(text: presentationData.strings.Conversation_TextCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
}
}
items[.winterGramInfo]!.append(PeerInfoScreenLabeledValueItem(id: 3099, label: "ID", text: displayId, textColor: .primary, action: { _, _ in
items[.winterGramInfo]!.append(PeerInfoScreenLabeledValueItem(id: 3099, label: "id", text: displayId, textColor: .primary, action: { _, _ in
copyId()
}, longTapAction: nil, contextAction: { _, gesture, _ in
copyId()
@ -192,9 +192,9 @@ func infoItems(
if currentWinterGramSettings.showRegistrationDate, !user.isDeleted, let date = winterGramEstimatedRegistrationDate(userId: user.id.id._internalGetInt64Value()) {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: presentationData.strings.baseLanguageCode)
formatter.dateFormat = "MMMM yyyy"
let dateText = "" + formatter.string(from: date)
items[.winterGramInfo]!.append(PeerInfoScreenLabeledValueItem(id: 3098, label: "Registered", text: dateText, textColor: .primary, action: nil, longTapAction: nil, contextAction: { _, gesture, _ in
formatter.setLocalizedDateFormatFromTemplate("MMMM yyyy")
let dateText = formatter.string(from: date)
items[.winterGramInfo]!.append(PeerInfoScreenLabeledValueItem(id: 3098, label: presentationData.strings.WinterGram_RegistrationDate, text: dateText, textColor: .primary, action: nil, longTapAction: nil, contextAction: { _, gesture, _ in
UIPasteboard.general.string = dateText
gesture?.cancel()
}, requestLayout: { animated in

View file

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

View file

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

View file

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

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.
*
* @param {Object} jsonld object containing any metadata that
* @param {Object} jsonld object containing any metadata that
* could be extracted from JSON-LD object.
*
* @return Object with optional "excerpt" and "byline" properties

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)
sharedApplicationContext.sharedContext.mediaManager.overlayMediaManager.attachOverlayMediaController(sharedApplicationContext.overlayMediaController)
self.winterGramSettingsDisposable.set(observeWinterGramSettings(accountManager: accountManager))
WinterGramBadgeManager.shared.start()
self.winterGramGlassDisposable.set((winterGramSettings(accountManager: accountManager)
|> deliverOnMainQueue).start(next: { settings in
let glass = settings.liquidGlass

View file

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

View file

@ -5494,7 +5494,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
return
}
// Find the bubble for this message (it must be at least partially
// visible we got here from a tap inside it) and compute the
// visible because the tap came from it) and compute the
// anchor's y in item-local coords. .bottom(anchorY) places the item
// so the anchor lands at the visual top of the rotated chat list's
// content area; .center(.custom) is bypassed for short items, so it

View file

@ -4051,7 +4051,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate {
// In overlay chat mode (self.containerNode != nil) historyNodeContainer is
// reparented out of contentContainerNode (see line ~1299), making the
// `aboveSubview: historyNodeContainer.view` insertion invalid. Return nil
// so callers fall back to CCEPN's clipping path portal-style transitions
// so callers fall back to CCEPN's clipping path. Portal-style transitions
// are not supported in overlay mode.
guard self.containerNode == nil else { return nil }
let parent = self.contentContainerNode.contentNode.view

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 {
actions.append(.action(ContextMenuActionItem(text: "Copy Message ID", icon: { theme in
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
/// (rather than flat-summing the translations in the destination space) lets
/// `CALayer.convert` propagate each correction through any remaining
/// transforms child `transform`, further ancestors' own model
/// `sublayerTransform`, etc. so the result is correct even when the chain
/// transforms, including child `transform` and ancestor model
/// `sublayerTransform`, so the result is correct even when the chain
/// contains non-translation transforms (rotations, scales).
private func convertAnimatingSourceRectFromWindow(_ windowRect: CGRect, toView: UIView) -> CGRect {
var chain: [CALayer] = []

View file

@ -43,7 +43,7 @@ final class MusicListenTracker {
func update(with stateAndType: (Account, SharedMediaPlayerItemPlaybackStateOrLoading, MediaManagerPlayerType)?) {
assert(Queue.mainQueue().isCurrent())
guard let (_, stateOrLoading, type) = stateAndType, type == .music else {
// Player closed or switched to non-music report and clear
// Report when the player closes or switches away from music.
self.reportAndReset()
return
}
@ -135,7 +135,7 @@ final class MusicListenTracker {
self.pauseTimer?.invalidate()
self.pauseTimer = nil
} else if !nowPlaying && wasPlaying {
// Just paused start pause timer
// Start the pause timer.
self.startPauseTimer()
}
@ -154,7 +154,7 @@ final class MusicListenTracker {
}
private func pauseTimerFired() {
// Paused > 60s report current session
// Report after a 60-second pause.
self.reportAndReset()
}

View file

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

View file

@ -3,7 +3,7 @@ import Display
import TelegramUIPreferences
// WinterGram: a persistent branding banner shown at the very top, centred in the Dynamic Island /
// notch band. It is a purely decorative overlay added to the key window `isUserInteractionEnabled`
// notch band. It is a decorative overlay added to the key window. `isUserInteractionEnabled`
// is false so it never intercepts touches. For now there is a single banner type (the bundled
// `WntGramBanner` image); `WinterGramTopBannerStyle.off` hides it, any other value shows it.
public final class WinterGramTopBannerView: UIView {

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)
self.id = try container.decode(String.self, forKey: .id)
self.gift = try container.decode(StarGift.self, forKey: .gift)
// `EnginePeer` isn't `Codable` and can't be rebuilt without a postbox, so like
// TelegramCore's own `StarGift` only the peer id is persisted and `fromPeer` is left
// `EnginePeer` isn't `Codable` and can't be rebuilt without a postbox.
// Like TelegramCore's `StarGift`, only the peer id is persisted and `fromPeer` is left
// nil on decode, to be resolved lazily by the consumer when a peer is actually needed.
self.fromPeer = nil
}
@ -816,40 +816,16 @@ public func observeWinterGramSettings(accountManager: AccountManager<TelegramAcc
}
public func isWinterGramOfficialPeer(_ peer: EnginePeer) -> Bool {
let peerIdValue = peer.id.id._internalGetInt64Value()
switch peer {
case .user:
return peerIdValue == 885166226 || peerIdValue == 5665997196
case .channel:
// Raw channel ids (the part after the -100 marker): @wntgram/@wntbeta plus -1003999337820 / -1004348385636.
return peerIdValue == 3943351959 || peerIdValue == 4316373875 || peerIdValue == 3999337820 || peerIdValue == 4348385636
default:
return false
}
return WinterGramBadgeManager.shared.badge(for: peer) != nil
}
// Developer accounts get a distinct backplated badge; other official peers keep the plain snowflake.
public func isWinterGramDeveloperPeer(_ peer: EnginePeer) -> Bool {
let peerIdValue = peer.id.id._internalGetInt64Value()
switch peer {
case .user:
return peerIdValue == 885166226 || peerIdValue == 5665997196
default:
return false
}
return WinterGramBadgeManager.shared.badge(for: peer)?.id == "developer"
}
// Name of the bundled badge image for a peer, or nil if the peer carries no WinterGram badge.
// Developers and official channels get the backplated badge; other official peers get the plain snowflake.
public func winterGramBadgeImageName(for peer: EnginePeer) -> String? {
if isWinterGramDeveloperPeer(peer) {
return "WntGramDeveloperBadge"
guard let badge = WinterGramBadgeManager.shared.badge(for: peer) else {
return nil
}
if isWinterGramOfficialPeer(peer) {
if case .channel = peer {
return "WntGramDeveloperBadge"
}
return "WinterGramSnowflake"
}
return nil
return badge.id == "developer" ? "WntGramDeveloperBadge" : "WinterGramSnowflake"
}

View file

@ -10,7 +10,7 @@ public func customEmojiMarkdownURL(fileId: Int64) -> String {
/// Backslash-escapes only the characters that would break a marker link's
/// display text (the `alt`): backslash, the link-text brackets, and newline.
/// Minimal by design the forward CommonMark parser unescapes these, so the
/// The CommonMark parser unescapes these, so the
/// alt round-trips. Shared by every site that emits a `[<alt>](tg://emoji?id=)`
/// marker so the escaping cannot drift between encoders.
public func escapeCustomEmojiMarkdownAlt(_ string: String) -> String {
@ -53,7 +53,7 @@ private let customEmojiMarkerRegex = try? NSRegularExpression(
/// markdown text. Used to populate the edit compose field so it shows the
/// animated emoji; on re-save the forward path reads the attribute's fileId back.
///
/// `file` is left nil the renderer resolves the emoji lazily from `fileId`,
/// `file` is nil because the renderer resolves the emoji lazily from `fileId`,
/// and the send path only needs the fileId. Known limitation: an alt containing
/// a literal `]` is not matched (emoji alts do not contain brackets).
public func chatInputTextWithReattachedCustomEmoji(_ markdown: String) -> NSAttributedString {

View file

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

View file

@ -51,12 +51,12 @@ func isAllowedBotMediaUrl(_ urlString: String) -> Bool {
// Strict canonical dotted-decimal IPv4 (4 octets, no leading zeros, each 0-255).
// Do NOT use inet_pton here: Darwin's inet_pton accepts "0177.0.0.1" as
// decimal 177.0.0.1, but getaddrinfo (used by URLSession) interprets the
// same string as octal 127.0.0.1 the divergence is a loopback bypass.
// same string as octal 127.0.0.1. The divergence is a loopback bypass.
if let v4Bytes = parseCanonicalIPv4(host) {
return isPublicIPv4(v4Bytes)
}
// IPv6 only host must contain ":" so we don't accidentally hand a
// IPv6 only. Require ":" to avoid handing a
// numeric-looking hostname to inet_pton.
if host.contains(":") {
var v6 = in6_addr()
@ -70,7 +70,7 @@ func isAllowedBotMediaUrl(_ urlString: String) -> Bool {
}
// Strict DNS-name validation. Anything that doesn't look like a real
// FQDN is rejected this catches non-canonical numeric IP forms
// FQDN is rejected. This catches non-canonical numeric IP forms
// (decimal-32 like "2130706433", octal like "0177.0.0.1", hex like
// "0x7f.0.0.1", short forms like "127.1") that the OS resolver may
// still treat as 127.0.0.1 even when inet_pton would accept them as

View file

@ -10,7 +10,15 @@ MESON_OPTIONS="--buildtype=release --default-library=static -Denable_tools=false
CROSSFILE=""
if [ "$ARCH" = "arm64" ]; then
CROSSFILE="../package/crossfiles/arm64-iPhoneOS.meson"
# Rewrite the hardcoded /Applications/Xcode.app sysroot to the active developer dir
# (e.g. Xcode-beta.app), mirroring the sim_arm64 branch — otherwise the meson cross
# build can't find the iPhoneOS SDK headers when Xcode lives at a non-default path.
TARGET_CROSSFILE="$BUILD_DIR/dav1d/package/crossfiles/arm64-iPhoneOS-custom.meson"
rm -f "$TARGET_CROSSFILE"
cp "$BUILD_DIR/dav1d/package/crossfiles/arm64-iPhoneOS.meson" "$TARGET_CROSSFILE"
custom_xcode_path="$(xcode-select -p)/"
sed -i '' "s|/Applications/Xcode.app/Contents/Developer/|$custom_xcode_path|g" "$TARGET_CROSSFILE"
CROSSFILE="../package/crossfiles/arm64-iPhoneOS-custom.meson"
elif [ "$ARCH" = "sim_arm64" ]; then
rm -f "arm64-iPhoneSimulator-custom.meson"
TARGET_CROSSFILE="$BUILD_DIR/dav1d/package/crossfiles/arm64-iPhoneSimulator-custom.meson"

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