chore: bump version to 1.2
6
.bazelrc
|
|
@ -1,4 +1,3 @@
|
||||||
build --action_env=ZERO_AR_DATE=1
|
|
||||||
|
|
||||||
build --enable_platform_specific_config
|
build --enable_platform_specific_config
|
||||||
|
|
||||||
|
|
@ -39,3 +38,8 @@ common:index_build --noannounce_rc
|
||||||
common:index_build --noshow_timestamps
|
common:index_build --noshow_timestamps
|
||||||
common:index_build --curses=no
|
common:index_build --curses=no
|
||||||
common:index_build --color=no
|
common:index_build --color=no
|
||||||
|
|
||||||
|
# Demote new Xcode 27 / iOS 27 deprecation and implicit-capture diagnostics to warnings.
|
||||||
|
build --features=-treat_warnings_as_errors
|
||||||
|
build --@build_bazel_rules_swift//swift:copt=-no-warnings-as-errors
|
||||||
|
build --copt=-Wno-deprecated-declarations
|
||||||
|
|
|
||||||
4
.gitignore
vendored
|
|
@ -53,6 +53,7 @@ xcuserdata
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
build-system/wintergram-development-configuration.json
|
build-system/wintergram-development-configuration.json
|
||||||
build-system/fake-codesigning-wintergram/
|
build-system/fake-codesigning-wintergram/
|
||||||
|
build-system/fake-codesigning-generated/
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Editor / AI assistant / local tooling
|
# Editor / AI assistant / local tooling
|
||||||
|
|
@ -121,3 +122,6 @@ AppBinary.xcworkspace/*
|
||||||
Project.xcodeproj/*
|
Project.xcodeproj/*
|
||||||
Watch/Watch.xcodeproj/*
|
Watch/Watch.xcodeproj/*
|
||||||
AppBundle.xcworkspace/*
|
AppBundle.xcworkspace/*
|
||||||
|
|
||||||
|
# wintergram badge preview gifs
|
||||||
|
.wintergram/icons/preview_*.gif
|
||||||
|
|
|
||||||
44
.wintergram/icons/README.md
Normal 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.
|
||||||
BIN
.wintergram/icons/developer/backplate.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
.wintergram/icons/developer/icon.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
51
.wintergram/icons/manifest.json
Normal 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" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
93
.wintergram/icons/manifest.schema.json
Normal 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)." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
.wintergram/icons/official/backplate.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
.wintergram/icons/official/icon.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
163
AGENTS.md
Normal 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`
|
||||||
|
|
@ -160,7 +160,7 @@ genrule(
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
minimum_os_version = "13.0"
|
minimum_os_version = "26.5"
|
||||||
|
|
||||||
notificationServiceExtensionVersion = "v1"
|
notificationServiceExtensionVersion = "v1"
|
||||||
|
|
||||||
|
|
@ -1850,7 +1850,7 @@ ios_application(
|
||||||
xcodeproj(
|
xcodeproj(
|
||||||
name = "Telegram_xcodeproj",
|
name = "Telegram_xcodeproj",
|
||||||
bazel_path = telegram_bazel_path,
|
bazel_path = telegram_bazel_path,
|
||||||
project_name = "Telegram",
|
project_name = "WinterGram",
|
||||||
tags = ["manual"],
|
tags = ["manual"],
|
||||||
top_level_targets = [
|
top_level_targets = [
|
||||||
top_level_target(":Telegram", target_environments = ["device", "simulator"]),
|
top_level_target(":Telegram", target_environments = ["device", "simulator"]),
|
||||||
|
|
|
||||||
|
|
@ -2612,60 +2612,36 @@ typealias CMJImage = UIImage
|
||||||
|
|
||||||
// MARK: - Public Namespace
|
// MARK: - Public Namespace
|
||||||
|
|
||||||
/// A lightweight utility for turning ordinary bitmap images into ``NSAdaptiveImageGlyph`` (Apple “Genmoji”) objects.
|
/// Utility for converting bitmaps into ``NSAdaptiveImageGlyph`` (Genmoji).
|
||||||
///
|
|
||||||
/// ## Overview
|
|
||||||
/// The public API is minimal — a single call to generate a glyph and helper utilities for
|
|
||||||
/// de/serialising attributed strings.
|
|
||||||
///
|
|
||||||
/// ```swift
|
|
||||||
/// // Synchronous usage
|
|
||||||
/// let glyph = try Customoji.makeGlyph(
|
|
||||||
/// from: image,
|
|
||||||
/// description: "alt text",
|
|
||||||
/// cropToSquare: true
|
|
||||||
/// )
|
|
||||||
/// let attr = NSAttributedString(adaptiveImageGlyph: glyph, attributes: [:])
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// ## Requirements
|
|
||||||
/// * iOS 18 / macOS 15
|
|
||||||
/// * Swift 5.10+
|
|
||||||
///
|
|
||||||
/// ## Topics
|
|
||||||
/// - Generating glyphs: ``makeGlyph(from:description:identifier:tileSizes:cropToSquare:heicQuality:)``
|
|
||||||
/// - Generating glyphs asynchronously: ``makeGlyphAsync(from:description:identifier:tileSizes:cropToSquare:heicQuality:)``
|
|
||||||
/// - Serialising: ``decompose(_:)`` / ``recompose(plain:ranges:blobs:)``
|
|
||||||
///
|
|
||||||
public struct Customoji {
|
public struct Customoji {
|
||||||
|
|
||||||
// MARK: Library-scoped Errors
|
// MARK: Library-scoped Errors
|
||||||
/// Errors that can be thrown by ``Customoji``.
|
/// Errors thrown by ``Customoji``.
|
||||||
public enum Error: Swift.Error, LocalizedError {
|
public enum Error: Swift.Error, LocalizedError {
|
||||||
/// The input image is not square and ``makeGlyph(from:description:identifier:tileSizes:cropToSquare:heicQuality:)`` was called with `cropToSquare == false`.
|
/// Input image is not square and cropping is disabled.
|
||||||
case nonSquare
|
case nonSquare
|
||||||
/// The longest side of the input image exceeds ``maxSide`` pixels (defaults to 4096).
|
/// Input image exceeds ``maxSide`` pixels.
|
||||||
case imageTooLarge
|
case imageTooLarge
|
||||||
/// Failed to create an in-memory HEIC container via *Image I/O*.
|
/// Failed to create the in-memory HEIC container.
|
||||||
case heicDestinationCreationFailed
|
case heicDestinationCreationFailed
|
||||||
/// Failed to finalise the HEIC container after writing all tiles.
|
/// Failed to finalize the HEIC container.
|
||||||
case heicFinalizeFailed
|
case heicFinalizeFailed
|
||||||
/// Internal resize operation failed when generating a tile.
|
/// Resizing a tile failed.
|
||||||
case imageScaleFailed
|
case imageScaleFailed
|
||||||
/// A `CGImage` representation could not be extracted from the source image.
|
/// Could not extract a `CGImage` from the source image.
|
||||||
case cgImageUnavailable
|
case cgImageUnavailable
|
||||||
/// The `tileSizes` parameter was empty, not strictly ascending, contained duplicates, or exceeded the source size.
|
/// `tileSizes` was empty, not strictly ascending, contained duplicates, or exceeded the source size.
|
||||||
case invalidTileSizes
|
case invalidTileSizes
|
||||||
/// No tiles were generated — normally unreachable unless all requested sizes were invalid.
|
/// No tiles were generated; normally unreachable unless all requested sizes were invalid.
|
||||||
case noTilesGenerated
|
case noTilesGenerated
|
||||||
/// The current platform is neither UIKit- nor AppKit-based (unlikely in practice).
|
/// Unsupported platform.
|
||||||
case unsupportedPlatform
|
case unsupportedPlatform
|
||||||
/// The `heicQuality` parameter was outside the 0.0 … 1.0 range.
|
/// `heicQuality` was outside the 0.0...1.0 range.
|
||||||
case invalidHeicQuality
|
case invalidHeicQuality
|
||||||
/// Cropping or scaling to a square failed.
|
/// Cropping or scaling to a square failed.
|
||||||
case squareCropFailed
|
case squareCropFailed
|
||||||
|
|
||||||
/// Textual description suitable for presenting to users.
|
/// Localized error description.
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .nonSquare: return "Input image must be square."
|
case .nonSquare: return "Input image must be square."
|
||||||
|
|
@ -2694,26 +2670,16 @@ public struct Customoji {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default tile sizes (in pixels) officially used by Apple for Genmoji.
|
/// Default Genmoji tile sizes.
|
||||||
public static let defaultTileSizes = [40, 64, 96, 160, 320]
|
public static let defaultTileSizes = [40, 64, 96, 160, 320]
|
||||||
|
|
||||||
/// Maximum allowed side length (in pixels) to guard against excessive memory consumption.
|
/// Maximum allowed side length, in pixels.
|
||||||
private static let maxSide = 4_096
|
private static let maxSide = 4_096
|
||||||
|
|
||||||
// MARK: - Synchronous Builder
|
// MARK: - Synchronous Builder
|
||||||
#if swift(>=5.10)
|
#if swift(>=5.10)
|
||||||
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *)
|
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *)
|
||||||
/// Generates an ``NSAdaptiveImageGlyph`` synchronously on the **current** thread.
|
/// Generate an ``NSAdaptiveImageGlyph`` from a bitmap.
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - image: Source image. Can be rectangular; see `cropToSquare`.
|
|
||||||
/// - description: A human-readable accessibility description (VoiceOver/Narrator).
|
|
||||||
/// - identifier: A globally unique content identifier. Defaults to a fresh UUID.
|
|
||||||
/// - tileSizes: Desired tile sizes, in pixels. Must be strictly ascending. Defaults to ``defaultTileSizes``.
|
|
||||||
/// - cropToSquare: When `true`, the central square area is cropped if the input image is not square.
|
|
||||||
/// - heicQuality: Lossy compression quality between `0.0` and `1.0`. Defaults to `0.8`.
|
|
||||||
/// - Throws: ``Customoji/Error`` if validation or encoding fails.
|
|
||||||
/// - Returns: A fully-formed glyph ready for attribution.
|
|
||||||
static func makeGlyph(
|
static func makeGlyph(
|
||||||
from image: CMJImage,
|
from image: CMJImage,
|
||||||
description: String,
|
description: String,
|
||||||
|
|
@ -2739,19 +2705,7 @@ public struct Customoji {
|
||||||
// MARK: - AttributedString Utilities
|
// MARK: - AttributedString Utilities
|
||||||
#if swift(>=5.10)
|
#if swift(>=5.10)
|
||||||
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *)
|
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *)
|
||||||
/// Breaks an ``NSAttributedString`` that may contain adaptive-image glyphs into three parts.
|
/// Split an attributed string into plain text, glyph ranges, and HEIC data.
|
||||||
///
|
|
||||||
/// This is handy when you need to **serialise** rich text for network transport or persistent
|
|
||||||
/// storage, because `NSAdaptiveImageGlyph` itself is not `Codable`.
|
|
||||||
///
|
|
||||||
/// ```swift
|
|
||||||
/// let (plain, ranges, blobs) = Customoji.decompose(attrString)
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// - Returns: A tuple where:
|
|
||||||
/// - plain: UTF-16 plain text.
|
|
||||||
/// - ranges: An array mapping text ranges to glyph identifiers.
|
|
||||||
/// - blobs: A dictionary mapping identifier → raw HEIC data.
|
|
||||||
@inlinable
|
@inlinable
|
||||||
public static func decompose(_ attr: NSAttributedString) -> (
|
public static func decompose(_ attr: NSAttributedString) -> (
|
||||||
plain: String, ranges: [(NSRange, String)], blobs: [String: Data]
|
plain: String, ranges: [(NSRange, String)], blobs: [String: Data]
|
||||||
|
|
@ -2773,13 +2727,7 @@ public struct Customoji {
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *)
|
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *)
|
||||||
/// Reassembles an attributed string previously produced by ``decompose(_:)``.
|
/// Reassemble an attributed string from ``decompose(_:)`` output.
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - plain: The plain UTF-16 text.
|
|
||||||
/// - ranges: An array of `(NSRange, identifier)` pairs.
|
|
||||||
/// - blobs: A dictionary of identifier → HEIC data. *Must* contain every identifier referenced by `ranges`.
|
|
||||||
/// - Returns: A fully restored `NSAttributedString` with adaptive-image glyphs reinstated.
|
|
||||||
public static func recompose(
|
public static func recompose(
|
||||||
plain: String,
|
plain: String,
|
||||||
ranges: [(NSRange, String)],
|
ranges: [(NSRange, String)],
|
||||||
|
|
@ -2805,8 +2753,7 @@ public struct Customoji {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - Helper: Pixel-Side Validation
|
// MARK: - Helper: Pixel-Side Validation
|
||||||
/// Ensures that the image’s **longer edge** does not exceed ``maxSide`` pixels, guarding against
|
/// Reject images whose longer edge exceeds ``maxSide``.
|
||||||
/// excessive memory usage during HEIC encoding.
|
|
||||||
private static func validatePixelSide(of image: CMJImage) throws {
|
private static func validatePixelSide(of image: CMJImage) throws {
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
let scale = image.scale == 0 ? 1 : image.scale
|
let scale = image.scale == 0 ? 1 : image.scale
|
||||||
|
|
@ -2821,11 +2768,6 @@ public struct Customoji {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================
|
|
||||||
// Below this line lies the internal implementation.
|
|
||||||
// Public-facing symbols have already been documented above.
|
|
||||||
// =============================================================
|
|
||||||
|
|
||||||
// MARK: - Core Builder
|
// MARK: - Core Builder
|
||||||
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *)
|
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, *)
|
||||||
extension Customoji {
|
extension Customoji {
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,8 @@
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
<string>voip</string>
|
<string>voip</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UIDesignRequiresCompatibility</key>
|
||||||
|
<true/>
|
||||||
<key>UIDeviceFamily</key>
|
<key>UIDeviceFamily</key>
|
||||||
<array>
|
<array>
|
||||||
<integer>1</integer>
|
<integer>1</integer>
|
||||||
|
|
|
||||||
|
|
@ -402,22 +402,22 @@
|
||||||
|
|
||||||
// Tour
|
// Tour
|
||||||
"Tour.Title1" = "WinterGram";
|
"Tour.Title1" = "WinterGram";
|
||||||
"Tour.Text1" = "The world's **fastest** messaging app.\nIt is **free** and **secure**.";
|
"Tour.Text1" = "A native **AyuGram** port for iPhone.\nAn open-source **Telegram** fork.";
|
||||||
|
|
||||||
"Tour.Title2" = "Fast";
|
"Tour.Title2" = "Telegram Fork";
|
||||||
"Tour.Text2" = "**WinterGram** delivers messages\nfaster than any other application.";
|
"Tour.Text2" = "Fully compatible with **Telegram**.\nYour chats, channels and accounts.";
|
||||||
|
|
||||||
"Tour.Title3" = "Powerful";
|
"Tour.Title3" = "Power Tools";
|
||||||
"Tour.Text3" = "**WinterGram** has no limits on\nthe size of your media and chats.";
|
"Tour.Text3" = "Saved **deleted messages**, full **edit\nhistory** and a **hidden archive**.";
|
||||||
|
|
||||||
"Tour.Title4" = "Secure";
|
"Tour.Title4" = "Make It Yours";
|
||||||
"Tour.Text4" = "**WinterGram** keeps your messages\nsafe from hacker attacks.";
|
"Tour.Text4" = "Deep **customization**, **Liquid Glass**,\nlocal **Premium** and a device **spoofer**.";
|
||||||
|
|
||||||
"Tour.Title5" = "Cloud-Based";
|
"Tour.Title5" = "Open Source";
|
||||||
"Tour.Text5" = "**WinterGram** lets you access your\nmessages from multiple devices.";
|
"Tour.Text5" = "Free and fully **open-source**.\nInspect and build it yourself.";
|
||||||
|
|
||||||
"Tour.Title6" = "Free";
|
"Tour.Title6" = "Ghost Mode";
|
||||||
"Tour.Text6" = "**WinterGram** provides free unlimited\ncloud storage for chats and media.";
|
"Tour.Text6" = "Read messages without sending\nread receipts, online or typing status.";
|
||||||
|
|
||||||
"Tour.StartButton" = "Start Messaging";
|
"Tour.StartButton" = "Start Messaging";
|
||||||
|
|
||||||
|
|
@ -16509,6 +16509,7 @@ Error: %8$@";
|
||||||
"WinterGram.ShowMessageSeconds" = "Show Message Seconds";
|
"WinterGram.ShowMessageSeconds" = "Show Message Seconds";
|
||||||
"WinterGram.ShowPeerID" = "Show Peer ID";
|
"WinterGram.ShowPeerID" = "Show Peer ID";
|
||||||
"WinterGram.ShowRegistrationDate" = "Show Registration Date";
|
"WinterGram.ShowRegistrationDate" = "Show Registration Date";
|
||||||
|
"WinterGram.RegistrationDate" = "Registration date";
|
||||||
"WinterGram.SingleCornerRadius" = "Single Corner Radius";
|
"WinterGram.SingleCornerRadius" = "Single Corner Radius";
|
||||||
"WinterGram.SomeSettingsWillTakeEffectAfterRestart" = "Some settings will take effect after restart.";
|
"WinterGram.SomeSettingsWillTakeEffectAfterRestart" = "Some settings will take effect after restart.";
|
||||||
"WinterGram.SpoofAppVersion" = "App Version";
|
"WinterGram.SpoofAppVersion" = "App Version";
|
||||||
|
|
|
||||||
|
|
@ -34,12 +34,12 @@ class UITests: XCTestCase {
|
||||||
deleteTestAccount(phone: "9996625296")
|
deleteTestAccount(phone: "9996625296")
|
||||||
app.launch()
|
app.launch()
|
||||||
|
|
||||||
// Welcome screen — tap Start Messaging
|
// Start messaging.
|
||||||
let startButton = app.buttons["Auth.Welcome.StartButton"]
|
let startButton = app.buttons["Auth.Welcome.StartButton"]
|
||||||
XCTAssert(startButton.waitForExistence(timeout: 5.0))
|
XCTAssert(startButton.waitForExistence(timeout: 5.0))
|
||||||
startButton.tap()
|
startButton.tap()
|
||||||
|
|
||||||
// Phone entry screen — enter test phone number
|
// Enter the test phone number.
|
||||||
let countryCodeField = app.textFields["Auth.PhoneEntry.CountryCodeField"]
|
let countryCodeField = app.textFields["Auth.PhoneEntry.CountryCodeField"]
|
||||||
XCTAssert(countryCodeField.waitForExistence(timeout: 10.0))
|
XCTAssert(countryCodeField.waitForExistence(timeout: 10.0))
|
||||||
countryCodeField.tap()
|
countryCodeField.tap()
|
||||||
|
|
@ -57,12 +57,12 @@ class UITests: XCTestCase {
|
||||||
XCTAssert(continueButton.isEnabled)
|
XCTAssert(continueButton.isEnabled)
|
||||||
continueButton.tap()
|
continueButton.tap()
|
||||||
|
|
||||||
// Confirmation dialog — tap Continue
|
// Confirm.
|
||||||
let confirmButton = app.buttons["Auth.PhoneConfirm.ContinueButton"]
|
let confirmButton = app.buttons["Auth.PhoneConfirm.ContinueButton"]
|
||||||
XCTAssert(confirmButton.waitForExistence(timeout: 5.0))
|
XCTAssert(confirmButton.waitForExistence(timeout: 5.0))
|
||||||
confirmButton.tap()
|
confirmButton.tap()
|
||||||
|
|
||||||
// Code entry screen — enter verification code
|
// Enter the verification code.
|
||||||
let codeEntryTitle = app.staticTexts["Auth.CodeEntry.Title"]
|
let codeEntryTitle = app.staticTexts["Auth.CodeEntry.Title"]
|
||||||
XCTAssert(codeEntryTitle.waitForExistence(timeout: 15.0))
|
XCTAssert(codeEntryTitle.waitForExistence(timeout: 15.0))
|
||||||
|
|
||||||
|
|
@ -70,7 +70,7 @@ class UITests: XCTestCase {
|
||||||
XCTAssert(codeField.waitForExistence(timeout: 3.0))
|
XCTAssert(codeField.waitForExistence(timeout: 3.0))
|
||||||
codeField.typeText("22222")
|
codeField.typeText("22222")
|
||||||
|
|
||||||
// Set name screen — enter name and submit
|
// Set the name.
|
||||||
let firstNameField = app.textFields["Auth.SetName.FirstNameField"]
|
let firstNameField = app.textFields["Auth.SetName.FirstNameField"]
|
||||||
XCTAssert(firstNameField.waitForExistence(timeout: 15.0))
|
XCTAssert(firstNameField.waitForExistence(timeout: 15.0))
|
||||||
firstNameField.tap()
|
firstNameField.tap()
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
against an exported tgwatch source tree, optionally codesigns the result, and exposes
|
against an exported tgwatch source tree, optionally codesigns the result, and exposes
|
||||||
it through the providers that `ios_application(watch_application = ...)` consumes:
|
it through the providers that `ios_application(watch_application = ...)` consumes:
|
||||||
|
|
||||||
* AppleBundleInfo — bundle metadata (the host reads only `.product_type`).
|
* AppleBundleInfo: bundle metadata (the host reads only `.product_type`).
|
||||||
* AppleEmbeddableInfo — `watch_bundles` (the zipped .app placed under Watch/).
|
* AppleEmbeddableInfo: `watch_bundles` (the zipped .app placed under Watch/).
|
||||||
|
|
||||||
The watch source tree is the committed in-repo snapshot at `Telegram/WatchApp/` (tracked
|
The watch source tree is the committed in-repo snapshot at `Telegram/WatchApp/` (tracked
|
||||||
inputs). To update it, re-sync from the standalone tgwatch repo via
|
inputs). To update it, re-sync from the standalone tgwatch repo via
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
# Worker for the apple_prebuilt_watchos_application Bazel rule.
|
# Worker for the apple_prebuilt_watchos_application Bazel rule.
|
||||||
#
|
#
|
||||||
# Builds the tgwatch watch app via xcodebuild (device, Release, UNSIGNED), then
|
# Builds the tgwatch watch app via xcodebuild (device, Release, UNSIGNED), then
|
||||||
# — if a provisioning profile is supplied — codesigns the app and its nested
|
# If a provisioning profile is supplied, codesigns the app and its nested
|
||||||
# frameworks with the watchkitapp provisioning profile and a matching identity,
|
# frameworks with the watchkitapp provisioning profile and a matching identity,
|
||||||
# and finally zips the .app into the rule's output archive.
|
# and finally zips the .app into the rule's output archive.
|
||||||
#
|
#
|
||||||
|
|
@ -45,7 +45,7 @@ DD="$(mktemp -d)"
|
||||||
trap 'rm -rf "$DD"' EXIT
|
trap 'rm -rf "$DD"' EXIT
|
||||||
|
|
||||||
# Build from a writable copy so xcodebuild/SwiftPM never write into the (possibly
|
# Build from a writable copy so xcodebuild/SwiftPM never write into the (possibly
|
||||||
# in-repo, read-only) source tree — e.g. SwiftPM's Package.resolved or the workspace.
|
# in-repo, read-only) source tree, such as SwiftPM's Package.resolved or the workspace.
|
||||||
# The tree is small (~12M); a plain cp on each (uncached) build is acceptable.
|
# The tree is small (~12M); a plain cp on each (uncached) build is acceptable.
|
||||||
WORKSRC="$DD/src"
|
WORKSRC="$DD/src"
|
||||||
mkdir -p "$WORKSRC"
|
mkdir -p "$WORKSRC"
|
||||||
|
|
@ -84,7 +84,7 @@ fi
|
||||||
# Sign the watch app whenever a provisioning profile is available. When no explicit
|
# Sign the watch app whenever a provisioning profile is available. When no explicit
|
||||||
# identity is supplied, derive it from the certificate embedded in that profile, so
|
# identity is supplied, derive it from the certificate embedded in that profile, so
|
||||||
# the watch app is signed with the same distribution/development identity as the host
|
# the watch app is signed with the same distribution/development identity as the host
|
||||||
# app (resolved from the shared codesigning material) — required for App Store, where
|
# app (resolved from the shared codesigning material), as required for App Store, where
|
||||||
# every nested bundle must carry the Apple submission certificate. Without a profile
|
# every nested bundle must carry the Apple submission certificate. Without a profile
|
||||||
# the app is left unsigned (the host does not re-sign it).
|
# the app is left unsigned (the host does not re-sign it).
|
||||||
if [ -n "$PROFILE" ]; then
|
if [ -n "$PROFILE" ]; then
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ def is_apple_silicon():
|
||||||
def get_clean_env(use_clean_env=True):
|
def get_clean_env(use_clean_env=True):
|
||||||
clean_env = os.environ.copy()
|
clean_env = os.environ.copy()
|
||||||
if use_clean_env:
|
if use_clean_env:
|
||||||
clean_env['PATH'] = '/usr/bin:/bin:/usr/sbin:/sbin'
|
# Force xcode-select to return the Xcode-beta path instead of CommandLineTools.
|
||||||
|
wrapper_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'build-system', 'xcode-wrapper')
|
||||||
|
clean_env['PATH'] = wrapper_dir + ':/usr/bin:/bin:/usr/sbin:/sbin'
|
||||||
return clean_env
|
return clean_env
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -533,13 +533,10 @@ def resolve_configuration(base_path, bazel_command_line: BazelCommandLine, argum
|
||||||
)
|
)
|
||||||
aps_environment = codesigning_data.aps_environment
|
aps_environment = codesigning_data.aps_environment
|
||||||
if aps_environment is None:
|
if aps_environment is None:
|
||||||
# WinterGram: for unsigned sideload builds (provisioning disabled), the fake profiles
|
# Unsigned builds use a placeholder aps-environment instead of failing.
|
||||||
# don't match the custom bundle id, so no aps-environment is found. Push won't work
|
if getattr(arguments, 'disableProvisioningProfiles', False) or getattr(arguments, 'disableExtensions', False):
|
||||||
# until AltStore / SideStore / LiveContainer re-signs with the user's profile, so we
|
print('No aps-environment found; disabling APNs entitlement for unsigned sideload build.')
|
||||||
# fall back to a placeholder instead of failing the build.
|
aps_environment = ''
|
||||||
if getattr(arguments, 'disableProvisioningProfiles', False):
|
|
||||||
print('No aps-environment found; defaulting to "development" for unsigned sideload build.')
|
|
||||||
aps_environment = 'development'
|
|
||||||
else:
|
else:
|
||||||
print('Could not find a valid aps-environment entitlement in the provided provisioning profiles')
|
print('Could not find a valid aps-environment entitlement in the provided provisioning profiles')
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
@ -636,7 +633,7 @@ def resolve_watch_provisioning_profile(arguments, base_path):
|
||||||
|
|
||||||
Returns the absolute path of the profile to sign the embedded watch app with, or None
|
Returns the absolute path of the profile to sign the embedded watch app with, or None
|
||||||
to build the watch app UNSIGNED. None is only returned (with a warning) for
|
to build the watch app UNSIGNED. None is only returned (with a warning) for
|
||||||
non-distribution codesigning — a distribution build (appstore/adhoc/enterprise) raises
|
non-distribution codesigning. A distribution build (appstore/adhoc/enterprise) raises
|
||||||
instead, because the host ios_application does NOT re-sign the embedded watch app, so an
|
instead, because the host ios_application does NOT re-sign the embedded watch app, so an
|
||||||
unsigned Watch/ payload ships as-is and is silently rejected at install time. Failing
|
unsigned Watch/ payload ships as-is and is silently rejected at install time. Failing
|
||||||
here turns that silent "won't install" into a build error that names the missing profile.
|
here turns that silent "won't install" into a build error that names the missing profile.
|
||||||
|
|
@ -664,11 +661,11 @@ def resolve_watch_provisioning_profile(arguments, base_path):
|
||||||
'--embedWatchApp is set for a distribution build (--gitCodesigningType={ct}), but no watchkitapp '
|
'--embedWatchApp is set for a distribution build (--gitCodesigningType={ct}), but no watchkitapp '
|
||||||
'provisioning profile resolved (looked for {p}).\n'
|
'provisioning profile resolved (looked for {p}).\n'
|
||||||
'The {ct} codesigning material does not contain a `.watchkitapp` profile, so the embedded watch app '
|
'The {ct} codesigning material does not contain a `.watchkitapp` profile, so the embedded watch app '
|
||||||
'would be UNSIGNED — the host app is not re-signed over it, so it ships unsigned and is silently '
|
'would be UNSIGNED. The host app is not re-signed over it, so it ships unsigned and is silently '
|
||||||
'rejected when installing on a watch.\n'
|
'rejected when installing on a watch.\n'
|
||||||
'Fix: fetch the latest codesigning material so the watchkitapp profile is present — drop '
|
'Fix: fetch the latest codesigning material so the watchkitapp profile is present. Drop '
|
||||||
'--gitCodesigningUseCurrent (it skips the fetch), or run '
|
'--gitCodesigningUseCurrent (it skips the fetch), or run '
|
||||||
'`git -C build-input/configuration-repository-workdir/encrypted pull` — or create/register the {ct} '
|
'`git -C build-input/configuration-repository-workdir/encrypted pull`, or create/register the {ct} '
|
||||||
'watchkitapp provisioning profile, or pass an explicit --watchProvisioningProfile.'.format(
|
'watchkitapp provisioning profile, or pass an explicit --watchProvisioningProfile.'.format(
|
||||||
ct=arguments.gitCodesigningType, p=resolved_watch_profile
|
ct=arguments.gitCodesigningType, p=resolved_watch_profile
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,10 @@ def generate_xcodeproj(build_environment: BuildEnvironment, disable_extensions,
|
||||||
|
|
||||||
call_executable(bazel_generate_arguments)
|
call_executable(bazel_generate_arguments)
|
||||||
|
|
||||||
xcodeproj_path = '{}.xcodeproj'.format(app_target_spec.replace(':', '/'))
|
if target_name == 'Telegram':
|
||||||
|
xcodeproj_path = 'Telegram/WinterGram.xcodeproj'
|
||||||
|
else:
|
||||||
|
xcodeproj_path = '{}.xcodeproj'.format(app_target_spec.replace(':', '/'))
|
||||||
return xcodeproj_path
|
return xcodeproj_path
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -207,8 +207,8 @@ enum CodeGenerator {
|
||||||
let structName = "\(apiPrefix)\(layerNumber)"
|
let structName = "\(apiPrefix)\(layerNumber)"
|
||||||
let filename = "\(apiPrefix)Layer\(layerNumber).swift"
|
let filename = "\(apiPrefix)Layer\(layerNumber).swift"
|
||||||
|
|
||||||
// All nested type refs (inside the struct) use `structName` as their prefix —
|
// Nested type refs use `structName` as their prefix.
|
||||||
// `apiPrefix` is used only to compute `structName` and `filename`. Helpers like
|
// `apiPrefix` only computes `structName` and `filename`. Helpers like
|
||||||
// typeReferenceRepresentation and generateFieldParsing get `structName`, not
|
// typeReferenceRepresentation and generateFieldParsing get `structName`, not
|
||||||
// `apiPrefix`, so e.g. fields render as `media: SecretApi8.DecryptedMessageMedia`.
|
// `apiPrefix`, so e.g. fields render as `media: SecretApi8.DecryptedMessageMedia`.
|
||||||
|
|
||||||
|
|
|
||||||
9
build-system/xcode-wrapper/xcode-select
Executable 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 "$@"
|
||||||
|
|
@ -23,7 +23,7 @@ from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Controller — must stay byte-for-byte equivalent to the Swift implementation.
|
# Keep the controller byte-for-byte equivalent to the Swift implementation.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
CONTROLLER_NAME = "v1" # set by --algo flag in main()
|
CONTROLLER_NAME = "v1" # set by --algo flag in main()
|
||||||
|
|
@ -130,7 +130,7 @@ class TextRevealControllerV2:
|
||||||
FRAME_DT_CAP = 0.05
|
FRAME_DT_CAP = 0.05
|
||||||
INITIAL_INPUT_RATE = 40.0 # fallback velocity for the first chunk
|
INITIAL_INPUT_RATE = 40.0 # fallback velocity for the first chunk
|
||||||
# When predicted_next_arrival has passed (stream stalled), don't speed up
|
# When predicted_next_arrival has passed (stream stalled), don't speed up
|
||||||
# further — clamp time_to_next at this minimum.
|
# Clamp time_to_next at this minimum.
|
||||||
STALL_FLOOR = 0.10
|
STALL_FLOOR = 0.10
|
||||||
|
|
||||||
def __init__(self, initial_revealed_count: int, initial_length: int) -> None:
|
def __init__(self, initial_revealed_count: int, initial_length: int) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,9 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# WinterGram build wrapper — produces WinterGram-named IPAs for every install target.
|
# WinterGram build wrapper.
|
||||||
# Lives in scripts/; all paths are resolved relative to the repo root (the script cd's there).
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./scripts/build-wintergram.sh
|
|
||||||
# ./scripts/build-wintergram.sh all
|
|
||||||
# ./scripts/build-wintergram.sh sideload
|
|
||||||
# ./scripts/build-wintergram.sh livecontainer
|
|
||||||
# ./scripts/build-wintergram.sh sim
|
|
||||||
#
|
|
||||||
# Convenience:
|
|
||||||
# ./scripts/build-wintergram.sh --install # build the simulator IPA and install it into the active Simulator (sim mode only)
|
|
||||||
# ./scripts/build-wintergram.sh --install --run # also launch the app in the active Simulator
|
|
||||||
# ./scripts/build-wintergram.sh --clean # remove ./build before building
|
|
||||||
# ./scripts/build-wintergram.sh --open-build-dir # open ./build in Finder after build
|
|
||||||
# ./scripts/build-wintergram.sh --help
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Resolve to the repo root regardless of where the script is invoked from (it lives in scripts/).
|
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
REPO="$(pwd)"
|
REPO="$(pwd)"
|
||||||
source ~/.zshrc 2>/dev/null || true
|
|
||||||
|
|
||||||
OUT_DIR="build"
|
OUT_DIR="build"
|
||||||
SIDELOAD_NAME="WinterGram.ipa"
|
SIDELOAD_NAME="WinterGram.ipa"
|
||||||
|
|
@ -29,12 +12,19 @@ SIM_NAME="WinterGram-Simulator.ipa"
|
||||||
WNT_BUNDLE_ID="dev.reekeer.wintergram"
|
WNT_BUNDLE_ID="dev.reekeer.wintergram"
|
||||||
BAZEL="./build-input/bazel-8.4.2-darwin-arm64"
|
BAZEL="./build-input/bazel-8.4.2-darwin-arm64"
|
||||||
DEVICE_SRC="bazel-bin/Telegram/Telegram.ipa"
|
DEVICE_SRC="bazel-bin/Telegram/Telegram.ipa"
|
||||||
|
_XCODE_DEV_DIR="${DEVELOPER_DIR:-$(xcode-select -p 2>/dev/null)}"
|
||||||
|
BAZEL_XCODE_ACTION_ENV="--action_env=DEVELOPER_DIR=${_XCODE_DEV_DIR} --host_action_env=DEVELOPER_DIR=${_XCODE_DEV_DIR}"
|
||||||
|
# Xcode 27 compatibility
|
||||||
|
BAZEL_SDK_COMPAT_ARGS="--features=-treat_warnings_as_errors --@build_bazel_rules_swift//swift:copt=-no-warnings-as-errors --copt=-Wno-deprecated-declarations"
|
||||||
|
|
||||||
MODE="all"
|
MODE="all"
|
||||||
|
INSTALL_REQUESTED=0
|
||||||
|
INSTALL_DEVICE=0
|
||||||
INSTALL_SIM=0
|
INSTALL_SIM=0
|
||||||
RUN_SIM=0
|
RUN_APP=0
|
||||||
CLEAN=0
|
CLEAN=0
|
||||||
OPEN_BUILD_DIR=0
|
OPEN_BUILD_DIR=0
|
||||||
|
DEVICE_SELECTOR=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
@ -45,19 +35,25 @@ Usage:
|
||||||
|
|
||||||
Modes:
|
Modes:
|
||||||
all Build all deliverables. Default.
|
all Build all deliverables. Default.
|
||||||
sideload, device Build device sideload IPA -> build/$SIDELOAD_NAME
|
sideload, device,
|
||||||
|
ios Build device sideload IPA -> build/$SIDELOAD_NAME
|
||||||
livecontainer, lc Build unsigned LiveContainer IPA -> build/$LC_NAME
|
livecontainer, lc Build unsigned LiveContainer IPA -> build/$LC_NAME
|
||||||
sim, simulator Build simulator IPA -> build/$SIM_NAME
|
sim, simulator Build simulator IPA -> build/$SIM_NAME
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--install Build the simulator IPA and install it into the active booted Simulator.
|
--install Install after build. With ios/device/sideload mode, installs on a connected
|
||||||
This forces "sim" mode (install only makes sense for the simulator).
|
iPhone/iPad via xcrun devicectl. With sim mode, installs into the active
|
||||||
|
booted Simulator. With no explicit mode, keeps the old simulator shortcut.
|
||||||
|
--device <id|name> Device selector for devicectl. Optional if exactly one iPhone/iPad is connected.
|
||||||
--run Launch the app after --install (implies --install).
|
--run Launch the app after --install (implies --install).
|
||||||
--clean Remove ./build before building.
|
--clean Remove ./build before building.
|
||||||
--open-build-dir Open ./build in Finder after build.
|
--open-build-dir Open ./build in Finder after build.
|
||||||
-h, --help Show this help.
|
-h, --help Show this help.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
$0 ios --install
|
||||||
|
$0 ios --install --run
|
||||||
|
$0 ios --install --device "Del's iPhone"
|
||||||
$0 --install
|
$0 --install
|
||||||
$0 --install --run
|
$0 --install --run
|
||||||
$0 sideload --clean
|
$0 sideload --clean
|
||||||
|
|
@ -83,16 +79,21 @@ MODE_WAS_EXPLICIT=0
|
||||||
|
|
||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
all|sideload|device|livecontainer|lc|sim|simulator)
|
all|sideload|device|ios|livecontainer|lc|sim|simulator)
|
||||||
MODE="$1"
|
MODE="$1"
|
||||||
MODE_WAS_EXPLICIT=1
|
MODE_WAS_EXPLICIT=1
|
||||||
;;
|
;;
|
||||||
--install)
|
--install)
|
||||||
INSTALL_SIM=1
|
INSTALL_REQUESTED=1
|
||||||
;;
|
;;
|
||||||
--run)
|
--run)
|
||||||
RUN_SIM=1
|
RUN_APP=1
|
||||||
INSTALL_SIM=1
|
INSTALL_REQUESTED=1
|
||||||
|
;;
|
||||||
|
--device)
|
||||||
|
shift
|
||||||
|
[ "$#" -gt 0 ] || die "--device requires a value"
|
||||||
|
DEVICE_SELECTOR="$1"
|
||||||
;;
|
;;
|
||||||
--clean)
|
--clean)
|
||||||
CLEAN=1
|
CLEAN=1
|
||||||
|
|
@ -111,13 +112,26 @@ while [ "$#" -gt 0 ]; do
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
|
||||||
# --install is simulator-only: installing a device/livecontainer IPA into a Simulator makes no
|
if [ "$INSTALL_REQUESTED" -eq 1 ]; then
|
||||||
# sense, so --install always forces "sim" mode (warning if a conflicting mode was given).
|
case "$MODE" in
|
||||||
if [ "$INSTALL_SIM" -eq 1 ]; then
|
sim|simulator)
|
||||||
if [ "$MODE_WAS_EXPLICIT" -eq 1 ] && [ "$MODE" != "sim" ] && [ "$MODE" != "simulator" ]; then
|
INSTALL_SIM=1
|
||||||
echo "==> --install is simulator-only; ignoring mode '$MODE' and building 'sim'." >&2
|
;;
|
||||||
fi
|
sideload|device|ios)
|
||||||
MODE="sim"
|
INSTALL_DEVICE=1
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
if [ "$MODE_WAS_EXPLICIT" -eq 1 ]; then
|
||||||
|
die "--install with 'all' is ambiguous. Use 'ios --install' for a device or 'sim --install' for Simulator."
|
||||||
|
fi
|
||||||
|
|
||||||
|
MODE="sim"
|
||||||
|
INSTALL_SIM=1
|
||||||
|
;;
|
||||||
|
livecontainer|lc)
|
||||||
|
die "--install does not support LiveContainer IPA. Use 'ios --install' for direct device install."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$CLEAN" -eq 1 ]; then
|
if [ "$CLEAN" -eq 1 ]; then
|
||||||
|
|
@ -135,8 +149,10 @@ build_sim() {
|
||||||
--cacheDir "$HOME/telegram-bazel-cache" \
|
--cacheDir "$HOME/telegram-bazel-cache" \
|
||||||
build \
|
build \
|
||||||
--configurationPath build-system/wintergram-development-configuration.json \
|
--configurationPath build-system/wintergram-development-configuration.json \
|
||||||
--codesigningInformationPath build-system/fake-codesigning-wintergram \
|
--codesigningInformationPath build-system/fake-codesigning \
|
||||||
|
--disableProvisioningProfiles \
|
||||||
--disableExtensions \
|
--disableExtensions \
|
||||||
|
--bazelArguments="$BAZEL_XCODE_ACTION_ENV $BAZEL_SDK_COMPAT_ARGS" \
|
||||||
--buildNumber=1 --configuration=debug_sim_arm64
|
--buildNumber=1 --configuration=debug_sim_arm64
|
||||||
|
|
||||||
[ -f "$DEVICE_SRC" ] || die "simulator artifact not found at $DEVICE_SRC"
|
[ -f "$DEVICE_SRC" ] || die "simulator artifact not found at $DEVICE_SRC"
|
||||||
|
|
@ -181,7 +197,7 @@ install_sim() {
|
||||||
|
|
||||||
echo "==> [Simulator] installed: $(basename "$APP_PATH")"
|
echo "==> [Simulator] installed: $(basename "$APP_PATH")"
|
||||||
|
|
||||||
if [ "$RUN_SIM" -eq 1 ]; then
|
if [ "$RUN_APP" -eq 1 ]; then
|
||||||
[ -n "$INSTALLED_BUNDLE_ID" ] || {
|
[ -n "$INSTALLED_BUNDLE_ID" ] || {
|
||||||
rm -rf "$TMP_DIR"
|
rm -rf "$TMP_DIR"
|
||||||
die "could not read CFBundleIdentifier from app Info.plist"
|
die "could not read CFBundleIdentifier from app Info.plist"
|
||||||
|
|
@ -194,6 +210,135 @@ install_sim() {
|
||||||
rm -rf "$TMP_DIR"
|
rm -rf "$TMP_DIR"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select_ios_device() {
|
||||||
|
require_cmd xcrun
|
||||||
|
|
||||||
|
if [ -n "$DEVICE_SELECTOR" ]; then
|
||||||
|
echo "$DEVICE_SELECTOR"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local JSON_PATH
|
||||||
|
JSON_PATH="$(mktemp)"
|
||||||
|
|
||||||
|
if ! xcrun devicectl list devices --timeout 20 --json-output "$JSON_PATH" >/dev/null; then
|
||||||
|
rm -f "$JSON_PATH"
|
||||||
|
die "could not list connected devices via xcrun devicectl. Unlock the phone, trust this Mac, and try again."
|
||||||
|
fi
|
||||||
|
|
||||||
|
local SELECTED
|
||||||
|
if ! SELECTED="$(python3 - "$JSON_PATH" 2>&1 <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = sys.argv[1]
|
||||||
|
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
devices = data.get("result", {}).get("devices", [])
|
||||||
|
|
||||||
|
def get(obj, dotted, default=""):
|
||||||
|
current = obj
|
||||||
|
for part in dotted.split("."):
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
return default
|
||||||
|
current = current.get(part)
|
||||||
|
return current if current is not None else default
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
for device in devices:
|
||||||
|
platform = str(get(device, "hardwareProperties.platform")).lower()
|
||||||
|
if platform not in ("ios", "ipados"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
identifier = (
|
||||||
|
device.get("identifier")
|
||||||
|
or get(device, "hardwareProperties.udid")
|
||||||
|
or get(device, "hardwareProperties.serialNumber")
|
||||||
|
)
|
||||||
|
if not identifier:
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = (
|
||||||
|
get(device, "deviceProperties.name")
|
||||||
|
or device.get("name")
|
||||||
|
or get(device, "hardwareProperties.marketingName")
|
||||||
|
or identifier
|
||||||
|
)
|
||||||
|
transport = get(device, "connectionProperties.transportType")
|
||||||
|
pair_state = get(device, "connectionProperties.pairingState")
|
||||||
|
candidates.append((str(identifier), str(name), str(transport), str(pair_state)))
|
||||||
|
|
||||||
|
if len(candidates) == 1:
|
||||||
|
print(candidates[0][0])
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
print("no connected iPhone/iPad found by devicectl", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print("multiple iPhone/iPad devices found; pass --device with one of these:", file=sys.stderr)
|
||||||
|
for identifier, name, transport, pair_state in candidates:
|
||||||
|
details = ", ".join(part for part in (transport, pair_state) if part)
|
||||||
|
suffix = f" ({details})" if details else ""
|
||||||
|
print(f" {identifier} {name}{suffix}", file=sys.stderr)
|
||||||
|
|
||||||
|
sys.exit(2)
|
||||||
|
PY
|
||||||
|
)"; then
|
||||||
|
rm -f "$JSON_PATH"
|
||||||
|
die "$SELECTED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$JSON_PATH"
|
||||||
|
echo "$SELECTED"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_device() {
|
||||||
|
require_cmd xcrun
|
||||||
|
|
||||||
|
local IPA="$OUT_DIR/$SIDELOAD_NAME"
|
||||||
|
[ -f "$IPA" ] || die "device IPA not found at $IPA"
|
||||||
|
|
||||||
|
echo "==> [Device] preparing app for install ..."
|
||||||
|
|
||||||
|
local TMP_DIR
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
|
||||||
|
unzip -q "$IPA" -d "$TMP_DIR"
|
||||||
|
|
||||||
|
local APP_PATH
|
||||||
|
APP_PATH="$(find "$TMP_DIR/Payload" -maxdepth 1 -type d -name "*.app" | head -n 1)"
|
||||||
|
|
||||||
|
[ -n "${APP_PATH:-}" ] || {
|
||||||
|
rm -rf "$TMP_DIR"
|
||||||
|
die "no .app found inside $IPA"
|
||||||
|
}
|
||||||
|
|
||||||
|
local INSTALLED_BUNDLE_ID
|
||||||
|
INSTALLED_BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$APP_PATH/Info.plist" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
local DEVICE
|
||||||
|
DEVICE="$(select_ios_device)"
|
||||||
|
|
||||||
|
echo "==> [Device] installing $(basename "$APP_PATH") on $DEVICE ..."
|
||||||
|
xcrun devicectl device install app --device "$DEVICE" "$APP_PATH"
|
||||||
|
|
||||||
|
echo "==> [Device] installed: $(basename "$APP_PATH")"
|
||||||
|
|
||||||
|
if [ "$RUN_APP" -eq 1 ]; then
|
||||||
|
[ -n "$INSTALLED_BUNDLE_ID" ] || {
|
||||||
|
rm -rf "$TMP_DIR"
|
||||||
|
die "could not read CFBundleIdentifier from app Info.plist"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> [Device] launching $INSTALLED_BUNDLE_ID on $DEVICE ..."
|
||||||
|
xcrun devicectl device process launch --device "$DEVICE" --terminate-existing "$INSTALLED_BUNDLE_ID" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$TMP_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
# --- device build shared by sideload + livecontainer -----------------------
|
# --- device build shared by sideload + livecontainer -----------------------
|
||||||
|
|
||||||
ensure_cert() {
|
ensure_cert() {
|
||||||
|
|
@ -207,13 +352,17 @@ ensure_cert() {
|
||||||
build_device() {
|
build_device() {
|
||||||
ensure_cert
|
ensure_cert
|
||||||
|
|
||||||
|
echo "==> [Device] generating fake provisioning profiles for ${WNT_BUNDLE_ID} ..."
|
||||||
|
python3 scripts/generate-fake-profiles.py build-system/fake-codesigning-generated
|
||||||
|
|
||||||
echo "==> [Device] build (debug_arm64, ${WNT_BUNDLE_ID}, extensions disabled) ..."
|
echo "==> [Device] build (debug_arm64, ${WNT_BUNDLE_ID}, extensions disabled) ..."
|
||||||
python3 build-system/Make/Make.py --overrideXcodeVersion \
|
python3 build-system/Make/Make.py --overrideXcodeVersion \
|
||||||
--cacheDir "$HOME/telegram-bazel-cache" \
|
--cacheDir "$HOME/telegram-bazel-cache" \
|
||||||
build \
|
build \
|
||||||
--configurationPath build-system/wintergram-development-configuration.json \
|
--configurationPath build-system/wintergram-development-configuration.json \
|
||||||
--codesigningInformationPath build-system/fake-codesigning-wintergram \
|
--codesigningInformationPath build-system/fake-codesigning-generated \
|
||||||
--disableExtensions \
|
--disableExtensions \
|
||||||
|
--bazelArguments="$BAZEL_XCODE_ACTION_ENV $BAZEL_SDK_COMPAT_ARGS" \
|
||||||
--buildNumber=1 --configuration=debug_arm64
|
--buildNumber=1 --configuration=debug_arm64
|
||||||
|
|
||||||
[ -f "$DEVICE_SRC" ] || die "device artifact not found at $DEVICE_SRC"
|
[ -f "$DEVICE_SRC" ] || die "device artifact not found at $DEVICE_SRC"
|
||||||
|
|
@ -265,7 +414,7 @@ case "$MODE" in
|
||||||
sim|simulator)
|
sim|simulator)
|
||||||
build_sim
|
build_sim
|
||||||
;;
|
;;
|
||||||
sideload|device)
|
sideload|device|ios)
|
||||||
build_device
|
build_device
|
||||||
make_sideload
|
make_sideload
|
||||||
;;
|
;;
|
||||||
|
|
@ -274,8 +423,7 @@ case "$MODE" in
|
||||||
make_livecontainer
|
make_livecontainer
|
||||||
;;
|
;;
|
||||||
all)
|
all)
|
||||||
# One device build feeds BOTH device deliverables; derive them before the sim build
|
# Derive device deliverables before the simulator build overwrites the artifact.
|
||||||
# overwrites bazel-bin/Telegram/Telegram.ipa with the simulator artifact.
|
|
||||||
build_device
|
build_device
|
||||||
make_sideload
|
make_sideload
|
||||||
make_livecontainer
|
make_livecontainer
|
||||||
|
|
@ -287,10 +435,13 @@ case "$MODE" in
|
||||||
esac
|
esac
|
||||||
|
|
||||||
if [ "$INSTALL_SIM" -eq 1 ]; then
|
if [ "$INSTALL_SIM" -eq 1 ]; then
|
||||||
# --install forced MODE=sim above, so the simulator IPA was just built — install it.
|
|
||||||
install_sim
|
install_sim
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$INSTALL_DEVICE" -eq 1 ]; then
|
||||||
|
install_device
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "==> WinterGram deliverables in ./$OUT_DIR/:"
|
echo "==> WinterGram deliverables in ./$OUT_DIR/:"
|
||||||
ls -1 "$OUT_DIR"/WinterGram*.ipa 2>/dev/null | sed 's#^# #' || true
|
ls -1 "$OUT_DIR"/WinterGram*.ipa 2>/dev/null | sed 's#^# #' || true
|
||||||
|
|
|
||||||
181
scripts/generate-fake-profiles.py
Normal 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()
|
||||||
98
scripts/generate-wintergram-xcodeproj.sh
Executable 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
|
|
@ -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())
|
||||||
|
|
@ -31,6 +31,7 @@ swift_library(
|
||||||
"//submodules/SolidRoundedButtonNode:SolidRoundedButtonNode",
|
"//submodules/SolidRoundedButtonNode:SolidRoundedButtonNode",
|
||||||
"//submodules/ImageCompression:ImageCompression",
|
"//submodules/ImageCompression:ImageCompression",
|
||||||
"//submodules/RMIntro:RMIntro",
|
"//submodules/RMIntro:RMIntro",
|
||||||
|
"//submodules/AppBundle:AppBundle",
|
||||||
"//submodules/QrCode:QrCode",
|
"//submodules/QrCode:QrCode",
|
||||||
"//submodules/PhoneInputNode:PhoneInputNode",
|
"//submodules/PhoneInputNode:PhoneInputNode",
|
||||||
"//submodules/CodeInputView:CodeInputView",
|
"//submodules/CodeInputView:CodeInputView",
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,8 @@ public final class AuthorizationSequencePhoneEntryController: ViewController, MF
|
||||||
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(self.cancelPressed))
|
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(self.cancelPressed))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let countriesConfiguration {
|
if let countriesConfiguration, !countriesConfiguration.countries.isEmpty {
|
||||||
|
// Keep the bundled fallback when the server config is empty.
|
||||||
AuthorizationSequenceCountrySelectionController.setupCountryCodes(countries: countriesConfiguration.countries, codesByPrefix: countriesConfiguration.countriesByPrefix)
|
AuthorizationSequenceCountrySelectionController.setupCountryCodes(countries: countriesConfiguration.countries, codesByPrefix: countriesConfiguration.countriesByPrefix)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import TelegramPresentationData
|
||||||
import LegacyComponents
|
import LegacyComponents
|
||||||
import SolidRoundedButtonNode
|
import SolidRoundedButtonNode
|
||||||
import RMIntro
|
import RMIntro
|
||||||
|
import AppBundle
|
||||||
|
|
||||||
public final class AuthorizationSequenceSplashController: ViewController {
|
public final class AuthorizationSequenceSplashController: ViewController {
|
||||||
private var controllerNode: AuthorizationSequenceSplashControllerNode {
|
private var controllerNode: AuthorizationSequenceSplashControllerNode {
|
||||||
|
|
@ -79,6 +80,10 @@ public final class AuthorizationSequenceSplashController: ViewController {
|
||||||
|
|
||||||
self._hasGlassStyle = true
|
self._hasGlassStyle = true
|
||||||
|
|
||||||
|
// Brand icon for the first intro page, matching the theme.
|
||||||
|
let iconName = theme.overallDarkAppearance ? "IntroLogoDark" : "IntroLogoLight"
|
||||||
|
self.controller.firstPageIcon = UIImage(bundleImageName: iconName)
|
||||||
|
|
||||||
self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait)
|
self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait)
|
||||||
|
|
||||||
self.statusBar.statusBarStyle = theme.intro.statusBarStyle.style
|
self.statusBar.statusBarStyle = theme.intro.statusBarStyle.style
|
||||||
|
|
|
||||||
|
|
@ -1173,7 +1173,7 @@ public func richMarkdownAttributeIfNeeded(context: AccountContext, attributedTex
|
||||||
// .textCustomEmoji in markdownInlineContent. A link is entity-expressible,
|
// .textCustomEmoji in markdownInlineContent. A link is entity-expressible,
|
||||||
// so an emoji-only message still classifies as not-rich (and falls through
|
// so an emoji-only message still classifies as not-rich (and falls through
|
||||||
// to the entity path, where its untouched attribute makes a .CustomEmoji
|
// to the entity path, where its untouched attribute makes a .CustomEmoji
|
||||||
// entity) — custom emoji alone never forces a rich message.
|
// entity). Custom emoji alone never forces a rich message.
|
||||||
let text = markdownSourceInjectingCustomEmojiMarkers(attributedText)
|
let text = markdownSourceInjectingCustomEmojiMarkers(attributedText)
|
||||||
guard markdownMightNeedRichLayout(text) else {
|
guard markdownMightNeedRichLayout(text) else {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ swift_library(
|
||||||
"//submodules/TelegramCore:TelegramCore",
|
"//submodules/TelegramCore:TelegramCore",
|
||||||
"//submodules/TelegramPresentationData:TelegramPresentationData",
|
"//submodules/TelegramPresentationData:TelegramPresentationData",
|
||||||
"//submodules/TelegramStringFormatting:TelegramStringFormatting",
|
"//submodules/TelegramStringFormatting:TelegramStringFormatting",
|
||||||
|
"//submodules/libphonenumber:libphonenumber",
|
||||||
"//submodules/SearchBarNode:SearchBarNode",
|
"//submodules/SearchBarNode:SearchBarNode",
|
||||||
"//submodules/AppBundle:AppBundle",
|
"//submodules/AppBundle:AppBundle",
|
||||||
"//submodules/ComponentFlow",
|
"//submodules/ComponentFlow",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import TelegramCore
|
||||||
import ComponentFlow
|
import ComponentFlow
|
||||||
import BundleIconComponent
|
import BundleIconComponent
|
||||||
import GlassBarButtonComponent
|
import GlassBarButtonComponent
|
||||||
|
import libphonenumber
|
||||||
|
|
||||||
|
private let countryPhoneNumberUtil = NBPhoneNumberUtil()
|
||||||
|
|
||||||
private func loadCountryCodes() -> [Country] {
|
private func loadCountryCodes() -> [Country] {
|
||||||
guard let filePath = getAppBundle().path(forResource: "PhoneCountries", ofType: "txt") else {
|
guard let filePath = getAppBundle().path(forResource: "PhoneCountries", ofType: "txt") else {
|
||||||
|
|
@ -56,10 +59,38 @@ private func loadCountryCodes() -> [Country] {
|
||||||
|
|
||||||
let countryName = locale.localizedString(forIdentifier: countryId) ?? ""
|
let countryName = locale.localizedString(forIdentifier: countryId) ?? ""
|
||||||
if let _ = Int(countryCode) {
|
if let _ = Int(countryCode) {
|
||||||
let code = Country.CountryCode(code: countryCode, prefixes: [], patterns: !pattern.isEmpty ? [pattern] : [])
|
let prefixes: [String]
|
||||||
|
if countryCode == "7" {
|
||||||
|
switch countryId {
|
||||||
|
case "RU":
|
||||||
|
prefixes = ["3", "4", "8", "9"]
|
||||||
|
case "KZ":
|
||||||
|
prefixes = ["6", "7"]
|
||||||
|
default:
|
||||||
|
prefixes = []
|
||||||
|
}
|
||||||
|
} else if countryCode == "599" {
|
||||||
|
switch countryId {
|
||||||
|
case "BQ":
|
||||||
|
prefixes = ["3", "4", "7"]
|
||||||
|
case "CW":
|
||||||
|
prefixes = ["1", "6", "9"]
|
||||||
|
default:
|
||||||
|
prefixes = []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
prefixes = []
|
||||||
|
}
|
||||||
|
let code = Country.CountryCode(code: countryCode, prefixes: prefixes, patterns: !pattern.isEmpty ? [pattern] : [])
|
||||||
let country = Country(id: countryId, name: countryName, localizedName: nil, countryCodes: [code], hidden: false)
|
let country = Country(id: countryId, name: countryName, localizedName: nil, countryCodes: [code], hidden: false)
|
||||||
result.append(country)
|
result.append(country)
|
||||||
countriesByPrefix["\(code.code)"] = (country, code)
|
if !prefixes.isEmpty {
|
||||||
|
for prefix in prefixes {
|
||||||
|
countriesByPrefix["\(code.code)\(prefix)"] = (country, code)
|
||||||
|
}
|
||||||
|
} else if countriesByPrefix[code.code] == nil {
|
||||||
|
countriesByPrefix[code.code] = (country, code)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let maybeNameRange = maybeNameRange {
|
if let maybeNameRange = maybeNameRange {
|
||||||
|
|
@ -74,27 +105,35 @@ private func loadCountryCodes() -> [Country] {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private var countryCodes: [Country] = loadCountryCodes()
|
|
||||||
private var countryCodesByPrefix: [String: (Country, Country.CountryCode)] = [:]
|
private var countryCodesByPrefix: [String: (Country, Country.CountryCode)] = [:]
|
||||||
|
private var countryCodes: [Country] = loadCountryCodes()
|
||||||
|
|
||||||
|
private func updateCountryCodes(_ countries: [Country]) {
|
||||||
|
guard !countries.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
countryCodes = countries
|
||||||
|
|
||||||
|
var updatedCodesByPrefix: [String: (Country, Country.CountryCode)] = [:]
|
||||||
|
for country in countries {
|
||||||
|
for code in country.countryCodes {
|
||||||
|
if !code.prefixes.isEmpty {
|
||||||
|
for prefix in code.prefixes {
|
||||||
|
updatedCodesByPrefix["\(code.code)\(prefix)"] = (country, code)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updatedCodesByPrefix[code.code] = (country, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
countryCodesByPrefix = updatedCodesByPrefix
|
||||||
|
}
|
||||||
|
|
||||||
public func loadServerCountryCodes(accountManager: AccountManager<TelegramAccountManagerTypes>, engine: TelegramEngineUnauthorized, completion: @escaping () -> Void) {
|
public func loadServerCountryCodes(accountManager: AccountManager<TelegramAccountManagerTypes>, engine: TelegramEngineUnauthorized, completion: @escaping () -> Void) {
|
||||||
let _ = (engine.localization.getCountriesList(accountManager: accountManager, langCode: nil)
|
let _ = (engine.localization.getCountriesList(accountManager: accountManager, langCode: nil)
|
||||||
|> deliverOnMainQueue).start(next: { countries in
|
|> deliverOnMainQueue).start(next: { countries in
|
||||||
countryCodes = countries
|
updateCountryCodes(countries)
|
||||||
|
|
||||||
var countriesByPrefix: [String: (Country, Country.CountryCode)] = [:]
|
|
||||||
for country in countries {
|
|
||||||
for code in country.countryCodes {
|
|
||||||
if !code.prefixes.isEmpty {
|
|
||||||
for prefix in code.prefixes {
|
|
||||||
countriesByPrefix["\(code.code)\(prefix)"] = (country, code)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
countriesByPrefix[code.code] = (country, code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
countryCodesByPrefix = countriesByPrefix
|
|
||||||
|
|
||||||
Queue.mainQueue().async {
|
Queue.mainQueue().async {
|
||||||
completion()
|
completion()
|
||||||
|
|
@ -105,21 +144,8 @@ public func loadServerCountryCodes(accountManager: AccountManager<TelegramAccoun
|
||||||
public func loadServerCountryCodes(accountManager: AccountManager<TelegramAccountManagerTypes>, engine: TelegramEngine, completion: @escaping () -> Void) {
|
public func loadServerCountryCodes(accountManager: AccountManager<TelegramAccountManagerTypes>, engine: TelegramEngine, completion: @escaping () -> Void) {
|
||||||
let _ = (engine.localization.getCountriesList(accountManager: accountManager, langCode: nil)
|
let _ = (engine.localization.getCountriesList(accountManager: accountManager, langCode: nil)
|
||||||
|> deliverOnMainQueue).start(next: { countries in
|
|> deliverOnMainQueue).start(next: { countries in
|
||||||
countryCodes = countries
|
updateCountryCodes(countries)
|
||||||
|
|
||||||
var countriesByPrefix: [String: (Country, Country.CountryCode)] = [:]
|
|
||||||
for country in countries {
|
|
||||||
for code in country.countryCodes {
|
|
||||||
if !code.prefixes.isEmpty {
|
|
||||||
for prefix in code.prefixes {
|
|
||||||
countriesByPrefix["\(code.code)\(prefix)"] = (country, code)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
countriesByPrefix[code.code] = (country, code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
countryCodesByPrefix = countriesByPrefix
|
|
||||||
Queue.mainQueue().async {
|
Queue.mainQueue().async {
|
||||||
completion()
|
completion()
|
||||||
}
|
}
|
||||||
|
|
@ -204,6 +230,9 @@ public final class AuthorizationSequenceCountrySelectionController: ViewControll
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func setupCountryCodes(countries: [Country], codesByPrefix: [String: (Country, Country.CountryCode)]) {
|
public static func setupCountryCodes(countries: [Country], codesByPrefix: [String: (Country, Country.CountryCode)]) {
|
||||||
|
guard !countries.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
countryCodes = countries
|
countryCodes = countries
|
||||||
countryCodesByPrefix = codesByPrefix
|
countryCodesByPrefix = codesByPrefix
|
||||||
}
|
}
|
||||||
|
|
@ -229,6 +258,7 @@ public final class AuthorizationSequenceCountrySelectionController: ViewControll
|
||||||
public static func lookupCountryIdByNumber(_ number: String, preferredCountries: [String: String]) -> (Country, Country.CountryCode)? {
|
public static func lookupCountryIdByNumber(_ number: String, preferredCountries: [String: String]) -> (Country, Country.CountryCode)? {
|
||||||
let number = removePlus(number)
|
let number = removePlus(number)
|
||||||
var results: [(Country, Country.CountryCode)]? = nil
|
var results: [(Country, Country.CountryCode)]? = nil
|
||||||
|
var matchedPrefixLength = 0
|
||||||
if number.count == 1, let preferredCountryId = preferredCountries[number], let country = lookupCountryById(preferredCountryId), let code = country.countryCodes.first {
|
if number.count == 1, let preferredCountryId = preferredCountries[number], let country = lookupCountryById(preferredCountryId), let code = country.countryCodes.first {
|
||||||
return (country, code)
|
return (country, code)
|
||||||
}
|
}
|
||||||
|
|
@ -244,10 +274,19 @@ public final class AuthorizationSequenceCountrySelectionController: ViewControll
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
results = [country]
|
results = [country]
|
||||||
|
matchedPrefixLength = prefix.count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let results = results {
|
if let results = results {
|
||||||
|
if let firstResult = results.first,
|
||||||
|
matchedPrefixLength <= firstResult.1.code.count,
|
||||||
|
let parsedNumber = try? countryPhoneNumberUtil.parse("+\(number)", defaultRegion: nil),
|
||||||
|
let regionCode = countryPhoneNumberUtil.getRegionCode(for: parsedNumber),
|
||||||
|
let matchedCountry = countryCodes.first(where: { $0.id == regionCode }),
|
||||||
|
let matchedCode = matchedCountry.countryCodes.first(where: { number.hasPrefix($0.code) }) {
|
||||||
|
return (matchedCountry, matchedCode)
|
||||||
|
}
|
||||||
if !preferredCountries.isEmpty, let (_, code) = results.first {
|
if !preferredCountries.isEmpty, let (_, code) = results.first {
|
||||||
if let preferredCountry = preferredCountries[code.code] {
|
if let preferredCountry = preferredCountries[code.code] {
|
||||||
for (country, code) in results {
|
for (country, code) in results {
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,8 @@ private func drawRectsImageContent(size: CGSize, context: CGContext, color: UICo
|
||||||
} else {
|
} else {
|
||||||
// The path-stitching loop below assumes consecutive rects in a group
|
// The path-stitching loop below assumes consecutive rects in a group
|
||||||
// are adjacent (overlapping or sharing an edge). When they're disjoint
|
// are adjacent (overlapping or sharing an edge). When they're disjoint
|
||||||
// — vertical gap, vertical inversion, or horizontal gap on the same
|
// with a vertical gap, vertical inversion, or horizontal gap on the same
|
||||||
// line — it bridges them with a polygon perimeter that renders as a
|
// line, it bridges them with a polygon perimeter that renders as a
|
||||||
// diagonal bridge across empty space. Split groups whenever the next
|
// diagonal bridge across empty space. Split groups whenever the next
|
||||||
// rect doesn't intersect the last one (1pt slop in both axes matches
|
// rect doesn't intersect the last one (1pt slop in both axes matches
|
||||||
// the snap loop's adjacency test).
|
// the snap loop's adjacency test).
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ import TelegramCore
|
||||||
/// Recurses the `InstantPage` block tree to locate the anchor `name`, returning the
|
/// Recurses the `InstantPage` block tree to locate the anchor `name`, returning the
|
||||||
/// details-sibling-ordinal path of enclosing `<details>` blocks (outermost first).
|
/// details-sibling-ordinal path of enclosing `<details>` blocks (outermost first).
|
||||||
///
|
///
|
||||||
/// - `nil` — the anchor exists nowhere.
|
/// - `nil`: the anchor does not exist.
|
||||||
/// - `[]` — the anchor exists outside any `<details>` (top level, a table cell, a quote, etc.).
|
/// - `[]`: the anchor is outside any `<details>`.
|
||||||
/// - `[2,0]`— inside the 3rd top-level `<details>`, then that details' 1st nested `<details>`.
|
/// - `[2, 0]`: the third top-level `<details>`, then its first nested `<details>`.
|
||||||
///
|
///
|
||||||
/// Ordinals (not layout indices) because the layout's details index counter is
|
/// Ordinals (not layout indices) because the layout's details index counter is
|
||||||
/// expansion-dependent. Consumers map ordinals to live indices via
|
/// expansion-dependent. Consumers map ordinals to live indices via
|
||||||
|
|
@ -71,23 +71,7 @@ private func instantPageAnchorPathSearch(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// .unsupported/.divider/.formula/.image/.video/.audio/.webEmbed/.channelBanner/.map —
|
// Keep recursion aligned with the V2 block layout.
|
||||||
// leaf/media blocks with no anchor-bearing text. (.relatedArticles also lands here: the
|
|
||||||
// V2 layout discards its title and lays out only the article media, so its title text is
|
|
||||||
// never rendered.)
|
|
||||||
//
|
|
||||||
// CRITICAL — the recursion set here must match the containers the V2 layout recurses
|
|
||||||
// through layoutBlock (and thus counts <details> in via detailsIndexCounter). Those are
|
|
||||||
// exactly .blockQuote, .cover, and .list's .blocks items — all handled above, sharing
|
|
||||||
// detailsOrdinal. The following carry [InstantPageBlock] children but are deliberately
|
|
||||||
// NOT recursed because the V2 layout does NOT lay their children out as blocks, so it
|
|
||||||
// never counts a nested <details> in them — recursing here while sharing detailsOrdinal
|
|
||||||
// would desync our ordinals from the layout:
|
|
||||||
// • .collage/.slideshow — layoutCollage/layoutSlideshow lay out only .image/.video children.
|
|
||||||
// • .postEmbed — layoutMediaWithCaption lays out only its caption (a real .text item,
|
|
||||||
// so a caption anchor is found by anchorFrame directly); its `blocks` are ignored.
|
|
||||||
// Any anchor inside a non-laid-out child is unresolvable by anchorFrame anyway, so
|
|
||||||
// skipping it here is a no-op either way.
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
|
||||||
// button. An ASControl's `.touchUpInside` is cancelled by the chat ListView's gesture
|
// button. An ASControl's `.touchUpInside` is cancelled by the chat ListView's gesture
|
||||||
// system (the control highlights on touch-down, but the action never fires), so an
|
// system (the control highlights on touch-down, but the action never fires), so an
|
||||||
// embedded audio control in a rich-message bubble could never start playback. A gesture
|
// embedded audio control in a rich-message bubble could never start playback. A gesture
|
||||||
// recognizer coordinates with the list's gestures and fires reliably — matching the V2
|
// recognizer coordinates with the list's gestures and fires reliably, matching the V2
|
||||||
// image node, the details-title hit view, and the regular file/music message. The plain
|
// image node, the details-title hit view, and the regular file/music message. The plain
|
||||||
// view sits above `statusNode` and is positioned over the icon in `layout()`. (Works in
|
// view sits above `statusNode` and is positioned over the icon in `layout()`. (Works in
|
||||||
// V1's full-page Instant View too; gesture recognizers fire inside its scroll view.)
|
// V1's full-page Instant View too; gesture recognizers fire inside its scroll view.)
|
||||||
|
|
@ -281,4 +281,3 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
|
||||||
self.scrubbingNode.frame = CGRect(origin: CGPoint(x: leftScrubberInset, y: 26.0 + topOffset), size: CGSize(width: size.width - leftScrubberInset - rightScrubberInset, height: 15.0))
|
self.scrubbingNode.frame = CGRect(origin: CGPoint(x: leftScrubberInset, y: 26.0 + topOffset), size: CGSize(width: size.width - leftScrubberInset - rightScrubberInset, height: 15.0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import LocationUI
|
||||||
/// - `.webpage(webPage)` cover → single-entry `InstantPageGalleryController`.
|
/// - `.webpage(webPage)` cover → single-entry `InstantPageGalleryController`.
|
||||||
/// - `.file(file)` with `isAnimated` → single-entry gallery in "playing video" mode.
|
/// - `.file(file)` with `isAnimated` → single-entry gallery in "playing video" mode.
|
||||||
/// - Default → multi-entry gallery built from `allMedias` (filtered to `.image` and non-audio
|
/// - Default → multi-entry gallery built from `allMedias` (filtered to `.image` and non-audio
|
||||||
/// `.file` — audio/music siblings are excluded), centered on the tapped media; "playing
|
/// `.file`; audio and music siblings are excluded), centered on the tapped media; "playing
|
||||||
/// video" mode is on.
|
/// video" mode is on.
|
||||||
///
|
///
|
||||||
/// Behavior matches V1's `InstantPageControllerNode.openMedia(_:)` bit-for-bit.
|
/// Behavior matches V1's `InstantPageControllerNode.openMedia(_:)` bit-for-bit.
|
||||||
|
|
@ -23,7 +23,7 @@ import LocationUI
|
||||||
/// - allMedias: every laid-out media on the page, in laid-out order. Used to build sibling
|
/// - allMedias: every laid-out media on the page, in laid-out order. Used to build sibling
|
||||||
/// entries when the gallery needs them. Callers may pass `[]` for paths that don't need
|
/// entries when the gallery needs them. Callers may pass `[]` for paths that don't need
|
||||||
/// siblings (e.g. webpage-cover single-entry gallery), but it's safer to always pass the
|
/// siblings (e.g. webpage-cover single-entry gallery), but it's safer to always pass the
|
||||||
/// full list — the helper filters/uses it only on the default branch.
|
/// full list. The helper filters it only on the default branch.
|
||||||
/// - transitionArgsForMedia: invoked by the gallery presentation to find the source rect for
|
/// - transitionArgsForMedia: invoked by the gallery presentation to find the source rect for
|
||||||
/// the swipe-back animation; return `nil` if the source view is not on screen.
|
/// the swipe-back animation; return `nil` if the source view is not on screen.
|
||||||
/// - hiddenMediaCallback: invoked while the gallery is foregrounded so callers can hide the
|
/// - hiddenMediaCallback: invoked while the gallery is foregrounded so callers can hide the
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol
|
||||||
// A blockquote is exploded by the layout into one text item per child line,
|
// A blockquote is exploded by the layout into one text item per child line,
|
||||||
// each stamped with quoteDepth > 0. Re-coalesce a run of consecutive quoted
|
// each stamped with quoteDepth > 0. Re-coalesce a run of consecutive quoted
|
||||||
// segments into a single block whose lines are joined by `\n` (each carrying
|
// segments into a single block whose lines are joined by `\n` (each carrying
|
||||||
// its own `> ` depth), matching the whole-message converter — otherwise every
|
// its own `> ` depth), matching the whole-message converter. Otherwise every
|
||||||
// quote line would become its own block and be separated by a blank line.
|
// quote line would become its own block and be separated by a blank line.
|
||||||
if depth > 0 {
|
if depth > 0 {
|
||||||
var lines: [String] = []
|
var lines: [String] = []
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import ShimmeringMask
|
||||||
|
|
||||||
/// Stable identity for an `InstantPageV2LaidOutItem` across `update()` calls. The renderer
|
/// Stable identity for an `InstantPageV2LaidOutItem` across `update()` calls. The renderer
|
||||||
/// uses this to harvest existing item views and reuse them when the new layout still has
|
/// uses this to harvest existing item views and reuse them when the new layout still has
|
||||||
/// an item with the same id — preventing the media wrappers from torching their fetch
|
/// an item with the same id, preserving media fetch
|
||||||
/// signals + image content on every chat-bubble re-apply.
|
/// signals + image content on every chat-bubble re-apply.
|
||||||
///
|
///
|
||||||
/// Media items use their `media.index` (already unique within a page and used as the
|
/// Media items use their `media.index` (already unique within a page and used as the
|
||||||
|
|
@ -42,7 +42,7 @@ public enum InstantPageV2ItemKind: Hashable {
|
||||||
// MARK: - Render context
|
// MARK: - Render context
|
||||||
|
|
||||||
/// Bundle of render-time dependencies required to display real media inside an InstantPage V2
|
/// Bundle of render-time dependencies required to display real media inside an InstantPage V2
|
||||||
/// view. Tied to an `InstantPageV2View` for the view's lifetime — if any field would change
|
/// view. Tied to an `InstantPageV2View` for the view's lifetime. If any field would change
|
||||||
/// (typically because the bubble was recycled with a different webpage), the caller must
|
/// (typically because the bubble was recycled with a different webpage), the caller must
|
||||||
/// rebuild the V2View with a fresh render context.
|
/// rebuild the V2View with a fresh render context.
|
||||||
///
|
///
|
||||||
|
|
@ -93,7 +93,7 @@ public final class InstantPageV2RenderContext {
|
||||||
/// streaming bubble to reuse one V2View across `stableVersion` bumps instead of rebuilding.
|
/// streaming bubble to reuse one V2View across `stableVersion` bumps instead of rebuilding.
|
||||||
/// Only `webpage` changes across chunks; the `imageReference`/`fileReference` closures keep
|
/// Only `webpage` changes across chunks; the `imageReference`/`fileReference` closures keep
|
||||||
/// their construction-time `MessageReference` snapshot, which is acceptable because the message
|
/// their construction-time `MessageReference` snapshot, which is acceptable because the message
|
||||||
/// id is stable across chunks (media resolves by id) and streamed AI content carries no media.
|
/// id is stable across chunks and streamed content carries no media.
|
||||||
public func updateContent(webpage: TelegramMediaWebpage) {
|
public func updateContent(webpage: TelegramMediaWebpage) {
|
||||||
self.webpage = webpage
|
self.webpage = webpage
|
||||||
}
|
}
|
||||||
|
|
@ -152,7 +152,7 @@ public final class InstantPageV2View: UIView {
|
||||||
private var emojiEnableLooping: Bool = true
|
private var emojiEnableLooping: Bool = true
|
||||||
|
|
||||||
/// Scroll-visibility rect in this view's coordinate space; gates emoji animation looping.
|
/// Scroll-visibility rect in this view's coordinate space; gates emoji animation looping.
|
||||||
/// `nil` means "not visible" → emoji don't animate. The root rect is set by the bubble and
|
/// `nil` means not visible, so emoji do not animate. The root rect is set by the bubble and
|
||||||
/// propagated down the nested tree (details/table) by `propagateVisibilityRect`.
|
/// propagated down the nested tree (details/table) by `propagateVisibilityRect`.
|
||||||
public var visibilityRect: CGRect? {
|
public var visibilityRect: CGRect? {
|
||||||
didSet {
|
didSet {
|
||||||
|
|
@ -179,7 +179,7 @@ public final class InstantPageV2View: UIView {
|
||||||
|
|
||||||
/// Walks the `rootMediaRegistryHost` chain transitively until it finds a self-referencing
|
/// Walks the `rootMediaRegistryHost` chain transitively until it finds a self-referencing
|
||||||
/// host (the true root). Necessary because nested details blocks can leave an inner body's
|
/// host (the true root). Necessary because nested details blocks can leave an inner body's
|
||||||
/// `rootMediaRegistryHost` pointing at an intermediate body rather than the outer root —
|
/// `rootMediaRegistryHost` pointing at an intermediate body rather than the outer root.
|
||||||
/// `propagateRegistryHost(to:)` only walks one hop, so the chain must be followed at lookup.
|
/// `propagateRegistryHost(to:)` only walks one hop, so the chain must be followed at lookup.
|
||||||
var trueRegistryRoot: InstantPageV2View {
|
var trueRegistryRoot: InstantPageV2View {
|
||||||
var host: InstantPageV2View = self
|
var host: InstantPageV2View = self
|
||||||
|
|
@ -251,7 +251,7 @@ public final class InstantPageV2View: UIView {
|
||||||
let newFrame = InstantPageV2View.actualFrame(forItem: item) // parent positions child
|
let newFrame = InstantPageV2View.actualFrame(forItem: item) // parent positions child
|
||||||
if animation.isAnimated && reusedView.frame != newFrame {
|
if animation.isAnimated && reusedView.frame != newFrame {
|
||||||
// A collapsing details view keeps its body alive; remove it once this
|
// A collapsing details view keeps its body alive; remove it once this
|
||||||
// frame-shrink (the clip that hides it) finishes — see finalizePendingCollapse().
|
// frame shrink finishes. See finalizePendingCollapse().
|
||||||
let detailsView = reusedView as? InstantPageV2DetailsView
|
let detailsView = reusedView as? InstantPageV2DetailsView
|
||||||
animation.animator.updateFrame(layer: reusedView.layer, frame: newFrame, completion: { [weak detailsView] _ in
|
animation.animator.updateFrame(layer: reusedView.layer, frame: newFrame, completion: { [weak detailsView] _ in
|
||||||
detailsView?.finalizePendingCollapse()
|
detailsView?.finalizePendingCollapse()
|
||||||
|
|
@ -316,7 +316,7 @@ public final class InstantPageV2View: UIView {
|
||||||
|
|
||||||
for view in self.itemViews {
|
for view in self.itemViews {
|
||||||
// Top-level `.text` items host their emoji directly. The thinking block hosts emoji on
|
// Top-level `.text` items host their emoji directly. The thinking block hosts emoji on
|
||||||
// its shimmer-wrapped inner text view, which the page never sees as a top-level item —
|
// its shimmer-wrapped inner text view, which is not a top-level item,
|
||||||
// so without this it is skipped and the emoji never get layers (invisible). Nested V2
|
// so without this it is skipped and the emoji never get layers (invisible). Nested V2
|
||||||
// sub-layouts (details bodies, table cells) instead run their own updateInlineEmoji.
|
// sub-layouts (details bodies, table cells) instead run their own updateInlineEmoji.
|
||||||
let textView: InstantPageV2TextView
|
let textView: InstantPageV2TextView
|
||||||
|
|
@ -574,7 +574,7 @@ public final class InstantPageV2View: UIView {
|
||||||
|
|
||||||
// Pushes this view's `visibilityRect` down into every nested V2 view (details body, table
|
// Pushes this view's `visibilityRect` down into every nested V2 view (details body, table
|
||||||
// title + cells), converted into each child's coordinate space. Each child's `visibilityRect`
|
// title + cells), converted into each child's coordinate space. Each child's `visibilityRect`
|
||||||
// didSet re-runs `updateEmojiVisibility`, which propagates one level further — so a single
|
// didSet re-runs `updateEmojiVisibility`, which propagates one level further, so a single
|
||||||
// root assignment fans out across the whole tree.
|
// root assignment fans out across the whole tree.
|
||||||
private func propagateVisibilityRect() {
|
private func propagateVisibilityRect() {
|
||||||
for view in self.itemViews {
|
for view in self.itemViews {
|
||||||
|
|
@ -829,8 +829,7 @@ public final class InstantPageV2View: UIView {
|
||||||
///
|
///
|
||||||
/// For most item types this is `item.frame`. `InstantPageV2TextView` widens its backing store
|
/// For most item types this is `item.frame`. `InstantPageV2TextView` widens its backing store
|
||||||
/// by `v2TextViewClippingInset` on every side to accommodate glyph overhang and underline
|
/// by `v2TextViewClippingInset` on every side to accommodate glyph overhang and underline
|
||||||
/// rendering past the text's logical `maxY` — the same inset its `init` applies when
|
/// rendering past the text's logical `maxY`. The reuse path applies the same expansion so re-layout
|
||||||
/// constructing the view. The reuse path must apply the same expansion so that re-layout
|
|
||||||
/// (theme change, bubble resize, etc.) does not clip italic glyphs or underlines.
|
/// (theme change, bubble resize, etc.) does not clip italic glyphs or underlines.
|
||||||
///
|
///
|
||||||
/// Keep this helper aligned with each view class's init-time frame computation.
|
/// Keep this helper aligned with each view class's init-time frame computation.
|
||||||
|
|
@ -924,7 +923,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reveal mask state — populated in Task 5.
|
// Reveal mask state.
|
||||||
private var maxCharacterDrawCount: Int?
|
private var maxCharacterDrawCount: Int?
|
||||||
private var previousMaxCharacterDrawCount: Int = 0
|
private var previousMaxCharacterDrawCount: Int = 0
|
||||||
private var revealMaskLayer: SimpleLayer?
|
private var revealMaskLayer: SimpleLayer?
|
||||||
|
|
@ -962,7 +961,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
|
||||||
self.item = item
|
self.item = item
|
||||||
// Lay every container out from the item's own (clipping-inset-expanded) frame rather than
|
// Lay every container out from the item's own (clipping-inset-expanded) frame rather than
|
||||||
// self.bounds, so the single path is correct regardless of when the parent assigns our
|
// self.bounds, so the single path is correct regardless of when the parent assigns our
|
||||||
// frame — and so a reused text view that changed size (e.g. AI streaming) re-frames its
|
// frame. A reused text view that changes size also updates its
|
||||||
// renderContainer/renderView too, which the old update path skipped.
|
// renderContainer/renderView too, which the old update path skipped.
|
||||||
let containerBounds = CGRect(origin: .zero, size: item.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset).size)
|
let containerBounds = CGRect(origin: .zero, size: item.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset).size)
|
||||||
self.renderContainer.frame = containerBounds
|
self.renderContainer.frame = containerBounds
|
||||||
|
|
@ -1165,7 +1164,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
|
||||||
|
|
||||||
let currentLineInfos = self.computeRevealedLines(characterLimit: effectiveCharacterDrawCount)
|
let currentLineInfos = self.computeRevealedLines(characterLimit: effectiveCharacterDrawCount)
|
||||||
|
|
||||||
// Snippet spawn pass — animate newly-revealed characters.
|
// Animate newly revealed characters.
|
||||||
if self.previousMaxCharacterDrawCount < effectiveCharacterDrawCount,
|
if self.previousMaxCharacterDrawCount < effectiveCharacterDrawCount,
|
||||||
let contents = self.renderView.layer.contents,
|
let contents = self.renderView.layer.contents,
|
||||||
animateNewSegments {
|
animateNewSegments {
|
||||||
|
|
@ -1235,9 +1234,9 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mask rebuild — when snippets are in flight, clamp to the lowest animating one
|
// Clamp the mask to the lowest animating snippet
|
||||||
// (so the mask never exposes a char a snippet is still flying for). With no animations
|
// (so the mask never exposes a char a snippet is still flying for). With no animations
|
||||||
// in flight, snap directly to the current target — `previousMaxCharacterDrawCount`
|
// in flight, snap directly to the current target. `previousMaxCharacterDrawCount`
|
||||||
// would lag by one call (it's updated at the end of this function) and is 0 on a fresh
|
// would lag by one call (it's updated at the end of this function) and is 0 on a fresh
|
||||||
// view, which would hide every char until the next tick.
|
// view, which would hide every char until the next tick.
|
||||||
let maskCharacterLimit: Int
|
let maskCharacterLimit: Int
|
||||||
|
|
@ -1257,7 +1256,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
|
||||||
// Per-glyph rect captures descenders, italic overhang, accents exactly. Per
|
// Per-glyph rect captures descenders, italic overhang, accents exactly. Per
|
||||||
// line we accumulate the union of revealed glyphs into one mask rect (one
|
// line we accumulate the union of revealed glyphs into one mask rect (one
|
||||||
// CALayer sublayer per line), and consecutive fully-revealed lines collapse
|
// CALayer sublayer per line), and consecutive fully-revealed lines collapse
|
||||||
// further into a single rect — so a fully-revealed prefix is always one
|
// further into a single rect, so a fully revealed prefix is always one
|
||||||
// sublayer regardless of line count.
|
// sublayer regardless of line count.
|
||||||
//
|
//
|
||||||
// Lines without per-character data (computeRevealCharacterRects == false on
|
// Lines without per-character data (computeRevealCharacterRects == false on
|
||||||
|
|
@ -1298,7 +1297,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
|
||||||
lineUnion = union
|
lineUnion = union
|
||||||
remainingChars -= characterRects.count
|
remainingChars -= characterRects.count
|
||||||
} else {
|
} else {
|
||||||
// No per-character data — expose the whole line.
|
// Expose the whole line without character data.
|
||||||
lineUnion = line.range.length > 0 ? renderLocalLineFrame : nil
|
lineUnion = line.range.length > 0 ? renderLocalLineFrame : nil
|
||||||
isFullLine = remainingChars >= line.range.length
|
isFullLine = remainingChars >= line.range.length
|
||||||
remainingChars -= line.range.length
|
remainingChars -= line.range.length
|
||||||
|
|
@ -1705,10 +1704,10 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
|
||||||
var bodyView: InstantPageV2View?
|
var bodyView: InstantPageV2View?
|
||||||
private let titleHitView: UIView
|
private let titleHitView: UIView
|
||||||
|
|
||||||
// The expanded chevron is the collapsed one rotated 180° (down → up).
|
// Rotate the collapsed chevron for the expanded state.
|
||||||
private static let expandedChevronTransform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0)
|
private static let expandedChevronTransform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0)
|
||||||
// On an animated collapse the body is kept until the toggle animation finishes (so the
|
// On an animated collapse the body is kept until the toggle animation finishes (so the
|
||||||
// shrinking clip can hide it), then removed in finalizePendingCollapse() — which the parent
|
// shrinking clip can hide it), then removed in finalizePendingCollapse(). The parent
|
||||||
// (InstantPageV2View.update) calls from the completion of the frame-shrink (clip) animation.
|
// (InstantPageV2View.update) calls from the completion of the frame-shrink (clip) animation.
|
||||||
private var bodyPendingRemoval = false
|
private var bodyPendingRemoval = false
|
||||||
|
|
||||||
|
|
@ -1744,7 +1743,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
|
||||||
|
|
||||||
super.init(frame: item.frame)
|
super.init(frame: item.frame)
|
||||||
self.backgroundColor = .clear // structural
|
self.backgroundColor = .clear // structural
|
||||||
self.clipsToBounds = true // structural — the parent's frame-height animation clips the body
|
self.clipsToBounds = true
|
||||||
|
|
||||||
self.addSubview(self.titleTextView)
|
self.addSubview(self.titleTextView)
|
||||||
self.addSubview(self.chevronView)
|
self.addSubview(self.chevronView)
|
||||||
|
|
@ -1755,7 +1754,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
|
||||||
self.titleHitView.addGestureRecognizer(tap)
|
self.titleHitView.addGestureRecognizer(tap)
|
||||||
|
|
||||||
// All content (title, chevron tint/position, separator, titleHit frame, body) flows through
|
// All content (title, chevron tint/position, separator, titleHit frame, body) flows through
|
||||||
// update — its expanded branch lazily creates the body, so init no longer builds it itself.
|
// update. Its expanded branch creates the body lazily.
|
||||||
self.update(item: item, theme: theme, renderContext: renderContext, animation: .None)
|
self.update(item: item, theme: theme, renderContext: renderContext, animation: .None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1786,7 +1785,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
|
||||||
self.titleHitView.frame = item.titleFrame
|
self.titleHitView.frame = item.titleFrame
|
||||||
|
|
||||||
// Body lifecycle. The reveal/hide of the body is produced by the parent animating this
|
// Body lifecycle. The reveal/hide of the body is produced by the parent animating this
|
||||||
// view's own frame height (clipsToBounds = true), not by the body itself — see
|
// view's own frame height (clipsToBounds = true), not by the body itself. See
|
||||||
// InstantPageV2View.update. The body's internal layout is forwarded `animation` so a
|
// InstantPageV2View.update. The body's internal layout is forwarded `animation` so a
|
||||||
// *nested* details block inside the body can also animate its own toggle.
|
// *nested* details block inside the body can also animate its own toggle.
|
||||||
let blockHeight: CGFloat
|
let blockHeight: CGFloat
|
||||||
|
|
@ -1842,7 +1841,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
|
||||||
height: UIScreenPixel
|
height: UIScreenPixel
|
||||||
), completion: nil)
|
), completion: nil)
|
||||||
|
|
||||||
// Chevron rotation. The body teardown on collapse is NOT tied to this completion — see
|
// Body teardown is not tied to chevron rotation. See
|
||||||
// finalizePendingCollapse(), which the parent calls from the frame-shrink (clip) animation.
|
// finalizePendingCollapse(), which the parent calls from the frame-shrink (clip) animation.
|
||||||
let targetTransform = item.isExpanded ? InstantPageV2DetailsView.expandedChevronTransform : CATransform3DIdentity
|
let targetTransform = item.isExpanded ? InstantPageV2DetailsView.expandedChevronTransform : CATransform3DIdentity
|
||||||
animation.animator.updateTransform(layer: self.chevronView.layer, transform: targetTransform, completion: nil)
|
animation.animator.updateTransform(layer: self.chevronView.layer, transform: targetTransform, completion: nil)
|
||||||
|
|
@ -1851,7 +1850,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
|
||||||
/// Removes the body kept alive across an animated collapse. The parent (InstantPageV2View.update)
|
/// Removes the body kept alive across an animated collapse. The parent (InstantPageV2View.update)
|
||||||
/// calls this from the completion of the frame-shrink animation that clips the body away, so the
|
/// calls this from the completion of the frame-shrink animation that clips the body away, so the
|
||||||
/// body is torn down exactly when it finishes being hidden. The guard makes a re-expand that
|
/// body is torn down exactly when it finishes being hidden. The guard makes a re-expand that
|
||||||
/// interrupts the collapse safe — the re-expand clears `bodyPendingRemoval` first.
|
/// interrupts the collapse safely because re-expanding clears `bodyPendingRemoval`.
|
||||||
func finalizePendingCollapse() {
|
func finalizePendingCollapse() {
|
||||||
if !self.item.isExpanded, self.bodyPendingRemoval {
|
if !self.item.isExpanded, self.bodyPendingRemoval {
|
||||||
self.bodyView?.removeFromSuperview()
|
self.bodyView?.removeFromSuperview()
|
||||||
|
|
@ -1938,8 +1937,8 @@ final class InstantPageV2ThinkingView: UIView, InstantPageItemView {
|
||||||
/// Parent positions self at the item frame (the bare line box). The shimmer and its gradient
|
/// Parent positions self at the item frame (the bare line box). The shimmer and its gradient
|
||||||
/// mask are sized to the text view's clipping-inset-EXPANDED frame and shifted to
|
/// mask are sized to the text view's clipping-inset-EXPANDED frame and shifted to
|
||||||
/// `(-inset, -inset)`, so the mask doesn't crop the glyph overhang the inset reserves (tall
|
/// `(-inset, -inset)`, so the mask doesn't crop the glyph overhang the inset reserves (tall
|
||||||
/// ascenders, descenders, the last line's underline) — the symptom of sizing the mask to the
|
/// ascenders, descenders, and the last line's underline). The inner text view fills the shimmer;
|
||||||
/// bare line box. The inner text view fills the shimmer; its `+inset` render translate lands the
|
/// its `+inset` render translate lands the
|
||||||
/// glyphs back at self's origin, so the text position is unchanged. Mirrors how a `.text` view's
|
/// glyphs back at self's origin, so the text position is unchanged. Mirrors how a `.text` view's
|
||||||
/// frame is inset-expanded (`actualFrame` / `InstantPageV2TextView.init`).
|
/// frame is inset-expanded (`actualFrame` / `InstantPageV2TextView.init`).
|
||||||
private func layoutContents() {
|
private func layoutContents() {
|
||||||
|
|
@ -2026,7 +2025,7 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
|
||||||
// every frame / colour / sub-layout below. Insertion order matches the original interleaved
|
// every frame / colour / sub-layout below. Insertion order matches the original interleaved
|
||||||
// build so the layer/subview z-order is unchanged (stripes at the bottom, then the title and
|
// build so the layer/subview z-order is unchanged (stripes at the bottom, then the title and
|
||||||
// cell sub-views, then the inner grid lines). Cell-count changes on later reuse are not
|
// cell sub-views, then the inner grid lines). Cell-count changes on later reuse are not
|
||||||
// reconciled here (pre-existing limitation) — update's index-guarded loops refresh in place.
|
// reconciled here; update's index-guarded loops refresh in place.
|
||||||
if item.titleSubLayout != nil {
|
if item.titleSubLayout != nil {
|
||||||
let v = InstantPageV2View(renderContext: renderContext)
|
let v = InstantPageV2View(renderContext: renderContext)
|
||||||
self.contentView.addSubview(v)
|
self.contentView.addSubview(v)
|
||||||
|
|
@ -2090,7 +2089,7 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stripe layers (cell backgrounds) — update color + frame + corner rounding in original order.
|
// Update cell background layers in their original order.
|
||||||
let effectiveBorderWidth = item.bordered ? v2TableBorderWidth : 0.0
|
let effectiveBorderWidth = item.bordered ? v2TableBorderWidth : 0.0
|
||||||
let gridHeight = item.contentSize.height - gridOffsetY
|
let gridHeight = item.contentSize.height - gridOffsetY
|
||||||
var stripeIndex = 0
|
var stripeIndex = 0
|
||||||
|
|
@ -2111,7 +2110,7 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inner line layers — refresh colour AND frame in place. (`lineLayers` holds only inner grid
|
// Refresh inner grid line colors and frames.
|
||||||
// lines; the outer border is the contentView layer's own rounded border, refreshed below.)
|
// lines; the outer border is the contentView layer's own rounded border, refreshed below.)
|
||||||
// Frames are set here (not in init) so reuse with a different grid re-positions the lines.
|
// Frames are set here (not in init) so reuse with a different grid re-positions the lines.
|
||||||
let lineRects = item.horizontalLines + item.verticalLines
|
let lineRects = item.horizontalLines + item.verticalLines
|
||||||
|
|
@ -2122,7 +2121,7 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rounded outer border — refresh radius/color/width (theme or `bordered` flag may change).
|
// Refresh the rounded outer border.
|
||||||
self.contentView.layer.cornerRadius = v2TableCornerRadius
|
self.contentView.layer.cornerRadius = v2TableCornerRadius
|
||||||
self.contentView.layer.borderColor = item.borderColor.cgColor
|
self.contentView.layer.borderColor = item.borderColor.cgColor
|
||||||
self.contentView.layer.borderWidth = item.bordered ? v2TableBorderWidth : 0.0
|
self.contentView.layer.borderWidth = item.bordered ? v2TableBorderWidth : 0.0
|
||||||
|
|
@ -2168,7 +2167,7 @@ public extension InstantPageV2View {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The frame (pageView-space) of the anchor `name` in the *currently laid-out* layout.
|
/// The frame (pageView-space) of the anchor `name` in the *currently laid-out* layout.
|
||||||
/// Returns nil if the anchor isn't present — e.g. it's inside a collapsed `<details>`
|
/// Returns nil if the anchor is missing or inside collapsed `<details>`
|
||||||
/// (whose inner blocks aren't laid out) or doesn't exist. Mirrors `findTextItem`.
|
/// (whose inner blocks aren't laid out) or doesn't exist. Mirrors `findTextItem`.
|
||||||
func anchorFrame(name: String) -> CGRect? {
|
func anchorFrame(name: String) -> CGRect? {
|
||||||
guard let layout = self.currentLayout else { return nil }
|
guard let layout = self.currentLayout else { return nil }
|
||||||
|
|
@ -2178,7 +2177,7 @@ public extension InstantPageV2View {
|
||||||
/// Given a details-sibling-ordinal path (from `instantPageAnchorPath`), walk the live layout
|
/// Given a details-sibling-ordinal path (from `instantPageAnchorPath`), walk the live layout
|
||||||
/// and return the `currentExpandedDetails` index of the FIRST not-yet-expanded `<details>` on
|
/// and return the `currentExpandedDetails` index of the FIRST not-yet-expanded `<details>` on
|
||||||
/// the path. Returns nil if every details on the path is already expanded, or the path doesn't
|
/// the path. Returns nil if every details on the path is already expanded, or the path doesn't
|
||||||
/// match the live layout. Reads indices from the laid-out items — never reproduces them.
|
/// match the live layout. Reads indices from the laid-out items.
|
||||||
func firstCollapsedDetails(forOrdinalPath path: [Int]) -> Int? {
|
func firstCollapsedDetails(forOrdinalPath path: [Int]) -> Int? {
|
||||||
guard let layout = self.currentLayout else { return nil }
|
guard let layout = self.currentLayout else { return nil }
|
||||||
var currentItems = layout.items
|
var currentItems = layout.items
|
||||||
|
|
@ -2403,7 +2402,7 @@ private func collectSelectableTextItems(
|
||||||
/// For block formulas wider than the bubble's available width, the layout sets
|
/// For block formulas wider than the bubble's available width, the layout sets
|
||||||
/// `isScrollable = true`; this view then wraps the image in a horizontal `UIScrollView`
|
/// `isScrollable = true`; this view then wraps the image in a horizontal `UIScrollView`
|
||||||
/// matching V1's `InstantPageScrollableNode` (no bounce on non-overflowing content,
|
/// matching V1's `InstantPageScrollableNode` (no bounce on non-overflowing content,
|
||||||
/// scroll indicator hidden — appropriate for content embedded inside a chat bubble).
|
/// scroll indicator hidden, for content embedded inside a chat bubble).
|
||||||
final class InstantPageV2FormulaView: UIView, InstantPageItemView {
|
final class InstantPageV2FormulaView: UIView, InstantPageItemView {
|
||||||
private(set) var item: InstantPageV2FormulaItem
|
private(set) var item: InstantPageV2FormulaItem
|
||||||
var itemFrame: CGRect { return self.item.frame }
|
var itemFrame: CGRect { return self.item.frame }
|
||||||
|
|
|
||||||
|
|
@ -364,8 +364,7 @@ public final class InstantPageTextItem: InstantPageItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? {
|
public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? {
|
||||||
// Hit-testing (taps on links/entities) wants the character under the finger — keep the
|
// Clamp taps to the character under the finger.
|
||||||
// strict, clamping behavior.
|
|
||||||
if !orNearest {
|
if !orNearest {
|
||||||
return self.attributesAtPoint(point)
|
return self.attributesAtPoint(point)
|
||||||
}
|
}
|
||||||
|
|
@ -402,8 +401,7 @@ public final class InstantPageTextItem: InstantPageItem {
|
||||||
} else if point.x >= lineFrame.maxX {
|
} else if point.x >= lineFrame.maxX {
|
||||||
// Trailing edge: return the line's upper bound (one past its last character) so a
|
// Trailing edge: return the line's upper bound (one past its last character) so a
|
||||||
// right-handle drag can include the last character/item of the line. The selection
|
// right-handle drag can include the last character/item of the line. The selection
|
||||||
// upper bound is exclusive, so clamping to the last character's index — as the strict
|
// upper bound is exclusive, so use the line end. Mirrors Display.TextNode.
|
||||||
// path does — would always leave it unselected. Mirrors Display.TextNode.
|
|
||||||
index = lineRange.location + lineRange.length
|
index = lineRange.location + lineRange.length
|
||||||
} else {
|
} else {
|
||||||
index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: point.x - lineFrame.minX, y: 0.0))
|
index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: point.x - lineFrame.minX, y: 0.0))
|
||||||
|
|
@ -964,7 +962,7 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt
|
||||||
// Size the inline emoji to the font's line height (A + D) plus a 4pt bump at the 17pt
|
// Size the inline emoji to the font's line height (A + D) plus a 4pt bump at the 17pt
|
||||||
// body font (scaled proportionally). Must match the V2 layout's emoji cell size
|
// body font (scaled proportionally). Must match the V2 layout's emoji cell size
|
||||||
// (InstantPageV2Layout.swift). The run delegate still reports the font's own
|
// (InstantPageV2Layout.swift). The run delegate still reports the font's own
|
||||||
// ascent/descent (below), so the line height is unchanged — only the emoji width changes.
|
// ascent/descent, so only the emoji width changes.
|
||||||
let itemSize = font.ascender - font.descender + 4.0 * font.pointSize / 17.0
|
let itemSize = font.ascender - font.descender + 4.0 * font.pointSize / 17.0
|
||||||
let extentBuffer = UnsafeMutablePointer<RunStruct>.allocate(capacity: 1)
|
let extentBuffer = UnsafeMutablePointer<RunStruct>.allocate(capacity: 1)
|
||||||
extentBuffer.initialize(to: RunStruct(ascent: font.ascender, descent: font.descender, width: itemSize))
|
extentBuffer.initialize(to: RunStruct(ascent: font.ascender, descent: font.descender, width: itemSize))
|
||||||
|
|
|
||||||
|
|
@ -195,8 +195,7 @@ final class InstantPageV2AudioContentNode: ASDisplayNode {
|
||||||
self.descriptionAttributedString = InstantPageV2AudioContentNode.descriptionString(file: self.file, incoming: incoming, presentationData: presentationData)
|
self.descriptionAttributedString = InstantPageV2AudioContentNode.descriptionString(file: self.file, incoming: incoming, presentationData: presentationData)
|
||||||
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
|
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
|
||||||
self.statusNode.backgroundNodeColor = messageTheme.mediaActiveControlColor
|
self.statusNode.backgroundNodeColor = messageTheme.mediaActiveControlColor
|
||||||
// foreground/overlay also depend on incoming + theme (set at construction) — refresh them
|
// Refresh colors after an in-place theme or direction change.
|
||||||
// too so the play glyph isn't miscolored after an in-place theme/direction change.
|
|
||||||
self.statusNode.foregroundNodeColor = (incoming && messageTheme.mediaActiveControlColor.rgb != 0xffffff) ? .white : .clear
|
self.statusNode.foregroundNodeColor = (incoming && messageTheme.mediaActiveControlColor.rgb != 0xffffff) ? .white : .clear
|
||||||
self.statusNode.overlayForegroundNodeColor = presentationData.theme.chat.message.mediaOverlayControlColors.foregroundColor
|
self.statusNode.overlayForegroundNodeColor = presentationData.theme.chat.message.mediaOverlayControlColors.foregroundColor
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,14 @@ import AccountContext
|
||||||
import PhotoResources
|
import PhotoResources
|
||||||
import MediaResources
|
import MediaResources
|
||||||
|
|
||||||
/// Lightweight inline image view for InstantPage V2 — wraps a `TransformImageNode`
|
/// Inline InstantPage V2 image backed by `TransformImageNode`.
|
||||||
/// to render a single `RichText.image` cell inside a text view.
|
/// to render a single `RichText.image` cell inside a text view.
|
||||||
///
|
///
|
||||||
/// Owned by `InstantPageV2View` (not an `InstantPageItemView` conformer; not in
|
/// Owned by `InstantPageV2View` (not an `InstantPageItemView` conformer; not in
|
||||||
/// the view-factory switch). Hosted inside the parent text view's
|
/// the view-factory switch). Hosted inside the parent text view's
|
||||||
/// `imageContainerView` (sibling of `renderContainer`, above the reveal mask,
|
/// `imageContainerView` (sibling of `renderContainer`, above the reveal mask,
|
||||||
/// below `emojiContainerView`), so the streaming reveal can wipe text glyphs
|
/// below `emojiContainerView`), so the streaming reveal can wipe text glyphs
|
||||||
/// while the image pops in independently. Non-interactive — taps pass through
|
/// while the image pops in independently. Taps pass through
|
||||||
/// to the text view, so a URL-wrapping `RichText.url(text: .image(...))`
|
/// to the text view, so a URL-wrapping `RichText.url(text: .image(...))`
|
||||||
/// continues to route taps to the URL handler.
|
/// continues to route taps to the URL handler.
|
||||||
final class InstantPageV2InlineImageView: UIView {
|
final class InstantPageV2InlineImageView: UIView {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ public struct InstantPageV2Layout {
|
||||||
/// `line.imageItems` MediaIds at update time. Nested layouts (details body,
|
/// `line.imageItems` MediaIds at update time. Nested layouts (details body,
|
||||||
/// table cells, table title) carry the parent's same map.
|
/// table cells, table title) carry the parent's same map.
|
||||||
public let media: [EngineMedia.Id: EngineMedia]
|
public let media: [EngineMedia.Id: EngineMedia]
|
||||||
/// Webpage carried for the same reason — `updateInlineImages()` needs it to
|
/// Webpage carried because `updateInlineImages()` needs it to
|
||||||
/// form the `WebpageReference` for `ImageMediaReference.webPage(...)`. May
|
/// form the `WebpageReference` for `ImageMediaReference.webPage(...)`. May
|
||||||
/// be nil for non-webpage-anchored layouts; in that case the lookup proceeds
|
/// be nil for non-webpage-anchored layouts; in that case the lookup proceeds
|
||||||
/// but no fetch signal can be bound (image view simply isn't created).
|
/// but no fetch signal can be bound (image view simply isn't created).
|
||||||
|
|
@ -121,7 +121,7 @@ public enum InstantPageV2LaidOutItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a copy of `self` with its top-level frame translated by `delta`.
|
/// Returns a copy of `self` with its top-level frame translated by `delta`.
|
||||||
/// Sub-layouts inside details/table cells are not re-translated — they're already
|
/// Sub-layouts inside details and table cells are already
|
||||||
/// expressed in their parent's local coordinates.
|
/// expressed in their parent's local coordinates.
|
||||||
public func offsetBy(_ delta: CGPoint) -> InstantPageV2LaidOutItem {
|
public func offsetBy(_ delta: CGPoint) -> InstantPageV2LaidOutItem {
|
||||||
switch self {
|
switch self {
|
||||||
|
|
@ -470,7 +470,7 @@ public func lastTextLineFrame(in layout: InstantPageV2Layout) -> CGRect? {
|
||||||
}
|
}
|
||||||
case let .table(table):
|
case let .table(table):
|
||||||
// Walk cells in reverse row-major order (last cell of last row first).
|
// Walk cells in reverse row-major order (last cell of last row first).
|
||||||
// The renderer shifts cells down by gridOffsetY (title height) — match that here.
|
// Match the renderer's title offset.
|
||||||
let gridOffsetY = table.titleFrame?.height ?? 0.0
|
let gridOffsetY = table.titleFrame?.height ?? 0.0
|
||||||
for cell in table.cells.reversed() {
|
for cell in table.cells.reversed() {
|
||||||
if let subLayout = cell.subLayout, let frame = lastTextLineFrame(in: subLayout) {
|
if let subLayout = cell.subLayout, let frame = lastTextLineFrame(in: subLayout) {
|
||||||
|
|
@ -497,7 +497,7 @@ public func lastTextLineFrame(in layout: InstantPageV2Layout) -> CGRect? {
|
||||||
/// anchor at `maxY + trailingBottomPadding` to align with where the text actually renders. The pad
|
/// anchor at `maxY + trailingBottomPadding` to align with where the text actually renders. The pad
|
||||||
/// is 0 when the line is taller than its font line height (a tall inline attachment, e.g. a formula,
|
/// is 0 when the line is taller than its font line height (a tall inline attachment, e.g. a formula,
|
||||||
/// already pushes maxY down to the right spot). Callers should NOT apply the pad when the status
|
/// already pushes maxY down to the right spot). Callers should NOT apply the pad when the status
|
||||||
/// wraps onto its own line below the text — there it should sit at the bare maxY.
|
/// wraps onto its own line below the text, where it sits at the bare maxY.
|
||||||
public func lastTextLineFrameIfLastItemIsText(in layout: InstantPageV2Layout) -> (frame: CGRect, trailingBottomPadding: CGFloat)? {
|
public func lastTextLineFrameIfLastItemIsText(in layout: InstantPageV2Layout) -> (frame: CGRect, trailingBottomPadding: CGFloat)? {
|
||||||
guard let bottomItem = layout.items.max(by: { $0.frame.maxY < $1.frame.maxY }),
|
guard let bottomItem = layout.items.max(by: { $0.frame.maxY < $1.frame.maxY }),
|
||||||
case let .text(text) = bottomItem,
|
case let .text(text) = bottomItem,
|
||||||
|
|
@ -505,11 +505,9 @@ public func lastTextLineFrameIfLastItemIsText(in layout: InstantPageV2Layout) ->
|
||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// The stored line frame always has minX = 0 — alignment (center / right / natural-RTL) is
|
// Alignment is applied at render time by `v2FrameForLine`. Apply the same correction here so the returned
|
||||||
// applied at render time by `v2FrameForLine`. Apply the same correction here so the returned
|
|
||||||
// frame's `maxX` reflects the line's actual on-screen right edge, not just its width anchored
|
// frame's `maxX` reflects the line's actual on-screen right edge, not just its width anchored
|
||||||
// at the textItem's left. Without this, a right-aligned or RTL last line — whose visible right
|
// at the text item's left. Without this, a right-aligned or RTL last line would feed the status
|
||||||
// edge sits at `textItem.width`, all the way at the right text inset — would feed the status
|
|
||||||
// node a `contentWidth` equal to just `lineWidth`. The trail/wrap decision would then think
|
// node a `contentWidth` equal to just `lineWidth`. The trail/wrap decision would then think
|
||||||
// the date fits trailing the line, and place it directly on top of the line at the right text
|
// the date fits trailing the line, and place it directly on top of the line at the right text
|
||||||
// inset where the line itself ends. The width is unchanged; only `origin.x` shifts.
|
// inset where the line itself ends. The width is unchanged; only `origin.x` shifts.
|
||||||
|
|
@ -602,8 +600,8 @@ private func layoutBlockSequence(
|
||||||
|
|
||||||
var contentSize = CGSize(width: boundingWidth, height: contentHeight)
|
var contentSize = CGSize(width: boundingWidth, height: contentHeight)
|
||||||
if context.fitToWidth {
|
if context.fitToWidth {
|
||||||
// Match V1 InstantPageLayout.swift:1114 — include `+ horizontalInset` so the contentSize
|
// Match V1 and include `horizontalInset` in the content size. This
|
||||||
// reserves a right margin equal to the left inset. Without this, the longest text item's
|
// reserves a right margin equal to the left inset. Without it, the longest text item's
|
||||||
// right edge equals contentSize.width, and the bubble's containerNode (sized to
|
// right edge equals contentSize.width, and the bubble's containerNode (sized to
|
||||||
// boundingSize.width - 2) clips the last 2pt of text.
|
// boundingSize.width - 2) clips the last 2pt of text.
|
||||||
var maxX: CGFloat = 0.0
|
var maxX: CGFloat = 0.0
|
||||||
|
|
@ -755,7 +753,7 @@ private func layoutBlock(
|
||||||
context: &context
|
context: &context
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Fallback when the image is not present in the page's media dict — preserve V1
|
// Preserve the V1 fallback when the image is missing.
|
||||||
// behavior, which returns an empty layout for unknown image IDs (V1
|
// behavior, which returns an empty layout for unknown image IDs (V1
|
||||||
// InstantPageLayout.swift:623). The existing layoutMediaWithCaption would emit a
|
// InstantPageLayout.swift:623). The existing layoutMediaWithCaption would emit a
|
||||||
// grey rectangle; matching V1 instead.
|
// grey rectangle; matching V1 instead.
|
||||||
|
|
@ -923,10 +921,8 @@ private func layoutBlock(
|
||||||
horizontalInset: horizontalInset, context: &context)
|
horizontalInset: horizontalInset, context: &context)
|
||||||
|
|
||||||
case let .map(latitude, longitude, zoom, dimensions, caption):
|
case let .map(latitude, longitude, zoom, dimensions, caption):
|
||||||
// AI/server-sent `.map` blocks can arrive with zero `dimensions` (the wire `w`/`h` are
|
// `.map` blocks can arrive with zero dimensions. A zero `naturalSize.height` collapses the media
|
||||||
// required, but the sender may put 0). A zero `naturalSize.height` collapses the media
|
// frame to height 0 (`instantPageV2MediaFrame`'s else branch). A zero-sized
|
||||||
// frame to height 0 (`instantPageV2MediaFrame`'s else branch) — the map takes no space,
|
|
||||||
// the caption slides up into it, and the pin floats over the caption — and a zero-sized
|
|
||||||
// `MapSnapshotMediaResource` makes `MKMapSnapshotter` render nothing. Substitute a sensible
|
// `MapSnapshotMediaResource` makes `MKMapSnapshotter` render nothing. Substitute a sensible
|
||||||
// default (a 2:1 map strip) for BOTH the layout size and the snapshot resource. Real web
|
// default (a 2:1 map strip) for BOTH the layout size and the snapshot resource. Real web
|
||||||
// articles (the V1 renderer) always carry real dimensions, so only the rich-message path
|
// articles (the V1 renderer) always carry real dimensions, so only the rich-message path
|
||||||
|
|
@ -1757,15 +1753,12 @@ private let instantPageV2MediaEdgeBleed: CGFloat = 4.0
|
||||||
// `boundingWidth`) with corner radius forced to 0, relying on the bubble's rounded clipping
|
// `boundingWidth`) with corner radius forced to 0, relying on the bubble's rounded clipping
|
||||||
// container to round media that meets the bubble's top/bottom edge. A media item that fills the
|
// container to round media that meets the bubble's top/bottom edge. A media item that fills the
|
||||||
// full width is widened by `instantPageV2MediaEdgeBleed` on the trailing edge (see the constant).
|
// full width is widened by `instantPageV2MediaEdgeBleed` on the trailing edge (see the constant).
|
||||||
// A media item narrower than the full width (a small image — NOT upscaled, the `min(_, 1.0)`
|
// A media item narrower than the full width (a small image that is not upscaled; the `min(_, 1.0)`
|
||||||
// scale cap is kept) stays at its natural size, flush-left at x = 0, with no bleed.
|
// scale cap is kept) stays at its natural size, flush-left at x = 0, with no bleed.
|
||||||
// (The `cornerRadius` argument is ignored when `flush == true` — flush media is always
|
// The `cornerRadius` argument is ignored when `flush == true`; flush media is unrounded.
|
||||||
// un-rounded; callers may still pass their legacy radius, it has no effect.)
|
|
||||||
//
|
//
|
||||||
// `flush == false`: DEAD as of the V2 audio port — audio was its last caller and now has its
|
// `flush == false` is unused after the V2 audio port.
|
||||||
// own `layoutAudio` arm (in `layoutBlock`), so this branch is currently unreachable (follow-up:
|
// Legacy behavior inset media by `horizontalInset` with the caller-supplied corner radius.
|
||||||
// drop the `flush` parameter and this branch). Legacy behavior was: inset by `horizontalInset`
|
|
||||||
// on each side with the caller-supplied corner radius.
|
|
||||||
//
|
//
|
||||||
// Returns the frame, the un-bled scaled content size (the caption is offset by
|
// Returns the frame, the un-bled scaled content size (the caption is offset by
|
||||||
// `scaledSize.height`), and the effective corner radius to stamp on the item.
|
// `scaledSize.height`), and the effective corner radius to stamp on the item.
|
||||||
|
|
@ -1862,7 +1855,7 @@ private func layoutCollage(
|
||||||
horizontalInset: CGFloat,
|
horizontalInset: CGFloat,
|
||||||
context: inout LayoutContext
|
context: inout LayoutContext
|
||||||
) -> [InstantPageV2LaidOutItem] {
|
) -> [InstantPageV2LaidOutItem] {
|
||||||
// 1. One size per inner block (zero for unresolved — V1 still reserves a mosaic slot).
|
// One size per inner block; V1 reserves a slot for unresolved media.
|
||||||
var itemSizes: [CGSize] = []
|
var itemSizes: [CGSize] = []
|
||||||
for block in innerBlocks {
|
for block in innerBlocks {
|
||||||
switch block {
|
switch block {
|
||||||
|
|
@ -1883,7 +1876,7 @@ private func layoutCollage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Mosaic geometry — the same engine V1 uses.
|
// Use the V1 mosaic geometry.
|
||||||
let (mosaic, mosaicSize) = chatMessageBubbleMosaicLayout(maxSize: CGSize(width: boundingWidth, height: boundingWidth), itemSizes: itemSizes)
|
let (mosaic, mosaicSize) = chatMessageBubbleMosaicLayout(maxSize: CGSize(width: boundingWidth, height: boundingWidth), itemSizes: itemSizes)
|
||||||
|
|
||||||
// 3. One typed media item per resolvable cell, at its mosaic frame.
|
// 3. One typed media item per resolvable cell, at its mosaic frame.
|
||||||
|
|
@ -2009,7 +2002,7 @@ private func layoutMediaWithCaption(
|
||||||
)
|
)
|
||||||
result.append(contentsOf: captionItems)
|
result.append(contentsOf: captionItems)
|
||||||
|
|
||||||
// isCover adds extra 14pt bottom padding — but only when caption/credit text was actually
|
// Covers add 14pt bottom padding when caption or credit text was
|
||||||
// rendered (matches V1 lines 204-206: `contentSize.height > 0 && isCover`). For an
|
// rendered (matches V1 lines 204-206: `contentSize.height > 0 && isCover`). For an
|
||||||
// empty-caption cover image no padding is added.
|
// empty-caption cover image no padding is added.
|
||||||
// Implemented by extending the last text item's frame rather than emitting an invisible shape
|
// Implemented by extending the last text item's frame rather than emitting an invisible shape
|
||||||
|
|
@ -2236,12 +2229,12 @@ private func layoutCodeBlock(
|
||||||
|
|
||||||
// Top-level (and <details>) code blocks span the full boundingWidth flush (x=0), matching V1
|
// Top-level (and <details>) code blocks span the full boundingWidth flush (x=0), matching V1
|
||||||
// (line 348). Inside a blockquote the child inset is raised above the page inset (by
|
// (line 348). Inside a blockquote the child inset is raised above the page inset (by
|
||||||
// lineInset), so honor it here — otherwise the full-width background bleeds out under the
|
// lineInset), so honor it here. Otherwise the full-width background bleeds under the
|
||||||
// quote bar instead of insetting to the quote's content gutter like the quote's text does.
|
// quote bar instead of insetting to the quote's content gutter like the quote's text does.
|
||||||
let blockHeight = textSize.height + backgroundInset * 2.0
|
let blockHeight = textSize.height + backgroundInset * 2.0
|
||||||
let isNestedInQuote = horizontalInset > context.pageHorizontalInset
|
let isNestedInQuote = horizontalInset > context.pageHorizontalInset
|
||||||
// Inset (quote-nested) code blocks get an 8pt rounded background; flush (top-level / details)
|
// Inset (quote-nested) code blocks get an 8pt rounded background; flush (top-level / details)
|
||||||
// ones stay square — the bubble's own rounded clip handles their edges.
|
// ones stay square. The bubble clip handles their edges.
|
||||||
let cornerRadius: CGFloat = isNestedInQuote ? 8.0 : 0.0
|
let cornerRadius: CGFloat = isNestedInQuote ? 8.0 : 0.0
|
||||||
let blockFrame = CGRect(
|
let blockFrame = CGRect(
|
||||||
x: isNestedInQuote ? horizontalInset : 0.0,
|
x: isNestedInQuote ? horizontalInset : 0.0,
|
||||||
|
|
@ -2364,9 +2357,9 @@ private func layoutBlockQuote(
|
||||||
attributedCaption,
|
attributedCaption,
|
||||||
boundingWidth: innerBoundingWidth,
|
boundingWidth: innerBoundingWidth,
|
||||||
alignment: context.rtl ? .right : .natural,
|
alignment: context.rtl ? .right : .natural,
|
||||||
// The caption is single-inset (band [H+lineInset, B-H]), unlike the double-inset
|
// The caption uses a single inset, unlike the double-inset
|
||||||
// child band, so it needs its own RTL mirror delta of -lineInset (→ [H, B-H-lineInset],
|
// child band, so it needs its own RTL mirror delta of -lineInset (→ [H, B-H-lineInset],
|
||||||
// tucked under the trailing bar) — NOT the children's bandOffsetX.
|
// tucked under the trailing bar), not the children's bandOffsetX.
|
||||||
offset: CGPoint(x: innerHorizontalInset + (context.rtl ? -lineInset : 0.0), y: contentHeight),
|
offset: CGPoint(x: innerHorizontalInset + (context.rtl ? -lineInset : 0.0), y: contentHeight),
|
||||||
fitToWidth: context.fitToWidth,
|
fitToWidth: context.fitToWidth,
|
||||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||||
|
|
@ -2477,7 +2470,7 @@ private func layoutQuoteText(
|
||||||
kind: .line(thickness: 1.0),
|
kind: .line(thickness: 1.0),
|
||||||
color: context.theme.textCategories.caption.color
|
color: context.theme.textCategories.caption.color
|
||||||
)
|
)
|
||||||
// Insert bottom rule before caption trailing space is consumed — append after final verticalInset.
|
// Insert the bottom rule before caption trailing space.
|
||||||
result.append(.shape(bottomLine))
|
result.append(.shape(bottomLine))
|
||||||
} else {
|
} else {
|
||||||
// blockQuote: vertical bar on the leading edge (V1 lines 547–549).
|
// blockQuote: vertical bar on the leading edge (V1 lines 547–549).
|
||||||
|
|
@ -2494,7 +2487,7 @@ private func layoutQuoteText(
|
||||||
|
|
||||||
// Tag this quote's produced text items at quote depth 1 so the markdown
|
// Tag this quote's produced text items at quote depth 1 so the markdown
|
||||||
// converter renders them with a `> ` prefix. Applies to BOTH block quotes
|
// converter renders them with a `> ` prefix. Applies to BOTH block quotes
|
||||||
// (single-paragraph fast path) and pull quotes — the whole-message markdown
|
// (single-paragraph fast path) and pull quotes. The whole-message markdown
|
||||||
// converter renders both flavors as `> `. Nested quotes are lifted further
|
// converter renders both flavors as `> `. Nested quotes are lifted further
|
||||||
// by the outer multi-block path's own bumpQuoteDepth(result) call.
|
// by the outer multi-block path's own bumpQuoteDepth(result) call.
|
||||||
bumpQuoteDepth(result)
|
bumpQuoteDepth(result)
|
||||||
|
|
@ -2514,12 +2507,10 @@ private func layoutList(
|
||||||
) -> [InstantPageV2LaidOutItem] {
|
) -> [InstantPageV2LaidOutItem] {
|
||||||
// Determine marker characteristics.
|
// Determine marker characteristics.
|
||||||
var maxIndexWidth: CGFloat = 0.0
|
var maxIndexWidth: CGFloat = 0.0
|
||||||
// hasNums: at least one ordered item carries an explicit `num` — in which case items
|
// hasNums means at least one ordered item carries an explicit `num`. Items
|
||||||
// without one fall back to a blank " " (preserves the source's numbering gaps) rather
|
// without one fall back to a blank " " rather
|
||||||
// than auto-generated `(i + 1).`. Unordered lists never auto-generate numbers, so this
|
// than auto-generated `(i + 1).`. Unordered lists never auto-generate numbers, so this
|
||||||
// flag is only meaningful when `ordered` is true. (`hasTaskMarkers` is no longer derived
|
// flag is only meaningful when `ordered` is true.
|
||||||
// — the uniform 8pt gap below replaced the per-list `indexSpacing` ternary that consumed
|
|
||||||
// it; column right-alignment handles mixed bullet/checkbox lists without flagging.)
|
|
||||||
var hasNums = false
|
var hasNums = false
|
||||||
if ordered {
|
if ordered {
|
||||||
for item in listItems {
|
for item in listItems {
|
||||||
|
|
@ -2542,10 +2533,8 @@ private func layoutList(
|
||||||
stroke: context.theme.pageBackgroundColor,
|
stroke: context.theme.pageBackgroundColor,
|
||||||
border: context.theme.controlColor
|
border: context.theme.controlColor
|
||||||
)
|
)
|
||||||
// Track maxIndexWidth for ALL marker kinds (ordered + unordered, all three shapes), not
|
// Track every marker kind and align them to one shared column edge.
|
||||||
// just ordered as V1/older V2 did. With every kind contributing to the marker column width
|
// Bullets and checkboxes align to the same x, and the same uniform
|
||||||
// we can right-align every marker to a single shared column edge — so in a mixed unordered
|
|
||||||
// list (bullets + checkboxes) both right-align flush to the same x, and the same uniform
|
|
||||||
// gap separates them from the text. The column width simply equals the widest marker; for
|
// gap separates them from the text. The column width simply equals the widest marker; for
|
||||||
// a pure bullet list `maxIndexWidth == 6` and the bullet sits at `horizontalInset` (visually
|
// a pure bullet list `maxIndexWidth == 6` and the bullet sits at `horizontalInset` (visually
|
||||||
// identical to the pre-change formula), and for a pure checkbox list `maxIndexWidth == 18`
|
// identical to the pre-change formula), and for a pure checkbox list `maxIndexWidth == 18`
|
||||||
|
|
@ -2594,12 +2583,8 @@ private func layoutList(
|
||||||
|
|
||||||
// Uniform 8pt marker→text gap across all four cases (ordered/unordered × bullet/number/
|
// Uniform 8pt marker→text gap across all four cases (ordered/unordered × bullet/number/
|
||||||
// checkbox). With markers right-aligned to a shared column of width `maxIndexWidth`, text
|
// checkbox). With markers right-aligned to a shared column of width `maxIndexWidth`, text
|
||||||
// starts at `horizontalInset + maxIndexWidth + indexSpacing` — so `indexSpacing` IS the
|
// starts at `horizontalInset + maxIndexWidth + indexSpacing`, so `indexSpacing` is the
|
||||||
// gap, regardless of marker shape. V1 used 12/16/20/24 (a mix of marker-area-width and
|
// gap, regardless of marker shape.
|
||||||
// gap-after-marker, depending on alignment); the four gaps came out to 12/16/14/6 — far
|
|
||||||
// from uniform, and a 14pt bullet gap looked especially loose. 8pt is a standard iOS list
|
|
||||||
// gap; it tightens bullets (14→8), numbers (12→8) and ordered-checkbox (16→8), and only
|
|
||||||
// loosens unordered-checkbox very slightly (6→8) so all four kinds match.
|
|
||||||
let indexSpacing: CGFloat = 8.0
|
let indexSpacing: CGFloat = 8.0
|
||||||
|
|
||||||
// Layout each item.
|
// Layout each item.
|
||||||
|
|
@ -2722,7 +2707,7 @@ private func layoutList(
|
||||||
|
|
||||||
// Nil-guard in stampMarkdownContext preserves any richer kind (e.g. .heading)
|
// Nil-guard in stampMarkdownContext preserves any richer kind (e.g. .heading)
|
||||||
// already stamped by a child block's own layout. So a heading nested inside a
|
// already stamped by a child block's own layout. So a heading nested inside a
|
||||||
// .blocks list item keeps .heading, not .listItem — multi-block list items are
|
// Multi-block list items keep `.heading`.
|
||||||
// a documented best-effort case for markdown reconstruction.
|
// a documented best-effort case for markdown reconstruction.
|
||||||
stampMarkdownContext(translatedItems, kind: .listItem(ordered: ordered, marker: markdownMarker, checked: markdownChecked))
|
stampMarkdownContext(translatedItems, kind: .listItem(ordered: ordered, marker: markdownMarker, checked: markdownChecked))
|
||||||
result.append(contentsOf: translatedItems)
|
result.append(contentsOf: translatedItems)
|
||||||
|
|
@ -2732,7 +2717,7 @@ private func layoutList(
|
||||||
|
|
||||||
// Mirror the .text case above (and what .checklist already does here): use the
|
// Mirror the .text case above (and what .checklist already does here): use the
|
||||||
// first text line's midY for centering. `originY` is the sub-block's TOP, NOT a
|
// first text line's midY for centering. `originY` is the sub-block's TOP, NOT a
|
||||||
// line midpoint — `markerFrameFor` then subtracts `size.height / 2`, so feeding
|
// line midpoint. `markerFrameFor` subtracts `size.height / 2`, so feeding
|
||||||
// `originY` placed the marker straddling the sub-block boundary, ½·marker-height
|
// `originY` placed the marker straddling the sub-block boundary, ½·marker-height
|
||||||
// ABOVE the first text line. V1 hid the same arithmetic under a 6×12 shape with a
|
// ABOVE the first text line. V1 hid the same arithmetic under a 6×12 shape with a
|
||||||
// 3pt internal offset (matching ½·fontLineHeight for 17pt paragraph text), which
|
// 3pt internal offset (matching ½·fontLineHeight for 17pt paragraph text), which
|
||||||
|
|
@ -2773,7 +2758,7 @@ private func layoutList(
|
||||||
/// For a pure-kind list `maxIndexWidth == markerWidth`, so the marker lands at `horizontalInset`
|
/// For a pure-kind list `maxIndexWidth == markerWidth`, so the marker lands at `horizontalInset`
|
||||||
/// exactly as before; for mixed unordered lists, bullets and checkboxes align flush at the
|
/// exactly as before; for mixed unordered lists, bullets and checkboxes align flush at the
|
||||||
/// column's inner edge. Column right-alignment is the single rule across every marker shape
|
/// column's inner edge. Column right-alignment is the single rule across every marker shape
|
||||||
/// — no `ordered` / `indexSpacing` split — which is why those parameters dropped.
|
/// without separate `ordered` or `indexSpacing` parameters.
|
||||||
private func markerFrameFor(
|
private func markerFrameFor(
|
||||||
kind: InstantPageV2ListMarkerKind,
|
kind: InstantPageV2ListMarkerKind,
|
||||||
naturalWidth: CGFloat,
|
naturalWidth: CGFloat,
|
||||||
|
|
@ -2802,7 +2787,7 @@ private func markerFrameFor(
|
||||||
return CGRect(x: x, y: floorToScreenPixels(lineMidY - size.height / 2.0), width: size.width, height: size.height)
|
return CGRect(x: x, y: floorToScreenPixels(lineMidY - size.height / 2.0), width: size.width, height: size.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Leading/trailing geometry helpers — the single source of truth for "which side is the
|
/// Leading and trailing geometry helpers.
|
||||||
/// block gutter on", gated on the page's explicit `rtl` flag. The `rtl == false` branch returns
|
/// block gutter on", gated on the page's explicit `rtl` flag. The `rtl == false` branch returns
|
||||||
/// the pre-existing literal so non-RTL pages are byte-identical.
|
/// the pre-existing literal so non-RTL pages are byte-identical.
|
||||||
|
|
||||||
|
|
@ -2969,7 +2954,7 @@ func v2FrameForLine(_ line: InstantPageTextLine, boundingWidth: CGFloat, alignme
|
||||||
// Returns the leading-edge x offset (line-origin-relative) for an inline-attachment's string
|
// Returns the leading-edge x offset (line-origin-relative) for an inline-attachment's string
|
||||||
// `range`, correct for both LTR and RTL runs. `CTLineGetOffsetForStringIndex` at the start index
|
// `range`, correct for both LTR and RTL runs. `CTLineGetOffsetForStringIndex` at the start index
|
||||||
// gives the glyph's LEFT edge in LTR text, but its RIGHT edge in RTL text (increasing string index
|
// gives the glyph's LEFT edge in LTR text, but its RIGHT edge in RTL text (increasing string index
|
||||||
// moves leftward) — so using the start-index offset alone as the left edge shoves an RTL attachment
|
// moves leftward), so using the start-index offset alone shifts an RTL attachment
|
||||||
// ~one advance too far right. Taking the min of the start- and end-index offsets yields the true
|
// ~one advance too far right. Taking the min of the start- and end-index offsets yields the true
|
||||||
// leading (left) edge in both directions. Mirrors `Display.TextNode`'s `addEmbeddedItem`, including
|
// leading (left) edge in both directions. Mirrors `Display.TextNode`'s `addEmbeddedItem`, including
|
||||||
// the directional-boundary secondary-offset handling. For a pure-LTR line this returns exactly the
|
// the directional-boundary secondary-offset handling. For a pure-LTR line this returns exactly the
|
||||||
|
|
@ -3195,7 +3180,7 @@ func layoutTextItem(
|
||||||
// Inline emoji and images do NOT inflate the line: they are centered on the font
|
// Inline emoji and images do NOT inflate the line: they are centered on the font
|
||||||
// line box and allowed to bleed above/below (mirroring V1 `layoutTextItemWithString`
|
// line box and allowed to bleed above/below (mirroring V1 `layoutTextItemWithString`
|
||||||
// and the chat `InteractiveTextComponent`). Their run delegates already report the
|
// and the chat `InteractiveTextComponent`). Their run delegates already report the
|
||||||
// font's own ascent/descent, so CoreText lays the line out at the normal height — the
|
// font's own ascent/descent, so CoreText keeps the normal line height.
|
||||||
// old `lineAscent = emoji.size` inflation both doubled the line height and (because the
|
// old `lineAscent = emoji.size` inflation both doubled the line height and (because the
|
||||||
// baseline sits at the bottom of the box) shoved the text baseline down. Only formulas,
|
// baseline sits at the bottom of the box) shoved the text baseline down. Only formulas,
|
||||||
// which carry their own typographic metrics, are allowed to grow the line.
|
// which carry their own typographic metrics, are allowed to grow the line.
|
||||||
|
|
@ -3335,9 +3320,7 @@ func layoutTextItem(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Per-character rects use each glyph's actual ink bounds via
|
// Caret advance width is too tight for italics,
|
||||||
// CTFontGetBoundingRectsForGlyphs — caret-position advance-width
|
|
||||||
// math (CTLineGetOffsetForStringIndex) is too tight for italics,
|
|
||||||
// accented marks, and any glyph with side bearings, which causes
|
// accented marks, and any glyph with side bearings, which causes
|
||||||
// the reveal mask to visibly clip the glyph edges. Mirrors
|
// the reveal mask to visibly clip the glyph edges. Mirrors
|
||||||
// InteractiveTextComponent.computeCharacterRectsForLine.
|
// InteractiveTextComponent.computeCharacterRectsForLine.
|
||||||
|
|
@ -3406,7 +3389,7 @@ func layoutTextItem(
|
||||||
// Image cell is centered on the font line box (see frame loop). Baseline-relative
|
// Image cell is centered on the font line box (see frame loop). Baseline-relative
|
||||||
// cell spans [fontLineHeight/2 − height/2, fontLineHeight/2 + height/2]; the full
|
// cell spans [fontLineHeight/2 − height/2, fontLineHeight/2 + height/2]; the full
|
||||||
// width feeds the reveal cost map so the streaming cursor is charged the image's
|
// width feeds the reveal cost map so the streaming cursor is charged the image's
|
||||||
// width when crossing it — same as an emoji cell.
|
// width when crossing it, like an emoji cell.
|
||||||
rects[localIndex] = CGRect(x: x, y: fontLineHeight / 2.0 - image.size.height / 2.0, width: image.size.width, height: image.size.height)
|
rects[localIndex] = CGRect(x: x, y: fontLineHeight / 2.0 - image.size.height / 2.0, width: image.size.width, height: image.size.height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,14 @@ import TelegramPresentationData
|
||||||
import GalleryUI
|
import GalleryUI
|
||||||
import UniversalMediaPlayer
|
import UniversalMediaPlayer
|
||||||
|
|
||||||
// Mutable weak box: lets a wrapper hand its `openMedia` closure a back-reference to itself,
|
// Mutable weak reference filled after `super.init`.
|
||||||
// filled in after `super.init` (when `self` becomes usable). SwiftSignalKit's `Weak<T>` requires
|
|
||||||
// a non-optional value at init time, so it can't be used here.
|
|
||||||
private final class WrapperRef {
|
private final class WrapperRef {
|
||||||
weak var view: UIView?
|
weak var view: UIView?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Shared media node factory
|
// MARK: - Shared media node factory
|
||||||
|
|
||||||
// Hosts a V1 `InstantPageImageNode` inside a V2 UIView wrapper. The caller sizes its own
|
// Creates an image node for a V2 media wrapper.
|
||||||
// frame from `item.frame` and adds the returned node's view as a subview.
|
|
||||||
func makeMediaWrapper(
|
func makeMediaWrapper(
|
||||||
frame: CGRect,
|
frame: CGRect,
|
||||||
media: InstantPageMedia,
|
media: InstantPageMedia,
|
||||||
|
|
@ -52,7 +49,7 @@ func makeMediaWrapper(
|
||||||
return imageNode
|
return imageNode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walks up the superview chain from `start` to find the nearest enclosing `InstantPageV2View`.
|
// Find the nearest enclosing V2 view.
|
||||||
private func findEnclosingV2View(from start: UIView?) -> InstantPageV2View? {
|
private func findEnclosingV2View(from start: UIView?) -> InstantPageV2View? {
|
||||||
var view: UIView? = start
|
var view: UIView? = start
|
||||||
while view != nil {
|
while view != nil {
|
||||||
|
|
@ -64,18 +61,13 @@ private func findEnclosingV2View(from start: UIView?) -> InstantPageV2View? {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registers `wrapper` in the root V2View's `mediaRegistry` under `mediaIndex`. The root is
|
// Register media at the root V2 view.
|
||||||
// reached by walking up the superview chain to the nearest `InstantPageV2View`, then walking
|
|
||||||
// its `rootMediaRegistryHost` chain transitively (nested details blocks can leave an inner
|
|
||||||
// body's host pointing at an intermediate body — see `trueRegistryRoot`). No-op if the wrapper
|
|
||||||
// isn't yet attached to a V2View ancestor.
|
|
||||||
func registerInRootRegistry(wrapper: UIView, mediaIndex: Int) {
|
func registerInRootRegistry(wrapper: UIView, mediaIndex: Int) {
|
||||||
guard let v2 = findEnclosingV2View(from: wrapper.superview) else { return }
|
guard let v2 = findEnclosingV2View(from: wrapper.superview) else { return }
|
||||||
v2.trueRegistryRoot.mediaRegistry[mediaIndex] = Weak(wrapper)
|
v2.trueRegistryRoot.mediaRegistry[mediaIndex] = Weak(wrapper)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routes a tap on `tapped` through `openInstantPageMedia`, sourcing sibling medias from the
|
// Open media with siblings from the root layout.
|
||||||
// root V2View's `currentLayout`. No-op if the wrapper isn't currently in a V2View tree.
|
|
||||||
func handleOpenMediaTap(
|
func handleOpenMediaTap(
|
||||||
tapped: InstantPageMedia,
|
tapped: InstantPageMedia,
|
||||||
wrapper: UIView,
|
wrapper: UIView,
|
||||||
|
|
@ -446,9 +438,7 @@ final class InstantPageV2MediaAudioView: UIView, InstantPageItemView {
|
||||||
let fetchMedia = item.media
|
let fetchMedia = item.media
|
||||||
self.audioNode.fetch = {
|
self.audioNode.fetch = {
|
||||||
guard case let .file(file) = fetchMedia.media, let message = fetchMessage, let messageId = message.id else { return }
|
guard case let .file(file) = fetchMedia.media, let message = fetchMessage, let messageId = message.id else { return }
|
||||||
// Route through the fetch manager (not freeMediaFileInteractiveFetched) so the
|
// Use the fetch manager so status reports `.Fetching`.
|
||||||
// messageMediaFileStatus signal — which keys progress off the fetch manager's
|
|
||||||
// `hasEntry` — surfaces .Fetching, letting the overlay show the animated ring.
|
|
||||||
let _ = messageMediaFileInteractiveFetched(fetchManager: fetchContext.fetchManager, messageId: messageId, messageReference: message, file: file, userInitiated: true, priority: .userInitiated).startStandalone()
|
let _ = messageMediaFileInteractiveFetched(fetchManager: fetchContext.fetchManager, messageId: messageId, messageReference: message, file: file, userInitiated: true, priority: .userInitiated).startStandalone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ extension InstantPageV2RevealCostMap {
|
||||||
|
|
||||||
// Reveal cost is in width units (points along the reading direction). The unit is uniform
|
// Reveal cost is in width units (points along the reading direction). The unit is uniform
|
||||||
// across all item kinds, so the streaming reveal pace ("points per second") is visually
|
// across all item kinds, so the streaming reveal pace ("points per second") is visually
|
||||||
// consistent — wide tables and media take proportionally longer than narrow inline text.
|
// consistent. Wide tables and media take longer than narrow inline text.
|
||||||
//
|
//
|
||||||
// For text items, the cost is the sum of glyph ink widths across all lines (the total ink
|
// For text items, the cost is the sum of glyph ink widths across all lines (the total ink
|
||||||
// extent in reading direction). For non-text items, the cost is `item.frame.width`. Zero-width
|
// extent in reading direction). For non-text items, the cost is `item.frame.width`. Zero-width
|
||||||
|
|
@ -112,7 +112,7 @@ public extension InstantPageV2RevealCostMap {
|
||||||
/// * `details` / `codeBlock`: the whole container appears as soon as revealedCount > entry.start
|
/// * `details` / `codeBlock`: the whole container appears as soon as revealedCount > entry.start
|
||||||
/// (background and chrome pop in atomically, then inner text reveals char-by-char).
|
/// (background and chrome pop in atomically, then inner text reveals char-by-char).
|
||||||
///
|
///
|
||||||
/// Used by the rich-data bubble to size itself to the revealed prefix during AI streaming,
|
/// Used by the rich-data bubble to size itself to the revealed prefix during streaming,
|
||||||
/// mirroring TextBubble's `clippedGlyphCountLayout = textLayout.layoutForCharacterCount(...)`.
|
/// mirroring TextBubble's `clippedGlyphCountLayout = textLayout.layoutForCharacterCount(...)`.
|
||||||
func revealedContentSize(revealedCount: Int, layout: InstantPageV2Layout) -> CGSize {
|
func revealedContentSize(revealedCount: Int, layout: InstantPageV2Layout) -> CGSize {
|
||||||
let bounds = computeRevealedBounds(items: layout.items, entries: self.topLevelEntries, revealedCount: revealedCount)
|
let bounds = computeRevealedBounds(items: layout.items, entries: self.topLevelEntries, revealedCount: revealedCount)
|
||||||
|
|
@ -121,7 +121,7 @@ public extension InstantPageV2RevealCostMap {
|
||||||
}
|
}
|
||||||
// The full layout reserves a closing spacing after the last top-level block (see
|
// The full layout reserves a closing spacing after the last top-level block (see
|
||||||
// `closingSpacing` in layoutBlockSequence). Mirror that so a partially-revealed
|
// `closingSpacing` in layoutBlockSequence). Mirror that so a partially-revealed
|
||||||
// bubble has the same bottom padding as a fully-revealed one — otherwise the last
|
// bubble has the same bottom padding as a fully revealed one. Otherwise the last
|
||||||
// revealed line sits flush against the bubble's bottom edge.
|
// revealed line sits flush against the bubble's bottom edge.
|
||||||
let lastItemMaxY = layout.items.map { $0.frame.maxY }.max() ?? 0.0
|
let lastItemMaxY = layout.items.map { $0.frame.maxY }.max() ?? 0.0
|
||||||
let closingPad = max(0.0, layout.contentSize.height - lastItemMaxY)
|
let closingPad = max(0.0, layout.contentSize.height - lastItemMaxY)
|
||||||
|
|
@ -130,7 +130,7 @@ public extension InstantPageV2RevealCostMap {
|
||||||
|
|
||||||
/// Returns the maxY of revealed items in `layout` coords (no closing pad). Use this to
|
/// Returns the maxY of revealed items in `layout` coords (no closing pad). Use this to
|
||||||
/// size the InstantPageV2View itself so its content never overflows past the revealed
|
/// size the InstantPageV2View itself so its content never overflows past the revealed
|
||||||
/// extent — the bubble's closing pad sits in containerNode space *outside* the pageView,
|
/// extent. The bubble's closing pad sits in containerNode space outside the pageView,
|
||||||
/// not inside it (where unrevealed items would otherwise draw).
|
/// not inside it (where unrevealed items would otherwise draw).
|
||||||
func revealedItemsMaxY(revealedCount: Int, layout: InstantPageV2Layout) -> CGFloat {
|
func revealedItemsMaxY(revealedCount: Int, layout: InstantPageV2Layout) -> CGFloat {
|
||||||
let bounds = computeRevealedBounds(items: layout.items, entries: self.topLevelEntries, revealedCount: revealedCount)
|
let bounds = computeRevealedBounds(items: layout.items, entries: self.topLevelEntries, revealedCount: revealedCount)
|
||||||
|
|
@ -308,7 +308,7 @@ private func computeEntries(items: [InstantPageV2LaidOutItem], cursor: inout Int
|
||||||
// Each cell consumes at least its frame.width worth of cursor advance,
|
// Each cell consumes at least its frame.width worth of cursor advance,
|
||||||
// even if its inner text ink width is smaller (or it has no subLayout
|
// even if its inner text ink width is smaller (or it has no subLayout
|
||||||
// at all). Without this floor, narrow- or empty-cell tables ran through
|
// at all). Without this floor, narrow- or empty-cell tables ran through
|
||||||
// the cursor much faster than their visual width warrants — a 3-column
|
// the cursor much faster than their visual width warrants. A 3-column
|
||||||
// table of "1"/"2"/"3" costs ~30pt while occupying ~200pt visually.
|
// table of "1"/"2"/"3" costs ~30pt while occupying ~200pt visually.
|
||||||
// Text inside a cell still char-reveals against its own ink widths; the
|
// Text inside a cell still char-reveals against its own ink widths; the
|
||||||
// "extra" cost (cell width − inner ink) is filler time during which the
|
// "extra" cost (cell width − inner ink) is filler time during which the
|
||||||
|
|
@ -330,7 +330,7 @@ private func computeEntries(items: [InstantPageV2LaidOutItem], cursor: inout Int
|
||||||
}
|
}
|
||||||
entries.append(.table(start: start, end: cursor, rows: rows, title: titleMap))
|
entries.append(.table(start: start, end: cursor, rows: rows, title: titleMap))
|
||||||
case .thinking:
|
case .thinking:
|
||||||
// Zero cost: do NOT advance the cursor. This is the linchpin — answer-content cursor
|
// Do not advance the cursor for thinking blocks.
|
||||||
// positions are identical whether or not thinking blocks are present, so adding/
|
// positions are identical whether or not thinking blocks are present, so adding/
|
||||||
// removing a thinking block never jumps the answer's reveal position.
|
// removing a thinking block never jumps the answer's reveal position.
|
||||||
entries.append(.thinking(start: cursor))
|
entries.append(.thinking(start: cursor))
|
||||||
|
|
@ -432,7 +432,7 @@ private func applyRevealEntry(view: InstantPageItemView, entry: InstantPageV2Rev
|
||||||
let _ = start
|
let _ = start
|
||||||
case let .thinking(start):
|
case let .thinking(start):
|
||||||
// Whole-block 0.12s alpha fade-in at the index position; inner text is drawn fully
|
// Whole-block 0.12s alpha fade-in at the index position; inner text is drawn fully
|
||||||
// (never char-reveal-masked) — the shimmer is the only ongoing animation.
|
// (never character-masked). The shimmer is the only ongoing animation.
|
||||||
let visible = revealedCount >= start
|
let visible = revealedCount >= start
|
||||||
applyVisibility(view: view, visible: visible, animated: animated)
|
applyVisibility(view: view, visible: visible, animated: animated)
|
||||||
}
|
}
|
||||||
|
|
@ -520,7 +520,7 @@ private func applyTableReveal(tableView: InstantPageV2TableView, start: Int, end
|
||||||
let rowMaxY = cellsInRow.map { $0.frame.maxY }.max() ?? 0.0
|
let rowMaxY = cellsInRow.map { $0.frame.maxY }.max() ?? 0.0
|
||||||
maskHeight = gridOffsetY + rowMaxY
|
maskHeight = gridOffsetY + rowMaxY
|
||||||
} else {
|
} else {
|
||||||
// No rows revealed yet — but the title (if any) is still visible above the grid.
|
// Keep the title visible before the first row.
|
||||||
maskHeight = (title != nil) ? gridOffsetY : 0.0
|
maskHeight = (title != nil) ? gridOffsetY : 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,7 @@ import AccountContext
|
||||||
import TelegramCore
|
import TelegramCore
|
||||||
import TelegramPresentationData
|
import TelegramPresentationData
|
||||||
|
|
||||||
// A paged carousel for an `InstantPageBlock.slideshow`. Ports V1's InstantPageSlideshowNode /
|
// Paged slideshow backed by `InstantPageImageNode`.
|
||||||
// InstantPageSlideshowPagerNode (InstantPageSlideshowItemNode.swift), simplified to create all pages
|
|
||||||
// eagerly (slideshows are short; this avoids V1's central±1 index bookkeeping and makes the gallery
|
|
||||||
// transition source available for every page). Each image page hosts an `InstantPageImageNode` exactly
|
|
||||||
// like the static media views; non-image medias render an empty page (matches V1).
|
|
||||||
final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollViewDelegate {
|
final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollViewDelegate {
|
||||||
private(set) var item: InstantPageV2SlideshowItem
|
private(set) var item: InstantPageV2SlideshowItem
|
||||||
var itemFrame: CGRect { return self.item.frame }
|
var itemFrame: CGRect { return self.item.frame }
|
||||||
|
|
@ -21,9 +17,7 @@ final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollVie
|
||||||
private let scrollView: UIScrollView
|
private let scrollView: UIScrollView
|
||||||
private let pageControlNode: PageControlNode
|
private let pageControlNode: PageControlNode
|
||||||
|
|
||||||
// One wrapper view per media (so page count stays aligned with the page control). `pageImageNodes`
|
// Keep one page per media so the page control stays aligned.
|
||||||
// holds only the real image nodes; it may be shorter than `pageViews` if a non-image media appears
|
|
||||||
// (which `layoutSlideshow` currently filters out). Nothing relies on positional correspondence.
|
|
||||||
private var pageViews: [UIView] = []
|
private var pageViews: [UIView] = []
|
||||||
private var pageImageNodes: [InstantPageImageNode] = []
|
private var pageImageNodes: [InstantPageImageNode] = []
|
||||||
|
|
||||||
|
|
@ -68,7 +62,7 @@ final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollVie
|
||||||
self.pageImageNodes = []
|
self.pageImageNodes = []
|
||||||
|
|
||||||
let renderContext = self.renderContext
|
let renderContext = self.renderContext
|
||||||
// The image node owns this closure, and is owned (transitively) by self — capture weakly.
|
// The image node retains this closure.
|
||||||
let openMedia: (InstantPageMedia) -> Void = { [weak self] tapped in
|
let openMedia: (InstantPageMedia) -> Void = { [weak self] tapped in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
handleOpenMediaTap(tapped: tapped, wrapper: self, renderContext: renderContext)
|
handleOpenMediaTap(tapped: tapped, wrapper: self, renderContext: renderContext)
|
||||||
|
|
@ -91,23 +85,21 @@ final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollVie
|
||||||
pageView.addSubview(node.view)
|
pageView.addSubview(node.view)
|
||||||
self.pageImageNodes.append(node)
|
self.pageImageNodes.append(node)
|
||||||
}
|
}
|
||||||
// Non-image medias (none in practice — layoutSlideshow filters to images) get an empty page
|
// Keep an empty page for unexpected non-image media.
|
||||||
// to keep page indices aligned with the page control.
|
|
||||||
self.scrollView.addSubview(pageView)
|
self.scrollView.addSubview(pageView)
|
||||||
self.pageViews.append(pageView)
|
self.pageViews.append(pageView)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.pageControlNode.pagesCount = self.item.medias.count
|
self.pageControlNode.pagesCount = self.item.medias.count
|
||||||
self.pageControlNode.setPage(0.0)
|
self.pageControlNode.setPage(0.0)
|
||||||
// Re-register media indices when rebuilding while already on-window (positional reuse with
|
// Re-register reused pages already attached to a window.
|
||||||
// changed content); no-ops before the view is attached, where didMoveToWindow handles it.
|
|
||||||
self.registerMedias()
|
self.registerMedias()
|
||||||
self.setNeedsLayout()
|
self.setNeedsLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func registerMedias() {
|
private func registerMedias() {
|
||||||
guard self.window != nil else { return }
|
guard self.window != nil else { return }
|
||||||
// Register under every contained media index so transitionArgsFor(media) can find this view.
|
// Register every media index for gallery transitions.
|
||||||
for media in self.item.medias {
|
for media in self.item.medias {
|
||||||
registerInRootRegistry(wrapper: self, mediaIndex: media.index)
|
registerInRootRegistry(wrapper: self, mediaIndex: media.index)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -248,8 +248,8 @@ public final class FFMpegMediaVideoFrameDecoder: MediaTrackFrameDecoder {
|
||||||
guard format == .YUV || format == .YUVA else {
|
guard format == .YUV || format == .YUVA else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Reject bottom-up / negative-stride frames before any row-copy loop — ffmpeg
|
// Reject bottom-up and negative-stride frames before copying rows.
|
||||||
// rawdec flips linesize[0] for codec_tag WRAW/cyuv/BottomUp, and other decoders
|
// ffmpeg rawdec flips linesize[0] for WRAW, cyuv, and BottomUp.
|
||||||
// (dxtory, mimic, mjpegdec, scpr) flip the chroma planes too.
|
// (dxtory, mimic, mjpegdec, scpr) flip the chroma planes too.
|
||||||
if frame.lineSize[0] < 0 || frame.lineSize[1] < 0 || frame.lineSize[2] < 0 {
|
if frame.lineSize[0] < 0 || frame.lineSize[1] < 0 || frame.lineSize[2] < 0 {
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -460,7 +460,7 @@ static bool executeGenerationCodeRecursive(NSString *code, HelloParseState *stat
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case HelloGenerationCommandEndAlternative: {
|
case HelloGenerationCommandEndAlternative: {
|
||||||
// signals end of current alternative — return to caller
|
// End the current alternative.
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case HelloGenerationCommandBeginAlternative:
|
case HelloGenerationCommandBeginAlternative:
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,9 @@
|
||||||
|
|
||||||
@property (nonatomic) CGRect defaultFrame;
|
@property (nonatomic) CGRect defaultFrame;
|
||||||
|
|
||||||
|
// Brand icon overlaid on the first intro page animation.
|
||||||
|
@property (nonatomic, strong) UIImage *firstPageIcon;
|
||||||
|
|
||||||
- (instancetype)initWithBackgroundColor:(UIColor *)backgroundColor primaryColor:(UIColor *)primaryColor buttonColor:(UIColor *)buttonColor accentColor:(UIColor *)accentColor regularDotColor:(UIColor *)regularDotColor highlightedDotColor:(UIColor *)highlightedDotColor suggestedLocalizationSignal:(SSignal *)suggestedLocalizationSignal;
|
- (instancetype)initWithBackgroundColor:(UIColor *)backgroundColor primaryColor:(UIColor *)primaryColor buttonColor:(UIColor *)buttonColor accentColor:(UIColor *)accentColor regularDotColor:(UIColor *)regularDotColor highlightedDotColor:(UIColor *)highlightedDotColor suggestedLocalizationSignal:(SSignal *)suggestedLocalizationSignal;
|
||||||
|
|
||||||
@property (nonatomic, copy) void (^startMessaging)(void);
|
@property (nonatomic, copy) void (^startMessaging)(void);
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,8 @@ typedef enum {
|
||||||
UIView *_wrapperView;
|
UIView *_wrapperView;
|
||||||
UIView *_startButton;
|
UIView *_startButton;
|
||||||
|
|
||||||
|
UIImageView *_firstPageIconView;
|
||||||
|
|
||||||
bool _loadedView;
|
bool _loadedView;
|
||||||
}
|
}
|
||||||
@end
|
@end
|
||||||
|
|
@ -379,6 +381,14 @@ typedef enum {
|
||||||
p.clearsContextBeforeDrawing = false;
|
p.clearsContextBeforeDrawing = false;
|
||||||
[_pageViews addObject:p];
|
[_pageViews addObject:p];
|
||||||
[_pageScrollView addSubview:p];
|
[_pageScrollView addSubview:p];
|
||||||
|
|
||||||
|
// Brand icon on the first intro page; frame is set in -updateLayout.
|
||||||
|
if (i == 0 && _firstPageIcon != nil) {
|
||||||
|
p.clipsToBounds = false;
|
||||||
|
_firstPageIconView = [[UIImageView alloc] initWithImage:_firstPageIcon];
|
||||||
|
_firstPageIconView.contentMode = UIViewContentModeScaleAspectFit;
|
||||||
|
[p addSubview:_firstPageIconView];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
[_pageScrollView setPage:0];
|
[_pageScrollView setPage:0];
|
||||||
|
|
||||||
|
|
@ -574,6 +584,17 @@ typedef enum {
|
||||||
[_pageViews enumerateObjectsUsingBlock:^(UIView *pageView, NSUInteger index, __unused BOOL *stop) {
|
[_pageViews enumerateObjectsUsingBlock:^(UIView *pageView, NSUInteger index, __unused BOOL *stop) {
|
||||||
pageView.frame = CGRectMake(index * self.view.bounds.size.width, (pageY - statusBarHeight), self.view.bounds.size.width, 150);
|
pageView.frame = CGRectMake(index * self.view.bounds.size.width, (pageY - statusBarHeight), self.view.bounds.size.width, 150);
|
||||||
}];
|
}];
|
||||||
|
|
||||||
|
if (_firstPageIconView != nil) {
|
||||||
|
bool isIpad = ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad);
|
||||||
|
CGFloat glSize = isIpad ? 200.0f * 1.2f : 200.0f;
|
||||||
|
CGFloat iconSize = floor(glSize * 0.6f);
|
||||||
|
// Center the icon on the GL animation square, in page-local coordinates.
|
||||||
|
CGFloat glCenterY = (_glkView.frame.origin.y + glSize / 2.0f);
|
||||||
|
CGFloat pageTopAbsolute = _pageScrollView.frame.origin.y + (pageY - statusBarHeight);
|
||||||
|
CGFloat iconCenterYLocal = glCenterY - pageTopAbsolute;
|
||||||
|
_firstPageIconView.frame = CGRectMake(floor((self.view.bounds.size.width - iconSize) / 2.0f), iconCenterYLocal - iconSize / 2.0f, iconSize, iconSize);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)viewWillAppear:(BOOL)animated
|
- (void)viewWillAppear:(BOOL)animated
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ swift_library(
|
||||||
"//submodules/PhoneInputNode:PhoneInputNode",
|
"//submodules/PhoneInputNode:PhoneInputNode",
|
||||||
"//submodules/PhotoResources:PhotoResources",
|
"//submodules/PhotoResources:PhotoResources",
|
||||||
"//submodules/StickerResources:StickerResources",
|
"//submodules/StickerResources:StickerResources",
|
||||||
|
"//submodules/SolidRoundedButtonNode:SolidRoundedButtonNode",
|
||||||
"//submodules/ProgressNavigationButtonNode:ProgressNavigationButtonNode",
|
"//submodules/ProgressNavigationButtonNode:ProgressNavigationButtonNode",
|
||||||
"//submodules/RadialStatusNode:RadialStatusNode",
|
"//submodules/RadialStatusNode:RadialStatusNode",
|
||||||
"//submodules/SearchBarNode:SearchBarNode",
|
"//submodules/SearchBarNode:SearchBarNode",
|
||||||
|
|
|
||||||
|
|
@ -393,7 +393,9 @@ private enum WinterGramDeletedMessagesControllerEntry: ItemListNodeEntry {
|
||||||
case let .topChat(_, peer, count, size, _):
|
case let .topChat(_, peer, count, size, _):
|
||||||
let sizeFormatting = DataSizeStringFormatting(strings: presentationData.strings, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator)
|
let sizeFormatting = DataSizeStringFormatting(strings: presentationData.strings, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator)
|
||||||
let subtitle = "\(count) • \(dataSizeString(size, formatting: sizeFormatting))"
|
let subtitle = "\(count) • \(dataSizeString(size, formatting: sizeFormatting))"
|
||||||
return ItemListPeerItem(presentationData: presentationData, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, context: arguments.context, peer: peer, presence: nil, text: .text(subtitle, .secondary), label: .none, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: nil), switchValue: nil, enabled: true, selectable: false, sectionId: self.section, action: nil, setPeerIdWithRevealedOptions: { _, _ in }, removePeer: { _ in })
|
return ItemListPeerItem(presentationData: presentationData, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, context: arguments.context, peer: peer, presence: nil, text: .text(subtitle, .secondary), label: .none, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: nil), switchValue: nil, enabled: true, selectable: true, sectionId: self.section, action: {
|
||||||
|
arguments.openChat(peer)
|
||||||
|
}, setPeerIdWithRevealedOptions: { _, _ in }, removePeer: { _ in })
|
||||||
case let .delete(_, text, enabled):
|
case let .delete(_, text, enabled):
|
||||||
return ItemListPeerActionItem(presentationData: presentationData, style: .blocks, icon: PresentationResourcesItemList.deleteIconImage(presentationData.theme), title: text, sectionId: self.section, color: enabled ? .destructive : .disabled, editing: false, action: enabled ? {
|
return ItemListPeerActionItem(presentationData: presentationData, style: .blocks, icon: PresentationResourcesItemList.deleteIconImage(presentationData.theme), title: text, sectionId: self.section, color: enabled ? .destructive : .disabled, editing: false, action: enabled ? {
|
||||||
arguments.deleteSelected()
|
arguments.deleteSelected()
|
||||||
|
|
@ -414,11 +416,13 @@ private final class WinterGramDeletedMessagesControllerArguments {
|
||||||
let context: AccountContext
|
let context: AccountContext
|
||||||
let toggleCategory: (WinterGramDeletedMessageCategory) -> Void
|
let toggleCategory: (WinterGramDeletedMessageCategory) -> Void
|
||||||
let deleteSelected: () -> Void
|
let deleteSelected: () -> Void
|
||||||
|
let openChat: (EnginePeer) -> Void
|
||||||
|
|
||||||
init(context: AccountContext, toggleCategory: @escaping (WinterGramDeletedMessageCategory) -> Void, deleteSelected: @escaping () -> Void) {
|
init(context: AccountContext, toggleCategory: @escaping (WinterGramDeletedMessageCategory) -> Void, deleteSelected: @escaping () -> Void, openChat: @escaping (EnginePeer) -> Void) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.toggleCategory = toggleCategory
|
self.toggleCategory = toggleCategory
|
||||||
self.deleteSelected = deleteSelected
|
self.deleteSelected = deleteSelected
|
||||||
|
self.openChat = openChat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -428,6 +432,15 @@ private func winterGramDeletedMessagesControllerEntries(stats: WinterGramDeleted
|
||||||
var entries: [WinterGramDeletedMessagesControllerEntry] = []
|
var entries: [WinterGramDeletedMessagesControllerEntry] = []
|
||||||
|
|
||||||
entries.append(.overviewHeader(theme, strings.WinterGram_DeletedMessages_Title.uppercased()))
|
entries.append(.overviewHeader(theme, strings.WinterGram_DeletedMessages_Title.uppercased()))
|
||||||
|
|
||||||
|
// Empty state: when nothing has been saved yet, drop the (otherwise empty) pie chart, the category
|
||||||
|
// checklist and the delete action — they read as a "cheap" empty ring — and just explain where saved
|
||||||
|
// deletions come from, matching the calm empty states of the native Storage Usage screen.
|
||||||
|
if stats.totalCount == 0 {
|
||||||
|
entries.append(.overviewInfo(theme, strings.WinterGram_DeletedAndEditedMessagesAreKeptLocallyOnThisDeviceOnly))
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
entries.append(.overviewInfo(theme, "\(strings.WinterGram_DeletedMessages_Total): \(stats.totalCount) • \(dataSizeString(stats.totalSize, formatting: sizeFormatting))"))
|
entries.append(.overviewInfo(theme, "\(strings.WinterGram_DeletedMessages_Total): \(stats.totalCount) • \(dataSizeString(stats.totalSize, formatting: sizeFormatting))"))
|
||||||
|
|
||||||
let chartTotal = stats.categories.reduce(Int64(0)) { $0 + max(0, $1.size) }
|
let chartTotal = stats.categories.reduce(Int64(0)) { $0 + max(0, $1.size) }
|
||||||
|
|
@ -468,9 +481,6 @@ private func winterGramDeletedMessagesControllerEntries(stats: WinterGramDeleted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let canDelete = !selectedCategories.isEmpty
|
|
||||||
entries.append(.delete(theme, strings.WinterGram_DeletedMessages_DeleteSelected, canDelete))
|
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -517,6 +527,10 @@ public func winterGramDeletedMessagesController(context: AccountContext) -> View
|
||||||
})
|
})
|
||||||
])])
|
])])
|
||||||
presentControllerImpl?(actionSheet, nil)
|
presentControllerImpl?(actionSheet, nil)
|
||||||
|
}, openChat: { peer in
|
||||||
|
if let navigationController = context.sharedContext.mainWindow?.viewController as? NavigationController {
|
||||||
|
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer)))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let statsSignal = winterGramDeletedMessagesStats(postbox: context.account.postbox)
|
let statsSignal = winterGramDeletedMessagesStats(postbox: context.account.postbox)
|
||||||
|
|
@ -545,7 +559,22 @@ public func winterGramDeletedMessagesController(context: AccountContext) -> View
|
||||||
)
|
)
|
||||||
|> map { presentationData, stats, selectedCategories, topPeers -> (ItemListControllerState, (ItemListNodeState, Any)) in
|
|> map { presentationData, stats, selectedCategories, topPeers -> (ItemListControllerState, (ItemListNodeState, Any)) in
|
||||||
let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(presentationData.strings.WinterGram_DeletedMessages_Title), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back))
|
let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(presentationData.strings.WinterGram_DeletedMessages_Title), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back))
|
||||||
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: winterGramDeletedMessagesControllerEntries(stats: stats, topPeers: topPeers, selectedCategories: selectedCategories, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, theme: presentationData.theme), style: .blocks, animateChanges: true)
|
|
||||||
|
let canDelete = !selectedCategories.isEmpty
|
||||||
|
let sizeFormatting = DataSizeStringFormatting(strings: presentationData.strings, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator)
|
||||||
|
let selectedSize = stats.categories.reduce(Int64(0)) { partial, stat in
|
||||||
|
return selectedCategories.contains(stat.category) ? partial + max(0, stat.size) : partial
|
||||||
|
}
|
||||||
|
var footerTitle = presentationData.strings.WinterGram_DeletedMessages_DeleteSelected
|
||||||
|
if canDelete && selectedSize > 0 {
|
||||||
|
footerTitle += " " + dataSizeString(selectedSize, formatting: sizeFormatting)
|
||||||
|
}
|
||||||
|
// No saved deletions → no destructive footer (matches the empty state from the entries builder).
|
||||||
|
let footerItem: WinterGramDeletedMessagesFooterItem? = stats.totalCount == 0 ? nil : WinterGramDeletedMessagesFooterItem(theme: presentationData.theme, title: footerTitle, isEnabled: canDelete, action: {
|
||||||
|
arguments.deleteSelected()
|
||||||
|
})
|
||||||
|
|
||||||
|
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: winterGramDeletedMessagesControllerEntries(stats: stats, topPeers: topPeers, selectedCategories: selectedCategories, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, theme: presentationData.theme), style: .blocks, footerItem: footerItem, animateChanges: true)
|
||||||
return (controllerState, (listState, arguments))
|
return (controllerState, (listState, arguments))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -276,10 +276,17 @@ class ThemeSettingsChatPreviewItemNode: ListViewItemNode {
|
||||||
}
|
}
|
||||||
node.updateFrame(CGRect(origin: CGPoint(x: 0.0, y: topOffset), size: node.frame.size), within: layoutSize)
|
node.updateFrame(CGRect(origin: CGPoint(x: 0.0, y: topOffset), size: node.frame.size), within: layoutSize)
|
||||||
if avatarFrame == nil, nodeIndex < displayedMessageItems.count, !displayedMessageItems[nodeIndex].outgoing {
|
if avatarFrame == nil, nodeIndex < displayedMessageItems.count, !displayedMessageItems[nodeIndex].outgoing {
|
||||||
let avatarSize: CGFloat = 40.0
|
// The message nodes live in `containerNode`, which is flipped 180° (π about Z),
|
||||||
|
// so a node placed at container-space top `topOffset` is drawn with its visual
|
||||||
|
// bottom at `contentSize.height - topOffset`. The avatar is a direct subnode of
|
||||||
|
// `self` (un-flipped), so compute its position in screen space and snap its
|
||||||
|
// bottom to the incoming bubble's tail — matching a real Telegram message row.
|
||||||
|
let avatarSize: CGFloat = 37.0
|
||||||
|
let tailBottomInset: CGFloat = -2.0
|
||||||
|
let visualBottom = contentSize.height - topOffset
|
||||||
avatarFrame = CGRect(
|
avatarFrame = CGRect(
|
||||||
x: params.leftInset + 9.0,
|
x: params.leftInset + 9.0,
|
||||||
y: topOffset + max(6.0, node.frame.height - avatarSize - 8.0),
|
y: visualBottom - avatarSize - tailBottomInset,
|
||||||
width: avatarSize,
|
width: avatarSize,
|
||||||
height: avatarSize
|
height: avatarSize
|
||||||
)
|
)
|
||||||
|
|
@ -292,7 +299,11 @@ class ThemeSettingsChatPreviewItemNode: ListViewItemNode {
|
||||||
let avatarRadius = min(avatarFrame.width, avatarFrame.height) * CGFloat(max(0, min(50, item.avatarCornerRadius))) / 100.0
|
let avatarRadius = min(avatarFrame.width, avatarFrame.height) * CGFloat(max(0, min(50, item.avatarCornerRadius))) / 100.0
|
||||||
strongSelf.avatarNode.layer.cornerRadius = avatarRadius
|
strongSelf.avatarNode.layer.cornerRadius = avatarRadius
|
||||||
strongSelf.avatarNode.layer.masksToBounds = true
|
strongSelf.avatarNode.layer.masksToBounds = true
|
||||||
strongSelf.avatarNode.setPeer(context: item.context, theme: item.componentTheme, peer: avatarPeer, synchronousLoad: false, displayDimensions: avatarFrame.size)
|
// Generate a SQUARE avatar image (clipStyle .none) and let this node's own
|
||||||
|
// cornerRadius define the shape. Otherwise AvatarNode bakes the *global*
|
||||||
|
// avatarCornerRadius into the image and early-returns on re-`setPeer`, so the
|
||||||
|
// live preview value (dragged slider) never takes effect.
|
||||||
|
strongSelf.avatarNode.setPeer(context: item.context, theme: item.componentTheme, peer: avatarPeer, clipStyle: .none, synchronousLoad: false, displayDimensions: avatarFrame.size)
|
||||||
strongSelf.avatarNode.updateSize(size: avatarFrame.size)
|
strongSelf.avatarNode.updateSize(size: avatarFrame.size)
|
||||||
} else {
|
} else {
|
||||||
strongSelf.avatarNode.isHidden = true
|
strongSelf.avatarNode.isHidden = true
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@ import TelegramPresentationData
|
||||||
import ItemListUI
|
import ItemListUI
|
||||||
import PresentationDataUtils
|
import PresentationDataUtils
|
||||||
|
|
||||||
// A hero banner shown at the very top of the WinterGram settings menu, just under the navigation
|
|
||||||
// bar / Dynamic Island: a snowflake app-tile, the WinterGram name and a short tagline.
|
|
||||||
private func winterGramBannerIcon(size: CGSize) -> UIImage {
|
private func winterGramBannerIcon(size: CGSize) -> UIImage {
|
||||||
let renderer = UIGraphicsImageRenderer(size: size)
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
return renderer.image { rendererContext in
|
return renderer.image { rendererContext in
|
||||||
|
|
@ -28,7 +26,6 @@ private func winterGramBannerIcon(size: CGSize) -> UIImage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renders the user's current app icon as a rounded-rect tile for the banner.
|
|
||||||
private func winterGramBannerRoundedIcon(image: UIImage, size: CGSize) -> UIImage {
|
private func winterGramBannerRoundedIcon(image: UIImage, size: CGSize) -> UIImage {
|
||||||
let renderer = UIGraphicsImageRenderer(size: size)
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
return renderer.image { _ in
|
return renderer.image { _ in
|
||||||
|
|
@ -137,7 +134,6 @@ class WinterGramBannerItemNode: ListViewItemNode {
|
||||||
strongSelf.item = item
|
strongSelf.item = item
|
||||||
let width = params.width
|
let width = params.width
|
||||||
|
|
||||||
// No grey backplate behind the banner — it sits flat on the grouped background.
|
|
||||||
strongSelf.backgroundNode.backgroundColor = .clear
|
strongSelf.backgroundNode.backgroundColor = .clear
|
||||||
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: contentHeight))
|
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: contentHeight))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,28 +35,29 @@ private enum WinterGramMainSettingsSection: Int32 {
|
||||||
case links
|
case links
|
||||||
}
|
}
|
||||||
|
|
||||||
// A link row shown in the "Links" section: SF Symbol, label, accent-coloured value and target URL.
|
|
||||||
private struct WinterGramLink {
|
private struct WinterGramLink {
|
||||||
let symbol: String
|
let symbol: String
|
||||||
let imageName: String?
|
let imageName: String?
|
||||||
let title: String
|
let title: String
|
||||||
let value: String
|
let value: String
|
||||||
let url: String
|
let url: String
|
||||||
|
let color: UIColor
|
||||||
|
|
||||||
init(symbol: String, imageName: String? = nil, title: String, value: String, url: String) {
|
init(symbol: String, imageName: String? = nil, title: String, value: String, url: String, color: UIColor = UIColor(rgb: 0x8E8E93)) {
|
||||||
self.symbol = symbol
|
self.symbol = symbol
|
||||||
self.imageName = imageName
|
self.imageName = imageName
|
||||||
self.title = title
|
self.title = title
|
||||||
self.value = value
|
self.value = value
|
||||||
self.url = url
|
self.url = url
|
||||||
|
self.color = color
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private let winterGramLinks: [WinterGramLink] = [
|
private let winterGramLinks: [WinterGramLink] = [
|
||||||
WinterGramLink(symbol: "paperplane.fill", title: "Channel", value: "@wntgram", url: "https://t.me/wntgram"),
|
WinterGramLink(symbol: "paperplane.fill", title: "Channel", value: "@wntgram", url: "https://t.me/wntgram", color: UIColor(rgb: 0x2AABEE)),
|
||||||
WinterGramLink(symbol: "sparkles", title: "Beta", value: "@wntbeta", url: "https://t.me/wntbeta"),
|
WinterGramLink(symbol: "sparkles", title: "Beta", value: "@wntbeta", url: "https://t.me/wntbeta", color: UIColor(rgb: 0xFF9F0A)),
|
||||||
WinterGramLink(symbol: "bubble.left.and.bubble.right.fill", title: "Chat", value: "@wntForum", url: "https://t.me/wntForum"),
|
WinterGramLink(symbol: "bubble.left.and.bubble.right.fill", title: "Chat", value: "@wntForum", url: "https://t.me/wntForum", color: UIColor(rgb: 0x34C759)),
|
||||||
WinterGramLink(symbol: "puzzlepiece.extension.fill", title: "Plugins", value: "@wntPlugins", url: "https://t.me/wntPlugins"),
|
WinterGramLink(symbol: "puzzlepiece.extension.fill", title: "Plugins", value: "@wntPlugins", url: "https://t.me/wntPlugins", color: UIColor(rgb: 0xBF5AF2)),
|
||||||
WinterGramLink(symbol: "link", imageName: "Item List/Icons/GitHub", title: "GitHub", value: "reekeer/WinterGram", url: "https://github.com/reekeer/WinterGram")
|
WinterGramLink(symbol: "link", imageName: "Item List/Icons/GitHub", title: "GitHub", value: "reekeer/WinterGram", url: "https://github.com/reekeer/WinterGram")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -150,7 +151,7 @@ private enum WinterGramMainSettingsEntry: ItemListNodeEntry {
|
||||||
case let .link(_, link):
|
case let .link(_, link):
|
||||||
return ItemListDisclosureItem(
|
return ItemListDisclosureItem(
|
||||||
presentationData: presentationData,
|
presentationData: presentationData,
|
||||||
icon: winterGramCategoryIcon(symbolName: link.symbol, imageName: link.imageName, backgroundColor: UIColor(rgb: 0x8E8E93)),
|
icon: winterGramCategoryIcon(symbolName: link.symbol, imageName: link.imageName, backgroundColor: link.color),
|
||||||
title: wntOption(link.title, presentationData.strings),
|
title: wntOption(link.title, presentationData.strings),
|
||||||
label: link.value,
|
label: link.value,
|
||||||
labelStyle: .coloredText(accent),
|
labelStyle: .coloredText(accent),
|
||||||
|
|
@ -162,15 +163,15 @@ private enum WinterGramMainSettingsEntry: ItemListNodeEntry {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
case .ayugram:
|
case .ayugram:
|
||||||
category = .ayugram; title = "Core"; iconName = "shield.fill"; iconColor = UIColor(rgb: 0x5856D6)
|
category = .ayugram; title = "Core"; iconName = "shield.fill"; iconColor = UIColor(rgb: 0x0A84FF)
|
||||||
case .features:
|
case .features:
|
||||||
category = .antiFeatures; title = "Features"; iconName = "sparkles"; iconColor = UIColor(rgb: 0xFF9500)
|
category = .antiFeatures; title = "Features"; iconName = "sparkles"; iconColor = UIColor(rgb: 0xFF9F0A)
|
||||||
case .other:
|
case .other:
|
||||||
category = .other; title = "Other"; iconName = "ellipsis.circle"; iconColor = UIColor(rgb: 0x8E8E93)
|
category = .other; title = "Other"; iconName = "ellipsis.circle"; iconColor = UIColor(rgb: 0x8E8E93)
|
||||||
case .spoofing:
|
case .spoofing:
|
||||||
category = .spoofing; title = "Spoofing"; iconName = "theatermasks"; iconColor = UIColor(rgb: 0xFF3B30)
|
category = .spoofing; title = "Spoofing"; iconName = "theatermasks.fill"; iconColor = UIColor(rgb: 0xBF5AF2)
|
||||||
case .hiddenArchive:
|
case .hiddenArchive:
|
||||||
category = .stash; title = "Hidden Archive"; iconName = "tray.full.fill"; iconColor = UIColor(rgb: 0x34C759)
|
category = .stash; title = "Hidden Archive"; iconName = "tray.full.fill"; iconColor = UIColor(rgb: 0x30D158)
|
||||||
}
|
}
|
||||||
return ItemListDisclosureItem(
|
return ItemListDisclosureItem(
|
||||||
presentationData: presentationData,
|
presentationData: presentationData,
|
||||||
|
|
@ -187,38 +188,38 @@ private enum WinterGramMainSettingsEntry: ItemListNodeEntry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders a rounded-rect backplate filled with `backgroundColor` and a white SF Symbol centred on
|
|
||||||
/// top — a clean settings tile look.
|
|
||||||
private func winterGramCategoryIcon(_ symbolName: String, _ backgroundColor: UIColor) -> UIImage? {
|
private func winterGramCategoryIcon(_ symbolName: String, _ backgroundColor: UIColor) -> UIImage? {
|
||||||
return winterGramCategoryIcon(symbolName: symbolName, imageName: nil, backgroundColor: backgroundColor)
|
return winterGramCategoryIcon(symbolName: symbolName, imageName: nil, backgroundColor: backgroundColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func winterGramCategoryIcon(symbolName: String, imageName: String?, backgroundColor: UIColor) -> UIImage? {
|
private func winterGramCategoryIcon(symbolName: String, imageName: String?, backgroundColor: UIColor) -> UIImage? {
|
||||||
let size = CGSize(width: 44.0, height: 44.0)
|
// Match the iOS-Settings-style colored icons Telegram uses for settings rows: the image *is* the
|
||||||
|
// rounded colored backplate (~30pt), centered by ItemListDisclosureItem in its icon column, with a
|
||||||
|
// white glyph that has comfortable padding. The previous 44pt image held a small 34pt backplate, so
|
||||||
|
// icons looked oversized/off-centre next to standard rows and the backplate looked too small.
|
||||||
|
let size = CGSize(width: 32.0, height: 32.0)
|
||||||
let renderer = UIGraphicsImageRenderer(size: size)
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
return renderer.image { _ in
|
return renderer.image { _ in
|
||||||
let bounds = CGRect(origin: .zero, size: size)
|
let bounds = CGRect(origin: .zero, size: size)
|
||||||
// Smaller colored backplate with a transparent margin so the white icon dominates.
|
// ~0.225 of the side gives the iOS app-icon "squircle" feel at this size.
|
||||||
let backplateInset: CGFloat = 5.0
|
let backplate = UIBezierPath(roundedRect: bounds, cornerRadius: 7.5)
|
||||||
let backplateRect = bounds.insetBy(dx: backplateInset, dy: backplateInset)
|
|
||||||
let backplate = UIBezierPath(roundedRect: backplateRect, cornerRadius: 9.0)
|
|
||||||
backgroundColor.setFill()
|
backgroundColor.setFill()
|
||||||
backplate.fill()
|
backplate.fill()
|
||||||
let icon: UIImage?
|
let icon: UIImage?
|
||||||
if let imageName = imageName {
|
if let imageName = imageName {
|
||||||
icon = UIImage(bundleImageName: imageName)?.withRenderingMode(.alwaysTemplate)
|
icon = UIImage(bundleImageName: imageName)?.withRenderingMode(.alwaysTemplate)
|
||||||
} else {
|
} else {
|
||||||
icon = UIImage(systemName: symbolName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 28.0, weight: .medium))?.withRenderingMode(.alwaysTemplate)
|
icon = UIImage(systemName: symbolName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 20.0, weight: .bold))?.withRenderingMode(.alwaysTemplate)
|
||||||
}
|
}
|
||||||
guard let symbol = icon?.withTintColor(.white, renderingMode: .alwaysOriginal) else {
|
guard let symbol = icon?.withTintColor(.white, renderingMode: .alwaysOriginal) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let maxIconSide: CGFloat = 30.0
|
let maxIconSide: CGFloat = 21.0
|
||||||
let symbolScale = min(maxIconSide / max(symbol.size.width, 1.0), maxIconSide / max(symbol.size.height, 1.0), 1.0)
|
let symbolScale = min(maxIconSide / max(symbol.size.width, 1.0), maxIconSide / max(symbol.size.height, 1.0), 1.0)
|
||||||
let symbolSize = CGSize(width: symbol.size.width * symbolScale, height: symbol.size.height * symbolScale)
|
let symbolSize = CGSize(width: symbol.size.width * symbolScale, height: symbol.size.height * symbolScale)
|
||||||
symbol.draw(in: CGRect(
|
symbol.draw(in: CGRect(
|
||||||
x: floor((size.width - symbolSize.width) / 2.0),
|
x: (size.width - symbolSize.width) / 2.0,
|
||||||
y: floor((size.height - symbolSize.height) / 2.0),
|
y: (size.height - symbolSize.height) / 2.0,
|
||||||
width: symbolSize.width,
|
width: symbolSize.width,
|
||||||
height: symbolSize.height
|
height: symbolSize.height
|
||||||
))
|
))
|
||||||
|
|
@ -263,7 +264,6 @@ public func winterGramMainSettingsController(context: AccountContext) -> ViewCon
|
||||||
pushControllerImpl?(winterGramSettingsController(context: context, category: category))
|
pushControllerImpl?(winterGramSettingsController(context: context, category: category))
|
||||||
},
|
},
|
||||||
openUrl: { url in
|
openUrl: { url in
|
||||||
// Open WinterGram channels in-app (resolve t.me links) rather than bouncing to the browser.
|
|
||||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||||
context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: url, forceExternal: false, presentationData: presentationData, navigationController: getNavigationControllerImpl?(), dismissInput: {})
|
context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: url, forceExternal: false, presentationData: presentationData, navigationController: getNavigationControllerImpl?(), dismissInput: {})
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,6 @@ enum WinterGramRadiusPreviewKind {
|
||||||
case bubble
|
case bubble
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draws a small live preview that reflects the chosen corner radius: a sample avatar
|
|
||||||
// (the user's actual avatar when available, otherwise a gradient rounded square with a person glyph)
|
|
||||||
// or a sample chat bubble.
|
|
||||||
private func winterGramRadiusPreviewImage(kind: WinterGramRadiusPreviewKind, value: Int32, minValue: Int32, maxValue: Int32, size: CGSize, theme: PresentationTheme, avatarImage: UIImage? = nil) -> UIImage {
|
private func winterGramRadiusPreviewImage(kind: WinterGramRadiusPreviewKind, value: Int32, minValue: Int32, maxValue: Int32, size: CGSize, theme: PresentationTheme, avatarImage: UIImage? = nil) -> UIImage {
|
||||||
let renderer = UIGraphicsImageRenderer(size: size)
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
return renderer.image { rendererContext in
|
return renderer.image { rendererContext in
|
||||||
|
|
@ -148,8 +145,6 @@ class WinterGramRadiusItemNode: ListViewItemNode {
|
||||||
|
|
||||||
self.addSubnode(self.titleNode)
|
self.addSubnode(self.titleNode)
|
||||||
self.addSubnode(self.valueNode)
|
self.addSubnode(self.valueNode)
|
||||||
// WinterGram: the inline radius preview next to the slider is intentionally NOT shown — the
|
|
||||||
// rounding is previewed in the chat preview instead. The slider spans the full width.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,8 @@ public enum WinterGramSettingsSection: Int32, CaseIterable {
|
||||||
case chat
|
case chat
|
||||||
case liquidGlass
|
case liquidGlass
|
||||||
case spoofing
|
case spoofing
|
||||||
// Combined "super-categories" shown in the redesigned main menu.
|
case ayugram
|
||||||
case ayugram // Ghost Mode + History + Hidden Archive
|
case other
|
||||||
case other // Chat tweaks + Spoofing (show id, registration date, spoofer, …)
|
|
||||||
|
|
||||||
public var title: String {
|
public var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
|
@ -106,7 +105,6 @@ public enum WinterGramSettingsSection: Int32, CaseIterable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public var iconName: String {
|
public var iconName: String {
|
||||||
// SF Symbols used for the main menu cells.
|
|
||||||
switch self {
|
switch self {
|
||||||
case .banner:
|
case .banner:
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -131,7 +129,6 @@ public enum WinterGramSettingsSection: Int32, CaseIterable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maps a deep-link path/section name (wnt://wintergram/<name>) to a settings subtab.
|
|
||||||
public init?(deepLinkName: String) {
|
public init?(deepLinkName: String) {
|
||||||
switch deepLinkName.lowercased() {
|
switch deepLinkName.lowercased() {
|
||||||
case "ghost", "ghostmode": self = .ghost
|
case "ghost", "ghostmode": self = .ghost
|
||||||
|
|
@ -156,8 +153,6 @@ private enum WinterGramDropdown: Equatable {
|
||||||
case stashPrivacy
|
case stashPrivacy
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single source of truth for each inline dropdown's options: display title (English; localized at
|
|
||||||
// render time), whether it is the current selection, and how to apply it.
|
|
||||||
private func winterGramDropdownOptions(_ dropdown: WinterGramDropdown, settings: WinterGramSettings) -> [(title: String, selected: Bool, apply: (WinterGramSettings) -> WinterGramSettings)] {
|
private func winterGramDropdownOptions(_ dropdown: WinterGramDropdown, settings: WinterGramSettings) -> [(title: String, selected: Bool, apply: (WinterGramSettings) -> WinterGramSettings)] {
|
||||||
switch dropdown {
|
switch dropdown {
|
||||||
case .stashPrivacy:
|
case .stashPrivacy:
|
||||||
|
|
@ -209,8 +204,6 @@ private func winterGramDropdownOptions(_ dropdown: WinterGramDropdown, settings:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Built-in device-model spoof presets shown as tappable cards in the Spoofing section.
|
|
||||||
// `model` is the string reported to Telegram (nil = the device's real model). `subtitle` describes it.
|
|
||||||
private let winterGramDevicePresets: [(name: String, subtitle: String, model: String?)] = [
|
private let winterGramDevicePresets: [(name: String, subtitle: String, model: String?)] = [
|
||||||
("Real device", "Report this device's real model", nil),
|
("Real device", "Report this device's real model", nil),
|
||||||
("iPhone 16 Pro Max", "iPhone17,2 · A18 Pro", "iPhone 16 Pro Max"),
|
("iPhone 16 Pro Max", "iPhone17,2 · A18 Pro", "iPhone 16 Pro Max"),
|
||||||
|
|
@ -309,7 +302,6 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry {
|
||||||
case spoofingApiHash(String)
|
case spoofingApiHash(String)
|
||||||
case spoofingFooter
|
case spoofingFooter
|
||||||
|
|
||||||
// Expandable single-select row: dropdown key, title, expanded flag, options.
|
|
||||||
case expandableSelection(WinterGramDropdown, String, Bool, [ItemListExpandableSelectionItem.Option])
|
case expandableSelection(WinterGramDropdown, String, Bool, [ItemListExpandableSelectionItem.Option])
|
||||||
|
|
||||||
var section: ItemListSectionId {
|
var section: ItemListSectionId {
|
||||||
|
|
@ -349,7 +341,6 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
var stableId: Int32 {
|
var stableId: Int32 {
|
||||||
// Device preset cards nest between spoofingDevice (30000) and WebView platform (40000).
|
|
||||||
if case let .spoofingDevicePreset(index, _) = self {
|
if case let .spoofingDevicePreset(index, _) = self {
|
||||||
return 30100 + Int32(index)
|
return 30100 + Int32(index)
|
||||||
}
|
}
|
||||||
|
|
@ -684,12 +675,15 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry {
|
||||||
case .spoofPresetsHeader:
|
case .spoofPresetsHeader:
|
||||||
return ItemListSectionHeaderItem(presentationData: presentationData, text: lang.WinterGram_Templates, sectionId: self.section)
|
return ItemListSectionHeaderItem(presentationData: presentationData, text: lang.WinterGram_Templates, sectionId: self.section)
|
||||||
case let .spoofPreset(index, name, subtitle, editing, selected):
|
case let .spoofPreset(index, name, subtitle, editing, selected):
|
||||||
if editing {
|
// Always an ItemListCheckboxItem so the row never swaps node *type* between display and
|
||||||
return ItemListCheckboxItem(presentationData: presentationData, systemStyle: .glass, title: name, subtitle: subtitle, style: .left, checked: selected, zeroSeparatorInsets: false, sectionId: self.section, action: {
|
// editing. ItemList updates a row in place by stableId and cannot re-type a node (the cast
|
||||||
|
// in updateNode fails silently), which previously left the disclosure row in place in edit
|
||||||
|
// mode and made selection unresponsive. In display mode tapping applies the template; in
|
||||||
|
// edit mode it toggles selection. Bonus: templates now match the device-preset rows below.
|
||||||
|
return ItemListCheckboxItem(presentationData: presentationData, title: name, subtitle: subtitle, style: .left, checked: editing ? selected : false, zeroSeparatorInsets: false, sectionId: self.section, action: {
|
||||||
|
if editing {
|
||||||
arguments.toggleSpoofTemplateSelected(index)
|
arguments.toggleSpoofTemplateSelected(index)
|
||||||
})
|
} else {
|
||||||
} else {
|
|
||||||
return ItemListDisclosureItem(presentationData: presentationData, title: name, label: subtitle, labelStyle: .detailText, sectionId: self.section, style: .blocks, disclosureStyle: .none, action: {
|
|
||||||
arguments.updateSettings { settings in
|
arguments.updateSettings { settings in
|
||||||
var settings = settings
|
var settings = settings
|
||||||
if index < settings.spoofPresets.count {
|
if index < settings.spoofPresets.count {
|
||||||
|
|
@ -699,8 +693,8 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry {
|
||||||
}
|
}
|
||||||
return settings
|
return settings
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}
|
})
|
||||||
case .spoofAddTemplate:
|
case .spoofAddTemplate:
|
||||||
return ItemListPeerActionItem(presentationData: presentationData, icon: PresentationResourcesItemList.plusIconImage(presentationData.theme), title: lang.WinterGram_AddTemplate, sectionId: self.section, height: .generic, color: .accent, editing: false, action: {
|
return ItemListPeerActionItem(presentationData: presentationData, icon: PresentationResourcesItemList.plusIconImage(presentationData.theme), title: lang.WinterGram_AddTemplate, sectionId: self.section, height: .generic, color: .accent, editing: false, action: {
|
||||||
arguments.addSpoofTemplate()
|
arguments.addSpoofTemplate()
|
||||||
|
|
@ -787,7 +781,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti
|
||||||
let lang = presentationData.strings
|
let lang = presentationData.strings
|
||||||
var entries: [WinterGramSettingsEntry] = []
|
var entries: [WinterGramSettingsEntry] = []
|
||||||
|
|
||||||
// Appends an expandable single-select row with checkbox options shown inline.
|
|
||||||
func appendDropdown(_ dropdown: WinterGramDropdown, _ title: String, _ section: WinterGramSettingsSection) {
|
func appendDropdown(_ dropdown: WinterGramDropdown, _ title: String, _ section: WinterGramSettingsSection) {
|
||||||
let options = winterGramDropdownOptions(dropdown, settings: settings).enumerated().map { index, option in
|
let options = winterGramDropdownOptions(dropdown, settings: settings).enumerated().map { index, option in
|
||||||
ItemListExpandableSelectionItem.Option(id: "\(dropdown)_\(index)", title: wntOption(option.title, lang), isSelected: option.selected, index: index)
|
ItemListExpandableSelectionItem.Option(id: "\(dropdown)_\(index)", title: wntOption(option.title, lang), isSelected: option.selected, index: index)
|
||||||
|
|
@ -797,7 +790,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti
|
||||||
|
|
||||||
func appendGhost() {
|
func appendGhost() {
|
||||||
entries.append(.ghostHeader)
|
entries.append(.ghostHeader)
|
||||||
// Expandable Ghost Mode switch with checkbox sub-items for each suppressed signal.
|
|
||||||
let subItems: [ItemListExpandableSwitchItem.SubItem] = [
|
let subItems: [ItemListExpandableSwitchItem.SubItem] = [
|
||||||
.init(id: "readMessages", title: lang.WinterGram_DontReadMessages, isSelected: settings.suppressesReadReceipts, isEnabled: true),
|
.init(id: "readMessages", title: lang.WinterGram_DontReadMessages, isSelected: settings.suppressesReadReceipts, isEnabled: true),
|
||||||
.init(id: "readStories", title: lang.WinterGram_DontReadStories, isSelected: settings.suppressesStoryViews, isEnabled: true),
|
.init(id: "readStories", title: lang.WinterGram_DontReadStories, isSelected: settings.suppressesStoryViews, isEnabled: true),
|
||||||
|
|
@ -839,7 +831,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti
|
||||||
entries.append(.stashMute(settings.stashMuteNotifications))
|
entries.append(.stashMute(settings.stashMuteNotifications))
|
||||||
entries.append(.stashAutoRead(settings.stashAutoMarkRead))
|
entries.append(.stashAutoRead(settings.stashAutoMarkRead))
|
||||||
entries.append(.stashPasscodeRow(settings.stashPasscode.isEmpty ? "None" : "••••"))
|
entries.append(.stashPasscodeRow(settings.stashPasscode.isEmpty ? "None" : "••••"))
|
||||||
// All auto-privacy toggles combined into one expandable multi-select checkbox row.
|
|
||||||
appendDropdown(.stashPrivacy, lang.WinterGram_AutoPrivacy, .stash)
|
appendDropdown(.stashPrivacy, lang.WinterGram_AutoPrivacy, .stash)
|
||||||
entries.append(.stashFooter)
|
entries.append(.stashFooter)
|
||||||
}
|
}
|
||||||
|
|
@ -868,7 +859,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti
|
||||||
entries.append(.confirmStickers(settings.stickerConfirmation))
|
entries.append(.confirmStickers(settings.stickerConfirmation))
|
||||||
entries.append(.confirmGif(settings.gifConfirmation))
|
entries.append(.confirmGif(settings.gifConfirmation))
|
||||||
entries.append(.confirmVoice(settings.voiceConfirmation))
|
entries.append(.confirmVoice(settings.voiceConfirmation))
|
||||||
// Footer last (stableId 67) so the section stays in ascending stableId order.
|
|
||||||
entries.append(.chatFooter)
|
entries.append(.chatFooter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -885,7 +875,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti
|
||||||
|
|
||||||
func appendSpoofing() {
|
func appendSpoofing() {
|
||||||
entries.append(.spoofingHeader)
|
entries.append(.spoofingHeader)
|
||||||
// Saved spoof templates at the top of the Spoofing section.
|
|
||||||
entries.append(.spoofPresetsHeader)
|
entries.append(.spoofPresetsHeader)
|
||||||
for (index, preset) in settings.spoofPresets.enumerated() {
|
for (index, preset) in settings.spoofPresets.enumerated() {
|
||||||
let subtitle = [preset.deviceModel, preset.appVersion].filter { !$0.isEmpty }.joined(separator: " · ")
|
let subtitle = [preset.deviceModel, preset.appVersion].filter { !$0.isEmpty }.joined(separator: " · ")
|
||||||
|
|
@ -895,7 +884,6 @@ private func winterGramSettingsEntries(presentationData: PresentationData, setti
|
||||||
if spoofTemplatesEditing && !selectedTemplates.isEmpty {
|
if spoofTemplatesEditing && !selectedTemplates.isEmpty {
|
||||||
entries.append(.spoofDeleteSelected)
|
entries.append(.spoofDeleteSelected)
|
||||||
}
|
}
|
||||||
// Device model is chosen via the preset cards below (incl. "Real device") — no separate prompt row.
|
|
||||||
for (i, preset) in winterGramDevicePresets.enumerated() {
|
for (i, preset) in winterGramDevicePresets.enumerated() {
|
||||||
entries.append(.spoofingDevicePreset(i, preset.model == settings.spoofDeviceModel))
|
entries.append(.spoofingDevicePreset(i, preset.model == settings.spoofDeviceModel))
|
||||||
}
|
}
|
||||||
|
|
@ -950,8 +938,6 @@ public func winterGramSettingsController(context: AccountContext, category: Wint
|
||||||
var pushControllerImpl: ((ViewController) -> Void)?
|
var pushControllerImpl: ((ViewController) -> Void)?
|
||||||
var refreshDeletedCount: (() -> Void)?
|
var refreshDeletedCount: (() -> Void)?
|
||||||
|
|
||||||
// The combined super-categories (.ayugram/.other) are not standalone tabs in the legacy
|
|
||||||
// no-category segmented view.
|
|
||||||
let sectionTabs: [WinterGramSettingsSection] = WinterGramSettingsSection.allCases.filter { $0 != .ayugram && $0 != .other }
|
let sectionTabs: [WinterGramSettingsSection] = WinterGramSettingsSection.allCases.filter { $0 != .ayugram && $0 != .other }
|
||||||
let selectedCategoryPromise = ValuePromise<WinterGramSettingsSection>(category ?? .ghost, ignoreRepeated: true)
|
let selectedCategoryPromise = ValuePromise<WinterGramSettingsSection>(category ?? .ghost, ignoreRepeated: true)
|
||||||
|
|
||||||
|
|
@ -968,13 +954,12 @@ public func winterGramSettingsController(context: AccountContext, category: Wint
|
||||||
newSettings.customApiHash != initialSettings.customApiHash ||
|
newSettings.customApiHash != initialSettings.customApiHash ||
|
||||||
newSettings.materialDesign != initialSettings.materialDesign ||
|
newSettings.materialDesign != initialSettings.materialDesign ||
|
||||||
newSettings.customFont != initialSettings.customFont ||
|
newSettings.customFont != initialSettings.customFont ||
|
||||||
newSettings.monoFont != initialSettings.monoFont
|
newSettings.monoFont != initialSettings.monoFont ||
|
||||||
|
newSettings.liquidGlass != initialSettings.liquidGlass
|
||||||
requiresRestartPromise.set(needsRestart)
|
requiresRestartPromise.set(needsRestart)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applies a change to the stashed-peer privacy settings and re-syncs exceptions for every
|
|
||||||
// currently stashed peer when the rules change.
|
|
||||||
let updateStashPrivacy: (@escaping (WinterGramStashPrivacySettings) -> WinterGramStashPrivacySettings) -> Void = { f in
|
let updateStashPrivacy: (@escaping (WinterGramStashPrivacySettings) -> WinterGramStashPrivacySettings) -> Void = { f in
|
||||||
let previous = currentWinterGramSettings.stashPrivacy
|
let previous = currentWinterGramSettings.stashPrivacy
|
||||||
updateSettings { settings in
|
updateSettings { settings in
|
||||||
|
|
@ -991,12 +976,9 @@ public func winterGramSettingsController(context: AccountContext, category: Wint
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Which inline dropdown (if any) is currently expanded. Atomic mirror for synchronous reads in the
|
|
||||||
// toggle/select closures; promise drives the list rebuild.
|
|
||||||
let expandedDropdownValue = Atomic<WinterGramDropdown?>(value: nil)
|
let expandedDropdownValue = Atomic<WinterGramDropdown?>(value: nil)
|
||||||
let expandedDropdownPromise = ValuePromise<WinterGramDropdown?>(nil, ignoreRepeated: true)
|
let expandedDropdownPromise = ValuePromise<WinterGramDropdown?>(nil, ignoreRepeated: true)
|
||||||
|
|
||||||
// Whether the Ghost Mode expandable section in the Core menu is open.
|
|
||||||
let ghostExpandedValue = Atomic<Bool>(value: false)
|
let ghostExpandedValue = Atomic<Bool>(value: false)
|
||||||
let ghostExpandedPromise = ValuePromise<Bool>(false, ignoreRepeated: true)
|
let ghostExpandedPromise = ValuePromise<Bool>(false, ignoreRepeated: true)
|
||||||
let spoofTemplatesEditingValue = Atomic<Bool>(value: false)
|
let spoofTemplatesEditingValue = Atomic<Bool>(value: false)
|
||||||
|
|
@ -1025,7 +1007,6 @@ public func winterGramSettingsController(context: AccountContext, category: Wint
|
||||||
updateSettings { options[index].apply($0) }
|
updateSettings { options[index].apply($0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Multi-select dropdowns stay open so the user can toggle more options.
|
|
||||||
if dropdown != .stashPrivacy {
|
if dropdown != .stashPrivacy {
|
||||||
let _ = expandedDropdownValue.swap(nil)
|
let _ = expandedDropdownValue.swap(nil)
|
||||||
expandedDropdownPromise.set(nil)
|
expandedDropdownPromise.set(nil)
|
||||||
|
|
@ -1290,8 +1271,6 @@ public func winterGramSettingsController(context: AccountContext, category: Wint
|
||||||
controller?.present(c, in: .window(.root), with: a)
|
controller?.present(c, in: .window(.root), with: a)
|
||||||
}
|
}
|
||||||
pushControllerImpl = { [weak controller] c in
|
pushControllerImpl = { [weak controller] c in
|
||||||
// Keep the restart-banner subscription alive for the controller's lifetime: this
|
|
||||||
// closure is retained by `arguments`, which the ItemListController holds via its state.
|
|
||||||
let _ = restartBannerDisposable
|
let _ = restartBannerDisposable
|
||||||
(controller?.navigationController as? NavigationController)?.pushViewController(c)
|
(controller?.navigationController as? NavigationController)?.pushViewController(c)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -902,7 +902,7 @@ func revalidateMediaResourceReference(accountPeerId: PeerId, postbox: Postbox, n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Rich-text messages (`RichTextMessageAttribute`) embed their media in the
|
// Rich-text messages (`RichTextMessageAttribute`) embed their media in the
|
||||||
// attribute's `InstantPage`, not in `message.media` — search there too so a
|
// attribute's `InstantPage`, not in `message.media`. Search there so a
|
||||||
// stale instant-page audio/image file reference can revalidate.
|
// stale instant-page audio/image file reference can revalidate.
|
||||||
for attribute in message.attributes {
|
for attribute in message.attributes {
|
||||||
if let attribute = attribute as? RichTextMessageAttribute {
|
if let attribute = attribute as? RichTextMessageAttribute {
|
||||||
|
|
|
||||||
|
|
@ -4462,17 +4462,50 @@ func replayFinalState(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case let .DeleteMessagesWithGlobalIds(ids):
|
case let .DeleteMessagesWithGlobalIds(ids):
|
||||||
|
// WinterGram: ordinary cloud deletions (private chats, basic groups) arrive here via
|
||||||
|
// updateDeleteMessages → deleteMessagesWithGlobalIds, NOT via .DeleteMessages (which only
|
||||||
|
// covers channels/supergroups). Resolve the global ids to message ids and preserve + mark
|
||||||
|
// them exactly like the .DeleteMessages path, otherwise these deletions stay unmarked.
|
||||||
|
var winterGramPreservedGlobalIds = false
|
||||||
if currentWinterGramCoreSettings.saveDeletedMessages {
|
if currentWinterGramCoreSettings.saveDeletedMessages {
|
||||||
break
|
let resolvedIds = transaction.messageIdsForGlobalIds(ids)
|
||||||
|
var winterGramShouldSave = !resolvedIds.isEmpty
|
||||||
|
// When "save for bots" is off, skip preserving deletions in bot chats.
|
||||||
|
if winterGramShouldSave && !currentWinterGramCoreSettings.saveForBots {
|
||||||
|
if let firstPeerId = resolvedIds.first?.peerId, let peer = transaction.getPeer(firstPeerId) as? TelegramUser, peer.botInfo != nil {
|
||||||
|
winterGramShouldSave = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if winterGramShouldSave {
|
||||||
|
let markDate = Int32(Date().timeIntervalSince1970)
|
||||||
|
winterGramRecordDeletedMessages(transaction: transaction, ids: resolvedIds)
|
||||||
|
for id in resolvedIds {
|
||||||
|
transaction.updateMessage(id, update: { currentMessage in
|
||||||
|
if currentMessage.attributes.contains(where: { $0 is WinterGramDeletedMessageAttribute }) {
|
||||||
|
return .skip
|
||||||
|
}
|
||||||
|
var storeForwardInfo: StoreMessageForwardInfo?
|
||||||
|
if let forwardInfo = currentMessage.forwardInfo {
|
||||||
|
storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags)
|
||||||
|
}
|
||||||
|
var attributes = currentMessage.attributes
|
||||||
|
attributes.append(WinterGramDeletedMessageAttribute(date: markDate))
|
||||||
|
return .update(StoreMessage(id: currentMessage.id, customStableId: nil, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
winterGramPreservedGlobalIds = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var resourceIds: [MediaResourceId] = []
|
if !winterGramPreservedGlobalIds {
|
||||||
transaction.deleteMessagesWithGlobalIds(ids, forEachMedia: { media in
|
var resourceIds: [MediaResourceId] = []
|
||||||
addMessageMediaResourceIdsToRemove(media: media, resourceIds: &resourceIds)
|
transaction.deleteMessagesWithGlobalIds(ids, forEachMedia: { media in
|
||||||
})
|
addMessageMediaResourceIdsToRemove(media: media, resourceIds: &resourceIds)
|
||||||
if !resourceIds.isEmpty {
|
})
|
||||||
let _ = mediaBox.removeCachedResources(Array(Set(resourceIds)), force: true).start()
|
if !resourceIds.isEmpty {
|
||||||
|
let _ = mediaBox.removeCachedResources(Array(Set(resourceIds)), force: true).start()
|
||||||
|
}
|
||||||
|
deletedMessageIds.append(contentsOf: ids.map { .global($0) })
|
||||||
}
|
}
|
||||||
deletedMessageIds.append(contentsOf: ids.map { .global($0) })
|
|
||||||
case let .DeleteMessages(ids):
|
case let .DeleteMessages(ids):
|
||||||
var winterGramShouldSave = currentWinterGramCoreSettings.saveDeletedMessages
|
var winterGramShouldSave = currentWinterGramCoreSettings.saveDeletedMessages
|
||||||
// When "save for bots" is off, skip preserving deletions in bot chats.
|
// When "save for bots" is off, skip preserving deletions in bot chats.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import Foundation
|
||||||
import Postbox
|
import Postbox
|
||||||
import SwiftSignalKit
|
import SwiftSignalKit
|
||||||
|
|
||||||
// Replaces a message's text locally only — no network edit is performed, so the
|
// Replaces a message's text locally without a network edit, so the
|
||||||
// server never learns about the change and no "edited" mark is added. The change
|
// server never learns about the change and no "edited" mark is added. The change
|
||||||
// persists in the local database until the message is re-fetched from the server.
|
// persists in the local database until the message is re-fetched from the server.
|
||||||
public func winterGramEditMessageLocally(postbox: Postbox, messageId: MessageId, text: String) -> Signal<Void, NoError> {
|
public func winterGramEditMessageLocally(postbox: Postbox, messageId: MessageId, text: String) -> Signal<Void, NoError> {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import Foundation
|
||||||
// Estimates the approximate registration date of a Telegram account from its user ID.
|
// Estimates the approximate registration date of a Telegram account from its user ID.
|
||||||
// Telegram user IDs are allocated roughly monotonically over time, so a piecewise-linear
|
// Telegram user IDs are allocated roughly monotonically over time, so a piecewise-linear
|
||||||
// interpolation between known (id, date) anchor points yields a usable estimate. This is an
|
// interpolation between known (id, date) anchor points yields a usable estimate. This is an
|
||||||
// approximation only — it is never exact and is meant for display with an "≈" prefix.
|
// approximation only and is meant for display with an "≈" prefix.
|
||||||
public func winterGramEstimatedRegistrationDate(userId: Int64) -> Date? {
|
public func winterGramEstimatedRegistrationDate(userId: Int64) -> Date? {
|
||||||
guard userId > 0 else {
|
guard userId > 0 else {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,6 @@ import AppBundle
|
||||||
import TelegramCore
|
import TelegramCore
|
||||||
import TelegramUIPreferences
|
import TelegramUIPreferences
|
||||||
|
|
||||||
// Runtime-composed WinterGram badge.
|
|
||||||
//
|
|
||||||
// Instead of shipping a fixed-colour PNG, the badge is composed on demand from two white-on-transparent
|
|
||||||
// shape assets (a scalloped backplate + a snowflake) so the backplate can follow the current theme
|
|
||||||
// colour. Per the design spec (1024² canvas): the backplate fills 1024@(0,0), the snowflake is 756²
|
|
||||||
// at (134,134). Results are cached per backplate colour; because every badge view re-renders when the
|
|
||||||
// presentation theme changes, this naturally recomposes the badge for the new theme.
|
|
||||||
|
|
||||||
private let winterGramBadgeCacheLock = NSLock()
|
private let winterGramBadgeCacheLock = NSLock()
|
||||||
private var winterGramComposedBadgeCache: [UInt32: UIImage] = [:]
|
private var winterGramComposedBadgeCache: [UInt32: UIImage] = [:]
|
||||||
|
|
||||||
|
|
@ -23,8 +15,6 @@ private func winterGramColorKey(_ color: UIColor) -> UInt32 {
|
||||||
return (channel(r) << 16) | (channel(g) << 8) | channel(b)
|
return (channel(r) << 16) | (channel(g) << 8) | channel(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Composes the badge image: a scalloped backplate filled with `backplateColor` and a white snowflake
|
|
||||||
/// on top, following the 1024 spec. Cached per colour.
|
|
||||||
public func winterGramComposedBadge(backplateColor: UIColor) -> UIImage? {
|
public func winterGramComposedBadge(backplateColor: UIColor) -> UIImage? {
|
||||||
let key = winterGramColorKey(backplateColor)
|
let key = winterGramColorKey(backplateColor)
|
||||||
winterGramBadgeCacheLock.lock()
|
winterGramBadgeCacheLock.lock()
|
||||||
|
|
@ -51,16 +41,65 @@ public func winterGramComposedBadge(backplateColor: UIColor) -> UIImage? {
|
||||||
return image
|
return image
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The backplate colour for the current theme: the accent colour, slightly darkened.
|
|
||||||
public func winterGramBadgeBackplateColor(theme: PresentationTheme) -> UIColor {
|
public func winterGramBadgeBackplateColor(theme: PresentationTheme) -> UIColor {
|
||||||
return theme.list.itemAccentColor.withMultipliedBrightnessBy(0.82)
|
return theme.list.itemAccentColor.withMultipliedBrightnessBy(0.82)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The themed badge image for a peer, or nil if the peer carries no WinterGram badge.
|
private var winterGramComposedBadgeCacheV2: [String: UIImage] = [:]
|
||||||
/// Every official peer (developers and official channels) gets the composed backplate badge.
|
|
||||||
|
private func winterGramLayerImage(source: String) -> UIImage? {
|
||||||
|
if let url = WinterGramBadgeManager.shared.localAssetFileURL(forSource: source), let image = UIImage(contentsOfFile: url.path) {
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
return UIImage(bundleImageName: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func winterGramComposedBadge(for badge: WinterGramBadgeDef, canvas: Double, themeColor: UIColor, size: CGSize) -> UIImage? {
|
||||||
|
let canvasValue = canvas > 0.0 ? canvas : 1024.0
|
||||||
|
let version = WinterGramBadgeManager.shared.manifest.version
|
||||||
|
let key = "\(badge.id)|\(version)|\(winterGramColorKey(themeColor))|\(Int(size.width))x\(Int(size.height))"
|
||||||
|
winterGramBadgeCacheLock.lock()
|
||||||
|
if let cached = winterGramComposedBadgeCacheV2[key] {
|
||||||
|
winterGramBadgeCacheLock.unlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
winterGramBadgeCacheLock.unlock()
|
||||||
|
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
|
let image = renderer.image { _ in
|
||||||
|
for layer in badge.layers {
|
||||||
|
if layer.isLottie {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard let layerImage = winterGramLayerImage(source: layer.source) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let rect = CGRect(
|
||||||
|
x: layer.x / canvasValue * size.width,
|
||||||
|
y: layer.y / canvasValue * size.height,
|
||||||
|
width: layer.width / canvasValue * size.width,
|
||||||
|
height: layer.height / canvasValue * size.height
|
||||||
|
)
|
||||||
|
switch layer.tint {
|
||||||
|
case .theme:
|
||||||
|
layerImage.withTintColor(themeColor, renderingMode: .alwaysOriginal).draw(in: rect)
|
||||||
|
case let .hex(value):
|
||||||
|
layerImage.withTintColor(UIColor(rgb: value), renderingMode: .alwaysOriginal).draw(in: rect)
|
||||||
|
case .original:
|
||||||
|
layerImage.draw(in: rect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
winterGramBadgeCacheLock.lock()
|
||||||
|
winterGramComposedBadgeCacheV2[key] = image
|
||||||
|
winterGramBadgeCacheLock.unlock()
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
public func winterGramBadgeImage(for peer: EnginePeer, theme: PresentationTheme) -> UIImage? {
|
public func winterGramBadgeImage(for peer: EnginePeer, theme: PresentationTheme) -> UIImage? {
|
||||||
guard isWinterGramOfficialPeer(peer) else {
|
guard let badge = WinterGramBadgeManager.shared.badge(for: peer) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return winterGramComposedBadge(backplateColor: winterGramBadgeBackplateColor(theme: theme))
|
let manifest = WinterGramBadgeManager.shared.manifest
|
||||||
|
return winterGramComposedBadge(for: badge, canvas: manifest.canvas, themeColor: winterGramBadgeBackplateColor(theme: theme), size: CGSize(width: 36.0, height: 36.0))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ private let winterGramRussianSeed: [String: String] = [
|
||||||
"WinterGram.ShowMessageSeconds": "Секунды в сообщениях",
|
"WinterGram.ShowMessageSeconds": "Секунды в сообщениях",
|
||||||
"WinterGram.ShowPeerID": "Показывать ID",
|
"WinterGram.ShowPeerID": "Показывать ID",
|
||||||
"WinterGram.ShowRegistrationDate": "Показывать дату регистрации",
|
"WinterGram.ShowRegistrationDate": "Показывать дату регистрации",
|
||||||
|
"WinterGram.RegistrationDate": "Дата регистрации",
|
||||||
"WinterGram.SingleCornerRadius": "Одиночное скругление",
|
"WinterGram.SingleCornerRadius": "Одиночное скругление",
|
||||||
"WinterGram.SomeSettingsWillTakeEffectAfterRestart": "Некоторые настройки вступят в силу после перезапуска.",
|
"WinterGram.SomeSettingsWillTakeEffectAfterRestart": "Некоторые настройки вступят в силу после перезапуска.",
|
||||||
"WinterGram.SpoofAppVersion": "Версия приложения",
|
"WinterGram.SpoofAppVersion": "Версия приложения",
|
||||||
|
|
@ -195,8 +196,8 @@ public func winterGramSeedStrings(languageCode: String) -> [String: String] {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Localizes a WinterGram option/section label chosen at runtime (dropdown selections, section
|
/// Localizes a WinterGram option/section label chosen at runtime (dropdown selections, section
|
||||||
/// titles) by routing the known English value to its generated accessor. Unknown values — user
|
/// titles) by routing the known English value to its generated accessor. Unknown user input
|
||||||
/// input such as custom fonts, reactions or spoofed identifiers — are returned unchanged.
|
/// such as custom fonts, reactions, or spoofed identifiers is returned unchanged.
|
||||||
public func wntOption(_ english: String, _ strings: PresentationStrings) -> String {
|
public func wntOption(_ english: String, _ strings: PresentationStrings) -> String {
|
||||||
switch english {
|
switch english {
|
||||||
case "Off": return strings.WinterGram_Off
|
case "Off": return strings.WinterGram_Off
|
||||||
|
|
|
||||||
|
|
@ -3897,7 +3897,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
|
||||||
let targetAlpha: CGFloat = isDeleted ? 0.45 : 1.0
|
let targetAlpha: CGFloat = isDeleted ? 0.45 : 1.0
|
||||||
animation.animator.updateAlpha(layer: strongSelf.mainContextSourceNode.contentNode.layer, alpha: targetAlpha, completion: nil)
|
animation.animator.updateAlpha(layer: strongSelf.mainContextSourceNode.contentNode.layer, alpha: targetAlpha, completion: nil)
|
||||||
|
|
||||||
if hasDeletedMark, let winterGramDeletedAttribute = winterGramDeletedAttribute {
|
if hasDeletedMark {
|
||||||
let deletedIconNode: ASImageNode
|
let deletedIconNode: ASImageNode
|
||||||
if let current = strongSelf.winterGramDeletedIconNode {
|
if let current = strongSelf.winterGramDeletedIconNode {
|
||||||
deletedIconNode = current
|
deletedIconNode = current
|
||||||
|
|
@ -3909,7 +3909,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
|
||||||
strongSelf.insertSubnode(newNode, belowSubnode: strongSelf.messageAccessibilityArea)
|
strongSelf.insertSubnode(newNode, belowSubnode: strongSelf.messageAccessibilityArea)
|
||||||
deletedIconNode = newNode
|
deletedIconNode = newNode
|
||||||
}
|
}
|
||||||
deletedIconNode.image = winterGramDeletedBadgeImage(emoji: currentWinterGramSettings.deletedMark, pillColor: item.presentationData.theme.theme.list.itemDestructiveColor, timeText: currentWinterGramSettings.showDeletedTime ? winterGramDeletedBadgeTimeText(winterGramDeletedAttribute.date) : nil)
|
// A small, unobtrusive mark (the chosen emoji, default 🧹) — NOT a coloured time pill.
|
||||||
|
// The deletion time lives in the long-press context menu instead (see ChatInterfaceStateContextMenus).
|
||||||
|
deletedIconNode.image = winterGramDeletedBadgeImage(emoji: currentWinterGramSettings.deletedMark, pillColor: item.presentationData.theme.theme.list.itemDestructiveColor, timeText: nil)
|
||||||
let iconSize = deletedIconNode.image?.size ?? CGSize(width: 16.0, height: 16.0)
|
let iconSize = deletedIconNode.image?.size ?? CGSize(width: 16.0, height: 16.0)
|
||||||
let spacing: CGFloat = 5.0
|
let spacing: CGFloat = 5.0
|
||||||
let iconY = backgroundFrame.maxY - iconSize.height - 2.0
|
let iconY = backgroundFrame.maxY - iconSize.height - 2.0
|
||||||
|
|
|
||||||
|
|
@ -22,17 +22,12 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
|
|
||||||
private let containerNode: ContainerNode
|
private let containerNode: ContainerNode
|
||||||
public var statusNode: ChatMessageDateAndStatusNode?
|
public var statusNode: ChatMessageDateAndStatusNode?
|
||||||
// `init()` may run off the main thread; UIView construction must happen on the main thread.
|
// `init()` may run off the main thread, so build the page view lazily on the main thread.
|
||||||
// The page view is built lazily inside the apply closure (always main-thread) via ensurePageView().
|
|
||||||
private var pageView: InstantPageV2View?
|
private var pageView: InstantPageV2View?
|
||||||
// Tracks the message (id + stableVersion) baked into the current pageView's render context.
|
// The synthesized webpage has a sentinel id shared across all richText messages, so key the
|
||||||
// The synthesized webpage uses a sentinel id (namespace 0, id 0) shared across all richText
|
// page view on the message id and stableVersion and rebuild when the bubble is recycled.
|
||||||
// messages, so we key cache invalidation on the message itself. When the bubble is recycled
|
|
||||||
// with a different message we must discard pageView (render context is constructor-fixed).
|
|
||||||
private var pageViewMessageKey: (id: EngineMessage.Id, stableVersion: UInt32, showMoreExpanded: Bool)?
|
private var pageViewMessageKey: (id: EngineMessage.Id, stableVersion: UInt32, showMoreExpanded: Bool)?
|
||||||
// messageStableVersion is in the cache key because the synthesized instantPage content
|
// Include stableVersion in the layout cache key so streamed chunks do not reuse stale layouts.
|
||||||
// mutates between streamed AI message chunks (each chunk bumps stableVersion); without
|
|
||||||
// this, the cached layout would shadow newly-arrived content during streaming.
|
|
||||||
private var currentPageLayout: (boundingWidth: CGFloat,
|
private var currentPageLayout: (boundingWidth: CGFloat,
|
||||||
presentationThemeIdentity: ObjectIdentifier,
|
presentationThemeIdentity: ObjectIdentifier,
|
||||||
expandedDetails: [Int: Bool],
|
expandedDetails: [Int: Bool],
|
||||||
|
|
@ -40,9 +35,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
showMoreExpanded: Bool,
|
showMoreExpanded: Bool,
|
||||||
layout: InstantPageV2Layout)?
|
layout: InstantPageV2Layout)?
|
||||||
private var currentExpandedDetails: [Int: Bool] = [:]
|
private var currentExpandedDetails: [Int: Bool] = [:]
|
||||||
// Intra-message anchor scroll that is waiting on a collapsed <details> to expand + relayout.
|
// Anchor scroll waiting on a collapsed <details> to expand and relayout.
|
||||||
private var pendingScrollAnchor: String?
|
private var pendingScrollAnchor: String?
|
||||||
// Progress guard: the details index expanded on the previous pending pass.
|
// Details index expanded on the previous pending pass, used to avoid looping.
|
||||||
private var lastExpandedPendingDetailsIndex: Int?
|
private var lastExpandedPendingDetailsIndex: Int?
|
||||||
private var linkProgressDisposable: Disposable?
|
private var linkProgressDisposable: Disposable?
|
||||||
private var linkProgressRects: [CGRect]?
|
private var linkProgressRects: [CGRect]?
|
||||||
|
|
@ -54,16 +49,13 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
private var textRevealController: TextRevealController?
|
private var textRevealController: TextRevealController?
|
||||||
private var textRevealLink: SharedDisplayLinkDriver.Link?
|
private var textRevealLink: SharedDisplayLinkDriver.Link?
|
||||||
private var currentRevealCostMap: InstantPageV2RevealCostMap?
|
private var currentRevealCostMap: InstantPageV2RevealCostMap?
|
||||||
// Cursor value pushed into pageView.applyReveal on the prior tick. The display-link tick
|
// Cursor from the previous display-link tick, used to detect when the revealed height
|
||||||
// compares the revealed prefix's height at this cursor vs the new cursor to decide when
|
// changes and request a full bubble relayout.
|
||||||
// to request a full bubble re-layout (so the bubble grows with the reveal).
|
|
||||||
private var lastAppliedRevealedCount: Int = 0
|
private var lastAppliedRevealedCount: Int = 0
|
||||||
private var displayContentsUnderSpoilers: Bool = false
|
private var displayContentsUnderSpoilers: Bool = false
|
||||||
private var relativeDateTimer: (timer: SwiftSignalKit.Timer, period: Int32)?
|
private var relativeDateTimer: (timer: SwiftSignalKit.Timer, period: Int32)?
|
||||||
|
|
||||||
// "Show more" affordance for partial rich messages (instantPage.isComplete == false).
|
// "Show more" link for partial rich messages, with a shimmer while the full text loads.
|
||||||
// Managed inline, mirroring the statusNode pattern: a bubble-owned TextNode below the page
|
|
||||||
// content, with a TextLoadingEffectView shimmer while the full-text request is in flight.
|
|
||||||
private var showMoreTextNode: TextNode?
|
private var showMoreTextNode: TextNode?
|
||||||
private var showMoreLoadingView: TextLoadingEffectView?
|
private var showMoreLoadingView: TextLoadingEffectView?
|
||||||
private var requestFullRichTextDisposable: Disposable?
|
private var requestFullRichTextDisposable: Disposable?
|
||||||
|
|
@ -115,7 +107,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
self.addSubnode(self.containerNode)
|
self.addSubnode(self.containerNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds (or reuses) the V2View. Same-message stableVersion bumps (streamed AI chunks) reuse
|
/// Builds or reuses the V2View. Same-message stableVersion bumps reuse
|
||||||
/// the existing view, updating only the webpage content in place. The view is rebuilt only when
|
/// the existing view, updating only the webpage content in place. The view is rebuilt only when
|
||||||
/// the bubble is recycled with a different message/webpage (different message id).
|
/// the bubble is recycled with a different message/webpage (different message id).
|
||||||
private func ensurePageView(item: ChatMessageBubbleContentItem, webpage: TelegramMediaWebpage, showMoreExpanded: Bool) -> InstantPageV2View {
|
private func ensurePageView(item: ChatMessageBubbleContentItem, webpage: TelegramMediaWebpage, showMoreExpanded: Bool) -> InstantPageV2View {
|
||||||
|
|
@ -136,7 +128,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
self.pageView?.removeFromSuperview()
|
self.pageView?.removeFromSuperview()
|
||||||
self.pageView = nil
|
self.pageView = nil
|
||||||
|
|
||||||
// Capture only the MessageReference (value type) — the closures are retained on the
|
// Capture only the value-type MessageReference.
|
||||||
// render context which is owned by the V2View, so we must avoid making them retain
|
// render context which is owned by the V2View, so we must avoid making them retain
|
||||||
// the bubble (`self`) or the message indirectly via `item`.
|
// the bubble (`self`) or the message indirectly via `item`.
|
||||||
let messageReference = MessageReference(item.message)
|
let messageReference = MessageReference(item.message)
|
||||||
|
|
@ -402,7 +394,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
current.layout.formattedDateUpdatePeriod == nil {
|
current.layout.formattedDateUpdatePeriod == nil {
|
||||||
// Reuse the cached layout only when it has no relative `textDate`. A relative
|
// Reuse the cached layout only when it has no relative `textDate`. A relative
|
||||||
// date's formatted string ("N minutes ago") is baked into the laid-out text at
|
// date's formatted string ("N minutes ago") is baked into the laid-out text at
|
||||||
// layout time, and none of the cache-key inputs change as wall-clock advances —
|
// layout time, and the cache key does not change as wall-clock time advances,
|
||||||
// so reusing it would freeze the date and defeat the refresh timer (which fires
|
// so reusing it would freeze the date and defeat the refresh timer (which fires
|
||||||
// `requestFullUpdate` precisely to re-run `layoutInstantPageV2` → `formatDate`).
|
// `requestFullUpdate` precisely to re-run `layoutInstantPageV2` → `formatDate`).
|
||||||
// Forcing a recompute for relative-date pages keeps the timer's tick visible.
|
// Forcing a recompute for relative-date pages keeps the timer's tick visible.
|
||||||
|
|
@ -456,7 +448,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
|
|
||||||
if hasDraft {
|
if hasDraft {
|
||||||
// The bubble's bottom inset is supplied by the `statusBottomEdge + 6.0`
|
// The bubble's bottom inset is supplied by the `statusBottomEdge + 6.0`
|
||||||
// max() in the measure closure below — but that branch is gated by
|
// max() in the measure closure below, but that branch is gated by
|
||||||
// `!hasDraft`, so during streaming the bubble has only its 1pt bottom rim
|
// `!hasDraft`, so during streaming the bubble has only its 1pt bottom rim
|
||||||
// past `revealedContentSize.height` (= bounds.maxY + closingPad). Without
|
// past `revealedContentSize.height` (= bounds.maxY + closingPad). Without
|
||||||
// this, descenders of the last revealed line sit cramped against the
|
// this, descenders of the last revealed line sit cramped against the
|
||||||
|
|
@ -591,7 +583,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
let frame = CGRect(origin: CGPoint(x: pageHorizontalInset, y: pageLayout.contentSize.height + showMoreTopSpacing), size: layout.0.size)
|
let frame = CGRect(origin: CGPoint(x: pageHorizontalInset, y: pageLayout.contentSize.height + showMoreTopSpacing), size: layout.0.size)
|
||||||
showMoreLayoutResult = layout
|
showMoreLayoutResult = layout
|
||||||
showMoreFramePageLocal = frame
|
showMoreFramePageLocal = frame
|
||||||
// Date trails the link line (or wraps below it if it doesn't fit) — reuse the
|
// Date trails the link line or wraps below it. Reuse the
|
||||||
// status machinery by substituting the link frame for the last-text-line frame.
|
// status machinery by substituting the link frame for the last-text-line frame.
|
||||||
lastTextLineFrame = frame
|
lastTextLineFrame = frame
|
||||||
lastTextLineTrailingPadding = 0.0
|
lastTextLineTrailingPadding = 0.0
|
||||||
|
|
@ -608,11 +600,11 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Measure trailing extent from the line's actual visible RIGHT EDGE (after
|
// Measure trailing extent from the line's actual visible RIGHT EDGE (after
|
||||||
// alignment, in page coords) — not just its intrinsic width. A right-aligned
|
// alignment, in page coordinates), not just its intrinsic width. A right-aligned
|
||||||
// or RTL last line has `lineWidth` worth of glyphs but sits all the way at
|
// or RTL last line has `lineWidth` worth of glyphs but sits all the way at
|
||||||
// the right text inset (lineFrame.maxX == text.frame.minX + textItem.width).
|
// the right text inset (lineFrame.maxX == text.frame.minX + textItem.width).
|
||||||
// Feeding the status node just `lineWidth` would let the trail/wrap decision
|
// Feeding the status node just `lineWidth` would let the trail/wrap decision
|
||||||
// place the date inline with the line — on top of it. `pageHorizontalInset`
|
// place the date inline with the line, on top of it. `pageHorizontalInset`
|
||||||
// is the offset between page-coords and status-node-local coords (the status
|
// is the offset between page-coords and status-node-local coords (the status
|
||||||
// node sits at x=pageHorizontalInset in self, and pageView sits at self-x 0).
|
// node sits at x=pageHorizontalInset in self, and pageView sits at self-x 0).
|
||||||
let trailingWidthToMeasure: CGFloat = lastTextLineFrame.map { $0.maxX - pageHorizontalInset } ?? 10000.0
|
let trailingWidthToMeasure: CGFloat = lastTextLineFrame.map { $0.maxX - pageHorizontalInset } ?? 10000.0
|
||||||
|
|
@ -657,7 +649,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
// lands at the right text inset rather than past the bubble's right edge.
|
// lands at the right text inset rather than past the bubble's right edge.
|
||||||
let statusSizeAndApply = statusSuggestedWidthAndContinue?.1(boundingWidth - pageHorizontalInset * 2.0)
|
let statusSizeAndApply = statusSuggestedWidthAndContinue?.1(boundingWidth - pageHorizontalInset * 2.0)
|
||||||
if let statusSizeAndApply, !hasDraft {
|
if let statusSizeAndApply, !hasDraft {
|
||||||
// Status node anchor Y in the content node's space — mirrors the apply
|
// Status node anchor Y in the content node's space mirrors the apply
|
||||||
// closure below.
|
// closure below.
|
||||||
let statusAnchorY: CGFloat
|
let statusAnchorY: CGFloat
|
||||||
if let lastTextLineFrame {
|
if let lastTextLineFrame {
|
||||||
|
|
@ -698,7 +690,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
|
|
||||||
// On the collapse→expand transition (tapping "Show more"), grow the bubble
|
// On the collapse→expand transition (tapping "Show more"), grow the bubble
|
||||||
// downward in screen space (inverted list offset direction) instead of pushing
|
// downward in screen space (inverted list offset direction) instead of pushing
|
||||||
// earlier messages up — matching the audio-transcription expand. The ListView
|
// earlier messages up, matching audio transcription expansion. The ListView
|
||||||
// clamps this to what fits, so "if possible" is handled for us. Only fires on a
|
// clamps this to what fits, so "if possible" is handled for us. Only fires on a
|
||||||
// change, and never on the first apply (appliedShowMoreExpanded is nil).
|
// change, and never on the first apply (appliedShowMoreExpanded is nil).
|
||||||
if let appliedShowMoreExpanded = self.appliedShowMoreExpanded, appliedShowMoreExpanded != showMoreExpanded {
|
if let appliedShowMoreExpanded = self.appliedShowMoreExpanded, appliedShowMoreExpanded != showMoreExpanded {
|
||||||
|
|
@ -791,7 +783,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
// Continue an in-flight anchor scroll that is waiting on a <details>
|
// Continue an in-flight anchor scroll that is waiting on a <details>
|
||||||
// expansion to re-lay-out. This runs on EVERY apply pass (not only the
|
// expansion to re-lay-out. This runs on EVERY apply pass (not only the
|
||||||
// expand-triggered one), but only does anything while a scroll is pending
|
// expand-triggered one), but only does anything while a scroll is pending
|
||||||
// — and scrollToAnchor is idempotent: each invocation either resolves and
|
// scrollToAnchor is idempotent: each invocation resolves and
|
||||||
// scrolls (clearing pending) or expands the next collapsed level, and the
|
// scrolls (clearing pending) or expands the next collapsed level, and the
|
||||||
// progress guard guarantees termination. So an unrelated relayout (theme,
|
// progress guard guarantees termination. So an unrelated relayout (theme,
|
||||||
// width, reactions) that lands mid-expand simply advances/no-ops the loop.
|
// width, reactions) that lands mid-expand simply advances/no-ops the loop.
|
||||||
|
|
@ -839,7 +831,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
}
|
}
|
||||||
|
|
||||||
if let formattedDateUpdatePeriod = pageLayout?.formattedDateUpdatePeriod {
|
if let formattedDateUpdatePeriod = pageLayout?.formattedDateUpdatePeriod {
|
||||||
// Recreate the timer only when the period changes — unlike the TextBubble
|
// Recreate the timer only when the period changes.
|
||||||
// reference (ChatMessageTextBubbleContentNode), which rebuilds it every apply.
|
// reference (ChatMessageTextBubbleContentNode), which rebuilds it every apply.
|
||||||
// The timer fires `requestFullUpdate`, which relays out and re-enters here; at
|
// The timer fires `requestFullUpdate`, which relays out and re-enters here; at
|
||||||
// a steady period this guard is false, so the running timer keeps its schedule
|
// a steady period this guard is false, so the running timer keeps its schedule
|
||||||
|
|
@ -862,7 +854,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
|
|
||||||
// 1. Compute / cache the cost map.
|
// 1. Compute / cache the cost map.
|
||||||
// Reuse the cost map computed in the layout pass (the bubble's
|
// Reuse the cost map computed in the layout pass (the bubble's
|
||||||
// size depended on it) — don't recompute. Keep the previous map
|
// size depended on it). Keep the previous map
|
||||||
// alive while a reveal/finalize is still in flight: on a post-
|
// alive while a reveal/finalize is still in flight: on a post-
|
||||||
// streaming pass (hasDraft && hadDraft both false) revealCostMap is
|
// streaming pass (hasDraft && hadDraft both false) revealCostMap is
|
||||||
// nil, and clobbering it would strand the display-link tick (whose
|
// nil, and clobbering it would strand the display-link tick (whose
|
||||||
|
|
@ -882,8 +874,8 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
}
|
}
|
||||||
// Seed the (possibly freshly rebuilt) V2 view to the reveal cursor's
|
// Seed the (possibly freshly rebuilt) V2 view to the reveal cursor's
|
||||||
// current position so we don't flash full text. Use the live controller
|
// current position so we don't flash full text. Use the live controller
|
||||||
// count rather than `previousAnimateGlyphCount`, which is nil — and would
|
// count rather than nil `previousAnimateGlyphCount`, which would
|
||||||
// reset the reveal to 0 — on post-streaming finalize passes where the
|
// reset the reveal to 0 on final passes where the
|
||||||
// controller is still animating.
|
// controller is still animating.
|
||||||
let seedCount = self.textRevealController?.currentGlyphCount ?? previousAnimateGlyphCount ?? 0
|
let seedCount = self.textRevealController?.currentGlyphCount ?? previousAnimateGlyphCount ?? 0
|
||||||
self.pageView?.applyReveal(revealedCount: seedCount,
|
self.pageView?.applyReveal(revealedCount: seedCount,
|
||||||
|
|
@ -1061,7 +1053,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
// "attribute substring with displayed range" API, so we cannot compare
|
// "attribute substring with displayed range" API, so we cannot compare
|
||||||
// displayed text to the resolved URL the way the chat text bubble does.
|
// displayed text to the resolved URL the way the chat text bubble does.
|
||||||
// The chat URL handler will show a confirmation when concealed is true
|
// The chat URL handler will show a confirmation when concealed is true
|
||||||
// and the visible text differs from the destination — safer default.
|
// and the visible text differs from the destination.
|
||||||
let concealed = true
|
let concealed = true
|
||||||
let url = ChatMessageBubbleContentTapAction.Url(url: urlHit.urlItem.url, concealed: concealed, allowInlineWebpageResolution: urlHit.urlItem.webpageId != nil)
|
let url = ChatMessageBubbleContentTapAction.Url(url: urlHit.urlItem.url, concealed: concealed, allowInlineWebpageResolution: urlHit.urlItem.webpageId != nil)
|
||||||
let rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
|
let rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
|
||||||
|
|
@ -1122,21 +1114,17 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
} else if let botCommand = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String {
|
} else if let botCommand = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String {
|
||||||
return .botCommand(botCommand)
|
return .botCommand(botCommand)
|
||||||
} else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag {
|
} else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag {
|
||||||
// Cashtags are carried as a Hashtag attribute (no dedicated cashtag key/tap-action exists);
|
// Cashtags use the hashtag attribute and retain their leading "$".
|
||||||
// the leading "$" in the string distinguishes them, and the chat hashtag handler searches both.
|
|
||||||
return .hashtag(hashtag.peerName, hashtag.hashtag)
|
return .hashtag(hashtag.peerName, hashtag.hashtag)
|
||||||
} else if let bankCard = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BankCard)] as? String {
|
} else if let bankCard = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BankCard)] as? String {
|
||||||
return .bankCard(bankCard)
|
return .bankCard(bankCard)
|
||||||
} else if let date = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Date)] as? Int32 {
|
} else if let date = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Date)] as? Int32 {
|
||||||
// The displayed string is unused downstream (ChatMessageBubbleItemNode matches `.date(date, _)`).
|
|
||||||
return .date(date, "")
|
return .date(date, "")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bridges an InstantPageUrlItem (used by the gallery's caption URL handler) to the
|
// Open gallery caption links through the chat URL handler.
|
||||||
/// chat layer's URL handler. `concealed: true` matches `tapActionAtPoint` for the same
|
|
||||||
/// reason: V2 cannot reliably compare displayed link text to the resolved URL.
|
|
||||||
private func openInstantPageUrl(_ url: InstantPageUrlItem) {
|
private func openInstantPageUrl(_ url: InstantPageUrlItem) {
|
||||||
guard let item = self.item else { return }
|
guard let item = self.item else { return }
|
||||||
item.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl(
|
item.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl(
|
||||||
|
|
@ -1147,9 +1135,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
}
|
}
|
||||||
|
|
||||||
private func computeHighlightRects(item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint) -> [CGRect] {
|
private func computeHighlightRects(item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint) -> [CGRect] {
|
||||||
// Text item returns rects in its local coords; translate back into containerNode-local coords.
|
// Translate text-local rects into container coordinates.
|
||||||
// containerNode is offset by (1, 1) from the bubble-content-node, but the highlight overlay lives
|
|
||||||
// *inside* containerNode, so we use layout-coords (= containerNode-local) for the rects.
|
|
||||||
let originX = parentOffset.x
|
let originX = parentOffset.x
|
||||||
let originY = parentOffset.y
|
let originY = parentOffset.y
|
||||||
return item.linkSelectionRects(at: localPoint).map { rect in
|
return item.linkSelectionRects(at: localPoint).map { rect in
|
||||||
|
|
@ -1443,7 +1429,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor)
|
item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 2. Not laid out — it may be buried in a collapsed <details>. Find the path and expand
|
// If hidden in collapsed details, find the path and expand
|
||||||
// the first collapsed details on it, then retry after the relayout (post-relayout hook).
|
// the first collapsed details on it, then retry after the relayout (post-relayout hook).
|
||||||
let anchorExpanded = (self.showMoreExpanded?.messageId == item.message.id) ? (self.showMoreExpanded?.value ?? false) : false
|
let anchorExpanded = (self.showMoreExpanded?.messageId == item.message.id) ? (self.showMoreExpanded?.value ?? false) : false
|
||||||
guard let instantPage = item.message.richText.map({ (anchorExpanded ? $0.fullInstantPage : nil) ?? $0.instantPage }),
|
guard let instantPage = item.message.richText.map({ (anchorExpanded ? $0.fullInstantPage : nil) ?? $0.instantPage }),
|
||||||
|
|
@ -1482,7 +1468,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
if let state = self.showMoreExpanded, state.messageId == messageId, state.value {
|
if let state = self.showMoreExpanded, state.messageId == messageId, state.value {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Full page already cached on the attribute — expand immediately, no network, no shimmer.
|
// Expand a cached full page immediately.
|
||||||
if attribute.fullInstantPage != nil {
|
if attribute.fullInstantPage != nil {
|
||||||
self.showMoreExpanded = (messageId, true)
|
self.showMoreExpanded = (messageId, true)
|
||||||
item.controllerInteraction.requestMessageUpdate(messageId, false, nil)
|
item.controllerInteraction.requestMessageUpdate(messageId, false, nil)
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ private final class PortalTransitionStaging {
|
||||||
/// Tears down staging. Reparents contentNode into the requested destination,
|
/// Tears down staging. Reparents contentNode into the requested destination,
|
||||||
/// removes clone from its overlay host, removes wrapper from surface.
|
/// removes clone from its overlay host, removes wrapper from surface.
|
||||||
/// All operations are explicit; we rely on Telegram's manual-animation policy
|
/// All operations are explicit; we rely on Telegram's manual-animation policy
|
||||||
/// (no implicit CALayer actions) — no CATransaction wrapping needed.
|
/// without implicit CALayer actions.
|
||||||
///
|
///
|
||||||
/// `presentationScale` re-application is intentionally not handled here: the
|
/// `presentationScale` re-application is intentionally not handled here: the
|
||||||
/// portal path is gated on `contentNode.presentationScale == 1.0` in CCEPN's
|
/// portal path is gated on `contentNode.presentationScale == 1.0` in CCEPN's
|
||||||
|
|
@ -223,7 +223,7 @@ private final class PortalTransitionStaging {
|
||||||
// ContentContainingNode/View added contentNode at init). Capturing
|
// ContentContainingNode/View added contentNode at init). Capturing
|
||||||
// contentNode.supernode at staging-enter time was overengineered: during
|
// contentNode.supernode at staging-enter time was overengineered: during
|
||||||
// animateOut staging that's offsetContainerNode (CCEPN's transient host),
|
// animateOut staging that's offsetContainerNode (CCEPN's transient host),
|
||||||
// which gets torn down with CCEPN — leaving the bubble parentless.
|
// which gets torn down with CCEPN, leaving the bubble parentless.
|
||||||
switch containingItem {
|
switch containingItem {
|
||||||
case let .node(containingNode):
|
case let .node(containingNode):
|
||||||
containingNode.addSubnode(containingNode.contentNode)
|
containingNode.addSubnode(containingNode.contentNode)
|
||||||
|
|
@ -761,7 +761,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo
|
||||||
contentNodeValue.sourceTransitionSurface = takeInfo.sourceTransitionSurface
|
contentNodeValue.sourceTransitionSurface = takeInfo.sourceTransitionSurface
|
||||||
|
|
||||||
// Mirror any ancestor scale on the source (e.g. a sheet's container transform) onto the offset
|
// Mirror any ancestor scale on the source (e.g. a sheet's container transform) onto the offset
|
||||||
// container so the extracted contents render at the same visual size as in-place — without this
|
// container so the extracted contents keep the same visual size.
|
||||||
// they pop to 1:1 when reparented into the unscaled overlay window.
|
// they pop to 1:1 when reparented into the unscaled overlay window.
|
||||||
let sourceView = takeInfo.containingItem.view
|
let sourceView = takeInfo.containingItem.view
|
||||||
let modeledWidth = sourceView.bounds.width
|
let modeledWidth = sourceView.bounds.width
|
||||||
|
|
@ -1425,7 +1425,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo
|
||||||
// Use ItemContentNode.frame (already includes contentVerticalOffset and
|
// Use ItemContentNode.frame (already includes contentVerticalOffset and
|
||||||
// additionalVisibleOffsetY from the layout pass) plus
|
// additionalVisibleOffsetY from the layout pass) plus
|
||||||
// containingItem.contentRect.origin to derive the bubble's actual rest
|
// containingItem.contentRect.origin to derive the bubble's actual rest
|
||||||
// window rect. Using bare `contentRect` skips those offsets — when reactions
|
// window rect. Using bare `contentRect` skips those offsets, so reactions
|
||||||
// are visible (additionalVisibleOffsetY = reactionContextNode.visibleExtensionDistance)
|
// are visible (additionalVisibleOffsetY = reactionContextNode.visibleExtensionDistance)
|
||||||
// the wrapper would sit ~10pt above the bubble's true rest, visibly offsetting
|
// the wrapper would sit ~10pt above the bubble's true rest, visibly offsetting
|
||||||
// the portal mirror until staging.settle reparents into offsetContainerNode.
|
// the portal mirror until staging.settle reparents into offsetContainerNode.
|
||||||
|
|
@ -1451,7 +1451,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo
|
||||||
// is visible at the chat-bubble slot; final (overlay clone, above
|
// is visible at the chat-bubble slot; final (overlay clone, above
|
||||||
// chrome) is visible at the menu position. Final fade-in begins
|
// chrome) is visible at the menu position. Final fade-in begins
|
||||||
// `crossfadeOverlap` seconds before source fade-out, so the final
|
// `crossfadeOverlap` seconds before source fade-out, so the final
|
||||||
// is already on screen before the source starts dropping out — no
|
// is already on screen before the source starts dropping out.
|
||||||
// single-frame seam at the handoff. Each fade runs for a fixed 0.1s.
|
// single-frame seam at the handoff. Each fade runs for a fixed 0.1s.
|
||||||
if let wrapper = staging.wrapper, let clone = staging.clone {
|
if let wrapper = staging.wrapper, let clone = staging.clone {
|
||||||
let sourceFadeDelay = 0.12 * duration
|
let sourceFadeDelay = 0.12 * duration
|
||||||
|
|
@ -1759,7 +1759,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo
|
||||||
// Mid-dismiss crossfade: clone (overlay portal target, above chrome)
|
// Mid-dismiss crossfade: clone (overlay portal target, above chrome)
|
||||||
// is visible at the menu position; final (chat-tree wrapper, behind
|
// is visible at the menu position; final (chat-tree wrapper, behind
|
||||||
// chrome) is visible once the bubble settles at the chat-bubble
|
// chrome) is visible once the bubble settles at the chat-bubble
|
||||||
// slot. Late handoff — clone carries the bubble through most of the
|
// slot. The clone carries the bubble through most of the
|
||||||
// dismiss; final picks up only as the bubble lands. Source fade-out
|
// dismiss; final picks up only as the bubble lands. Source fade-out
|
||||||
// is anchored at 80% of the spring duration; final fade-in starts
|
// is anchored at 80% of the spring duration; final fade-in starts
|
||||||
// `crossfadeOverlap` seconds earlier. Each fade runs for 0.1s.
|
// `crossfadeOverlap` seconds earlier. Each fade runs for 0.1s.
|
||||||
|
|
@ -2138,4 +2138,3 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,8 @@ import GZip
|
||||||
import HierarchyTrackingLayer
|
import HierarchyTrackingLayer
|
||||||
import TelegramUIPreferences
|
import TelegramUIPreferences
|
||||||
|
|
||||||
// WinterGram: bakes a white-on-transparent shape asset, tinted to `color`, into a CGImage usable as
|
// WinterGram: bake the tinted shape into a CGImage; `UIImage.withTintColor(...).cgImage` returns the
|
||||||
// layer contents. Drawing into a renderer context is required because `UIImage.withTintColor(...).cgImage`
|
// original (untinted) pixels, so drawing into a renderer context is required. Cached per (shape, color).
|
||||||
// returns the original (untinted) pixels. Cached per (shape, colour).
|
|
||||||
private var winterGramTintedShapeCache: [String: CGImage] = [:]
|
private var winterGramTintedShapeCache: [String: CGImage] = [:]
|
||||||
func winterGramBakeTintedShape(_ name: String, color: UIColor) -> CGImage? {
|
func winterGramBakeTintedShape(_ name: String, color: UIColor) -> CGImage? {
|
||||||
var r: CGFloat = 0.0, g: CGFloat = 0.0, b: CGFloat = 0.0, a: CGFloat = 0.0
|
var r: CGFloat = 0.0, g: CGFloat = 0.0, b: CGFloat = 0.0, a: CGFloat = 0.0
|
||||||
|
|
@ -266,9 +265,8 @@ public final class EmojiStatusComponent: Component {
|
||||||
|
|
||||||
private var winterGramBackplateLayer: SimpleLayer?
|
private var winterGramBackplateLayer: SimpleLayer?
|
||||||
private var winterGramSnowflakeLayer: SimpleLayer?
|
private var winterGramSnowflakeLayer: SimpleLayer?
|
||||||
// Whether the badge backplate should be spinning. Tracked separately so the rotation can be
|
// Track whether the badge backplate should spin so the animation can be restored when the
|
||||||
// re-applied when the layer re-enters the hierarchy (Core Animation drops detached animations
|
// layer re-enters the hierarchy.
|
||||||
// when a layer leaves the window — e.g. cell reuse / scrolling / backgrounding).
|
|
||||||
private var winterGramBadgeAnimating: Bool = false
|
private var winterGramBadgeAnimating: Bool = false
|
||||||
|
|
||||||
private var animationLayer: InlineStickerItemLayer?
|
private var animationLayer: InlineStickerItemLayer?
|
||||||
|
|
@ -300,9 +298,7 @@ public final class EmojiStatusComponent: Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// (Re)applies the infinite backplate-rotation animation for the WinterGram badge. Safe to call
|
// Restore or update the infinite backplate rotation for the WinterGram badge.
|
||||||
// repeatedly: it removes any existing animation first and only re-adds it when animation is
|
|
||||||
// enabled, so re-entering the hierarchy resumes the spin instead of leaving it frozen.
|
|
||||||
private func applyWinterGramBadgeRotation() {
|
private func applyWinterGramBadgeRotation() {
|
||||||
guard let backplateLayer = self.winterGramBackplateLayer else {
|
guard let backplateLayer = self.winterGramBackplateLayer else {
|
||||||
return
|
return
|
||||||
|
|
@ -605,8 +601,7 @@ public final class EmojiStatusComponent: Component {
|
||||||
|
|
||||||
if case let .winterGramBadge(backplateColor) = component.content {
|
if case let .winterGramBadge(backplateColor) = component.content {
|
||||||
size = availableSize
|
size = availableSize
|
||||||
// The tint must be BAKED into the bitmap: `UIImage.withTintColor(...).cgImage` returns the
|
// Bake the tint into the bitmap; `UIImage.withTintColor(...).cgImage` would keep the white pixels.
|
||||||
// original (white) pixels, which would render the backplate white instead of the theme colour.
|
|
||||||
let backplateImage = winterGramBakeTintedShape("WntGramBackplateShape", color: backplateColor)
|
let backplateImage = winterGramBakeTintedShape("WntGramBackplateShape", color: backplateColor)
|
||||||
let snowflakeImage = winterGramBakeTintedShape("WntGramSnowflakeShape", color: .white)
|
let snowflakeImage = winterGramBakeTintedShape("WntGramSnowflakeShape", color: .white)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2917,8 +2917,7 @@ final class TextContentItemLayer: SimpleLayer {
|
||||||
effectiveCharacterDrawCount = maxCharacterDrawCount
|
effectiveCharacterDrawCount = maxCharacterDrawCount
|
||||||
} else {
|
} else {
|
||||||
if self.previousMaxCharacterDrawCount > 0 || !self.animatingSnippetLayers.isEmpty {
|
if self.previousMaxCharacterDrawCount > 0 || !self.animatingSnippetLayers.isEmpty {
|
||||||
// Reveal finished — compute total character count so mask and snippets
|
// Keep mask active until all snippet animations finish.
|
||||||
// can continue updating until all snippet animations complete
|
|
||||||
var totalCharCount = 0
|
var totalCharCount = 0
|
||||||
for line in lines {
|
for line in lines {
|
||||||
if let characterRects = line.characterRects {
|
if let characterRects = line.characterRects {
|
||||||
|
|
@ -2932,7 +2931,7 @@ final class TextContentItemLayer: SimpleLayer {
|
||||||
}
|
}
|
||||||
effectiveCharacterDrawCount = totalCharCount
|
effectiveCharacterDrawCount = totalCharCount
|
||||||
} else {
|
} else {
|
||||||
// Nothing left to animate — remove the mask
|
// Remove mask when nothing remains to animate.
|
||||||
if let _ = self.revealMaskLayer {
|
if let _ = self.revealMaskLayer {
|
||||||
self.renderNodeContainer.mask = nil
|
self.renderNodeContainer.mask = nil
|
||||||
self.revealMaskLayer = nil
|
self.revealMaskLayer = nil
|
||||||
|
|
@ -3036,8 +3035,7 @@ final class TextContentItemLayer: SimpleLayer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build mask rects — use the lowest animating snippet index as the mask limit
|
// Limit mask to the lowest animating snippet index.
|
||||||
// so the mask never extends past any character still being animated
|
|
||||||
let maskCharacterLimit: Int
|
let maskCharacterLimit: Int
|
||||||
if let lowestAnimating = self.animatingSnippetLayers.min(by: { $0.characterIndex < $1.characterIndex })?.characterIndex {
|
if let lowestAnimating = self.animatingSnippetLayers.min(by: { $0.characterIndex < $1.characterIndex })?.characterIndex {
|
||||||
maskCharacterLimit = lowestAnimating
|
maskCharacterLimit = lowestAnimating
|
||||||
|
|
|
||||||
|
|
@ -1693,7 +1693,6 @@ public final class MessageInputPanelComponent: Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI Button
|
|
||||||
do {
|
do {
|
||||||
let isTallPanel = textFieldSize.height >= 70.0
|
let isTallPanel = textFieldSize.height >= 70.0
|
||||||
let textLength = self.textFieldExternalState.textLength
|
let textLength = self.textFieldExternalState.textLength
|
||||||
|
|
|
||||||
|
|
@ -195,9 +195,7 @@ public final class PeerInfoGiftsCoverComponent: Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func recomputeGifts() {
|
private func recomputeGifts() {
|
||||||
// WinterGram: visual (fake NFT) gifts are merged via `additionalGifts`, so we must render
|
// WinterGram: merge visual gifts from `additionalGifts` even before the real gifts state loads.
|
||||||
// them even before the real gifts state has loaded (e.g. a profile with no pinned gifts).
|
|
||||||
// Don't bail when `currentGiftsState` is nil — just treat the pinned set as empty.
|
|
||||||
let giftStatusId = self.currentGiftStatusId
|
let giftStatusId = self.currentGiftStatusId
|
||||||
let pinnedGifts = (self.currentGiftsState?.gifts ?? []).filter { gift in
|
let pinnedGifts = (self.currentGiftsState?.gifts ?? []).filter { gift in
|
||||||
if gift.pinnedToTop {
|
if gift.pinnedToTop {
|
||||||
|
|
|
||||||
|
|
@ -1642,8 +1642,7 @@ final class PeerInfoHeaderNode: ASDisplayNode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WinterGram: render the snowflake/developer badge last, to the right of the premium emoji
|
// WinterGram: render the snowflake/developer badge last, mirroring the chat-list ordering.
|
||||||
// status, the verified mark and any credibility icon — mirroring the chat-list ordering.
|
|
||||||
if let winterGramIconSize = self.winterGramIconSize, let titleExpandedWinterGramIconSize = self.titleExpandedWinterGramIconSize, winterGramIconSize.width > 0.0 {
|
if let winterGramIconSize = self.winterGramIconSize, let titleExpandedWinterGramIconSize = self.titleExpandedWinterGramIconSize, winterGramIconSize.width > 0.0 {
|
||||||
// Extra leading gap (vs the 4pt used between other icons) so the badge sits ~symmetrically
|
// Extra leading gap (vs the 4pt used between other icons) so the badge sits ~symmetrically
|
||||||
// after the premium emoji status, which carries its own internal transparent padding.
|
// after the premium emoji status, which carries its own internal transparent padding.
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ func infoItems(
|
||||||
}
|
}
|
||||||
var displayId = "\(rawId)"
|
var displayId = "\(rawId)"
|
||||||
if let winterGramDcId = winterGramDcId {
|
if let winterGramDcId = winterGramDcId {
|
||||||
displayId += " (DC: \(winterGramDcId))"
|
displayId += " (dc: \(winterGramDcId))"
|
||||||
}
|
}
|
||||||
let copyId: () -> Void = { [weak interaction] in
|
let copyId: () -> Void = { [weak interaction] in
|
||||||
UIPasteboard.general.string = "\(rawId)"
|
UIPasteboard.general.string = "\(rawId)"
|
||||||
|
|
@ -179,7 +179,7 @@ func infoItems(
|
||||||
controller.present(UndoOverlayController(presentationData: presentationData, content: .copy(text: presentationData.strings.Conversation_TextCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
|
controller.present(UndoOverlayController(presentationData: presentationData, content: .copy(text: presentationData.strings.Conversation_TextCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items[.winterGramInfo]!.append(PeerInfoScreenLabeledValueItem(id: 3099, label: "ID", text: displayId, textColor: .primary, action: { _, _ in
|
items[.winterGramInfo]!.append(PeerInfoScreenLabeledValueItem(id: 3099, label: "id", text: displayId, textColor: .primary, action: { _, _ in
|
||||||
copyId()
|
copyId()
|
||||||
}, longTapAction: nil, contextAction: { _, gesture, _ in
|
}, longTapAction: nil, contextAction: { _, gesture, _ in
|
||||||
copyId()
|
copyId()
|
||||||
|
|
@ -192,9 +192,9 @@ func infoItems(
|
||||||
if currentWinterGramSettings.showRegistrationDate, !user.isDeleted, let date = winterGramEstimatedRegistrationDate(userId: user.id.id._internalGetInt64Value()) {
|
if currentWinterGramSettings.showRegistrationDate, !user.isDeleted, let date = winterGramEstimatedRegistrationDate(userId: user.id.id._internalGetInt64Value()) {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.locale = Locale(identifier: presentationData.strings.baseLanguageCode)
|
formatter.locale = Locale(identifier: presentationData.strings.baseLanguageCode)
|
||||||
formatter.dateFormat = "MMMM yyyy"
|
formatter.setLocalizedDateFormatFromTemplate("MMMM yyyy")
|
||||||
let dateText = "≈ " + formatter.string(from: date)
|
let dateText = formatter.string(from: date)
|
||||||
items[.winterGramInfo]!.append(PeerInfoScreenLabeledValueItem(id: 3098, label: "Registered", text: dateText, textColor: .primary, action: nil, longTapAction: nil, contextAction: { _, gesture, _ in
|
items[.winterGramInfo]!.append(PeerInfoScreenLabeledValueItem(id: 3098, label: presentationData.strings.WinterGram_RegistrationDate, text: dateText, textColor: .primary, action: nil, longTapAction: nil, contextAction: { _, gesture, _ in
|
||||||
UIPasteboard.general.string = dateText
|
UIPasteboard.general.string = dateText
|
||||||
gesture?.cancel()
|
gesture?.cancel()
|
||||||
}, requestLayout: { animated in
|
}, requestLayout: { animated in
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,7 @@ import AccountContext
|
||||||
import TelegramPresentationData
|
import TelegramPresentationData
|
||||||
import GiftItemComponent
|
import GiftItemComponent
|
||||||
|
|
||||||
// WinterGram: a simple grid picker of all regular (generic) star gifts — including sold-out ones still
|
// WinterGram: grid picker of regular star gifts for adding a non-unique gift to the profile.
|
||||||
// present in the cached catalog — used to add a NON-unique gift "visually" to the profile.
|
|
||||||
public final class WinterGramGiftPickerScreen: ViewController {
|
public final class WinterGramGiftPickerScreen: ViewController {
|
||||||
private final class Node: ViewControllerTracingNode {
|
private final class Node: ViewControllerTracingNode {
|
||||||
private weak var controller: WinterGramGiftPickerScreen?
|
private weak var controller: WinterGramGiftPickerScreen?
|
||||||
|
|
@ -36,8 +35,7 @@ public final class WinterGramGiftPickerScreen: ViewController {
|
||||||
self.scrollNode.view.alwaysBounceVertical = true
|
self.scrollNode.view.alwaysBounceVertical = true
|
||||||
self.addSubnode(self.scrollNode)
|
self.addSubnode(self.scrollNode)
|
||||||
|
|
||||||
// Make sure the gift catalog is fetched/cached — otherwise the picker would be empty if the
|
// Ensure the gift catalog is cached so the picker is not empty on first open.
|
||||||
// user never opened a gift screen before.
|
|
||||||
self.keepUpdatedDisposable = context.engine.payments.keepStarGiftsUpdated().startStrict()
|
self.keepUpdatedDisposable = context.engine.payments.keepStarGiftsUpdated().startStrict()
|
||||||
|
|
||||||
self.disposable = (context.engine.payments.cachedStarGifts()
|
self.disposable = (context.engine.payments.cachedStarGifts()
|
||||||
|
|
|
||||||
|
|
@ -707,11 +707,11 @@ public final class TabBarComponent: Component {
|
||||||
|
|
||||||
let equalWidth = floorToScreenPixels(availableItemsWidth / CGFloat(component.items.count))
|
let equalWidth = floorToScreenPixels(availableItemsWidth / CGFloat(component.items.count))
|
||||||
if unboundItemWidths.allSatisfy({ $0 <= equalWidth }) {
|
if unboundItemWidths.allSatisfy({ $0 <= equalWidth }) {
|
||||||
// All items fit in equal width — use equal widths for optical alignment
|
// Use equal widths when all items fit.
|
||||||
itemWidths = Array(repeating: equalWidth, count: component.items.count)
|
itemWidths = Array(repeating: equalWidth, count: component.items.count)
|
||||||
totalItemsWidth = equalWidth * CGFloat(component.items.count)
|
totalItemsWidth = equalWidth * CGFloat(component.items.count)
|
||||||
} else {
|
} else {
|
||||||
// Some items need more space — use weighted fit
|
// Use weighted widths when some items need more space.
|
||||||
let itemWeightNorm: CGFloat = availableItemsWidth / unboundItemWidthSum
|
let itemWeightNorm: CGFloat = availableItemsWidth / unboundItemWidthSum
|
||||||
var widths: [CGFloat] = []
|
var widths: [CGFloat] = []
|
||||||
var total: CGFloat = 0.0
|
var total: CGFloat = 0.0
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ import TextInputMenu
|
||||||
import ObjCRuntimeUtils
|
import ObjCRuntimeUtils
|
||||||
import MultilineTextComponent
|
import MultilineTextComponent
|
||||||
|
|
||||||
public final class EmptyInputView: UIView, UIInputViewAudioFeedback {
|
// Internal to avoid collision with ChatEntityKeyboardInputNode.EmptyInputView.
|
||||||
|
final class EmptyInputView: UIView, UIInputViewAudioFeedback {
|
||||||
public var enableInputClicksWhenVisible: Bool {
|
public var enableInputClicksWhenVisible: Bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
submodules/TelegramUI/Images.xcassets/IntroLogoDark.imageset/Contents.json
vendored
Normal 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 }
|
||||||
|
}
|
||||||
BIN
submodules/TelegramUI/Images.xcassets/IntroLogoDark.imageset/IntroLogoDark@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
submodules/TelegramUI/Images.xcassets/IntroLogoDark.imageset/IntroLogoDark@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 118 KiB |
8
submodules/TelegramUI/Images.xcassets/IntroLogoLight.imageset/Contents.json
vendored
Normal 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 }
|
||||||
|
}
|
||||||
BIN
submodules/TelegramUI/Images.xcassets/IntroLogoLight.imageset/IntroLogoLight@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
submodules/TelegramUI/Images.xcassets/IntroLogoLight.imageset/IntroLogoLight@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 127 KiB |
|
|
@ -1724,7 +1724,7 @@ Readability.prototype = {
|
||||||
/**
|
/**
|
||||||
* Attempts to get excerpt and byline metadata for the article.
|
* Attempts to get excerpt and byline metadata for the article.
|
||||||
*
|
*
|
||||||
* @param {Object} jsonld — object containing any metadata that
|
* @param {Object} jsonld object containing any metadata that
|
||||||
* could be extracted from JSON-LD object.
|
* could be extracted from JSON-LD object.
|
||||||
*
|
*
|
||||||
* @return Object with optional "excerpt" and "byline" properties
|
* @return Object with optional "excerpt" and "byline" properties
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1224,6 +1224,7 @@ private func extractAccountManagerState(records: AccountRecordsView<TelegramAcco
|
||||||
let sharedApplicationContext = SharedApplicationContext(sharedContext: sharedContext, notificationManager: notificationManager, wakeupManager: wakeupManager)
|
let sharedApplicationContext = SharedApplicationContext(sharedContext: sharedContext, notificationManager: notificationManager, wakeupManager: wakeupManager)
|
||||||
sharedApplicationContext.sharedContext.mediaManager.overlayMediaManager.attachOverlayMediaController(sharedApplicationContext.overlayMediaController)
|
sharedApplicationContext.sharedContext.mediaManager.overlayMediaManager.attachOverlayMediaController(sharedApplicationContext.overlayMediaController)
|
||||||
self.winterGramSettingsDisposable.set(observeWinterGramSettings(accountManager: accountManager))
|
self.winterGramSettingsDisposable.set(observeWinterGramSettings(accountManager: accountManager))
|
||||||
|
WinterGramBadgeManager.shared.start()
|
||||||
self.winterGramGlassDisposable.set((winterGramSettings(accountManager: accountManager)
|
self.winterGramGlassDisposable.set((winterGramSettings(accountManager: accountManager)
|
||||||
|> deliverOnMainQueue).start(next: { settings in
|
|> deliverOnMainQueue).start(next: { settings in
|
||||||
let glass = settings.liquidGlass
|
let glass = settings.liquidGlass
|
||||||
|
|
|
||||||
|
|
@ -140,9 +140,7 @@ final class ChatSendMessageRichTextPreview: ChatSendMessageContextScreenRichText
|
||||||
fitToWidth: true
|
fitToWidth: true
|
||||||
)
|
)
|
||||||
self.pageView.update(layout: layout, theme: pageTheme, animation: .None)
|
self.pageView.update(layout: layout, theme: pageTheme, animation: .None)
|
||||||
// The parent (MessageItemView) owns and sets `pageView`'s frame; `update` only
|
// The parent owns the page frame and transition.
|
||||||
// rebuilds content and reports the size. Rendering is static (.None) — the screen
|
|
||||||
// drives the size/crossfade transition.
|
|
||||||
|
|
||||||
self.cachedBoundingWidth = boundingWidth
|
self.cachedBoundingWidth = boundingWidth
|
||||||
self.cachedThemeIdentity = themeIdentity
|
self.cachedThemeIdentity = themeIdentity
|
||||||
|
|
|
||||||
|
|
@ -5494,7 +5494,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Find the bubble for this message (it must be at least partially
|
// Find the bubble for this message (it must be at least partially
|
||||||
// visible — we got here from a tap inside it) and compute the
|
// visible because the tap came from it) and compute the
|
||||||
// anchor's y in item-local coords. .bottom(anchorY) places the item
|
// anchor's y in item-local coords. .bottom(anchorY) places the item
|
||||||
// so the anchor lands at the visual top of the rotated chat list's
|
// so the anchor lands at the visual top of the rotated chat list's
|
||||||
// content area; .center(.custom) is bypassed for short items, so it
|
// content area; .center(.custom) is bypassed for short items, so it
|
||||||
|
|
|
||||||
|
|
@ -4051,7 +4051,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate {
|
||||||
// In overlay chat mode (self.containerNode != nil) historyNodeContainer is
|
// In overlay chat mode (self.containerNode != nil) historyNodeContainer is
|
||||||
// reparented out of contentContainerNode (see line ~1299), making the
|
// reparented out of contentContainerNode (see line ~1299), making the
|
||||||
// `aboveSubview: historyNodeContainer.view` insertion invalid. Return nil
|
// `aboveSubview: historyNodeContainer.view` insertion invalid. Return nil
|
||||||
// so callers fall back to CCEPN's clipping path — portal-style transitions
|
// so callers fall back to CCEPN's clipping path. Portal-style transitions
|
||||||
// are not supported in overlay mode.
|
// are not supported in overlay mode.
|
||||||
guard self.containerNode == nil else { return nil }
|
guard self.containerNode == nil else { return nil }
|
||||||
let parent = self.contentContainerNode.contentNode.view
|
let parent = self.contentContainerNode.contentNode.view
|
||||||
|
|
|
||||||
|
|
@ -1434,6 +1434,18 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let deletedAttribute = message.attributes.first(where: { $0 is WinterGramDeletedMessageAttribute }) as? WinterGramDeletedMessageAttribute {
|
||||||
|
// WinterGram: surface WHEN a preserved message was deleted, in the long-press menu,
|
||||||
|
// instead of stamping a coloured time pill onto the bubble.
|
||||||
|
let deletedDateText = humanReadableStringForTimestamp(strings: chatPresentationInterfaceState.strings, dateTimeFormat: chatPresentationInterfaceState.dateTimeFormat, timestamp: deletedAttribute.date, alwaysShowTime: true, allowYesterday: true, format: nil).string
|
||||||
|
let deletedMark = currentWinterGramSettings.deletedMark.isEmpty ? "🧹" : currentWinterGramSettings.deletedMark
|
||||||
|
actions.append(.action(ContextMenuActionItem(text: "\(deletedMark) \(deletedDateText)", textColor: .primary, icon: { theme in
|
||||||
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.actionSheet.primaryTextColor)
|
||||||
|
}, action: { _, f in
|
||||||
|
f(.default)
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
if currentWinterGramSettings.showPeerId != .hidden {
|
if currentWinterGramSettings.showPeerId != .hidden {
|
||||||
actions.append(.action(ContextMenuActionItem(text: "Copy Message ID", icon: { theme in
|
actions.append(.action(ContextMenuActionItem(text: "Copy Message ID", icon: { theme in
|
||||||
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.actionSheet.primaryTextColor)
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.actionSheet.primaryTextColor)
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,8 @@ private func pendingAdditiveSublayerTranslation(_ layer: CALayer) -> CGPoint {
|
||||||
/// `convert(_:to:)` into the child. Applying the correction at the right level
|
/// `convert(_:to:)` into the child. Applying the correction at the right level
|
||||||
/// (rather than flat-summing the translations in the destination space) lets
|
/// (rather than flat-summing the translations in the destination space) lets
|
||||||
/// `CALayer.convert` propagate each correction through any remaining
|
/// `CALayer.convert` propagate each correction through any remaining
|
||||||
/// transforms — child `transform`, further ancestors' own model
|
/// transforms, including child `transform` and ancestor model
|
||||||
/// `sublayerTransform`, etc. — so the result is correct even when the chain
|
/// `sublayerTransform`, so the result is correct even when the chain
|
||||||
/// contains non-translation transforms (rotations, scales).
|
/// contains non-translation transforms (rotations, scales).
|
||||||
private func convertAnimatingSourceRectFromWindow(_ windowRect: CGRect, toView: UIView) -> CGRect {
|
private func convertAnimatingSourceRectFromWindow(_ windowRect: CGRect, toView: UIView) -> CGRect {
|
||||||
var chain: [CALayer] = []
|
var chain: [CALayer] = []
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ final class MusicListenTracker {
|
||||||
func update(with stateAndType: (Account, SharedMediaPlayerItemPlaybackStateOrLoading, MediaManagerPlayerType)?) {
|
func update(with stateAndType: (Account, SharedMediaPlayerItemPlaybackStateOrLoading, MediaManagerPlayerType)?) {
|
||||||
assert(Queue.mainQueue().isCurrent())
|
assert(Queue.mainQueue().isCurrent())
|
||||||
guard let (_, stateOrLoading, type) = stateAndType, type == .music else {
|
guard let (_, stateOrLoading, type) = stateAndType, type == .music else {
|
||||||
// Player closed or switched to non-music — report and clear
|
// Report when the player closes or switches away from music.
|
||||||
self.reportAndReset()
|
self.reportAndReset()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +135,7 @@ final class MusicListenTracker {
|
||||||
self.pauseTimer?.invalidate()
|
self.pauseTimer?.invalidate()
|
||||||
self.pauseTimer = nil
|
self.pauseTimer = nil
|
||||||
} else if !nowPlaying && wasPlaying {
|
} else if !nowPlaying && wasPlaying {
|
||||||
// Just paused — start pause timer
|
// Start the pause timer.
|
||||||
self.startPauseTimer()
|
self.startPauseTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,7 +154,7 @@ final class MusicListenTracker {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pauseTimerFired() {
|
private func pauseTimerFired() {
|
||||||
// Paused > 60s — report current session
|
// Report after a 60-second pause.
|
||||||
self.reportAndReset()
|
self.reportAndReset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -443,8 +443,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur
|
||||||
navigationController?.pushViewController(controller)
|
navigationController?.pushViewController(controller)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// WinterGram: wnt://profile opens the current account's own profile; wnt://profile?id=<id>
|
// Open the current or requested cached profile.
|
||||||
// opens that user's profile (resolved from the local cache — works for known peers).
|
|
||||||
if host == "profile" {
|
if host == "profile" {
|
||||||
let targetPeerId: EnginePeer.Id
|
let targetPeerId: EnginePeer.Id
|
||||||
if let idString = parsedUrl.query.flatMap({ QueryParameters($0)?["id"] }), let idValue = Int64(idString) {
|
if let idString = parsedUrl.query.flatMap({ QueryParameters($0)?["id"] }), let idValue = Int64(idString) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import Display
|
||||||
import TelegramUIPreferences
|
import TelegramUIPreferences
|
||||||
|
|
||||||
// WinterGram: a persistent branding banner shown at the very top, centred in the Dynamic Island /
|
// WinterGram: a persistent branding banner shown at the very top, centred in the Dynamic Island /
|
||||||
// notch band. It is a purely decorative overlay added to the key window — `isUserInteractionEnabled`
|
// notch band. It is a decorative overlay added to the key window. `isUserInteractionEnabled`
|
||||||
// is false so it never intercepts touches. For now there is a single banner type (the bundled
|
// is false so it never intercepts touches. For now there is a single banner type (the bundled
|
||||||
// `WntGramBanner` image); `WinterGramTopBannerStyle.off` hides it, any other value shows it.
|
// `WntGramBanner` image); `WinterGramTopBannerStyle.off` hides it, any other value shows it.
|
||||||
public final class WinterGramTopBannerView: UIView {
|
public final class WinterGramTopBannerView: UIView {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -197,8 +197,8 @@ public struct WinterGramVisualGift: Codable, Equatable {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.id = try container.decode(String.self, forKey: .id)
|
self.id = try container.decode(String.self, forKey: .id)
|
||||||
self.gift = try container.decode(StarGift.self, forKey: .gift)
|
self.gift = try container.decode(StarGift.self, forKey: .gift)
|
||||||
// `EnginePeer` isn't `Codable` and can't be rebuilt without a postbox, so — like
|
// `EnginePeer` isn't `Codable` and can't be rebuilt without a postbox.
|
||||||
// TelegramCore's own `StarGift` — only the peer id is persisted and `fromPeer` is left
|
// Like TelegramCore's `StarGift`, only the peer id is persisted and `fromPeer` is left
|
||||||
// nil on decode, to be resolved lazily by the consumer when a peer is actually needed.
|
// nil on decode, to be resolved lazily by the consumer when a peer is actually needed.
|
||||||
self.fromPeer = nil
|
self.fromPeer = nil
|
||||||
}
|
}
|
||||||
|
|
@ -816,40 +816,16 @@ public func observeWinterGramSettings(accountManager: AccountManager<TelegramAcc
|
||||||
}
|
}
|
||||||
|
|
||||||
public func isWinterGramOfficialPeer(_ peer: EnginePeer) -> Bool {
|
public func isWinterGramOfficialPeer(_ peer: EnginePeer) -> Bool {
|
||||||
let peerIdValue = peer.id.id._internalGetInt64Value()
|
return WinterGramBadgeManager.shared.badge(for: peer) != nil
|
||||||
switch peer {
|
|
||||||
case .user:
|
|
||||||
return peerIdValue == 885166226 || peerIdValue == 5665997196
|
|
||||||
case .channel:
|
|
||||||
// Raw channel ids (the part after the -100 marker): @wntgram/@wntbeta plus -1003999337820 / -1004348385636.
|
|
||||||
return peerIdValue == 3943351959 || peerIdValue == 4316373875 || peerIdValue == 3999337820 || peerIdValue == 4348385636
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Developer accounts get a distinct backplated badge; other official peers keep the plain snowflake.
|
|
||||||
public func isWinterGramDeveloperPeer(_ peer: EnginePeer) -> Bool {
|
public func isWinterGramDeveloperPeer(_ peer: EnginePeer) -> Bool {
|
||||||
let peerIdValue = peer.id.id._internalGetInt64Value()
|
return WinterGramBadgeManager.shared.badge(for: peer)?.id == "developer"
|
||||||
switch peer {
|
|
||||||
case .user:
|
|
||||||
return peerIdValue == 885166226 || peerIdValue == 5665997196
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name of the bundled badge image for a peer, or nil if the peer carries no WinterGram badge.
|
|
||||||
// Developers and official channels get the backplated badge; other official peers get the plain snowflake.
|
|
||||||
public func winterGramBadgeImageName(for peer: EnginePeer) -> String? {
|
public func winterGramBadgeImageName(for peer: EnginePeer) -> String? {
|
||||||
if isWinterGramDeveloperPeer(peer) {
|
guard let badge = WinterGramBadgeManager.shared.badge(for: peer) else {
|
||||||
return "WntGramDeveloperBadge"
|
return nil
|
||||||
}
|
}
|
||||||
if isWinterGramOfficialPeer(peer) {
|
return badge.id == "developer" ? "WntGramDeveloperBadge" : "WinterGramSnowflake"
|
||||||
if case .channel = peer {
|
|
||||||
return "WntGramDeveloperBadge"
|
|
||||||
}
|
|
||||||
return "WinterGramSnowflake"
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ public func customEmojiMarkdownURL(fileId: Int64) -> String {
|
||||||
|
|
||||||
/// Backslash-escapes only the characters that would break a marker link's
|
/// Backslash-escapes only the characters that would break a marker link's
|
||||||
/// display text (the `alt`): backslash, the link-text brackets, and newline.
|
/// display text (the `alt`): backslash, the link-text brackets, and newline.
|
||||||
/// Minimal by design — the forward CommonMark parser unescapes these, so the
|
/// The CommonMark parser unescapes these, so the
|
||||||
/// alt round-trips. Shared by every site that emits a `[<alt>](tg://emoji?id=…)`
|
/// alt round-trips. Shared by every site that emits a `[<alt>](tg://emoji?id=…)`
|
||||||
/// marker so the escaping cannot drift between encoders.
|
/// marker so the escaping cannot drift between encoders.
|
||||||
public func escapeCustomEmojiMarkdownAlt(_ string: String) -> String {
|
public func escapeCustomEmojiMarkdownAlt(_ string: String) -> String {
|
||||||
|
|
@ -53,7 +53,7 @@ private let customEmojiMarkerRegex = try? NSRegularExpression(
|
||||||
/// markdown text. Used to populate the edit compose field so it shows the
|
/// markdown text. Used to populate the edit compose field so it shows the
|
||||||
/// animated emoji; on re-save the forward path reads the attribute's fileId back.
|
/// animated emoji; on re-save the forward path reads the attribute's fileId back.
|
||||||
///
|
///
|
||||||
/// `file` is left nil — the renderer resolves the emoji lazily from `fileId`,
|
/// `file` is nil because the renderer resolves the emoji lazily from `fileId`,
|
||||||
/// and the send path only needs the fileId. Known limitation: an alt containing
|
/// and the send path only needs the fileId. Known limitation: an alt containing
|
||||||
/// a literal `]` is not matched (emoji alts do not contain brackets).
|
/// a literal `]` is not matched (emoji alts do not contain brackets).
|
||||||
public func chatInputTextWithReattachedCustomEmoji(_ markdown: String) -> NSAttributedString {
|
public func chatInputTextWithReattachedCustomEmoji(_ markdown: String) -> NSAttributedString {
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ public struct RangeSet<Bound: Comparable> {
|
||||||
/// The ranges stored by a range set are never empty, never overlap,
|
/// The ranges stored by a range set are never empty, never overlap,
|
||||||
/// and are always stored in ascending order when comparing their lower
|
/// and are always stored in ascending order when comparing their lower
|
||||||
/// or upper bounds. In addition to not overlapping, no two consecutive
|
/// or upper bounds. In addition to not overlapping, no two consecutive
|
||||||
/// ranges share an upper and lower bound — `[0..<5, 5..<10]` is ill-formed,
|
/// ranges share an upper and lower bound. `[0..<5, 5..<10]` is ill-formed,
|
||||||
/// and would instead be represented as `[0..<10]`.
|
/// and would instead be represented as `[0..<10]`.
|
||||||
internal func _checkInvariants() {
|
internal func _checkInvariants() {
|
||||||
for (a, b) in zip(ranges, ranges.dropFirst()) {
|
for (a, b) in zip(ranges, ranges.dropFirst()) {
|
||||||
|
|
|
||||||
|
|
@ -51,12 +51,12 @@ func isAllowedBotMediaUrl(_ urlString: String) -> Bool {
|
||||||
// Strict canonical dotted-decimal IPv4 (4 octets, no leading zeros, each 0-255).
|
// Strict canonical dotted-decimal IPv4 (4 octets, no leading zeros, each 0-255).
|
||||||
// Do NOT use inet_pton here: Darwin's inet_pton accepts "0177.0.0.1" as
|
// Do NOT use inet_pton here: Darwin's inet_pton accepts "0177.0.0.1" as
|
||||||
// decimal 177.0.0.1, but getaddrinfo (used by URLSession) interprets the
|
// decimal 177.0.0.1, but getaddrinfo (used by URLSession) interprets the
|
||||||
// same string as octal 127.0.0.1 — the divergence is a loopback bypass.
|
// same string as octal 127.0.0.1. The divergence is a loopback bypass.
|
||||||
if let v4Bytes = parseCanonicalIPv4(host) {
|
if let v4Bytes = parseCanonicalIPv4(host) {
|
||||||
return isPublicIPv4(v4Bytes)
|
return isPublicIPv4(v4Bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPv6 only — host must contain ":" so we don't accidentally hand a
|
// IPv6 only. Require ":" to avoid handing a
|
||||||
// numeric-looking hostname to inet_pton.
|
// numeric-looking hostname to inet_pton.
|
||||||
if host.contains(":") {
|
if host.contains(":") {
|
||||||
var v6 = in6_addr()
|
var v6 = in6_addr()
|
||||||
|
|
@ -70,7 +70,7 @@ func isAllowedBotMediaUrl(_ urlString: String) -> Bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strict DNS-name validation. Anything that doesn't look like a real
|
// Strict DNS-name validation. Anything that doesn't look like a real
|
||||||
// FQDN is rejected — this catches non-canonical numeric IP forms
|
// FQDN is rejected. This catches non-canonical numeric IP forms
|
||||||
// (decimal-32 like "2130706433", octal like "0177.0.0.1", hex like
|
// (decimal-32 like "2130706433", octal like "0177.0.0.1", hex like
|
||||||
// "0x7f.0.0.1", short forms like "127.1") that the OS resolver may
|
// "0x7f.0.0.1", short forms like "127.1") that the OS resolver may
|
||||||
// still treat as 127.0.0.1 even when inet_pton would accept them as
|
// still treat as 127.0.0.1 even when inet_pton would accept them as
|
||||||
|
|
|
||||||
10
third-party/dav1d/build-dav1d-bazel.sh
vendored
|
|
@ -10,7 +10,15 @@ MESON_OPTIONS="--buildtype=release --default-library=static -Denable_tools=false
|
||||||
CROSSFILE=""
|
CROSSFILE=""
|
||||||
|
|
||||||
if [ "$ARCH" = "arm64" ]; then
|
if [ "$ARCH" = "arm64" ]; then
|
||||||
CROSSFILE="../package/crossfiles/arm64-iPhoneOS.meson"
|
# Rewrite the hardcoded /Applications/Xcode.app sysroot to the active developer dir
|
||||||
|
# (e.g. Xcode-beta.app), mirroring the sim_arm64 branch — otherwise the meson cross
|
||||||
|
# build can't find the iPhoneOS SDK headers when Xcode lives at a non-default path.
|
||||||
|
TARGET_CROSSFILE="$BUILD_DIR/dav1d/package/crossfiles/arm64-iPhoneOS-custom.meson"
|
||||||
|
rm -f "$TARGET_CROSSFILE"
|
||||||
|
cp "$BUILD_DIR/dav1d/package/crossfiles/arm64-iPhoneOS.meson" "$TARGET_CROSSFILE"
|
||||||
|
custom_xcode_path="$(xcode-select -p)/"
|
||||||
|
sed -i '' "s|/Applications/Xcode.app/Contents/Developer/|$custom_xcode_path|g" "$TARGET_CROSSFILE"
|
||||||
|
CROSSFILE="../package/crossfiles/arm64-iPhoneOS-custom.meson"
|
||||||
elif [ "$ARCH" = "sim_arm64" ]; then
|
elif [ "$ARCH" = "sim_arm64" ]; then
|
||||||
rm -f "arm64-iPhoneSimulator-custom.meson"
|
rm -f "arm64-iPhoneSimulator-custom.meson"
|
||||||
TARGET_CROSSFILE="$BUILD_DIR/dav1d/package/crossfiles/arm64-iPhoneSimulator-custom.meson"
|
TARGET_CROSSFILE="$BUILD_DIR/dav1d/package/crossfiles/arm64-iPhoneSimulator-custom.meson"
|
||||||
|
|
|
||||||