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