diff --git a/.cursorignore b/.cursorignore deleted file mode 100644 index db9dd609e9..0000000000 --- a/.cursorignore +++ /dev/null @@ -1,2 +0,0 @@ -# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) -spm-files \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7a21ba6ee2..e7f834374d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,33 @@ -fastlane/README.md -fastlane/report.xml -fastlane/test_output/* -fastlane/fastlanematch -build/* +# WinterGram — files that should never be committed + +# ----------------------------------------------------------------------------- +# Build outputs and caches +# ----------------------------------------------------------------------------- +build/ +build-input/ +bazel-bin +bazel-bin/* +bazel-genfiles +bazel-genfiles/* +bazel-out +bazel-out/* +bazel-testlogs +bazel-testlogs/* +bazel-telegram-ios +bazel-telegram-ios/* +bazel-WinterGram +bazel-WinterGram/* +buck-out/* +.buckd/* +DerivedData +*.hmap +*.dSYM +*.dSYM.zip +*.ipa + +# ----------------------------------------------------------------------------- +# Xcode / IDE user state +# ----------------------------------------------------------------------------- *.pbxuser !default.pbxuser *.mode1v3 @@ -15,28 +40,52 @@ xcuserdata *.xccheckout *.xcscmblueprint *.moved-aside -DerivedData -*.hmap -*.ipa *.xcuserstate -.DS_Store -*.dSYM -*.dSYM.zip -*.ipa */xcuserdata/* -buck-out/* -.buckd/* -tools/buck -tools/bazel -AppBinary.xcworkspace/* -Project.xcodeproj/* -Watch/Watch.xcodeproj/* -AppBundle.xcworkspace/* *.xcworkspace *.xcodeproj !*_Xcode.xcodeproj .idea */.idea/* + +# ----------------------------------------------------------------------------- +# WinterGram local configuration +# ----------------------------------------------------------------------------- +build-system/wintergram-development-configuration.json +build-system/fake-codesigning-wintergram/ + +# ----------------------------------------------------------------------------- +# Editor / AI assistant / local tooling +# ----------------------------------------------------------------------------- +.vscode/ +.claude/ +**/.claude/settings.local.json +.xcodebuildmcp/ +tools/buck +tools/bazel + +# ----------------------------------------------------------------------------- +# OS and swap files +# ----------------------------------------------------------------------------- +.DS_Store +*.swp +*/*.swp + +# ----------------------------------------------------------------------------- +# Python / Node artifacts +# ----------------------------------------------------------------------------- +**/*.pyc +*.pyc +**/node_modules/ +tools/sim-watcher/dist/ + +# ----------------------------------------------------------------------------- +# Submodule-generated headers and prebuilt binaries +# ----------------------------------------------------------------------------- +submodules/OpusBinding/SharedHeaders/* +submodules/FFMpegBinding/SharedHeaders/* +submodules/OpenSSLEncryptionProvider/SharedHeaders/* +submodules/TelegramCore/FlatSerialization/Sources/* submodules/MtProtoKit/TON/macOS/lib/libtonlibjson.0.5.dylib submodules/MtProtoKit/TON/macOS/lib/libtonlibjson.dylib submodules/MtProtoKit/TON/macOS/lib/cmake/Tonlib/TonlibConfig.cmake @@ -49,37 +98,26 @@ submodules/MtProtoKit/TON/macOS/lib/fift/Lisp.fif submodules/MtProtoKit/TON/macOS/lib/fift/Lists.fif submodules/MtProtoKit/TON/macOS/lib/fift/Stack.fif submodules/MtProtoKit/TON/macOS/lib/fift/TonUtil.fif -bazel-bin -bazel-bin/* -bazel-genfiles -bazel-genfiles/* -bazel-out -bazel-out/* -bazel-telegram-ios -bazel-telegram-ios/* -bazel-testlogs -bazel-testlogs/* + +# ----------------------------------------------------------------------------- +# Bazel / SPM / LSP generated files +# ----------------------------------------------------------------------------- xcodeproj.bazelrc -*/*.swp -*.swp -build-input/* -**/*.pyc -*.pyc -submodules/OpusBinding/SharedHeaders/* -submodules/FFMpegBinding/SharedHeaders/* -submodules/OpenSSLEncryptionProvider/SharedHeaders/* -submodules/TelegramCore/FlatSerialization/Sources/* buildServer.json .build/** -Telegram.LSP.json **/.build/** +Telegram.LSP.json spm-files xcode-files .bsp/** .sourcekit-lsp/** -/.claude/ -**/.claude/settings.local.json -**/.vscode/launch.json -/buildbox/* -**/node_modules/ -tools/sim-watcher/dist/ + +# Legacy / tool-specific +fastlane/README.md +fastlane/report.xml +fastlane/test_output/* +fastlane/fastlanematch +AppBinary.xcworkspace/* +Project.xcodeproj/* +Watch/Watch.xcodeproj/* +AppBundle.xcworkspace/* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 1a268ae4e1..0000000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,196 +0,0 @@ -stages: - - build - - deploy - - verifysanity - - verify - - submit - -variables: - LANG: "en_US.UTF-8" - LC_ALL: "en_US.UTF-8" - GIT_SUBMODULE_STRATEGY: normal - -internal: - tags: - - ios_internal - stage: build - only: - - master - except: - - tags - script: - - export PATH=/opt/homebrew/opt/ruby/bin:$PATH - - export PATH=`gem environment gemdir`/bin:$PATH - - python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="build-system/appcenter-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=adhoc --configuration=release_arm64 --embedWatchApp --watchApiId="$TELEGRAM_WATCHOS_APP_ID" --watchApiHash="$TELEGRAM_WATCHOS_APP_HASH" - - python3 -u build-system/Make/DeployBuild.py --configuration="$TELEGRAM_PRIVATE_DATA_PATH/deploy-configurations/internal-configuration.json" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip" - - rm -rf build-input/configuration-repository-workdir - - rm -rf build-input/configuration-repository - - python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="$TELEGRAM_PRIVATE_DATA_PATH/build-configurations/enterprise-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=enterprise --configuration=release_arm64 --embedWatchApp --watchApiId="$TELEGRAM_WATCHOS_APP_ID" --watchApiHash="$TELEGRAM_WATCHOS_APP_HASH" - - python3 -u build-system/Make/DeployBuild.py --configuration="$TELEGRAM_PRIVATE_DATA_PATH/deploy-configurations/enterprise-configuration.json" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip" - environment: - name: internal - -internal_testflight: - tags: - - ios_internal - stage: deploy - only: - - master - except: - - tags - script: - - python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="build-system/appstore-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=appstore --configuration=release_arm64 --embedWatchApp --watchApiId="$TELEGRAM_WATCHOS_APP_ID" --watchApiHash="$TELEGRAM_WATCHOS_APP_HASH" - - python3 -u build-system/Make/Make.py remote-deploy-testflight --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip" - environment: - name: testflight_llc - artifacts: - when: always - paths: - - build/artifacts - expire_in: 1 week - -appstore_development: - tags: - - ios_internal - stage: build - only: - - appstore-development - except: - - tags - script: - - python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="$TELEGRAM_PRIVATE_DATA_PATH/build-configurations/enterprise-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=enterprise --configuration=release_arm64 - environment: - name: appstore-development - artifacts: - paths: - - build/artifacts/Telegram.DSYMs.zip - expire_in: 1 week - -experimental_i: - tags: - - ios_experimental - stage: build - only: - - experimental-3 - except: - - tags - script: - - export PATH=/opt/homebrew/opt/ruby/bin:$PATH - - export PATH=`gem environment gemdir`/bin:$PATH - - python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="build-system/appcenter-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=adhoc --configuration=release_arm64 - - python3 -u build-system/Make/Make.py remote-deploy-testflight --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip" - environment: - name: experimental - artifacts: - paths: - - build/artifacts/Telegram.DSYMs.zip - expire_in: 1 week - -experimental: - tags: - - ios_internal - stage: build - only: - - experimental-2 - except: - - tags - script: - - python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="$TELEGRAM_PRIVATE_DATA_PATH/build-configurations/enterprise-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=enterprise --configuration=release_arm64 - environment: - name: experimental-2 - artifacts: - paths: - - build/artifacts/Telegram.DSYMs.zip - expire_in: 1 week - -beta_testflight: - tags: - - ios_beta - stage: build - only: - - beta - - hotfix - except: - - tags - script: - - export PATH=/opt/homebrew/opt/ruby/bin:$PATH - - export PATH=`gem environment gemdir`/bin:$PATH - - python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="build-system/appstore-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=appstore --configuration=release_arm64 --embedWatchApp --watchApiId="$TELEGRAM_WATCHOS_APP_ID" --watchApiHash="$TELEGRAM_WATCHOS_APP_HASH" - environment: - name: testflight_llc - artifacts: - paths: - - build/artifacts - expire_in: 3 weeks - -deploy_beta_testflight: - tags: - - ios_beta - stage: deploy - only: - - beta - - hotfix - except: - - tags - script: - - python3 -u build-system/Make/Make.py remote-deploy-testflight --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip" - environment: - name: testflight_llc - -verifysanity_beta_testflight: - tags: - - ios_beta - stage: verifysanity - only: - - beta - - hotfix - except: - - tags - script: - - rm -rf build/verify-input && mkdir -p build/verify-input && mv build/artifacts/Telegram.ipa build/verify-input/TelegramVerifySource.ipa - - python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="build-system/appstore-configuration.json" --codesigningInformationPath=build-system/fake-codesigning --configuration=release_arm64 - - python3 -u build-system/Make/Make.py remote-ipa-diff --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --ipa1="build/artifacts/Telegram.ipa" --ipa2="build/verify-input/TelegramVerifySource.ipa" - - if [ $? -ne 0 ]; then echo "Verification failed"; mkdir -p build/verifysanity_artifacts; cp build/artifacts/Telegram.ipa build/verifysanity_artifacts/; exit 1; fi - environment: - name: testflight_llc - artifacts: - when: on_failure - paths: - - build/artifacts - expire_in: 1 week - -verify_beta_testflight: - tags: - - ios_beta - stage: verify - only: - - beta - - hotfix - except: - - tags - script: - - rm -rf build/verify-input && mkdir -p build/verify-input && mv build/artifacts/Telegram.ipa build/verify-input/TelegramVerifySource.ipa - - python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --configurationPath="build-system/appstore-configuration.json" --codesigningInformationPath=build-system/fake-codesigning --configuration=release_arm64 - - python3 -u build-system/Make/Make.py remote-ipa-diff --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --ipa1="build/artifacts/Telegram.ipa" --ipa2="build/verify-input/TelegramVerifySource.ipa" - - if [ $? -ne 0 ]; then echo "Verification failed"; mkdir -p build/verify_artifacts; cp build/artifacts/Telegram.ipa build/verify_artifacts/; exit 1; fi - environment: - name: testflight_llc - artifacts: - when: on_failure - paths: - - build/artifacts - expire_in: 1 week - -submit_appstore: - tags: - - deploy - only: - - beta - - hotfix - stage: submit - needs: [] - when: manual - script: - - sh "$TELEGRAM_SUBMIT_BUILD" - environment: - name: testflight_llc diff --git a/.gitmodules b/.gitmodules index 54ccfe3516..a8e9af28d0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "submodules/rlottie/rlottie"] path = submodules/rlottie/rlottie - url=../rlottie.git + url = https://github.com/TelegramMessenger/rlottie.git [submodule "build-system/bazel-rules/rules_apple"] path = build-system/bazel-rules/rules_apple url=https://github.com/ali-fareed/rules_apple.git @@ -13,7 +13,7 @@ url=https://github.com/bazelbuild/rules_swift.git url = https://github.com/bazelbuild/apple_support.git [submodule "submodules/TgVoipWebrtc/tgcalls"] path = submodules/TgVoipWebrtc/tgcalls -url=../tgcalls.git + url = https://github.com/TelegramMessenger/tgcalls.git [submodule "third-party/libvpx/libvpx"] path = third-party/libvpx/libvpx url = https://github.com/webmproject/libvpx.git diff --git a/CLAUDE.md b/CLAUDE.md index adfca9c415..e6749f70e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,9 @@ This file provides guidance to AI assistants when working with code in this repo 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. -**Command:** +**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 \ diff --git a/README.md b/README.md index 2ba6771007..7c6f498e34 100644 --- a/README.md +++ b/README.md @@ -1,122 +1,102 @@ -

WinterGram

- -

WinterGram (Wnt) is a feature-rich, privacy-focused Telegram client for iPhone — a native iOS port of the AyuGram experience, built on top of Telegram-iOS.

+# WinterGram

- License - Platform - Swift - Bazel - Stars - Last Commit + WinterGram white icon

---- +

+ Privacy-focused Telegram client for iOS +

-**WinterGram** brings the most-loved AyuGram features to iOS with a clean, Material-inspired interface, a configurable **Liquid Glass** appearance, and a single dedicated settings tab where everything lives. It speaks both the standard `tg://` deep links and its own `wnt://` scheme. +

+ License + Version 1.1 + Platform + Swift + Bazel +

+ +WinterGram (Wnt) is an independent iOS client for Telegram. It keeps the familiar Telegram experience and adds a dedicated **WinterGram** settings tab where privacy tools, history recovery, appearance controls, and other enhancements live in one place. + +The app speaks both `tg://` deep links and its own `wnt://` scheme. --- -## ✨ Features +## Download -### 👻 Privacy & Ghost Mode -- **Ghost Mode**: don't send read receipts, typing/upload status, or online presence — toggle it all at once. -- **Send without sound**: never / only in Ghost Mode / always. -- **Story ghost**: view stories without marking them seen, with an optional confirmation prompt. -- **Mark read after action**, **go offline after going online**, and per-toggle locks. +Prebuilt unsigned IPAs are published on the [Releases](../../releases) page. Install with [AltStore](https://altstore.io), [SideStore](https://sidestore.io), or another sideloading tool. -### 🗂 History & Recovery -- **Save deleted messages**: keep messages locally even after the other side deletes them. -- **Edit history**: store every revision of a message and browse it. -- **Semi-transparent deleted markers** and a customizable deleted / edited mark. - -### 🧊 Hidden Archive ("AАrchive") -- Stash chats into a **separate, settings-only archive** — no notifications, no badge. -- Optional **auto-mark-read** for everything sent to the stash. - -### 🛡️ Anti-Features -- **Disable ads** (sponsored messages). -- **Local Telegram Premium** — unlock Premium-gated UI locally. -- **Shadow ban** — silently hide a user's messages from your view. -- **Hide / disable stories**, **hide Premium statuses**, **disable open-link warning**. - -### 💬 Chat Conveniences -- **Sticker / GIF / voice send confirmations**. -- **Message seconds** in timestamps and **peer ID** display (Telegram or Bot API form). -- **Message translation** with a selectable provider (Telegram / Google / Yandex / system). -- **WebView platform spoofing** (auto / iOS / Android / macOS / desktop) and taller WebViews. - -### 🎨 Appearance & Customization -- **Liquid Glass** — frosted, translucent surfaces across the chat list, navigation, and tab bar, with **on/off toggle**, adjustable **transparency**, **blur radius**, **tint**, and per-surface application. -- **Material Design** switches and controls. -- **Avatar corner radius** (round → squircle → square) and **message bubble radius**, with optional single-corner mode. -- **Custom fonts** (UI + monospace). -- **App icons**, including the bundled WinterGram dark icon, plus **AyuGram / exteraGram icon-pack compatibility**. -- **Custom emoji** support, with an option to show only your added emoji & stickers. +WinterGram is not distributed on the App Store. Free Apple IDs must re-sign the app every 7 days; AltStore and SideStore can automate this. --- -## 🔗 Deep Links +## Features -WinterGram registers and resolves two URL schemes — anything that works with `tg://` works with `wnt://`: +WinterGram adds Ghost Mode, saved deleted messages, edit history, a hidden archive, local Premium UI, ad removal, Liquid Glass appearance, spoofing, chat conveniences, and more. + +A complete, structured feature list is in [`docs/FEATURES.md`](docs/FEATURES.md). + +Developer implementation notes: [`docs/wintergram-features.md`](docs/wintergram-features.md). + +--- + +## Quick Start (build from source) + +**Requirements:** macOS, Xcode, Python 3, ~60 GB free disk space. + +```sh +git clone --recursive https://github.com/reekeer/WinterGram.git +cd WinterGram +cp build-system/wintergram-development-configuration.example.json \ + build-system/wintergram-development-configuration.json +# Edit the JSON: api_id, api_hash, bundle_id, team_id +./scripts/build-wintergram.sh sim +``` + +The simulator IPA lands in `build/WinterGram-Simulator.ipa`. Build straight into a running Simulator with `./scripts/build-wintergram.sh --install` (add `--run` to launch it). Full instructions (device builds, signing, AltStore): [`docs/wintergram-setup.md`](docs/wintergram-setup.md). + +--- + +## Configuration + +All WinterGram options are stored in `WinterGramSettings` and exposed through the WinterGram tab in Settings. English UI strings ship in `en.lproj`; Russian translations are seeded in `submodules/TelegramPresentationData/Sources/WinterGramStrings.swift`. + +--- + +## Project Layout + +``` +WinterGram/ +├── Telegram/ App entry, extensions, icons, xcconfig +├── submodules/ Feature libraries (Swift / Obj-C) +├── branding/ Source art: app-icon PNGs (wnt-app-icon-*.png) + badge/snowflake shapes +├── docs/ Setup guide, feature list, architecture notes +├── build-system/ Bazel wrapper (Make.py) and configs +└── scripts/ Build + tooling + ├── build-wintergram.sh Convenience build script (sim / sideload / livecontainer) + └── generate-app-icons.sh Regenerate every app icon from branding/wnt-app-icon-*.png +``` + +--- + +## Deep Links + +Anything that works with `tg://` also works with `wnt://`: ``` tg://resolve?domain=durov wnt://resolve?domain=durov +wnt://wintergram/ghost ``` -`wnt://` links are normalized to the standard resolver at the entry point, so they route through exactly the same handling as native Telegram links. +`wnt://` URLs are normalized to `tg://` at the app entry point. --- -## 🚀 Build +## Contributing -WinterGram builds with the standard Telegram-iOS toolchain (Bazel via the `Make.py` wrapper) on **macOS with Xcode**. - -```sh -python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber=1 --configuration=debug_sim_arm64 -``` - -See [`docs/wintergram-features.md`](docs/wintergram-features.md) for the feature → implementation map and the project's architecture notes. +Maintainers: [**IMDelewer**](https://github.com/IMDelewer), [**salenyo**](https://github.com/salenyo) under the [reekeer](https://github.com/reekeer) organization. See [`MAINTAINERS.md`](MAINTAINERS.md). --- -## ⚙️ Configuration - -All WinterGram options live in a single settings store (`WinterGramSettings`), persisted with the app's shared-data system and exposed through reactive signals. There is one dedicated **WinterGram** tab in Settings — no scattered toggles. - ---- - -## 🗂 Structure - -``` -WinterGram/ -├── Telegram/ ← App entry points and extensions -├── submodules/ ← Feature libraries (Swift / Obj-C) -│ └── TelegramUIPreferences/ -│ └── Sources/WinterGramSettings.swift ← all WinterGram options -├── branding/ ← WinterGram icons and brand assets -├── docs/ ← Architecture and feature documentation -├── build-system/ ← Bazel build wrapper (Make.py) -└── README.md -``` - ---- - -## 🤝 Contributing - -Contributions are welcome. WinterGram is maintained by [**IMDelewer**](https://github.com/IMDelewer) and [**salenyo**](https://github.com/salenyo) under the [reekeer](https://github.com/reekeer) organization. See [`MAINTAINERS.md`](MAINTAINERS.md). - ---- - -

- Built on Telegram-iOS · inspired by AyuGram -

- -

GPLv2 © reekeer

+

GPLv2 © reekeer

diff --git a/Random.txt b/Random.txt deleted file mode 100644 index 1860059bf9..0000000000 --- a/Random.txt +++ /dev/null @@ -1 +0,0 @@ -c27f02cf6e413fdc diff --git a/Telegram/BUILD b/Telegram/BUILD index ef5fa5dbdf..4231b24d96 100644 --- a/Telegram/BUILD +++ b/Telegram/BUILD @@ -317,21 +317,25 @@ filegroup( ) alternate_icon_folders = [ - "BlackIcon", "BlackClassicIcon", "BlackFilledIcon", - "BlueIcon", + "BlackIcon", "BlueClassicIcon", "BlueFilledIcon", - "WhiteFilledIcon", + "BlueIcon", "New1", "New2", "Premium", "PremiumBlack", "PremiumTurbo", + "WinterGramDark", + "WinterGramDeveloper", + "WinterGramHouseDark", + "WinterGramHouseLight", + "WinterGramLight", ] -composer_icon_folders = ["Telegram"] +composer_icon_folders = [] [ filegroup( @@ -434,6 +438,16 @@ plist_fragment( tonsite + + CFBundleTypeRole + Viewer + CFBundleURLName + {telegram_bundle_id}.wnt + CFBundleURLSchemes + + wnt + + """.format( telegram_bundle_id = telegram_bundle_id, @@ -525,6 +539,26 @@ associated_domains_fragment = "" if telegram_bundle_id not in official_bundle_id """ +background_modes_fragment = """ + UIBackgroundModes + + audio + fetch + location + remote-notification + voip + processing + +""" if telegram_aps_environment != "" else """ + UIBackgroundModes + + audio + fetch + location + processing + +""" + siri_fragment = "" if not telegram_enable_siri else """ com.apple.developer.siri @@ -629,7 +663,7 @@ plist_fragment( template = """ CFBundleDisplayName - Telegram + WinterGram """ ) @@ -955,14 +989,14 @@ ios_framework( plist_fragment( name = "ShareInfoPlist", extension = "plist", - template = + template = """ CFBundleDevelopmentRegion en CFBundleIdentifier {telegram_bundle_id}.Share CFBundleName - Telegram + WinterGram CFBundlePackageType XPC! NSExtension @@ -1047,14 +1081,14 @@ ios_extension( plist_fragment( name = "NotificationContentInfoPlist", extension = "plist", - template = + template = """ CFBundleDevelopmentRegion en CFBundleIdentifier {telegram_bundle_id}.NotificationContent CFBundleName - Telegram + WinterGram CFBundlePackageType XPC! NSExtension @@ -1154,14 +1188,14 @@ ios_extension( plist_fragment( name = "WidgetInfoPlist", extension = "plist", - template = + template = """ CFBundleDevelopmentRegion en CFBundleIdentifier {telegram_bundle_id}.Widget CFBundleName - Telegram + WinterGram CFBundlePackageType XPC! NSExtension @@ -1267,14 +1301,14 @@ ios_extension( plist_fragment( name = "IntentsInfoPlist", extension = "plist", - template = + template = """ CFBundleDevelopmentRegion en CFBundleIdentifier {telegram_bundle_id}.SiriIntents CFBundleName - Telegram + WinterGram CFBundlePackageType XPC! NSExtension @@ -1398,14 +1432,14 @@ ios_extension( plist_fragment( name = "BroadcastUploadInfoPlist", extension = "plist", - template = + template = """ CFBundleDevelopmentRegion en CFBundleIdentifier {telegram_bundle_id}.BroadcastUpload CFBundleName - Telegram + WinterGram CFBundlePackageType XPC! NSExtension @@ -1492,14 +1526,14 @@ ios_extension( plist_fragment( name = "NotificationServiceInfoPlist", extension = "plist", - template = + template = """ CFBundleDevelopmentRegion en CFBundleIdentifier {telegram_bundle_id}.NotificationService CFBundleName - Telegram + WinterGram CFBundlePackageType XPC! NSExtension @@ -1555,7 +1589,7 @@ ios_extension( plist_fragment( name = "TelegramInfoPlist", extension = "plist", - template = + template = """ BGTaskSchedulerPermittedIdentifiers @@ -1568,11 +1602,11 @@ plist_fragment( CFBundleDevelopmentRegion en CFBundleDisplayName - Telegram + WinterGram CFBundleIdentifier {telegram_bundle_id} CFBundleName - Telegram + WinterGram CFBundlePackageType APPL CFBundleSignature @@ -1629,17 +1663,17 @@ plist_fragment( NSCameraUsageDescription We need this so that you can take and share photos and videos. NSContactsUsageDescription - Telegram stores your contacts heavily encrypted in the cloud to let you connect with your friends across all your devices. + WinterGram stores your contacts heavily encrypted in the cloud to let you connect with your friends across all your devices. NSFaceIDUsageDescription You can use Face ID to unlock the app. NSLocationAlwaysUsageDescription - When you send your location to your friends, Telegram needs access to show them a map. You also need this to send locations from an Apple Watch. + When you send your location to your friends, WinterGram needs access to show them a map. You also need this to send locations from an Apple Watch. NSLocationWhenInUseUsageDescription - When you send your location to your friends, Telegram needs access to show them a map. + When you send your location to your friends, WinterGram needs access to show them a map. NSMicrophoneUsageDescription We need this so that you can record and share voice messages and videos with sound. NSMotionUsageDescription - When you send your location to your friends, Telegram needs access to show them a map. + When you send your location to your friends, WinterGram needs access to show them a map. NSPhotoLibraryAddUsageDescription We need this so that you can share photos and videos from your photo library. NSPhotoLibraryUsageDescription @@ -1658,15 +1692,7 @@ plist_fragment( AremacFS-Regular.otf AremacFS-Semibold.otf - UIBackgroundModes - - audio - fetch - location - remote-notification - voip - processing - +{background_modes_fragment} UIDeviceFamily 1 @@ -1707,13 +1733,13 @@ plist_fragment( public.data UTTypeDescription - Telegram iOS Color Theme File + WinterGram iOS Color Theme File UTTypeIconFiles BlueIcon@3x.png UTTypeIdentifier - org.telegram.Telegram-iOS.theme + dev.reekeer.wintergram.theme UTTypeTagSpecification public.filename-extension @@ -1729,6 +1755,7 @@ plist_fragment( """.format( telegram_bundle_id = telegram_bundle_id, + background_modes_fragment = background_modes_fragment, ) ) @@ -1777,12 +1804,13 @@ ios_application( ":UrlTypesInfoPlist", ], app_icons = [ ":{}_icon".format(name) for name in composer_icon_folders ], + primary_app_icon = "WinterGramDarkIcon", alternate_icons = [ ":{}".format(name) for name in alternate_icon_folders ], resources = [ ":LaunchScreen", - #":DefaultAppIcon", + ":DefaultAppIcon", ], frameworks = [ ":MtProtoKitFramework", @@ -1845,9 +1873,9 @@ filegroup( ) ios_test_runner( - name = "iPhone-17__26.2", + name = "iPhone-17__26.5", device_type = "iPhone 17", - os_version = "26.2", + os_version = "26.5", ) @@ -1864,7 +1892,7 @@ ios_ui_test_suite( bundle_id = "org.telegram.Telegram-iOS-uitests", minimum_os_version = minimum_os_version, runners = [ - ":iPhone-17__26.2", + ":iPhone-17__26.5", ], tags = ["manual"], test_host = "//Telegram", diff --git a/Telegram/Telegram-iOS/AlternateIcons-iPad.plist b/Telegram/Telegram-iOS/AlternateIcons-iPad.plist index a9ebd9173d..b62e1e2306 100644 --- a/Telegram/Telegram-iOS/AlternateIcons-iPad.plist +++ b/Telegram/Telegram-iOS/AlternateIcons-iPad.plist @@ -72,7 +72,29 @@ UIPrerenderedIcon - New1 + WinterGramDark + + CFBundleIconFiles + + WinterGramDarkIpad + WinterGramDarkLargeIpad + WinterGramDarkNotificationIcon + + UIPrerenderedIcon + + + WinterGramLight + + CFBundleIconFiles + + WinterGramLightIpad + WinterGramLightLargeIpad + WinterGramLightNotificationIcon + + UIPrerenderedIcon + + + New1 CFBundleIconFiles diff --git a/Telegram/Telegram-iOS/AlternateIcons.plist b/Telegram/Telegram-iOS/AlternateIcons.plist index d97e8c714a..ec44e1711d 100644 --- a/Telegram/Telegram-iOS/AlternateIcons.plist +++ b/Telegram/Telegram-iOS/AlternateIcons.plist @@ -66,6 +66,26 @@ UIPrerenderedIcon + WinterGramDark + + CFBundleIconFiles + + WinterGramDark + WinterGramDarkNotificationIcon + + UIPrerenderedIcon + + + WinterGramLight + + CFBundleIconFiles + + WinterGramLight + WinterGramLightNotificationIcon + + UIPrerenderedIcon + + New1 CFBundleIconFiles diff --git a/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDark.imageset/Contents.json b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDark.imageset/Contents.json new file mode 100644 index 0000000000..8a71db8fa4 --- /dev/null +++ b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDark.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "WinterGramDark@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "WinterGramDark@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDark.imageset/WinterGramDark@2x.png b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDark.imageset/WinterGramDark@2x.png new file mode 100644 index 0000000000..f6328bfc03 Binary files /dev/null and b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDark.imageset/WinterGramDark@2x.png differ diff --git a/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDark.imageset/WinterGramDark@3x.png b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDark.imageset/WinterGramDark@3x.png new file mode 100644 index 0000000000..8c495fab69 Binary files /dev/null and b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDark.imageset/WinterGramDark@3x.png differ diff --git a/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDeveloper.imageset/Contents.json b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDeveloper.imageset/Contents.json new file mode 100644 index 0000000000..02debbbd84 --- /dev/null +++ b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDeveloper.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "WinterGramDeveloper@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "WinterGramDeveloper@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDeveloper.imageset/WinterGramDeveloper@2x.png b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDeveloper.imageset/WinterGramDeveloper@2x.png new file mode 100644 index 0000000000..4b3174ab35 Binary files /dev/null and b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDeveloper.imageset/WinterGramDeveloper@2x.png differ diff --git a/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDeveloper.imageset/WinterGramDeveloper@3x.png b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDeveloper.imageset/WinterGramDeveloper@3x.png new file mode 100644 index 0000000000..d14a6f7dd4 Binary files /dev/null and b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramDeveloper.imageset/WinterGramDeveloper@3x.png differ diff --git a/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseDark.imageset/Contents.json b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseDark.imageset/Contents.json new file mode 100644 index 0000000000..53b8c63624 --- /dev/null +++ b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseDark.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "WinterGramHouseDark@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "WinterGramHouseDark@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseDark.imageset/WinterGramHouseDark@2x.png b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseDark.imageset/WinterGramHouseDark@2x.png new file mode 100644 index 0000000000..cfcad99d96 Binary files /dev/null and b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseDark.imageset/WinterGramHouseDark@2x.png differ diff --git a/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseDark.imageset/WinterGramHouseDark@3x.png b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseDark.imageset/WinterGramHouseDark@3x.png new file mode 100644 index 0000000000..31902ab7dc Binary files /dev/null and b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseDark.imageset/WinterGramHouseDark@3x.png differ diff --git a/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseLight.imageset/Contents.json b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseLight.imageset/Contents.json new file mode 100644 index 0000000000..132c05d07d --- /dev/null +++ b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseLight.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "WinterGramHouseLight@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "WinterGramHouseLight@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseLight.imageset/WinterGramHouseLight@2x.png b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseLight.imageset/WinterGramHouseLight@2x.png new file mode 100644 index 0000000000..b6d9c255ad Binary files /dev/null and b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseLight.imageset/WinterGramHouseLight@2x.png differ diff --git a/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseLight.imageset/WinterGramHouseLight@3x.png b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseLight.imageset/WinterGramHouseLight@3x.png new file mode 100644 index 0000000000..3719819bc7 Binary files /dev/null and b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramHouseLight.imageset/WinterGramHouseLight@3x.png differ diff --git a/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramLight.imageset/Contents.json b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramLight.imageset/Contents.json new file mode 100644 index 0000000000..4be036864f --- /dev/null +++ b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramLight.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "WinterGramLight@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "WinterGramLight@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramLight.imageset/WinterGramLight@2x.png b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramLight.imageset/WinterGramLight@2x.png new file mode 100644 index 0000000000..24c435a6dc Binary files /dev/null and b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramLight.imageset/WinterGramLight@2x.png differ diff --git a/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramLight.imageset/WinterGramLight@3x.png b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramLight.imageset/WinterGramLight@3x.png new file mode 100644 index 0000000000..4d6aceafdd Binary files /dev/null and b/Telegram/Telegram-iOS/AppIcons.xcassets/WinterGramLight.imageset/WinterGramLight@3x.png differ diff --git a/Telegram/Telegram-iOS/Config-AppStoreLLC.xcconfig b/Telegram/Telegram-iOS/Config-AppStoreLLC.xcconfig index 76c1f1e594..d91a311569 100644 --- a/Telegram/Telegram-iOS/Config-AppStoreLLC.xcconfig +++ b/Telegram/Telegram-iOS/Config-AppStoreLLC.xcconfig @@ -1,4 +1,4 @@ -APP_NAME=Telegram +APP_NAME=WinterGram APP_BUNDLE_ID=ph.telegra.Telegraph APP_SPECIFIC_URL_SCHEME=tgapp diff --git a/Telegram/Telegram-iOS/Config-Fork.xcconfig b/Telegram/Telegram-iOS/Config-Fork.xcconfig index c3192251c1..8a190ee5b8 100644 --- a/Telegram/Telegram-iOS/Config-Fork.xcconfig +++ b/Telegram/Telegram-iOS/Config-Fork.xcconfig @@ -1,4 +1,4 @@ -APP_NAME=Telegram Fork +APP_NAME=WinterGram APP_BUNDLE_ID=fork.telegram.Fork APP_SPECIFIC_URL_SCHEME=tgfork diff --git a/Telegram/Telegram-iOS/Config-WinterGram.xcconfig b/Telegram/Telegram-iOS/Config-WinterGram.xcconfig new file mode 100644 index 0000000000..aca6ac860a --- /dev/null +++ b/Telegram/Telegram-iOS/Config-WinterGram.xcconfig @@ -0,0 +1,8 @@ +APP_NAME=WinterGram +APP_BUNDLE_ID=dev.reekeer.wintergram +APP_SPECIFIC_URL_SCHEME=wnt + +GLOBAL_CONSTANTS = APP_CONFIG_IS_INTERNAL_BUILD=true APP_CONFIG_IS_APPSTORE_BUILD=false APP_CONFIG_APPSTORE_ID=0 APP_SPECIFIC_URL_SCHEME="\"$(APP_SPECIFIC_URL_SCHEME)\"" +GCC_PREPROCESSOR_DEFINITIONS = $(inherited) $(GLOBAL_CONSTANTS) + +GCC_PREPROCESSOR_DEFINITIONS = $(inherited) APP_CONFIG_API_ID=2040 APP_CONFIG_API_HASH="\"b18441a1ff607e10a989891a5462e627\"" APP_CONFIG_HOCKEYAPP_ID="" diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/Contents.json b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/Contents.json index 4d65457087..72a8d5035b 100644 --- a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/Contents.json +++ b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/Contents.json @@ -1,115 +1,110 @@ { - "images" : [ + "images": [ { - "filename" : "BlueNotificationIcon@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" + "filename": "BlueNotificationIcon@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "20x20" }, { - "filename" : "BlueNotificationIcon@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" + "filename": "BlueNotificationIcon@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "20x20" }, { - "filename" : "Simple@58x58.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" + "filename": "Simple@58x58.png", + "idiom": "iphone", + "scale": "2x", + "size": "29x29" }, { - "filename" : "Simple@87x87.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" + "filename": "Simple@87x87.png", + "idiom": "iphone", + "scale": "3x", + "size": "29x29" }, { - "filename" : "Simple@80x80.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" + "filename": "Simple@80x80.png", + "idiom": "iphone", + "scale": "2x", + "size": "40x40" }, { - "filename" : "BlueIcon@2x-1.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" + "filename": "BlueIcon@2x-1.png", + "idiom": "iphone", + "scale": "3x", + "size": "40x40" }, { - "filename" : "BlueIcon@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" + "filename": "BlueIcon@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "60x60" }, { - "filename" : "BlueIcon@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" + "filename": "BlueIcon@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "60x60" }, { - "filename" : "BlueNotificationIcon.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" + "filename": "BlueNotificationIcon.png", + "idiom": "ipad", + "scale": "1x", + "size": "20x20" }, { - "filename" : "BlueNotificationIcon@2x-1.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "20x20" + "filename": "BlueNotificationIcon@2x-1.png", + "idiom": "ipad", + "scale": "2x", + "size": "20x20" }, { - "filename" : "Simple@29x29.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" + "filename": "Simple@29x29.png", + "idiom": "ipad", + "scale": "1x", + "size": "29x29" }, { - "filename" : "Simple@58x58-1.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "29x29" + "filename": "Simple@58x58-1.png", + "idiom": "ipad", + "scale": "2x", + "size": "29x29" }, { - "filename" : "Simple@40x40-1.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" + "filename": "Simple@40x40-1.png", + "idiom": "ipad", + "scale": "1x", + "size": "40x40" }, { - "filename" : "Simple@80x80-1.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "40x40" + "filename": "Simple@80x80-1.png", + "idiom": "ipad", + "scale": "2x", + "size": "40x40" }, { - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" + "filename": "BlueIconIpad@2x.png", + "idiom": "ipad", + "scale": "2x", + "size": "76x76" }, { - "filename" : "BlueIconIpad@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" + "filename": "BlueIconLargeIpad@2x.png", + "idiom": "ipad", + "scale": "2x", + "size": "83.5x83.5" }, { - "filename" : "BlueIconLargeIpad@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "83.5x83.5" - }, - { - "filename" : "Simple-iTunesArtwork.png", - "idiom" : "ios-marketing", - "scale" : "1x", - "size" : "1024x1024" + "filename": "Simple-iTunesArtwork.png", + "idiom": "ios-marketing", + "scale": "1x", + "size": "1024x1024" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramBadge.imageset/Contents.json b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramBadge.imageset/Contents.json new file mode 100644 index 0000000000..09e3c1d8b1 --- /dev/null +++ b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramBadge.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "WinterGramBadge@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "WinterGramBadge@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramBadge.imageset/WinterGramBadge@2x.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramBadge.imageset/WinterGramBadge@2x.png new file mode 100644 index 0000000000..cf12532c88 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramBadge.imageset/WinterGramBadge@2x.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramBadge.imageset/WinterGramBadge@3x.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramBadge.imageset/WinterGramBadge@3x.png new file mode 100644 index 0000000000..47081de31d Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramBadge.imageset/WinterGramBadge@3x.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Contents.json b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Contents.json index 172ed83a1f..e5f794209c 100644 --- a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Contents.json +++ b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Contents.json @@ -84,12 +84,6 @@ "scale" : "2x", "size" : "40x40" }, - { - "filename" : "Icon-76.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" - }, { "filename" : "Icon-152.png", "idiom" : "ipad", diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-1024.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-1024.png index 7a301e6332..4b5ccee658 100644 Binary files a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-1024.png and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-1024.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-120.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-120.png index a65b5897a8..f6328bfc03 100644 Binary files a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-120.png and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-120.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-152.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-152.png index dd7a6f1dd6..4af76d3dd5 100644 Binary files a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-152.png and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-152.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-167.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-167.png index 02d5cf0e9d..56c37d1f8e 100644 Binary files a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-167.png and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-167.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-180.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-180.png index 926d014f30..8c495fab69 100644 Binary files a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-180.png and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-180.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-20.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-20.png index 6aee2e35c4..8ef699a0b3 100644 Binary files a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-20.png and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-20.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-29.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-29.png index 1fea6d62a4..41f8e796af 100644 Binary files a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-29.png and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-29.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-40.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-40.png index dcd3c0784f..b25cd5f428 100644 Binary files a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-40.png and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-40.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-58.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-58.png index cd11b73ca2..f7adbb3ada 100644 Binary files a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-58.png and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-58.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-60.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-60.png index 294da6cf0a..edda6a53d0 100644 Binary files a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-60.png and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-60.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-76.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-76.png deleted file mode 100644 index 200a039341..0000000000 Binary files a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-76.png and /dev/null differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-80.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-80.png index d1c6ea5932..db01075308 100644 Binary files a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-80.png and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-80.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-87.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-87.png index ede13b9a80..8d53b9279d 100644 Binary files a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-87.png and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset/Icon-87.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramSnowflake.imageset/Contents.json b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramSnowflake.imageset/Contents.json new file mode 100644 index 0000000000..275b802d69 --- /dev/null +++ b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramSnowflake.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "WinterGramSnowflake@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "WinterGramSnowflake@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramSnowflake.imageset/WinterGramSnowflake@2x.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramSnowflake.imageset/WinterGramSnowflake@2x.png new file mode 100644 index 0000000000..1b266a029c Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramSnowflake.imageset/WinterGramSnowflake@2x.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramSnowflake.imageset/WinterGramSnowflake@3x.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramSnowflake.imageset/WinterGramSnowflake@3x.png new file mode 100644 index 0000000000..3a51f589ef Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramSnowflake.imageset/WinterGramSnowflake@3x.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBackplateShape.imageset/Contents.json b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBackplateShape.imageset/Contents.json new file mode 100644 index 0000000000..d558a93bad --- /dev/null +++ b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBackplateShape.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "filename" : "WntGramBackplateShape.png", "idiom" : "universal", "scale" : "1x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBackplateShape.imageset/WntGramBackplateShape.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBackplateShape.imageset/WntGramBackplateShape.png new file mode 100644 index 0000000000..0820196879 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBackplateShape.imageset/WntGramBackplateShape.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBanner.imageset/Contents.json b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBanner.imageset/Contents.json new file mode 100644 index 0000000000..a96dbf15f6 --- /dev/null +++ b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBanner.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "WntGramBanner@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "WntGramBanner@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBanner.imageset/WntGramBanner@2x.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBanner.imageset/WntGramBanner@2x.png new file mode 100644 index 0000000000..5dae6791c9 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBanner.imageset/WntGramBanner@2x.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBanner.imageset/WntGramBanner@3x.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBanner.imageset/WntGramBanner@3x.png new file mode 100644 index 0000000000..3c7b3420cc Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBanner.imageset/WntGramBanner@3x.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBannerDefault.imageset/Contents.json b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBannerDefault.imageset/Contents.json new file mode 100644 index 0000000000..742d28e5ad --- /dev/null +++ b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBannerDefault.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "WntGramBannerDefault@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "WntGramBannerDefault@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBannerDefault.imageset/WntGramBannerDefault@2x.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBannerDefault.imageset/WntGramBannerDefault@2x.png new file mode 100644 index 0000000000..ef9e989a34 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBannerDefault.imageset/WntGramBannerDefault@2x.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBannerDefault.imageset/WntGramBannerDefault@3x.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBannerDefault.imageset/WntGramBannerDefault@3x.png new file mode 100644 index 0000000000..80b5b50679 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramBannerDefault.imageset/WntGramBannerDefault@3x.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramDeveloperBadge.imageset/Contents.json b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramDeveloperBadge.imageset/Contents.json new file mode 100644 index 0000000000..d4ac40ec1d --- /dev/null +++ b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramDeveloperBadge.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "WntGramDeveloperBadge@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "WntGramDeveloperBadge@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramDeveloperBadge.imageset/WntGramDeveloperBadge@2x.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramDeveloperBadge.imageset/WntGramDeveloperBadge@2x.png new file mode 100644 index 0000000000..e62ae8e755 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramDeveloperBadge.imageset/WntGramDeveloperBadge@2x.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramDeveloperBadge.imageset/WntGramDeveloperBadge@3x.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramDeveloperBadge.imageset/WntGramDeveloperBadge@3x.png new file mode 100644 index 0000000000..971cce5e4f Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramDeveloperBadge.imageset/WntGramDeveloperBadge@3x.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramSnowflakeShape.imageset/Contents.json b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramSnowflakeShape.imageset/Contents.json new file mode 100644 index 0000000000..77016ad4ed --- /dev/null +++ b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramSnowflakeShape.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "filename" : "WntGramSnowflakeShape.png", "idiom" : "universal", "scale" : "1x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramSnowflakeShape.imageset/WntGramSnowflakeShape.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramSnowflakeShape.imageset/WntGramSnowflakeShape.png new file mode 100644 index 0000000000..bf2b21321b Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WntGramSnowflakeShape.imageset/WntGramSnowflakeShape.png differ diff --git a/Telegram/Telegram-iOS/Info.plist b/Telegram/Telegram-iOS/Info.plist index 389f866c2b..e603c8d817 100644 --- a/Telegram/Telegram-iOS/Info.plist +++ b/Telegram/Telegram-iOS/Info.plist @@ -85,11 +85,31 @@ UIPrerenderedIcon + WinterGramDark + + CFBundleIconFiles + + WinterGramDark + WinterGramDarkNotificationIcon + + UIPrerenderedIcon + + + WinterGramLight + + CFBundleIconFiles + + WinterGramLight + WinterGramLightNotificationIcon + + UIPrerenderedIcon + + CFBundlePrimaryIcon CFBundleIconName - AppIconLLC + WinterGramDarkIcon UIPrerenderedIcon @@ -171,11 +191,33 @@ UIPrerenderedIcon + WinterGramDark + + CFBundleIconFiles + + WinterGramDarkIpad + WinterGramDarkLargeIpad + WinterGramDarkNotificationIcon + + UIPrerenderedIcon + + + WinterGramLight + + CFBundleIconFiles + + WinterGramLightIpad + WinterGramLightLargeIpad + WinterGramLightNotificationIcon + + UIPrerenderedIcon + + CFBundlePrimaryIcon CFBundleIconName - AppIconLLC + WinterGramDarkIcon UIPrerenderedIcon @@ -277,17 +319,17 @@ NSCameraUsageDescription We need this so that you can take and share photos and videos. NSContactsUsageDescription - Telegram stores your contacts heavily encrypted in the cloud to let you connect with your friends across all your devices. + WinterGram stores your contacts heavily encrypted in the cloud to let you connect with your friends across all your devices. NSFaceIDUsageDescription You can use Face ID to unlock the app. NSLocationAlwaysUsageDescription - When you send your location to your friends, Telegram needs access to show them a map. You also need this to send locations from an Apple Watch. + When you send your location to your friends, WinterGram needs access to show them a map. You also need this to send locations from an Apple Watch. NSLocationWhenInUseUsageDescription - When you send your location to your friends, Telegram needs access to show them a map. + When you send your location to your friends, WinterGram needs access to show them a map. NSMicrophoneUsageDescription We need this so that you can record and share voice messages and videos with sound. NSMotionUsageDescription - When you send your location to your friends, Telegram needs access to show them a map. + When you send your location to your friends, WinterGram needs access to show them a map. NSPhotoLibraryAddUsageDescription We need this so that you can share photos and videos from your photo library. NSPhotoLibraryUsageDescription @@ -355,13 +397,13 @@ public.data UTTypeDescription - Telegram iOS Color Theme File + WinterGram iOS Color Theme File UTTypeIconFiles BlueIcon@3x.png UTTypeIdentifier - org.telegram.Telegram-iOS.theme + dev.reekeer.wintergram.theme UTTypeTagSpecification public.filename-extension diff --git a/Telegram/Telegram-iOS/InfoBazel.plist b/Telegram/Telegram-iOS/InfoBazel.plist index e9b91c4c41..59c7067a11 100644 --- a/Telegram/Telegram-iOS/InfoBazel.plist +++ b/Telegram/Telegram-iOS/InfoBazel.plist @@ -111,17 +111,17 @@ NSCameraUsageDescription We need this so that you can take and share photos and videos. NSContactsUsageDescription - Telegram stores your contacts heavily encrypted in the cloud to let you connect with your friends across all your devices. + WinterGram stores your contacts heavily encrypted in the cloud to let you connect with your friends across all your devices. NSFaceIDUsageDescription You can use Face ID to unlock the app. NSLocationAlwaysUsageDescription - When you send your location to your friends, Telegram needs access to show them a map. You also need this to send locations from an Apple Watch. + When you send your location to your friends, WinterGram needs access to show them a map. You also need this to send locations from an Apple Watch. NSLocationWhenInUseUsageDescription - When you send your location to your friends, Telegram needs access to show them a map. + When you send your location to your friends, WinterGram needs access to show them a map. NSMicrophoneUsageDescription We need this so that you can record and share voice messages and videos with sound. NSMotionUsageDescription - When you send your location to your friends, Telegram needs access to show them a map. + When you send your location to your friends, WinterGram needs access to show them a map. NSPhotoLibraryAddUsageDescription We need this so that you can share photos and videos from your photo library. NSPhotoLibraryUsageDescription @@ -189,13 +189,13 @@ public.data UTTypeDescription - Telegram iOS Color Theme File + WinterGram iOS Color Theme File UTTypeIconFiles BlueIcon@3x.png UTTypeIdentifier - org.telegram.Telegram-iOS.theme + dev.reekeer.wintergram.theme UTTypeTagSpecification public.filename-extension diff --git a/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDark@2x.png b/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDark@2x.png new file mode 100644 index 0000000000..f6328bfc03 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDark@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDark@3x.png b/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDark@3x.png new file mode 100644 index 0000000000..8c495fab69 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDark@3x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkIpad.png b/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkIpad.png new file mode 100644 index 0000000000..305dd817aa Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkIpad.png differ diff --git a/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkIpad@2x.png b/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkIpad@2x.png new file mode 100644 index 0000000000..4af76d3dd5 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkIpad@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkLargeIpad@2x.png b/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkLargeIpad@2x.png new file mode 100644 index 0000000000..56c37d1f8e Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkLargeIpad@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkNotificationIcon.png b/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkNotificationIcon.png new file mode 100644 index 0000000000..8ef699a0b3 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkNotificationIcon.png differ diff --git a/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkNotificationIcon@2x.png b/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkNotificationIcon@2x.png new file mode 100644 index 0000000000..b25cd5f428 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkNotificationIcon@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkNotificationIcon@3x.png b/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkNotificationIcon@3x.png new file mode 100644 index 0000000000..edda6a53d0 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramDark.alticon/WinterGramDarkNotificationIcon@3x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloper@2x.png b/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloper@2x.png new file mode 100644 index 0000000000..4b3174ab35 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloper@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloper@3x.png b/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloper@3x.png new file mode 100644 index 0000000000..d14a6f7dd4 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloper@3x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperIpad.png b/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperIpad.png new file mode 100644 index 0000000000..936b425472 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperIpad.png differ diff --git a/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperIpad@2x.png b/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperIpad@2x.png new file mode 100644 index 0000000000..ccf576b4eb Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperIpad@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperLargeIpad@2x.png b/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperLargeIpad@2x.png new file mode 100644 index 0000000000..ace48b8d33 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperLargeIpad@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperNotificationIcon.png b/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperNotificationIcon.png new file mode 100644 index 0000000000..bd50dc216a Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperNotificationIcon.png differ diff --git a/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperNotificationIcon@2x.png b/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperNotificationIcon@2x.png new file mode 100644 index 0000000000..f8493a29b0 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperNotificationIcon@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperNotificationIcon@3x.png b/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperNotificationIcon@3x.png new file mode 100644 index 0000000000..b0b62189c0 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramDeveloper.alticon/WinterGramDeveloperNotificationIcon@3x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDark@2x.png b/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDark@2x.png new file mode 100644 index 0000000000..cfcad99d96 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDark@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDark@3x.png b/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDark@3x.png new file mode 100644 index 0000000000..31902ab7dc Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDark@3x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkIpad.png b/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkIpad.png new file mode 100644 index 0000000000..0b67ec3c53 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkIpad.png differ diff --git a/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkIpad@2x.png b/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkIpad@2x.png new file mode 100644 index 0000000000..ff20c97c8d Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkIpad@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkLargeIpad@2x.png b/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkLargeIpad@2x.png new file mode 100644 index 0000000000..49f23b8af0 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkLargeIpad@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkNotificationIcon.png b/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkNotificationIcon.png new file mode 100644 index 0000000000..8eba748c25 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkNotificationIcon.png differ diff --git a/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkNotificationIcon@2x.png b/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkNotificationIcon@2x.png new file mode 100644 index 0000000000..3c60717624 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkNotificationIcon@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkNotificationIcon@3x.png b/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkNotificationIcon@3x.png new file mode 100644 index 0000000000..3adabbc319 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramHouseDark.alticon/WinterGramHouseDarkNotificationIcon@3x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLight@2x.png b/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLight@2x.png new file mode 100644 index 0000000000..b6d9c255ad Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLight@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLight@3x.png b/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLight@3x.png new file mode 100644 index 0000000000..3719819bc7 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLight@3x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightIpad.png b/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightIpad.png new file mode 100644 index 0000000000..b6230a3a9a Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightIpad.png differ diff --git a/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightIpad@2x.png b/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightIpad@2x.png new file mode 100644 index 0000000000..c05e486ca2 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightIpad@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightLargeIpad@2x.png b/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightLargeIpad@2x.png new file mode 100644 index 0000000000..5846d8bb08 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightLargeIpad@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightNotificationIcon.png b/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightNotificationIcon.png new file mode 100644 index 0000000000..056decb375 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightNotificationIcon.png differ diff --git a/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightNotificationIcon@2x.png b/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightNotificationIcon@2x.png new file mode 100644 index 0000000000..a293a3291e Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightNotificationIcon@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightNotificationIcon@3x.png b/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightNotificationIcon@3x.png new file mode 100644 index 0000000000..2f30936cc3 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramHouseLight.alticon/WinterGramHouseLightNotificationIcon@3x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLight@2x.png b/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLight@2x.png new file mode 100644 index 0000000000..24c435a6dc Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLight@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLight@3x.png b/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLight@3x.png new file mode 100644 index 0000000000..4d6aceafdd Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLight@3x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightIpad.png b/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightIpad.png new file mode 100644 index 0000000000..0294b8550e Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightIpad.png differ diff --git a/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightIpad@2x.png b/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightIpad@2x.png new file mode 100644 index 0000000000..4fa008ce3a Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightIpad@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightLargeIpad@2x.png b/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightLargeIpad@2x.png new file mode 100644 index 0000000000..3ff6768694 Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightLargeIpad@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightNotificationIcon.png b/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightNotificationIcon.png new file mode 100644 index 0000000000..784fde47ea Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightNotificationIcon.png differ diff --git a/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightNotificationIcon@2x.png b/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightNotificationIcon@2x.png new file mode 100644 index 0000000000..cade06a3ca Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightNotificationIcon@2x.png differ diff --git a/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightNotificationIcon@3x.png b/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightNotificationIcon@3x.png new file mode 100644 index 0000000000..ee896c516f Binary files /dev/null and b/Telegram/Telegram-iOS/WinterGramLight.alticon/WinterGramLightNotificationIcon@3x.png differ diff --git a/Telegram/Telegram-iOS/ar.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/ar.lproj/InfoPlist.strings index 174276a904..2212661071 100644 --- a/Telegram/Telegram-iOS/ar.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/ar.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ /* Localized versions of Info.plist keys */ -"CFBundleDisplayName" = "تيليجرام"; -"NSContactsUsageDescription" = "سيقوم تيليجرام برفع جهات الاتصال الخاصة بك باستمرار إلى خوادم التخزين السحابية ذات التشفير العالي لتتمكن من التواصل مع أصدقائك من خلال جميع أجهزتك."; -"NSLocationWhenInUseUsageDescription" = "عندما ترغب في مشاركة مكانك مع أصدقائك، تيليجرام يحتاج لصلاحيات لعرض الخريطة لهم."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "عندما تختار أن تشارك مكانك بشكل حي مع أصدقائك في المحادثة، يحتاج تيليجرام إلى الوصول لموقعك في الخلفية حتى بعد إغلاق تيليجرام خلال فترة المشاركة."; -"NSLocationAlwaysUsageDescription" = "عندما تقوم بمشاركة موقعك مع أصدقائك، تيليجرام يحتاج إلى الصلاحية ليعرض لهم الخريطة. كما تحتاج لإعطاء تيليجرام الصلاحية لتتمكن من إرسال موقعك من ساعة آبل."; +"CFBundleDisplayName" = "WinterGram"; +"NSContactsUsageDescription" = "سيقوم WinterGram برفع جهات الاتصال الخاصة بك باستمرار إلى خوادم التخزين السحابية ذات التشفير العالي لتتمكن من التواصل مع أصدقائك من خلال جميع أجهزتك."; +"NSLocationWhenInUseUsageDescription" = "عندما ترغب في مشاركة مكانك مع أصدقائك، WinterGram يحتاج لصلاحيات لعرض الخريطة لهم."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "عندما تختار أن تشارك مكانك بشكل حي مع أصدقائك في المحادثة، يحتاج WinterGram إلى الوصول لموقعك في الخلفية حتى بعد إغلاق WinterGram خلال فترة المشاركة."; +"NSLocationAlwaysUsageDescription" = "عندما تقوم بمشاركة موقعك مع أصدقائك، WinterGram يحتاج إلى الصلاحية ليعرض لهم الخريطة. كما تحتاج لإعطاء WinterGram الصلاحية لتتمكن من إرسال موقعك من ساعة آبل."; "NSCameraUsageDescription" = "نحتاج ذلك لتتمكن من التقاط وإرسال الصور والفيديوهات."; "NSPhotoLibraryUsageDescription" = "نحتاج ذلك لتتمكن من إرسال الصور والفيديوهات من ألبوم الصور."; "NSPhotoLibraryAddUsageDescription" = "نحتاج هذه الصلاحية لتتمكن من حفظ وسائطك في مكتبة الصور الخاصة بك."; diff --git a/Telegram/Telegram-iOS/be.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/be.lproj/InfoPlist.strings index 3f3e41d337..1e81b044af 100644 --- a/Telegram/Telegram-iOS/be.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/be.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ /* Localized versions of Info.plist keys */ -"NSContactsUsageDescription" = "Telegram будзе запампоўваць вашы кантакты на свае моцна абароненыя і зашыфраваныя воблачныя серверы, каб вы маглі кантактаваць са сваімі сябрамі з любой вашай прылады."; -"NSLocationWhenInUseUsageDescription" = "Калі вы адпраўляе месцазнаходжанне сваім сябрам, Telegram патрэбны доступ да сэрвісаў геалакацыі, каб размясціць вас на карце."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "Калі вы трансліруеце ваша месцазнаходжанне сябрам у чаце, Telegram патрэбны доступ у фонавым рэжыме да вашага месцазнаходжання, каб абнаўляць яго падчас трансляцыі."; -"NSLocationAlwaysUsageDescription" = "Калі вы трансліруеце ваша месцазнаходжанне сябрам у чаце, Telegram патрэбны доступ у фонавым рэжыме да вашага месцазнаходжання, каб абнаўляць яго падчас трансляцыі. Гэта таксама неабходна для таго, каб адпраўляць месцазнаходжанне праз Apple Watch."; +"NSContactsUsageDescription" = "WinterGram будзе запампоўваць вашы кантакты на свае моцна абароненыя і зашыфраваныя воблачныя серверы, каб вы маглі кантактаваць са сваімі сябрамі з любой вашай прылады."; +"NSLocationWhenInUseUsageDescription" = "Калі вы адпраўляе месцазнаходжанне сваім сябрам, WinterGram патрэбны доступ да сэрвісаў геалакацыі, каб размясціць вас на карце."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "Калі вы трансліруеце ваша месцазнаходжанне сябрам у чаце, WinterGram патрэбны доступ у фонавым рэжыме да вашага месцазнаходжання, каб абнаўляць яго падчас трансляцыі."; +"NSLocationAlwaysUsageDescription" = "Калі вы трансліруеце ваша месцазнаходжанне сябрам у чаце, WinterGram патрэбны доступ у фонавым рэжыме да вашага месцазнаходжання, каб абнаўляць яго падчас трансляцыі. Гэта таксама неабходна для таго, каб адпраўляць месцазнаходжанне праз Apple Watch."; "NSCameraUsageDescription" = "Нам неабходны гэты дазвол, каб вы маглі рабіць і адпраўляць фота і відэа, а таксама відэавыклікі."; "NSPhotoLibraryUsageDescription" = "Нам неабходны гэты дазвол, каб вы маглі абагульваць фота і відэа са сваёй галерэі."; "NSPhotoLibraryAddUsageDescription" = "Нам неабходны гэты дазвол, каб вы маглі захоўваць фота і відэа ў сваю галерэю."; diff --git a/Telegram/Telegram-iOS/ca.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/ca.lproj/InfoPlist.strings index f57e0cecfb..2ab5c31adf 100644 --- a/Telegram/Telegram-iOS/ca.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/ca.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ /* Localized versions of Info.plist keys */ -"NSContactsUsageDescription" = "Telegram pujarà automàticament els vostres contactes als seus servidors xifrats, perquè pugueu connectar-vos amb els amics des de qualsevol dispositiu."; -"NSLocationWhenInUseUsageDescription" = "Si envieu la vostra ubicació als amics, Telegram requereix accés per a mostra-los un mapa."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "Si trieu de compartir la vostra ubicació en directe amb amics en un xat, Telegram requereix accés en segon pla a la vostra ubicació per a actualitzar-la durant la compartició en directe."; -"NSLocationAlwaysUsageDescription" = "Si trieu de compartir la vostra ubicació en directe amb amics en un xat, Telegram requereix de tenir accés en segon pla a la vostra ubicació per a actualitzar-la durant la compartició en directe. També necessiteu això per a enviar ubicacions des d'un Apple Watch."; +"NSContactsUsageDescription" = "WinterGram pujarà automàticament els vostres contactes als seus servidors xifrats, perquè pugueu connectar-vos amb els amics des de qualsevol dispositiu."; +"NSLocationWhenInUseUsageDescription" = "Si envieu la vostra ubicació als amics, WinterGram requereix accés per a mostra-los un mapa."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "Si trieu de compartir la vostra ubicació en directe amb amics en un xat, WinterGram requereix accés en segon pla a la vostra ubicació per a actualitzar-la durant la compartició en directe."; +"NSLocationAlwaysUsageDescription" = "Si trieu de compartir la vostra ubicació en directe amb amics en un xat, WinterGram requereix de tenir accés en segon pla a la vostra ubicació per a actualitzar-la durant la compartició en directe. També necessiteu això per a enviar ubicacions des d'un Apple Watch."; "NSCameraUsageDescription" = "Ens cal això perquè pugueu fer i compartir fotos i vídeos, així com fer videotrucades."; "NSPhotoLibraryUsageDescription" = "Ens cal això perquè pugueu compartir fotos i vídeos de la biblioteca de fotos."; "NSPhotoLibraryAddUsageDescription" = "Ens cal això perquè així pugueu desar fotos i vídeos a la biblioteca de fotos."; diff --git a/Telegram/Telegram-iOS/de.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/de.lproj/InfoPlist.strings index 792eaa3cc3..c7bbf7bbb7 100644 --- a/Telegram/Telegram-iOS/de.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/de.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ /* Localized versions of Info.plist keys */ -"NSContactsUsageDescription" = "Telegram lädt deine Kontakte durchgehend auf die stark verschlüsselten Cloud Server, damit du dich mit deinen Freunden auf all deinen Geräten verbinden kannst."; -"NSLocationWhenInUseUsageDescription" = "Wenn du Freunden deinen Standort mitteilen willst, musst du Telegram den Zugriff darauf erlauben."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "Wenn du deinen Live-Standort mit Freunden im Chat teilen möchtest, benötigt Telegram so lange im Hintergrund Zugriff auf deinen Standort, bis du ihn nicht mehr teilen willst."; -"NSLocationAlwaysUsageDescription" = "Wenn du Freunden deinen Standort mitteilen willst, musst du Telegram den Zugriff darauf erlauben. Diese Bereichtigung wird auch für die Apple Watch benötigt."; +"NSContactsUsageDescription" = "WinterGram lädt deine Kontakte durchgehend auf die stark verschlüsselten Cloud Server, damit du dich mit deinen Freunden auf all deinen Geräten verbinden kannst."; +"NSLocationWhenInUseUsageDescription" = "Wenn du Freunden deinen Standort mitteilen willst, musst du WinterGram den Zugriff darauf erlauben."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "Wenn du deinen Live-Standort mit Freunden im Chat teilen möchtest, benötigt WinterGram so lange im Hintergrund Zugriff auf deinen Standort, bis du ihn nicht mehr teilen willst."; +"NSLocationAlwaysUsageDescription" = "Wenn du Freunden deinen Standort mitteilen willst, musst du WinterGram den Zugriff darauf erlauben. Diese Bereichtigung wird auch für die Apple Watch benötigt."; "NSCameraUsageDescription" = "Brauchen wir, damit du Bilder und Videos aufnehmen und teilen kannst."; "NSPhotoLibraryUsageDescription" = "Brauchen wir, damit du Bilder und Videos aus deiner Fotomediathek teilen kannst."; "NSPhotoLibraryAddUsageDescription" = "Brauchen wir, damit du Bilder und Videos in deiner Fotomediathek speichern kannst."; diff --git a/Telegram/Telegram-iOS/en.lproj/AppIntentVocabulary.plist b/Telegram/Telegram-iOS/en.lproj/AppIntentVocabulary.plist index 504ece4483..de4e438bc4 100644 --- a/Telegram/Telegram-iOS/en.lproj/AppIntentVocabulary.plist +++ b/Telegram/Telegram-iOS/en.lproj/AppIntentVocabulary.plist @@ -9,7 +9,7 @@ INSendMessageIntent IntentExamples - Send a Telegram message to Alex saying I'll be there in 10 minutes + Send a WinterGram message to Alex saying I'll be there in 10 minutes diff --git a/Telegram/Telegram-iOS/en.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/en.lproj/InfoPlist.strings index e77338c5d5..ba2e3ce37c 100644 --- a/Telegram/Telegram-iOS/en.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/en.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ /* Localized versions of Info.plist keys */ -"NSContactsUsageDescription" = "Telegram will continuously upload your contacts to its heavily encrypted cloud servers to let you connect with your friends across all your devices."; -"NSLocationWhenInUseUsageDescription" = "When you send your location to your friends, Telegram needs access to show them a map."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "When you choose to share your Live Location with friends in a chat, Telegram needs background access to your location to keep them updated for the duration of the live sharing."; -"NSLocationAlwaysUsageDescription" = "When you choose to share your live location with friends in a chat, Telegram needs background access to your location to keep them updated for the duration of the live sharing. You also need this to send locations from an Apple Watch."; +"NSContactsUsageDescription" = "WinterGram will continuously upload your contacts to its heavily encrypted cloud servers to let you connect with your friends across all your devices."; +"NSLocationWhenInUseUsageDescription" = "When you send your location to your friends, WinterGram needs access to show them a map."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "When you choose to share your Live Location with friends in a chat, WinterGram needs background access to your location to keep them updated for the duration of the live sharing."; +"NSLocationAlwaysUsageDescription" = "When you choose to share your live location with friends in a chat, WinterGram needs background access to your location to keep them updated for the duration of the live sharing. You also need this to send locations from an Apple Watch."; "NSCameraUsageDescription" = "We need this so that you can take and share photos and videos, as well as make video calls."; "NSPhotoLibraryUsageDescription" = "We need this so that you can share photos and videos from your photo library and save the ones you capture."; "NSPhotoLibraryAddUsageDescription" = "We need this so that you can save photos and videos to your photo library and save the ones you capture."; diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 52ad3e640f..9a92601e2f 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -50,7 +50,7 @@ "PUSH_MESSAGE_STICKER" = "%1$@|sent you a %2$@sticker"; "PUSH_CHAT_MESSAGE_STICKER" = "%2$@|%1$@ sent a %3$@sticker"; -"PUSH_CONTACT_JOINED" = "%1$@|joined Telegram!"; +"PUSH_CONTACT_JOINED" = "%1$@|joined WinterGram!"; "PUSH_CHANNEL_MESSAGE_TEXT" = "%1$@|%2$@"; "PUSH_CHANNEL_MESSAGE_NOTEXT" = "%1$@|posted a message"; @@ -205,7 +205,7 @@ "PUSH_PINNED_GIF" = "%1$@|pinned a GIF"; "PUSH_PINNED_PAID_MEDIA" = "%1$@|pinned a paid post for %2$@"; -"PUSH_CONTACT_JOINED" = "%1$@|joined Telegram!"; +"PUSH_CONTACT_JOINED" = "%1$@|joined WinterGram!"; "PUSH_AUTH_UNKNOWN" = "New login|from unrecognized device %1$@"; "PUSH_AUTH_REGION" = "New login|from unrecognized device %1$@, location: %2$@"; @@ -401,38 +401,38 @@ "Date.ChatDateHeaderYear" = "%1$@ %2$@, %3$@"; // Tour -"Tour.Title1" = "Telegram"; +"Tour.Title1" = "WinterGram"; "Tour.Text1" = "The world's **fastest** messaging app.\nIt is **free** and **secure**."; "Tour.Title2" = "Fast"; -"Tour.Text2" = "**Telegram** delivers messages\nfaster than any other application."; +"Tour.Text2" = "**WinterGram** delivers messages\nfaster than any other application."; "Tour.Title3" = "Powerful"; -"Tour.Text3" = "**Telegram** has no limits on\nthe size of your media and chats."; +"Tour.Text3" = "**WinterGram** has no limits on\nthe size of your media and chats."; "Tour.Title4" = "Secure"; -"Tour.Text4" = "**Telegram** keeps your messages\nsafe from hacker attacks."; +"Tour.Text4" = "**WinterGram** keeps your messages\nsafe from hacker attacks."; "Tour.Title5" = "Cloud-Based"; -"Tour.Text5" = "**Telegram** lets you access your\nmessages from multiple devices."; +"Tour.Text5" = "**WinterGram** lets you access your\nmessages from multiple devices."; "Tour.Title6" = "Free"; -"Tour.Text6" = "**Telegram** provides free unlimited\ncloud storage for chats and media."; +"Tour.Text6" = "**WinterGram** provides free unlimited\ncloud storage for chats and media."; "Tour.StartButton" = "Start Messaging"; // Login "Login.PhoneAndCountryHelp" = "Please confirm your country code\nand enter your phone number."; -"Login.CodeSentInternal" = "We've sent the code to the **Telegram** app on your other device"; +"Login.CodeSentInternal" = "We've sent the code to the **WinterGram** app on your other device"; "Login.HaveNotReceivedCodeInternal" = "Didn't get the code?"; "Login.CodeSentSms" = "We've sent you an SMS with the code"; "Login.Code" = "Code"; -"Login.WillCallYou" = "Telegram will call you in %@"; -"Login.CallRequestState2" = "Requesting a call from Telegram…"; -"Login.CallRequestState3" = "Telegram dialed your number\n[Didn't get the code?]"; +"Login.WillCallYou" = "WinterGram will call you in %@"; +"Login.CallRequestState2" = "Requesting a call from WinterGram…"; +"Login.CallRequestState3" = "WinterGram dialed your number\n[Didn't get the code?]"; "Login.EmailNotConfiguredError" = "An email account is required so that you can send us details about the error.\n\nPlease go to your device‘s settings > Passwords & Accounts > Add account and set up an email account."; "Login.EmailCodeSubject" = "%@, no code"; -"Login.EmailCodeBody" = "My phone number is:\n%@\nI can't get an activation code for Telegram."; +"Login.EmailCodeBody" = "My phone number is:\n%@\nI can't get an activation code for WinterGram."; "Login.UnknownError" = "An error occurred, please try again later."; "Login.InvalidCodeError" = "Invalid code, please try again."; "Login.NetworkError" = "Please check your internet connection and try again."; @@ -443,13 +443,13 @@ "Login.InvalidLastNameError" = "Sorry, this last name can't be used."; "Login.InvalidPhoneEmailSubject" = "Invalid phone number: %@"; -"Login.InvalidPhoneEmailBody" = "I'm trying to use my mobile phone number: %1$@\nBut Telegram says it's invalid. Please help.\n\nApp version: %2$@\nOS version: %3$@\nLocale: %4$@\nMNC: %5$@"; +"Login.InvalidPhoneEmailBody" = "I'm trying to use my mobile phone number: %1$@\nBut WinterGram says it's invalid. Please help.\n\nApp version: %2$@\nOS version: %3$@\nLocale: %4$@\nMNC: %5$@"; "Login.PhoneBannedEmailSubject" = "Banned phone number: %@"; -"Login.PhoneBannedEmailBody" = "I'm trying to use my mobile phone number: %1$@\nBut Telegram says it's banned. Please help.\n\nApp version: %2$@\nOS version: %3$@\nLocale: %4$@\nMNC: %5$@"; +"Login.PhoneBannedEmailBody" = "I'm trying to use my mobile phone number: %1$@\nBut WinterGram says it's banned. Please help.\n\nApp version: %2$@\nOS version: %3$@\nLocale: %4$@\nMNC: %5$@"; -"Login.PhoneGenericEmailSubject" = "Telegram iOS error: %@"; -"Login.PhoneGenericEmailBody" = "I'm trying to use my mobile phone number: %1$@\nBut Telegram shows an error. Please help.\n\nError: %2$@\nApp version: %3$@\nOS version: %4$@\nLocale: %5$@\nMNC: %6$@"; +"Login.PhoneGenericEmailSubject" = "WinterGram iOS error: %@"; +"Login.PhoneGenericEmailBody" = "I'm trying to use my mobile phone number: %1$@\nBut WinterGram shows an error. Please help.\n\nError: %2$@\nApp version: %3$@\nOS version: %4$@\nLocale: %5$@\nMNC: %6$@"; "Login.PhonePaidEmailSubject" = "Payment Issue"; @@ -492,11 +492,11 @@ "Contacts.TabTitle" = "Contacts"; "Contacts.Title" = "Contacts"; "Contacts.FailedToSendInvitesMessage" = "An error occurred."; -"Contacts.AccessDeniedError" = "Telegram does not have access to your contacts"; -"Contacts.AccessDeniedHelpLandscape" = "Please go to your %@ Settings — Privacy — Contacts.\nThen select ON for Telegram."; -"Contacts.AccessDeniedHelpPortrait" = "Please go to your %@ Settings — Privacy — Contacts. Then select ON for Telegram."; +"Contacts.AccessDeniedError" = "WinterGram does not have access to your contacts"; +"Contacts.AccessDeniedHelpLandscape" = "Please go to your %@ Settings — Privacy — Contacts.\nThen select ON for WinterGram."; +"Contacts.AccessDeniedHelpPortrait" = "Please go to your %@ Settings — Privacy — Contacts. Then select ON for WinterGram."; "Contacts.AccessDeniedHelpON" = "ON"; -"Contacts.InviteToTelegram" = "Invite to Telegram"; +"Contacts.InviteToTelegram" = "Invite to WinterGram"; "Contacts.InviteFriends" = "Invite Friends"; "Contacts.SelectAll" = "Select All"; @@ -532,7 +532,7 @@ "Conversation.Contact" = "Contact"; "Conversation.BlockUser" = "Block User"; "Conversation.UnblockUser" = "Unblock User"; -"Conversation.UnsupportedMedia" = "This message is not supported on your version of Telegram. Update the app to view:\nhttps://telegram.org/update"; +"Conversation.UnsupportedMedia" = "This message is not supported on your version of WinterGram. Update the app to view:\nhttps://telegram.org/update"; "Conversation.EncryptionWaiting" = "Waiting for %@ to get online..."; "Conversation.EncryptionProcessing" = "Exchanging encryption keys..."; "Conversation.EmptyPlaceholder" = "No messages here yet..."; @@ -580,9 +580,9 @@ "Notification.CreatedChannel" = "Channel created"; "Notification.CreatedGroup" = "Group created"; "Notification.CreatedChatWithTitle" = "%@ created the group \"%@\" "; -"Notification.Joined" = "%@ joined Telegram"; +"Notification.Joined" = "%@ joined WinterGram"; "Notification.ChangedGroupName" = "%@ changed group name to \"%@\" "; -"Notification.NewAuthDetected" = "%1$@,\nWe detected a login into your account from a new device on %2$@, %3$@ at %4$@\n\nDevice: %5$@\nLocation: %6$@\n\nIf this wasn't you, you can go to Settings — Privacy and Security — Sessions and terminate that session.\n\nIf you think that somebody logged in to your account against your will, you can enable two-step verification in Privacy and Security settings.\n\nSincerely,\nThe Telegram Team"; +"Notification.NewAuthDetected" = "%1$@,\nWe detected a login into your account from a new device on %2$@, %3$@ at %4$@\n\nDevice: %5$@\nLocation: %6$@\n\nIf this wasn't you, you can go to Settings — Privacy and Security — Sessions and terminate that session.\n\nIf you think that somebody logged in to your account against your will, you can enable two-step verification in Privacy and Security settings.\n\nSincerely,\nThe WinterGram Team"; "Notification.MessageLifetimeChanged" = "%1$@ set the self-destruct timer to %2$@"; "Notification.MessageLifetimeChangedOutgoing" = "You set the self-destruct timer to %1$@"; "Notification.MessageLifetimeRemoved" = "%1$@ disabled the self-destruct timer"; @@ -647,7 +647,7 @@ // User Profile "Profile.CreateEncryptedChatError" = "An error occurred."; -"Profile.CreateEncryptedChatOutdatedError" = "Cannot create a secret chat with %@.\n%@ is using an older version of Telegram and needs to update first."; +"Profile.CreateEncryptedChatOutdatedError" = "Cannot create a secret chat with %@.\n%@ is using an older version of WinterGram and needs to update first."; "Profile.CreateNewContact" = "Create New Contact"; "Profile.AddToExisting" = "Add to Existing Contact"; "Profile.EncryptionKey" = "Encryption Key"; @@ -679,7 +679,7 @@ "UserInfo.NotificationsDisabled" = "Disabled"; "UserInfo.NotificationsEnable" = "Enable"; "UserInfo.NotificationsDisable" = "Disable"; -"UserInfo.Invite" = "Invite to Telegram"; +"UserInfo.Invite" = "Invite to WinterGram"; // New Contact "NewContact.Title" = "New Contact"; @@ -753,9 +753,9 @@ "Settings.BlockedUsers" = "Blocked Users"; "Settings.ChatBackground" = "Chat Background"; "Settings.Support" = "Ask a Question"; -"Settings.FAQ" = "Telegram FAQ"; +"Settings.FAQ" = "WinterGram FAQ"; "Settings.FAQ_URL" = "https://telegram.org/faq#general"; -"Settings.FAQ_Intro" = "Please note that Telegram Support is done by volunteers. We try to respond as quickly as possible, but it may take a while.\n\nPlease take a look at the Telegram FAQ: it has important troubleshooting tips and answers to most questions."; +"Settings.FAQ_Intro" = "Please note that WinterGram Support is done by volunteers. We try to respond as quickly as possible, but it may take a while.\n\nPlease take a look at the WinterGram FAQ: it has important troubleshooting tips and answers to most questions."; "Settings.FAQ_Button" = "FAQ"; "Settings.SaveIncomingPhotos" = "Save Incoming Photos"; @@ -812,7 +812,7 @@ "Cache.Title" = "Storage Usage"; "Cache.ClearCache" = "Clear Cache"; "Cache.KeepMedia" = "Keep Media"; -"Cache.Help" = "Photos, videos and other files from cloud chats that you have **not accessed** during this period will be removed from this device to save disk space.\n\nAll media will stay in the Telegram cloud and can be re-downloaded if you need it again."; +"Cache.Help" = "Photos, videos and other files from cloud chats that you have **not accessed** during this period will be removed from this device to save disk space.\n\nAll media will stay in the WinterGram cloud and can be re-downloaded if you need it again."; // Blocked Users "BlockedUsers.Title" = "Blocked"; @@ -836,9 +836,9 @@ "BroadcastListInfo.AddRecipient" = "Add Recipient"; "Settings.LogoutConfirmationTitle" = "Log out?"; -"Settings.LogoutConfirmationText" = "\nNote that you can seamlessly use Telegram on all your devices at once.\n\nRemember, logging out kills all your Secret Chats."; +"Settings.LogoutConfirmationText" = "\nNote that you can seamlessly use WinterGram on all your devices at once.\n\nRemember, logging out kills all your Secret Chats."; -"Login.PadPhoneHelp" = "\nYou can use your main mobile number to log in to Telegram on all devices.\nDon't use your iPad's SIM number here — we'll need to send you an SMS.\n\nIs this number correct?\n{number}"; +"Login.PadPhoneHelp" = "\nYou can use your main mobile number to log in to WinterGram on all devices.\nDon't use your iPad's SIM number here — we'll need to send you an SMS.\n\nIs this number correct?\n{number}"; "Login.PadPhoneHelpTitle" = "Your Number"; "MessageTimer.Custom" = "Custom"; @@ -926,7 +926,7 @@ "Activity.RecordingAudio" = "recording audio"; "Activity.RecordingVideoMessage" = "recording video"; -"Compatibility.SecretMediaVersionTooLow" = "%@ is using an older version of Telegram, so secret photos will be shown in compatibility mode.\n\nOnce %@ updates Telegram, photos with timers for 1 minute or less will start working in 'Tap and hold to view' mode, and you will be notified whenever the other party takes a screenshot."; +"Compatibility.SecretMediaVersionTooLow" = "%@ is using an older version of WinterGram, so secret photos will be shown in compatibility mode.\n\nOnce %@ updates WinterGram, photos with timers for 1 minute or less will start working in 'Tap and hold to view' mode, and you will be notified whenever the other party takes a screenshot."; "Contacts.GlobalSearch" = "Global Search"; "Profile.Username" = "username"; @@ -935,7 +935,7 @@ "Username.Title" = "Username"; "Username.Placeholder" = "Your Username"; -"Username.Help" = "You can choose a username on **Telegram**. If you do, other people will be able to find you by this username and contact you without knowing your phone number.\n\nYou can use **a-z**, **0-9** and underscores. Minimum length is **5** characters."; +"Username.Help" = "You can choose a username on **WinterGram**. If you do, other people will be able to find you by this username and contact you without knowing your phone number.\n\nYou can use **a-z**, **0-9** and underscores. Minimum length is **5** characters."; "Username.InvalidTooShort" = "A username must have at least 5 characters."; "Username.InvalidStartsWithNumber" = "Sorry, a username can't start with a number."; "Username.InvalidCharacters" = "Sorry, this username is invalid."; @@ -1040,8 +1040,8 @@ "Settings.PhoneNumber" = "Change Number"; -"PhoneNumberHelp.Help" = "You can change your Telegram number here. Your account and all your cloud data — messages, media, contacts, etc. will be moved to the new number.\n\n**Important:** all your Telegram contacts will get your **new number** added to their address book, provided they had your old number and you haven't blocked them in Telegram."; -"PhoneNumberHelp.Alert" = "All your Telegram contacts will get your new number added to their address book, provided they had your old number and you haven't blocked them in Telegram."; +"PhoneNumberHelp.Help" = "You can change your WinterGram number here. Your account and all your cloud data — messages, media, contacts, etc. will be moved to the new number.\n\n**Important:** all your WinterGram contacts will get your **new number** added to their address book, provided they had your old number and you haven't blocked them in WinterGram."; +"PhoneNumberHelp.Alert" = "All your WinterGram contacts will get your new number added to their address book, provided they had your old number and you haven't blocked them in WinterGram."; "PhoneNumberHelp.ChangeNumber" = "Change Number"; "ChangePhoneNumberNumber.Title" = "Change Number"; @@ -1052,9 +1052,9 @@ "ChangePhoneNumberCode.Code" = "YOUR CODE"; "ChangePhoneNumberCode.CodePlaceholder" = "Code"; "ChangePhoneNumberCode.Help" = "We have sent you an SMS with the code"; -"ChangePhoneNumberCode.CallTimer" = "Telegram will call you in %@"; -"ChangePhoneNumberCode.RequestingACall" = "Requesting a call from Telegram..."; -"ChangePhoneNumberCode.Called" = "Telegram dialed your number"; +"ChangePhoneNumberCode.CallTimer" = "WinterGram will call you in %@"; +"ChangePhoneNumberCode.RequestingACall" = "Requesting a call from WinterGram..."; +"ChangePhoneNumberCode.Called" = "WinterGram dialed your number"; "LoginPassword.Title" = "Your Password"; "LoginPassword.PasswordPlaceholder" = "Password"; @@ -1206,7 +1206,7 @@ "SharedMedia.EmptyFilesText" = "You can send and receive\nfiles of any type up to 1.5 GB each\nand access them anywhere."; "ShareFileTip.Title" = "Sharing Files"; -"ShareFileTip.Text" = "You can share **uncompressed** media files from your Camera Roll here.\n\nTo share files of any other type, open them on your %@ (e.g. in your browser), tap **Open in...** or the action button and choose Telegram."; +"ShareFileTip.Text" = "You can share **uncompressed** media files from your Camera Roll here.\n\nTo share files of any other type, open them on your %@ (e.g. in your browser), tap **Open in...** or the action button and choose WinterGram."; "ShareFileTip.CloseTip" = "Close Tip"; "DialogList.SearchSectionDialogs" = "Chats and Contacts"; @@ -1214,7 +1214,7 @@ "DialogList.SearchSectionGlobal" = "Global Search"; "DialogList.SearchSectionMessages" = "Messages"; -"Username.LinkHint" = "This link opens a chat with you in Telegram:[\nhttps://t.me/%@]"; +"Username.LinkHint" = "This link opens a chat with you in WinterGram:[\nhttps://t.me/%@]"; "Username.LinkCopied" = "Copied link to clipboard"; "SharedMedia.DeleteItemsConfirmation_1" = "Delete media file?"; @@ -1234,18 +1234,18 @@ "PasscodeSettings.SimplePasscode" = "Simple Passcode"; "PasscodeSettings.SimplePasscodeHelp" = "A simple passcode is a 4 digit number."; "PasscodeSettings.EncryptData" = "Encrypt Local Database"; -"PasscodeSettings.EncryptDataHelp" = "Experimental feature, use with caution. Encrypt your local Telegram data, using a derivative of your passcode as the key."; +"PasscodeSettings.EncryptDataHelp" = "Experimental feature, use with caution. Encrypt your local WinterGram data, using a derivative of your passcode as the key."; -"EnterPasscode.EnterTitle" = "Enter your Telegram Passcode"; +"EnterPasscode.EnterTitle" = "Enter your WinterGram Passcode"; "EnterPasscode.ChangeTitle" = "Change Passcode"; -"EnterPasscode.EnterPasscode" = "Enter your Telegram Passcode"; +"EnterPasscode.EnterPasscode" = "Enter your WinterGram Passcode"; "EnterPasscode.EnterNewPasscodeNew" = "Enter a passcode"; "EnterPasscode.EnterNewPasscodeChange" = "Enter your new passcode"; "EnterPasscode.RepeatNewPasscode" = "Re-enter your new passcode"; "EnterPasscode.EnterCurrentPasscode" = "Enter your current passcode"; -"EnterPasscode.TouchId" = "Unlock Telegram"; +"EnterPasscode.TouchId" = "Unlock WinterGram"; -"DialogList.PasscodeLockHelp" = "Tap to lock Telegram"; +"DialogList.PasscodeLockHelp" = "Tap to lock WinterGram"; "PasscodeSettings.AutoLock" = "Auto-Lock"; "PasscodeSettings.AutoLock.Disabled" = "Disabled"; @@ -1264,32 +1264,32 @@ "AccessDenied.Title" = "Please Allow Access"; -"AccessDenied.Contacts" = "Telegram messaging is based on your existing contact list.\n\nPlease go to Settings > Privacy > Contacts and set Telegram to ON."; +"AccessDenied.Contacts" = "WinterGram messaging is based on your existing contact list.\n\nPlease go to Settings > Privacy > Contacts and set WinterGram to ON."; -"AccessDenied.VoiceMicrophone" = "Telegram needs access to your microphone to send voice messages.\n\nPlease go to Settings > Privacy > Microphone and set Telegram to ON."; +"AccessDenied.VoiceMicrophone" = "WinterGram needs access to your microphone to send voice messages.\n\nPlease go to Settings > Privacy > Microphone and set WinterGram to ON."; -"AccessDenied.VideoMicrophone" = "Telegram needs access to your microphone to record sound in videos recording.\n\nPlease go to Settings > Privacy > Microphone and set Telegram to ON."; +"AccessDenied.VideoMicrophone" = "WinterGram needs access to your microphone to record sound in videos recording.\n\nPlease go to Settings > Privacy > Microphone and set WinterGram to ON."; -"AccessDenied.MicrophoneRestricted" = "Microphone access is restricted for Telegram.\n\nPlease go to Settings > General > Restrictions > Microphone and set Telegram to ON."; +"AccessDenied.MicrophoneRestricted" = "Microphone access is restricted for WinterGram.\n\nPlease go to Settings > General > Restrictions > Microphone and set WinterGram to ON."; -"AccessDenied.Camera" = "Telegram needs access to your camera to take photos and videos.\n\nPlease go to Settings > Privacy > Camera and set Telegram to ON."; +"AccessDenied.Camera" = "WinterGram needs access to your camera to take photos and videos.\n\nPlease go to Settings > Privacy > Camera and set WinterGram to ON."; -"AccessDenied.CameraRestricted" = "Camera access is restricted for Telegram.\n\nPlease go to Settings > General > Restrictions > Camera and set Telegram to ON."; +"AccessDenied.CameraRestricted" = "Camera access is restricted for WinterGram.\n\nPlease go to Settings > General > Restrictions > Camera and set WinterGram to ON."; "AccessDenied.CameraDisabled" = "Camera access is globally restricted on your phone.\n\nPlease go to Settings > General > Restrictions and set Camera to ON"; -"AccessDenied.PhotosAndVideos" = "Telegram needs access to your photo library to send photos and videos.\n\nPlease go to Settings > Privacy > Photos and set Telegram to ON."; +"AccessDenied.PhotosAndVideos" = "WinterGram needs access to your photo library to send photos and videos.\n\nPlease go to Settings > Privacy > Photos and set WinterGram to ON."; -"AccessDenied.SaveMedia" = "Telegram needs access to your photo library to save photos and videos.\n\nPlease go to Settings > Privacy > Photos and set Telegram to ON."; +"AccessDenied.SaveMedia" = "WinterGram needs access to your photo library to save photos and videos.\n\nPlease go to Settings > Privacy > Photos and set WinterGram to ON."; -"AccessDenied.PhotosRestricted" = "Photo access is restricted for Telegram.\n\nPlease go to Settings > General > Restrictions > Photos and set Telegram to ON."; +"AccessDenied.PhotosRestricted" = "Photo access is restricted for WinterGram.\n\nPlease go to Settings > General > Restrictions > Photos and set WinterGram to ON."; -"AccessDenied.LocationDenied" = "Telegram needs access to your location so that you can share it with your contacts.\n\nPlease go to Settings > Privacy > Location Services and set Telegram to ON."; +"AccessDenied.LocationDenied" = "WinterGram needs access to your location so that you can share it with your contacts.\n\nPlease go to Settings > Privacy > Location Services and set WinterGram to ON."; -"AccessDenied.LocationDisabled" = "Telegram needs access to your location so that you can share it with your contacts.\n\nPlease go to Settings > Privacy > Location Services and set it to ON."; +"AccessDenied.LocationDisabled" = "WinterGram needs access to your location so that you can share it with your contacts.\n\nPlease go to Settings > Privacy > Location Services and set it to ON."; -"AccessDenied.LocationTracking" = "Telegram needs access to your location to show you on the map.\n\nPlease go to Settings > Privacy > Location Services and set it to ON."; +"AccessDenied.LocationTracking" = "WinterGram needs access to your location to show you on the map.\n\nPlease go to Settings > Privacy > Location Services and set it to ON."; "AccessDenied.Settings" = "Settings"; @@ -1378,7 +1378,7 @@ "TwoStepAuth.SetupEmail" = "Set Recovery E-Mail"; "TwoStepAuth.ChangeEmail" = "Change Recovery E-Mail"; "TwoStepAuth.PendingEmailHelp" = "Your recovery e-mail %@ is not yet active and pending confirmation."; -"TwoStepAuth.GenericHelp" = "You have enabled Two-Step verification.\nYou'll need the password you set up here to log in to your Telegram account."; +"TwoStepAuth.GenericHelp" = "You have enabled Two-Step verification.\nYou'll need the password you set up here to log in to your WinterGram account."; "TwoStepAuth.ConfirmationTitle" = "Two-Step Verification"; "TwoStepAuth.ConfirmationText" = "Please check your e-mail and click on the validation link to complete Two-Step Verification setup. Be sure to check the spam folder as well."; @@ -1398,7 +1398,7 @@ "TwoStepAuth.EmailTitle" = "Recovery E-Mail"; "TwoStepAuth.EmailSkip" = "Skip"; -"TwoStepAuth.EmailSkipAlert" = "No, seriously.\n\nIf you forget your password, you will lose access to your Telegram account. There will be no way to restore it."; +"TwoStepAuth.EmailSkipAlert" = "No, seriously.\n\nIf you forget your password, you will lose access to your WinterGram account. There will be no way to restore it."; "TwoStepAuth.Email" = "E-Mail"; "TwoStepAuth.EmailPlaceholder" = "Your E-Mail"; "TwoStepAuth.EmailHelp" = "Please add your valid e-mail. It is the only way to recover a forgotten password."; @@ -1427,7 +1427,7 @@ "Conversation.FileDropbox" = "Dropbox"; "Conversation.FileOpenIn" = "Open in..."; -"Conversation.FileHowToText" = "To share files of any type, open them on your %@ (e.g. in your browser), tap **Open in...** or the action button and choose Telegram."; +"Conversation.FileHowToText" = "To share files of any type, open them on your %@ (e.g. in your browser), tap **Open in...** or the action button and choose WinterGram."; "Map.LocationTitle" = "Location"; "Map.OpenInMaps" = "Open in Maps"; @@ -1461,9 +1461,9 @@ "Map.ETAHours_any" = "%@ h"; "Map.ETAHours_many" = "%@ h"; -"ChangePhone.ErrorOccupied" = "The number %@ is already connected to a Telegram account. Please delete that account before migrating to the new number."; +"ChangePhone.ErrorOccupied" = "The number %@ is already connected to a WinterGram account. Please delete that account before migrating to the new number."; -"AccessDenied.LocationTracking" = "Telegram needs access to your location to show you on the map.\n\nPlease go to Settings > Privacy > Location Services and set it to ON."; +"AccessDenied.LocationTracking" = "WinterGram needs access to your location to show you on the map.\n\nPlease go to Settings > Privacy > Location Services and set it to ON."; "PrivacySettings.AuthSessions" = "Active Sessions"; "AuthSessions.Title" = "Active Sessions"; @@ -1473,7 +1473,7 @@ "AuthSessions.TerminateSession" = "Terminate session"; "AuthSessions.OtherSessions" = "ACTIVE SESSIONS"; "AuthSessions.EmptyTitle" = "No other sessions"; -"AuthSessions.EmptyText" = "You can log in to Telegram from other mobile, tablet and desktop devices, using the same phone number. All your data will be instantly synchronized."; +"AuthSessions.EmptyText" = "You can log in to WinterGram from other mobile, tablet and desktop devices, using the same phone number. All your data will be instantly synchronized."; "AuthSessions.AppUnofficial" = "(ID: %@)"; "WebPreview.GettingLinkInfo" = "Getting Link Info..."; @@ -1486,7 +1486,7 @@ "GroupInfo.InviteLink.Title" = "Invite Link"; "GroupInfo.InviteLink.LinkSection" = "LINK"; -"GroupInfo.InviteLink.Help" = "Anyone who has Telegram installed will be able to join your group by following this link."; +"GroupInfo.InviteLink.Help" = "Anyone who has WinterGram installed will be able to join your group by following this link."; "GroupInfo.InviteLink.CopyLink" = "Copy Link"; "GroupInfo.InviteLink.RevokeLink" = "Revoke Link"; "GroupInfo.InviteLink.ShareLink" = "Share Link"; @@ -1573,7 +1573,7 @@ "Login.PhoneNumberHelp" = "Help"; "Login.EmailPhoneSubject" = "Invalid number %@"; -"Login.EmailPhoneBody" = "I'm trying to use my mobile phone number: %@\nBut Telegram says it's invalid. Please help.\nAdditional Info: %@, %@."; +"Login.EmailPhoneBody" = "I'm trying to use my mobile phone number: %@\nBut WinterGram says it's invalid. Please help.\nAdditional Info: %@, %@."; "SharedMedia.TitleLink" = "Shared Links"; "SharedMedia.EmptyLinksText" = "All links shared in this chat will appear here."; @@ -1600,8 +1600,8 @@ "Channel.Edit.LinkItem" = "Link"; "Channel.Username.Title" = "Link"; -"Channel.Username.Help" = "You can choose a channel name on **Telegram**. If you do, other people will be able to find your channel by this name.\n\nYou can use **a-z**, **0-9** and underscores. Minimum length is **5** characters."; -"Channel.Username.LinkHint" = "This link opens your channel in Telegram:[\nhttps://t.me/%@]"; +"Channel.Username.Help" = "You can choose a channel name on **WinterGram**. If you do, other people will be able to find your channel by this name.\n\nYou can use **a-z**, **0-9** and underscores. Minimum length is **5** characters."; +"Channel.Username.LinkHint" = "This link opens your channel in WinterGram:[\nhttps://t.me/%@]"; "Channel.Username.InvalidTooShort" = "Channel names must have at least 5 characters."; "Channel.Username.InvalidStartsWithNumber" = "Channel names can't start with a number."; "Channel.Username.InvalidCharacters" = "Sorry, this name is invalid."; @@ -1646,7 +1646,7 @@ "Channel.Setup.Title" = "Channel"; -"Channel.Username.CreatePublicLinkHelp" = "People can share this link with others and find your channel using Telegram search."; +"Channel.Username.CreatePublicLinkHelp" = "People can share this link with others and find your channel using WinterGram search."; "Channel.Username.CreatePrivateLinkHelp" = "People can join your channel by following this link. You can revoke the link at any time."; "Channel.Setup.PublicNoLink" = "Please choose a link for your public channel, so that people can find it in search and share with others.\n\nIf you're not interested, we suggest creating a private channel instead."; @@ -1772,7 +1772,7 @@ "Group.Management.AddModeratorHelp" = "You can add admins to help you manage your group."; -"Watch.AppName" = "Telegram"; +"Watch.AppName" = "WinterGram"; "Watch.Compose.AddContact" = "Choose Contact"; "Watch.Compose.CreateMessage" = "Create Message"; "Watch.Compose.CurrentLocation" = "Current Location"; @@ -1807,7 +1807,7 @@ "Watch.Message.ForwardedFrom" = "Forwarded from"; -"Watch.Notification.Joined" = "Joined Telegram"; +"Watch.Notification.Joined" = "Joined WinterGram"; "Watch.MessageView.Title" = "Message"; "Watch.MessageView.Forward" = "Forward"; @@ -1821,9 +1821,9 @@ "Watch.Stickers.StickerPacks" = "Sticker Sets"; "Watch.Location.Current" = "Current Location"; -"Watch.Location.Access" = "Allow Telegram to access location on your phone"; +"Watch.Location.Access" = "Allow WinterGram to access location on your phone"; -"Watch.AuthRequired" = "Log in to Telegram on your phone to get started"; +"Watch.AuthRequired" = "Log in to WinterGram on your phone to get started"; "Watch.NoConnection" = "No Connection"; "Watch.ConnectionDescription" = "Your Watch needs to be connected for the app to work"; @@ -1870,7 +1870,7 @@ "Cache.ClearProgress" = "Please Wait..."; "Cache.ClearEmpty" = "Empty"; "Cache.ByPeerHeader" = "CHATS"; -"Cache.Indexing" = "Telegram is calculating current cache size.\nThis can take a few minutes."; +"Cache.Indexing" = "WinterGram is calculating current cache size.\nThis can take a few minutes."; "ExplicitContent.AlertTitle" = "Sorry"; "ExplicitContent.AlertChannel" = "You can't access this channel because it violates App Store rules."; @@ -1951,7 +1951,7 @@ "PrivacyLastSeenSettings.GroupsAndChannelsHelp" = "Change who can add you to groups and channels."; "MusicPlayer.VoiceNote" = "Voice Message"; -"Watch.Microphone.Access" = "Allow Telegram to access the microphone on your phone"; +"Watch.Microphone.Access" = "Allow WinterGram to access the microphone on your phone"; "Settings.AppleWatch" = "Apple Watch"; "AppleWatch.Title" = "Apple Watch"; @@ -1970,7 +1970,7 @@ "KeyCommand.SendMessage" = "Send Message"; "KeyCommand.ChatInfo" = "Chat Info"; -"Conversation.SecretLinkPreviewAlert" = "Would you like to enable extended link previews in Secret Chats? Note that link previews are generated on Telegram servers."; +"Conversation.SecretLinkPreviewAlert" = "Would you like to enable extended link previews in Secret Chats? Note that link previews are generated on WinterGram servers."; "Conversation.SecretChatContextBotAlert" = "Please note that inline bots are provided by third-party developers. For the bot to work, the symbols you type after the bot's username are sent to the respective developer."; "Map.OpenInWaze" = "Open in Waze"; @@ -2044,7 +2044,7 @@ "Group.Setup.TypePublicHelp" = "Public groups can be found in search, chat history is available to everyone and anyone can join."; "Group.Setup.TypePrivateHelp" = "Private groups can only be joined if you were invited or have an invite link."; -"Group.Username.CreatePublicLinkHelp" = "People can share this link with others and find your group using Telegram search."; +"Group.Username.CreatePublicLinkHelp" = "People can share this link with others and find your group using WinterGram search."; "Group.Username.CreatePrivateLinkHelp" = "People can join your group by following this link. You can revoke the link at any time."; "Conversation.PinMessageAlertGroup" = "Pin this message and notify all members of the group?"; @@ -2148,11 +2148,11 @@ "Login.CodeSentCall" = "We are calling your phone to dictate a code."; "Login.WillSendSms" = "You can request an SMS in %@"; -"Login.SmsRequestState2" = "Requesting an SMS from Telegram..."; -"Login.SmsRequestState3" = "Telegram sent you an SMS\n[Didn't get the code?]"; +"Login.SmsRequestState2" = "Requesting an SMS from WinterGram..."; +"Login.SmsRequestState3" = "WinterGram sent you an SMS\n[Didn't get the code?]"; "CancelResetAccount.Title" = "Cancel Account Reset"; -"CancelResetAccount.TextSMS" = "Somebody with access to your phone number %@ has requested to delete your Telegram account and reset your 2-Step Verification password.\n\nIf it wasn't you, please enter the code we've just sent you via SMS to your number."; +"CancelResetAccount.TextSMS" = "Somebody with access to your phone number %@ has requested to delete your WinterGram account and reset your 2-Step Verification password.\n\nIf it wasn't you, please enter the code we've just sent you via SMS to your number."; "CancelResetAccount.Success" = "The deletion process was cancelled for your account %@."; "MediaPicker.MomentsDateRangeSameMonthYearFormat" = "{month} {day1} – {day2}, {year}"; @@ -2251,7 +2251,7 @@ "StickerPack.RemoveMaskCount_many" = "Remove %@ Masks"; "StickerPack.RemoveMaskCount_0" = "Remove %@ Masks"; -"Conversation.BotInteractiveUrlAlert" = "Allow %@ to pass your Telegram name and id (not your phone number) to pages you open with this bot?"; +"Conversation.BotInteractiveUrlAlert" = "Allow %@ to pass your WinterGram name and id (not your phone number) to pages you open with this bot?"; "StickerPacksSettings.ArchivedMasks" = "Archived Masks"; "StickerSettings.MaskContextInfo" = "If you archive a set of masks, you can quickly restore it later from the Archived Masks section."; "StickerPacksSettings.ArchivedMasks.Info" = "You can have up to 200 sets of masks. @@ -2259,9 +2259,9 @@ Unused sets are archived when you add more."; "CloudStorage.Title" = "Cloud Storage"; -"Widget.AuthRequired" = "Log in to Telegram"; +"Widget.AuthRequired" = "Log in to WinterGram"; "Widget.NoUsers" = "Start messaging to see your friends here"; -"Widget.GalleryTitle" = "Telegram"; +"Widget.GalleryTitle" = "WinterGram"; "Widget.GalleryDescription" = "Select chats"; "ShareMenu.CopyShareLinkGame" = "Copy link to game"; @@ -2286,11 +2286,11 @@ Unused sets are archived when you add more."; "Conversation.JumpToDate" = "Jump To Date"; "Conversation.AddToReadingList" = "Add to Reading List"; -"AccessDenied.CallMicrophone" = "Telegram needs access to your microphone for voice calls.\n\nPlease go to Settings > Privacy > Microphone and set Telegram to ON."; +"AccessDenied.CallMicrophone" = "WinterGram needs access to your microphone for voice calls.\n\nPlease go to Settings > Privacy > Microphone and set WinterGram to ON."; "Call.EncryptionKey.Title" = "Encryption Key"; -"Application.Name" = "Telegram"; +"Application.Name" = "WinterGram"; "DialogList.Pin" = "Pin"; "DialogList.Unpin" = "Unpin"; "DialogList.PinLimitError" = "Sorry, you can pin no more than %@ chats to the top."; @@ -2340,7 +2340,7 @@ Unused sets are archived when you add more."; "Calls.AddTab" = "Add Tab"; "Calls.NewCall" = "New Call"; -"Calls.RatingTitle" = "Please rate the quality\nof your Telegram call"; +"Calls.RatingTitle" = "Please rate the quality\nof your WinterGram call"; "Calls.SubmitRating" = "Submit"; "Call.Seconds_1" = "%@ second"; @@ -2492,10 +2492,10 @@ Unused sets are archived when you add more."; "Your_card_was_declined" = "Your card was declined."; /* Error when the card's expiration month is not valid */ -"Your_cards_expiration_month_is_invalid" ="You've entered an invalid expiration month."; +"Your_cards_expiration_month_is_invalid" = "You've entered an invalid expiration month."; /* Error when the card's expiration year is not valid */ -"Your_cards_expiration_year_is_invalid" ="You've entered an invalid expiration year."; +"Your_cards_expiration_year_is_invalid" = "You've entered an invalid expiration year."; /* Error when the card number is not valid */ "Your_cards_number_is_invalid" = "You've entered an invalid card number."; @@ -2527,14 +2527,14 @@ Unused sets are archived when you add more."; "Calls.RatingFeedback" = "Write a comment..."; -"Call.StatusIncoming" = "Telegram Audio..."; +"Call.StatusIncoming" = "WinterGram Audio..."; "Call.IncomingVoiceCall" = "Incoming Voice Call"; "Call.IncomingVideoCall" = "Incoming Video Call"; "Call.StatusRequesting" = "Contacting..."; "Call.StatusWaiting" = "Waiting..."; "Call.StatusRinging" = "Ringing..."; "Call.StatusConnecting" = "Connecting..."; -"Call.StatusOngoing" = "Telegram Audio %@"; +"Call.StatusOngoing" = "WinterGram Audio %@"; "Call.StatusEnded" = "Call Ended"; "Call.StatusFailed" = "Call Failed"; "Call.StatusBusy" = "Busy"; @@ -2599,7 +2599,7 @@ Unused sets are archived when you add more."; "Call.AudioRouteHeadphones" = "Headphones"; "Call.AudioRouteHide" = "Hide"; -"Call.PhoneCallInProgressMessage" = "You can’t place a Telegram call if you’re already on a phone call."; +"Call.PhoneCallInProgressMessage" = "You can’t place a WinterGram call if you’re already on a phone call."; "Call.RecordingDisabledMessage" = "Please end your call before recording a voice message."; "Call.EmojiDescription" = "If these emoji are the same on %@'s screen, this call is 100%% secure."; @@ -2609,8 +2609,8 @@ Unused sets are archived when you add more."; "Conversation.HoldForAudio" = "Hold to record audio. Tap to switch to video."; "Conversation.HoldForVideo" = "Hold to record video. Tap to switch to audio."; -"UserInfo.TelegramCall" = "Telegram Call"; -"UserInfo.TelegramVideoCall" = "Telegram Video Call"; +"UserInfo.TelegramCall" = "WinterGram Call"; +"UserInfo.TelegramVideoCall" = "WinterGram Video Call"; "UserInfo.PhoneCall" = "Phone Call"; "SharedMedia.CategoryMedia" = "Media"; @@ -2618,8 +2618,8 @@ Unused sets are archived when you add more."; "SharedMedia.CategoryLinks" = "Links"; "SharedMedia.CategoryOther" = "Audio"; -"AccessDenied.VideoMessageCamera" = "Telegram needs access to your camera to send video messages.\n\nPlease go to Settings > Privacy > Camera and set Telegram to ON."; -"AccessDenied.VideoMessageMicrophone" = "Telegram needs access to your microphone to send video messages.\n\nPlease go to Settings > Privacy > Microphone and set Telegram to ON."; +"AccessDenied.VideoMessageCamera" = "WinterGram needs access to your camera to send video messages.\n\nPlease go to Settings > Privacy > Camera and set WinterGram to ON."; +"AccessDenied.VideoMessageMicrophone" = "WinterGram needs access to your microphone to send video messages.\n\nPlease go to Settings > Privacy > Microphone and set WinterGram to ON."; "ChatSettings.AutomaticVideoMessageDownload" = "AUTOMATIC VIDEO MESSAGE DOWNLOAD"; @@ -2645,7 +2645,7 @@ Unused sets are archived when you add more."; "Privacy.PaymentsTitle" = "PAYMENTS"; "Privacy.PaymentsClearInfo" = "Clear payment & shipping info"; -"Privacy.PaymentsClearInfoHelp" = "You can delete your shipping info and instruct all payment providers to remove your saved credit cards. Note that Telegram never stores your credit card data."; +"Privacy.PaymentsClearInfoHelp" = "You can delete your shipping info and instruct all payment providers to remove your saved credit cards. Note that WinterGram never stores your credit card data."; "Privacy.PaymentsClear.PaymentInfo" = "Payment Info"; "Privacy.PaymentsClear.ShippingInfo" = "Shipping Info"; @@ -2800,7 +2800,7 @@ Unused sets are archived when you add more."; "Contacts.PhoneNumber" = "Phone Number"; "Contacts.AddPhoneNumber" = "Add %@"; -"Contacts.ShareTelegram" = "Share Telegram"; +"Contacts.ShareTelegram" = "Share WinterGram"; "Conversation.ViewChannel" = "VIEW CHANNEL"; "Conversation.ViewGroup" = "VIEW GROUP"; @@ -2864,10 +2864,10 @@ Unused sets are archived when you add more."; "Group.Members.AddMemberBotErrorNotAllowed" = "Sorry, you don't have the necessary permissions to add bots to this group."; "Privacy.Calls.P2P" = "Peer-to-Peer"; -"Privacy.Calls.P2PHelp" = "Disabling peer-to-peer will relay all calls through Telegram servers to avoid revealing your IP address, but will slightly decrease audio quality."; +"Privacy.Calls.P2PHelp" = "Disabling peer-to-peer will relay all calls through WinterGram servers to avoid revealing your IP address, but will slightly decrease audio quality."; "Privacy.Calls.Integration" = "iOS Call Integration"; -"Privacy.Calls.IntegrationHelp" = "iOS Call Integration shows Telegram calls on the lock screen and in the system's call history. If iCloud sync is enabled, your call history is shared with Apple."; +"Privacy.Calls.IntegrationHelp" = "iOS Call Integration shows WinterGram calls on the lock screen and in the system's call history. If iCloud sync is enabled, your call history is shared with Apple."; "Call.ReportPlaceholder" = "What went wrong?"; "Call.ReportIncludeLog" = "Send technical information"; @@ -2894,12 +2894,12 @@ Unused sets are archived when you add more."; "Stickers.FrequentlyUsed" = "Recently Used"; -"Contacts.ImportersCount_1" = "1 contact on Telegram"; -"Contacts.ImportersCount_2" = "2 contacts on Telegram"; -"Contacts.ImportersCount_3_10" = "%@ contacts on Telegram"; -"Contacts.ImportersCount_any" = "%@ contacts on Telegram"; -"Contacts.ImportersCount_many" = "%@ contacts on Telegram"; -"Contacts.ImportersCount_0" = "%@ contacts on Telegram"; +"Contacts.ImportersCount_1" = "1 contact on WinterGram"; +"Contacts.ImportersCount_2" = "2 contacts on WinterGram"; +"Contacts.ImportersCount_3_10" = "%@ contacts on WinterGram"; +"Contacts.ImportersCount_any" = "%@ contacts on WinterGram"; +"Contacts.ImportersCount_many" = "%@ contacts on WinterGram"; +"Contacts.ImportersCount_0" = "%@ contacts on WinterGram"; "Conversation.ContextMenuBan" = "Restrict"; @@ -2907,13 +2907,13 @@ Unused sets are archived when you add more."; "SocksProxySetup.UseForCallsHelp" = "Proxy servers may degrade the quality of your calls."; "InviteText.URL" = "https://telegram.org/dl"; -"InviteText.SingleContact" = "Hey, I'm using Telegram to chat. Join me! Download it here: %@"; -"InviteText.ContactsCountText_1" = "Hey, I'm using Telegram to chat. Join me! Download it here: {url}"; -"InviteText.ContactsCountText_2" = "Hey, I'm using Telegram to chat – and so are 2 of our other contacts. Join us! Download it here: {url}"; -"InviteText.ContactsCountText_3_10" = "Hey, I'm using Telegram to chat – and so are %@ of our other contacts. Join us! Download it here: {url}"; -"InviteText.ContactsCountText_any" = "Hey, I'm using Telegram to chat – and so are %@ of our other contacts. Join us! Download it here: {url}"; -"InviteText.ContactsCountText_many" = "Hey, I'm using Telegram to chat – and so are %@ of our other contacts. Join us! Download it here: {url}"; -"InviteText.ContactsCountText_0" = "Hey, I'm using Telegram to chat. Join me! Download it here: {url}"; +"InviteText.SingleContact" = "Hey, I'm using WinterGram to chat. Join me! Download it here: %@"; +"InviteText.ContactsCountText_1" = "Hey, I'm using WinterGram to chat. Join me! Download it here: {url}"; +"InviteText.ContactsCountText_2" = "Hey, I'm using WinterGram to chat – and so are 2 of our other contacts. Join us! Download it here: {url}"; +"InviteText.ContactsCountText_3_10" = "Hey, I'm using WinterGram to chat – and so are %@ of our other contacts. Join us! Download it here: {url}"; +"InviteText.ContactsCountText_any" = "Hey, I'm using WinterGram to chat – and so are %@ of our other contacts. Join us! Download it here: {url}"; +"InviteText.ContactsCountText_many" = "Hey, I'm using WinterGram to chat – and so are %@ of our other contacts. Join us! Download it here: {url}"; +"InviteText.ContactsCountText_0" = "Hey, I'm using WinterGram to chat. Join me! Download it here: {url}"; "Invite.LargeRecipientsCountWarning" = "Please note that it may take some time for your device to send all of these invitations"; @@ -3069,12 +3069,12 @@ Unused sets are archived when you add more."; "NotificationSettings.ContactJoined" = "New Contacts"; -"AccessDenied.LocationAlwaysDenied" = "If you'd like to share your Live Location with friends, Telegram needs location access when the app is in the background.\n\nPlease go to Settings > Privacy > Location Services and set Telegram to Always."; +"AccessDenied.LocationAlwaysDenied" = "If you'd like to share your Live Location with friends, WinterGram needs location access when the app is in the background.\n\nPlease go to Settings > Privacy > Location Services and set WinterGram to Always."; "UserInfo.UnblockConfirmation" = "Unblock %@?"; "Login.BannedPhoneSubject" = "Banned phone number: %@"; -"Login.BannedPhoneBody" = "I'm trying to use my mobile phone number: %@\nBut Telegram says it's banned. Please help."; +"Login.BannedPhoneBody" = "I'm trying to use my mobile phone number: %@\nBut WinterGram says it's banned. Please help."; "Conversation.StopLiveLocation" = "Stop Sharing"; @@ -3145,7 +3145,7 @@ Unused sets are archived when you add more."; "Channel.Setup.TypePrivateHelp" = "Private channels can only be joined if you were invited or have an invite link."; "Group.Username.InvalidTooShort" = "Group names must have at least 5 characters."; "Group.Username.InvalidStartsWithNumber" = "Group names can't start with a number."; -"Group.Username.CreatePublicLinkHelp" = "People can share this link with others and find your group using Telegram search."; +"Group.Username.CreatePublicLinkHelp" = "People can share this link with others and find your group using WinterGram search."; "Channel.TypeSetup.Title" = "Channel Type"; "Group.Setup.TypePrivate" = "Private"; @@ -3157,16 +3157,16 @@ Unused sets are archived when you add more."; "Privacy.PaymentsClearInfoDoneHelp" = "Payment & shipping info cleared."; -"InfoPlist.NSContactsUsageDescription" = "Telegram will continuously upload your contacts to its heavily encrypted cloud servers to let you connect with your friends across all your devices."; -"InfoPlist.NSLocationWhenInUseUsageDescription" = "When you send your location to your friends, Telegram needs access to show them a map."; +"InfoPlist.NSContactsUsageDescription" = "WinterGram will continuously upload your contacts to its heavily encrypted cloud servers to let you connect with your friends across all your devices."; +"InfoPlist.NSLocationWhenInUseUsageDescription" = "When you send your location to your friends, WinterGram needs access to show them a map."; "InfoPlist.NSCameraUsageDescription" = "We need this so that you can take and share photos and videos, as well as make video calls."; "InfoPlist.NSPhotoLibraryUsageDescription" = "We need this so that you can share photos and videos from your photo library."; "InfoPlist.NSPhotoLibraryAddUsageDescription" = "We need this so that you can save photos and videos to your photo library."; "InfoPlist.NSMicrophoneUsageDescription" = "We need this so that you can record and share voice messages and videos with sound."; "InfoPlist.NSSiriUsageDescription" = "You can use Siri to send messages."; -"InfoPlist.NSLocationAlwaysAndWhenInUseUsageDescription" = "When you choose to share your Live Location with friends in a chat, Telegram needs background access to your location to keep them updated for the duration of the live sharing."; -"InfoPlist.NSLocationAlwaysUsageDescription" = "When you choose to share your live location with friends in a chat, Telegram needs background access to your location to keep them updated for the duration of the live sharing. You also need this to send locations from an Apple Watch."; -"InfoPlist.NSLocationWhenInUseUsageDescription" = "When you send your location to your friends, Telegram needs access to show them a map."; +"InfoPlist.NSLocationAlwaysAndWhenInUseUsageDescription" = "When you choose to share your Live Location with friends in a chat, WinterGram needs background access to your location to keep them updated for the duration of the live sharing."; +"InfoPlist.NSLocationAlwaysUsageDescription" = "When you choose to share your live location with friends in a chat, WinterGram needs background access to your location to keep them updated for the duration of the live sharing. You also need this to send locations from an Apple Watch."; +"InfoPlist.NSLocationWhenInUseUsageDescription" = "When you send your location to your friends, WinterGram needs access to show them a map."; "InfoPlist.NSFaceIDUsageDescription" = "You can use Face ID to unlock the app."; "Privacy.Calls.P2PNever" = "Never"; @@ -3210,10 +3210,10 @@ Unused sets are archived when you add more."; "Channel.AdminLogFilter.EventsNewSubscribers" = "New Subscribers"; "Channel.AdminLogFilter.EventsLeavingSubscribers" = "Subscribers Removed"; -"Conversation.ClearPrivateHistory" = "This will delete all messages and media in this chat from your Telegram cloud. Your chat partner will still have them."; -"Conversation.ClearGroupHistory" = "This will delete all messages and media in this chat from your Telegram cloud. Other members of the group will still have them."; +"Conversation.ClearPrivateHistory" = "This will delete all messages and media in this chat from your WinterGram cloud. Your chat partner will still have them."; +"Conversation.ClearGroupHistory" = "This will delete all messages and media in this chat from your WinterGram cloud. Other members of the group will still have them."; "Conversation.ClearSecretHistory" = "This will delete all messages and media in this chat for both you and your chat partner."; -"Conversation.ClearSelfHistory" = "This will delete all messages and media in this chat from your Telegram cloud."; +"Conversation.ClearSelfHistory" = "This will delete all messages and media in this chat from your WinterGram cloud."; "MediaPicker.LivePhotoDescription" = "The live photo will be sent as a GIF."; @@ -3257,7 +3257,7 @@ Unused sets are archived when you add more."; "AuthSessions.Sessions" = "Sessions"; "AuthSessions.LoggedIn" = "Websites"; "AuthSessions.LogOutApplications" = "Disconnect All Websites"; -"AuthSessions.LogOutApplicationsHelp" = "You can log in on websites that support signing in with Telegram."; +"AuthSessions.LogOutApplicationsHelp" = "You can log in on websites that support signing in with WinterGram."; "AuthSessions.LoggedInWithTelegram" = "CONNECTED WEBSITES"; "AuthSessions.LogOut" = "Disconnect"; "AuthSessions.Message" = "You allowed this bot to message you when you logged in on %@."; @@ -3310,26 +3310,26 @@ Unused sets are archived when you add more."; "DialogList.AdLabel" = "Proxy Sponsor"; "DialogList.AdNoticeAlert" = "The proxy you are using displays a sponsored channel in your chat list."; -"SocksProxySetup.AdNoticeHelp" = "This proxy may display a sponsored channel in your chat list. This doesn't reveal your Telegram traffic."; +"SocksProxySetup.AdNoticeHelp" = "This proxy may display a sponsored channel in your chat list. This doesn't reveal your WinterGram traffic."; "SocksProxySetup.ShareProxyList" = "Share Proxy List"; "Privacy.SecretChatsTitle" = "SECRET CHATS"; "Privacy.SecretChatsLinkPreviews" = "Link Previews"; -"Privacy.SecretChatsLinkPreviewsHelp" = "Link previews will be generated on Telegram servers. We do not store data about the links you send."; +"Privacy.SecretChatsLinkPreviewsHelp" = "Link previews will be generated on WinterGram servers. We do not store data about the links you send."; "Privacy.ContactsTitle" = "CONTACTS"; "Privacy.ContactsSync" = "Sync Contacts"; "Privacy.ContactsSyncHelp" = "Turn on to continuously sync contacts from this device with your account."; "Privacy.ContactsReset" = "Delete Synced Contacts"; -"Privacy.ContactsResetConfirmation" = "This will remove your contacts from the Telegram servers.\nIf 'Sync Contacts' is enabled, contacts will be re-synced."; +"Privacy.ContactsResetConfirmation" = "This will remove your contacts from the WinterGram servers.\nIf 'Sync Contacts' is enabled, contacts will be re-synced."; "Login.TermsOfServiceDecline" = "Decline"; "Login.TermsOfServiceAgree" = "Agree & Continue"; "Login.TermsOfService.ProceedBot" = "Please agree and proceed to %@."; -"Login.TermsOfServiceSignupDecline" = "We're very sorry, but this means you can't sign up for Telegram.\n\nUnlike others, we don't use your data for ad targeting or other commercial purposes. Telegram only stores the information it needs to function as a feature-rich cloud service. You can adjust how we use your data (e.g., delete synced contacts) in Privacy & Security settings.\n\nBut if you're generally not OK with Telegram's modest needs, it won't be possible for us to provide this service."; +"Login.TermsOfServiceSignupDecline" = "We're very sorry, but this means you can't sign up for WinterGram.\n\nUnlike others, we don't use your data for ad targeting or other commercial purposes. WinterGram only stores the information it needs to function as a feature-rich cloud service. You can adjust how we use your data (e.g., delete synced contacts) in Privacy & Security settings.\n\nBut if you're generally not OK with WinterGram's modest needs, it won't be possible for us to provide this service."; "UserInfo.BotPrivacy" = "Privacy Policy"; @@ -3342,20 +3342,20 @@ Unused sets are archived when you add more."; "PrivacyPolicy.AgeVerificationAgree" = "Agree"; "PrivacyPolicy.DeclineTitle" = "Decline"; -"PrivacyPolicy.DeclineMessage" = "We're very sorry, but this means we must part ways here. Unlike others, we don't use your data for ad targeting or other commercial purposes. Telegram only stores the information it needs to function as a feature-rich cloud service. You can adjust how we use your data (e.g., delete synced contacts) in Privacy & Security settings.\n\nBut if you're generally not OK with Telegram's modest needs, it won't be possible for us to provide this service."; +"PrivacyPolicy.DeclineMessage" = "We're very sorry, but this means we must part ways here. Unlike others, we don't use your data for ad targeting or other commercial purposes. WinterGram only stores the information it needs to function as a feature-rich cloud service. You can adjust how we use your data (e.g., delete synced contacts) in Privacy & Security settings.\n\nBut if you're generally not OK with WinterGram's modest needs, it won't be possible for us to provide this service."; "PrivacyPolicy.DeclineDeclineAndDelete" = "Decline and Delete"; -"PrivacyPolicy.DeclineLastWarning" = "Warning, this will irreversibly delete your Telegram account along with all the data you store in the Telegram cloud.\n\nWe will provide a tool to download your data before June, 23 – so you may want to wait a little before deleting."; +"PrivacyPolicy.DeclineLastWarning" = "Warning, this will irreversibly delete your WinterGram account along with all the data you store in the WinterGram cloud.\n\nWe will provide a tool to download your data before June, 23 – so you may want to wait a little before deleting."; "PrivacyPolicy.DeclineDeleteNow" = "Delete Now"; -"Settings.Passport" = "Telegram Passport"; +"Settings.Passport" = "WinterGram Passport"; "Passport.Title" = "Passport"; "Passport.RequestHeader" = "%@ requests access to your personal data to sign you up for their services."; -"Passport.InfoTitle" = "What is Telegram Passport?"; -"Passport.InfoText" = "With **Telegram Passport** you can easily sign up for websites and services that require identity verification.\n\nYour information, personal data, and documents are protected by end-to-end encryption. Nobody, including Telegram, can access them without your permission."; +"Passport.InfoTitle" = "What is WinterGram Passport?"; +"Passport.InfoText" = "With **WinterGram Passport** you can easily sign up for websites and services that require identity verification.\n\nYour information, personal data, and documents are protected by end-to-end encryption. Nobody, including WinterGram, can access them without your permission."; "Passport.InfoLearnMore" = "Learn More"; "Passport.InfoFAQ_URL" = "https://telegram.org/faq#passport"; @@ -3376,19 +3376,19 @@ Unused sets are archived when you add more."; "Passport.AcceptHelp" = "You are sending your documents directly to %1$@ and allowing their @%2$@ to send you messages."; "Passport.Authorize" = "Authorize"; -"Passport.DeletePassport" = "Delete Telegram Passport"; -"Passport.DeletePassportConfirmation" = "Are you sure you want to delete your Telegram Passport? All details will be lost."; +"Passport.DeletePassport" = "Delete WinterGram Passport"; +"Passport.DeletePassportConfirmation" = "Are you sure you want to delete your WinterGram Passport? All details will be lost."; -"Passport.PasswordHelp" = "Please enter your Telegram Password\nto decrypt your data"; +"Passport.PasswordHelp" = "Please enter your WinterGram Password\nto decrypt your data"; "Passport.PasswordPlaceholder" = "Enter your password"; "Passport.InvalidPasswordError" = "Invalid password. Please try again."; "Passport.FloodError" = "Limit exceeded. Please try again later."; -"Passport.UpdateRequiredError" = "Sorry, your Telegram app is out of date and can’t handle this request. Please update Telegram."; +"Passport.UpdateRequiredError" = "Sorry, your WinterGram app is out of date and can’t handle this request. Please update WinterGram."; "Passport.ForgottenPassword" = "Forgotten Password"; -"Passport.PasswordReset" = "All documents uploaded to your Telegram Passport will be lost. You will be able to upload new documents."; +"Passport.PasswordReset" = "All documents uploaded to your WinterGram Passport will be lost. You will be able to upload new documents."; -"Passport.PasswordDescription" = "Please create a password to secure your personal data with end-to-end encryption.\n\nThis password will also be required whenever you log in to Telegram on a new device."; +"Passport.PasswordDescription" = "Please create a password to secure your personal data with end-to-end encryption.\n\nThis password will also be required whenever you log in to WinterGram on a new device."; "Passport.PasswordCreate" = "Create a Password"; "Passport.PasswordCompleteSetup" = "Complete Password Setup"; "Passport.PasswordNext" = "Next"; @@ -3508,14 +3508,14 @@ Unused sets are archived when you add more."; "Passport.Phone.Title" = "Phone Number"; "Passport.Phone.UseTelegramNumber" = "Use %@"; -"Passport.Phone.UseTelegramNumberHelp" = "Use the same phone number as on Telegram."; +"Passport.Phone.UseTelegramNumberHelp" = "Use the same phone number as on WinterGram."; "Passport.Phone.EnterOtherNumber" = "OR ENTER NEW PHONE NUMBER"; "Passport.Phone.Help" = "Note: You will receive a confirmation code on the phone number you provide."; "Passport.Phone.Delete" = "Delete Phone Number"; "Passport.Email.Title" = "Email"; "Passport.Email.UseTelegramEmail" = "Use %@"; -"Passport.Email.UseTelegramEmailHelp" = "Use the same address as on Telegram."; +"Passport.Email.UseTelegramEmailHelp" = "Use the same address as on WinterGram."; "Passport.Email.EnterOtherEmail" = "OR ENTER NEW EMAIL ADDRESS"; "Passport.Email.EmailPlaceholder" = "Enter your email address"; "Passport.Email.Help" = "Note: You will receive a confirmation code to the email address you provide."; @@ -3541,7 +3541,7 @@ Unused sets are archived when you add more."; "Passport.ScanPassport" = "Scan Your Passport"; "Passport.ScanPassportHelp" = "Scan your passport or identity card with machine-readable zone to fill personal details automatically."; -"TwoStepAuth.PasswordRemovePassportConfirmation" = "Are you sure you want to disable your password?\n\nWarning! All data saved in your Telegram Passport will be lost!"; +"TwoStepAuth.PasswordRemovePassportConfirmation" = "Are you sure you want to disable your password?\n\nWarning! All data saved in your WinterGram Passport will be lost!"; "Application.Update" = "Update"; @@ -3586,17 +3586,17 @@ Unused sets are archived when you add more."; "Passport.CorrectErrors" = "Tap to correct errors"; -"Passport.NotLoggedInMessage" = "Please log in to your account to use Telegram Passport"; +"Passport.NotLoggedInMessage" = "Please log in to your account to use WinterGram Passport"; -"Update.Title" = "Telegram Update"; -"Update.AppVersion" = "Telegram %@"; -"Update.UpdateApp" = "Update Telegram"; +"Update.Title" = "WinterGram Update"; +"Update.AppVersion" = "WinterGram %@"; +"Update.UpdateApp" = "Update WinterGram"; "Update.Skip" = "Skip"; "ReportPeer.ReasonCopyright" = "Copyright"; "PrivacySettings.DataSettings" = "Data Settings"; -"PrivacySettings.DataSettingsHelp" = "Control which of your data is stored in the cloud and used by Telegram to enable advanced features."; +"PrivacySettings.DataSettingsHelp" = "Control which of your data is stored in the cloud and used by WinterGram to enable advanced features."; "PrivateDataSettings.Title" = "Data Settings"; "Privacy.ChatsTitle" = "CHATS"; @@ -3781,8 +3781,8 @@ Unused sets are archived when you add more."; "SocksProxySetup.PasteFromClipboard" = "Paste From Clipboard"; -"Share.AuthTitle" = "Log in to Telegram"; -"Share.AuthDescription" = "Open Telegram and log in to share."; +"Share.AuthTitle" = "Log in to WinterGram"; +"Share.AuthDescription" = "Open WinterGram and log in to share."; "Notifications.DisplayNamesOnLockScreen" = "Names on lock-screen"; "Notifications.DisplayNamesOnLockScreenInfoWithLink" = "Display names in notifications when the device is locked. To disable, make sure that \"Show Previews\" is also set to \"When Unlocked\" or \"Never\" in [iOS Settings]"; @@ -3828,7 +3828,7 @@ Unused sets are archived when you add more."; "ApplyLanguage.ChangeLanguageAction" = "Change"; "ApplyLanguage.ApplyLanguageAction" = "Change"; "ApplyLanguage.UnsufficientDataTitle" = "Insufficient Data"; -"ApplyLanguage.UnsufficientDataText" = "Unfortunately, this custom language pack (%1$@) doesn't contain data for Telegram iOS. You can contribute to this language pack using the [translations platform]()"; +"ApplyLanguage.UnsufficientDataText" = "Unfortunately, this custom language pack (%1$@) doesn't contain data for WinterGram iOS. You can contribute to this language pack using the [translations platform]()"; "ApplyLanguage.LanguageNotSupportedError" = "Sorry, this language doesn't seem to exist."; "ApplyLanguage.ApplySuccess" = "Language changed"; @@ -3869,8 +3869,8 @@ Unused sets are archived when you add more."; "InstantPage.TapToOpenLink" = "Tap to open the link:"; "InstantPage.RelatedArticleAuthorAndDateTitle" = "%1$@ • %2$@"; -"AuthCode.Alert" = "Your login code is %@. Enter it in the Telegram app where you are trying to log in.\n\nDo not give this code to anyone."; -"Login.CheckOtherSessionMessages" = "Check your Telegram messages"; +"AuthCode.Alert" = "Your login code is %@. Enter it in the WinterGram app where you are trying to log in.\n\nDo not give this code to anyone."; +"Login.CheckOtherSessionMessages" = "Check your WinterGram messages"; "Login.SendCodeViaSms" = "Get the code via SMS"; "Login.SendCodeViaCall" = "Call me to dictate the code"; "Login.SendCodeViaFlashCall" = "Get the code via phone call"; @@ -3880,7 +3880,7 @@ Unused sets are archived when you add more."; "Login.CodeExpired" = "Code expired, please login again."; "Login.CancelSignUpConfirmation" = "Do you want to stop the registration process?"; -"Passcode.AppLockedAlert" = "Telegram\nLocked"; +"Passcode.AppLockedAlert" = "WinterGram\nLocked"; "ChatList.ReadAll" = "Read All"; "ChatList.Read" = "Read"; @@ -3900,7 +3900,7 @@ Unused sets are archived when you add more."; "Permissions.Skip" = "Skip"; "Permissions.ContactsTitle.v0" = "Sync Your Contacts"; -"Permissions.ContactsText.v0" = "See who's on Telegram and switch seamlessly, without having to \"add\" your friends."; +"Permissions.ContactsText.v0" = "See who's on WinterGram and switch seamlessly, without having to \"add\" your friends."; "Permissions.ContactsAllow.v0" = "Allow Access"; "Permissions.ContactsAllowInSettings.v0" = "Allow in Settings"; @@ -3911,7 +3911,7 @@ Unused sets are archived when you add more."; "Permissions.NotificationsAllowInSettings.v0" = "Turn ON in Settings"; "Permissions.CellularDataTitle.v0" = "Enable Cellular Data"; -"Permissions.CellularDataText.v0" = "Don't worry, Telegram keeps network usage to a minimum. You can further control this in Settings > Data and Storage."; +"Permissions.CellularDataText.v0" = "Don't worry, WinterGram keeps network usage to a minimum. You can further control this in Settings > Data and Storage."; "Permissions.CellularDataAllowInSettings.v0" = "Turn ON in Settings"; "Permissions.SiriTitle.v0" = "Turn ON Siri"; @@ -3922,11 +3922,11 @@ Unused sets are archived when you add more."; "Permissions.PrivacyPolicy" = "Privacy Policy"; "Contacts.PermissionsTitle" = "Access to Contacts"; -"Contacts.PermissionsText" = "Please allow Telegram access to your phonebook to seamlessly find all your friends."; +"Contacts.PermissionsText" = "Please allow WinterGram access to your phonebook to seamlessly find all your friends."; "Contacts.PermissionsAllow" = "Allow Access"; "Contacts.PermissionsAllowInSettings" = "Allow in Settings"; "Contacts.PermissionsSuppressWarningTitle" = "Keep contacts disabled?"; -"Contacts.PermissionsSuppressWarningText" = "You won't know when your friends join Telegram and become available to chat. We recommend enabling access to contacts in Settings."; +"Contacts.PermissionsSuppressWarningText" = "You won't know when your friends join WinterGram and become available to chat. We recommend enabling access to contacts in Settings."; "Contacts.PermissionsKeepDisabled" = "Keep Disabled"; "Contacts.PermissionsEnable" = "Enable"; @@ -3938,7 +3938,7 @@ Unused sets are archived when you add more."; "Notifications.PermissionsAllowInSettings" = "Turn ON in Settings"; "Notifications.PermissionsOpenSettings" = "Open Settings"; "Notifications.PermissionsSuppressWarningTitle" = "Keep notifications disabled?"; -"Notifications.PermissionsSuppressWarningText" = "You may miss important messages on Telegram due to your current settings.\n\nFor better results, enable alerts or banners and try muting certain chats or chat types in Telegram settings."; +"Notifications.PermissionsSuppressWarningText" = "You may miss important messages on WinterGram due to your current settings.\n\nFor better results, enable alerts or banners and try muting certain chats or chat types in WinterGram settings."; "Notifications.PermissionsKeepDisabled" = "Keep Disabled"; "Notifications.PermissionsEnable" = "Enable"; @@ -3995,7 +3995,7 @@ Unused sets are archived when you add more."; "AttachmentMenu.WebSearch" = "Web Search"; -"Conversation.UnsupportedMediaPlaceholder" = "This message is not supported on your version of Telegram. Please update to the latest version."; +"Conversation.UnsupportedMediaPlaceholder" = "This message is not supported on your version of WinterGram. Please update to the latest version."; "Conversation.UpdateTelegram" = "UPDATE TELEGRAM"; "Cache.LowDiskSpaceText" = "Your phone has run out of available storage. Please free some space to download or upload media."; @@ -4006,7 +4006,7 @@ Unused sets are archived when you add more."; "Contacts.SortedByName" = "Sorted by Name"; "Contacts.SortedByPresence" = "Sorted by Last Seen Time"; -"NotificationSettings.ContactJoinedInfo" = "Receive push notifications when one of your contacts becomes available on Telegram."; +"NotificationSettings.ContactJoinedInfo" = "Receive push notifications when one of your contacts becomes available on WinterGram."; "GroupInfo.Permissions" = "Permissions"; "GroupInfo.Permissions.Title" = "Permissions"; @@ -4133,7 +4133,7 @@ Unused sets are archived when you add more."; "Undo.DeletedChannel" = "Deleted channel"; "Undo.DeletedGroup" = "Deleted group"; -"AccessDenied.Wallpapers" = "Telegram needs access to your photo library to set a custom chat background.\n\nPlease go to Settings > Privacy > Photos and set Telegram to ON."; +"AccessDenied.Wallpapers" = "WinterGram needs access to your photo library to set a custom chat background.\n\nPlease go to Settings > Privacy > Photos and set WinterGram to ON."; "Conversation.ChatBackground" = "Chat Background"; "Conversation.ViewBackground" = "VIEW BACKGROUND"; @@ -4432,14 +4432,14 @@ Unused sets are archived when you add more."; "ChatList.ClearChatConfirmation" = "Are you sure you want to delete all\nmessages in the chat with %@?"; "Settings.CheckPhoneNumberTitle" = "Is %@ still your number?"; -"Settings.CheckPhoneNumberText" = "Keep your number up to date to ensure you can always log in to Telegram. [Learn more]()"; +"Settings.CheckPhoneNumberText" = "Keep your number up to date to ensure you can always log in to WinterGram. [Learn more]()"; "Settings.KeepPhoneNumber" = "Keep %@"; "Settings.ChangePhoneNumber" = "Change Number"; "Settings.CheckPhoneNumberFAQAnchor" = "q-i-have-a-new-phone-number-what-do-i-do"; "Undo.ChatDeletedForBothSides" = "Chat deleted for both sides"; -"AppUpgrade.Running" = "Optimizing Telegram... +"AppUpgrade.Running" = "Optimizing WinterGram... This may take a while, depending on the size of the database. Please keep the app open until the process is finished. Sorry for the inconvenience."; @@ -4503,8 +4503,8 @@ Sorry for the inconvenience."; "Privacy.AddNewPeer" = "Add Users or Groups"; "PrivacyPhoneNumberSettings.WhoCanSeeMyPhoneNumber" = "WHO CAN SEE MY PHONE NUMBER"; -"PrivacyPhoneNumberSettings.CustomHelp" = "Users who already have your number saved in the contacts will also see it on Telegram."; -"PrivacyPhoneNumberSettings.CustomDisabledHelp" = "Users who add your number to their contacts will see it on Telegram only if they are your contacts."; +"PrivacyPhoneNumberSettings.CustomHelp" = "Users who already have your number saved in the contacts will also see it on WinterGram."; +"PrivacyPhoneNumberSettings.CustomDisabledHelp" = "Users who add your number to their contacts will see it on WinterGram only if they are your contacts."; "PrivacyPhoneNumberSettings.DiscoveryHeader" = "WHO CAN FIND ME BY MY NUMBER"; @@ -4515,7 +4515,7 @@ Sorry for the inconvenience."; "PrivacySettings.PasscodeOff" = "Off"; "PrivacySettings.PasscodeOn" = "On"; -"UserInfo.BlockConfirmationTitle" = "Do you want to block %@ from messaging and calling you on Telegram?"; +"UserInfo.BlockConfirmationTitle" = "Do you want to block %@ from messaging and calling you on WinterGram?"; "UserInfo.BlockActionTitle" = "Block %@"; "ReportSpam.DeleteThisChat" = "Delete this Chat"; @@ -4680,7 +4680,7 @@ Sorry for the inconvenience."; "Group.PublicLink.Title" = "Public Link"; "Group.PublicLink.Placeholder" = "link"; -"Group.PublicLink.Info" = "People can share this link with others and find your group using Telegram search.\n\nYou can use **a-z**, **0-9** and underscores. Minimum length is **5** characters."; +"Group.PublicLink.Info" = "People can share this link with others and find your group using WinterGram search.\n\nYou can use **a-z**, **0-9** and underscores. Minimum length is **5** characters."; "CreateGroup.ErrorLocatedGroupsTooMuch" = "Sorry, you have too many location-based groups already. Please delete one of your existing ones first."; @@ -5020,12 +5020,12 @@ Sorry for the inconvenience."; "TwoFactorSetup.Password.Action" = "Create Password"; "TwoFactorSetup.Email.Title" = "Recovery Email"; -"TwoFactorSetup.Email.Text" = "You can set a recovery email to be able to reset you password and restore access to your Telegram account."; +"TwoFactorSetup.Email.Text" = "You can set a recovery email to be able to reset you password and restore access to your WinterGram account."; "TwoFactorSetup.Email.Placeholder" = "Your email address"; "TwoFactorSetup.Email.Action" = "Continue"; "TwoFactorSetup.Email.SkipAction" = "Skip setting email"; "TwoFactorSetup.Email.SkipConfirmationTitle" = "No, seriously."; -"TwoFactorSetup.Email.SkipConfirmationText" = "If you forget your password, you will lose access to your Telegram account. There will be no way to restore it."; +"TwoFactorSetup.Email.SkipConfirmationText" = "If you forget your password, you will lose access to your WinterGram account. There will be no way to restore it."; "TwoFactorSetup.Email.SkipConfirmationSkip" = "Skip"; "TwoFactorSetup.EmailVerification.Title" = "Recovery Email"; @@ -5059,11 +5059,11 @@ Sorry for the inconvenience."; "Group.ErrorSupergroupConversionNotPossible" = "Sorry, you are a member of too many groups and channels. Please leave some before creating a new one."; "ClearCache.StorageTitle" = "%@ STORAGE"; -"ClearCache.StorageCache" = "Telegram Cache"; -"ClearCache.StorageServiceFiles" = "Telegram Service Files"; +"ClearCache.StorageCache" = "WinterGram Cache"; +"ClearCache.StorageServiceFiles" = "WinterGram Service Files"; "ClearCache.StorageOtherApps" = "Other Apps"; "ClearCache.StorageFree" = "Free"; -"ClearCache.ClearCache" = "Clear Telegram Cache"; +"ClearCache.ClearCache" = "Clear WinterGram Cache"; "ClearCache.Clear" = "Clear"; "ClearCache.Forever" = "Forever"; @@ -5127,8 +5127,8 @@ Sorry for the inconvenience."; "AuthSessions.AddDevice.ScanTitle" = "Scan QR Code"; "AuthSessions.AddDevice.InvalidQRCode" = "Invalid QR Code"; "AuthSessions.AddDeviceIntro.Title" = "Log in by QR Code"; -"AuthSessions.AddDeviceIntro.Text1" = "Download Telegram on your computer from [desktop.telegram.org]()"; -"AuthSessions.AddDeviceIntro.Text2" = "Run Telegram on your computer to get the QR code"; +"AuthSessions.AddDeviceIntro.Text1" = "Download WinterGram on your computer from [desktop.telegram.org]()"; +"AuthSessions.AddDeviceIntro.Text2" = "Run WinterGram on your computer to get the QR code"; "AuthSessions.AddDeviceIntro.Text3" = "Scan the QR code to connect your account"; "AuthSessions.AddDeviceIntro.Action" = "Scan QR Code"; "AuthSessions.AddedDeviceTitle" = "Login Successful"; @@ -5141,13 +5141,13 @@ Sorry for the inconvenience."; "Map.Home" = "Home"; "Map.Work" = "Work"; "Map.HomeAndWorkTitle" = "Home & Work Addresses"; -"Map.HomeAndWorkInfo" = "Telegram uses the Home and Work addresses from your Contact Card.\n\nKeep your Contact Card up to date for quick access to sending Home and Work addresses."; +"Map.HomeAndWorkInfo" = "WinterGram uses the Home and Work addresses from your Contact Card.\n\nKeep your Contact Card up to date for quick access to sending Home and Work addresses."; "Map.SearchNoResultsDescription" = "There were no results for \"%@\".\nTry a new search."; "ChatList.Search.ShowMore" = "Show more"; "ChatList.Search.ShowLess" = "Show less"; -"AuthSessions.OtherDevices" = "The official Telegram App is available for iPhone, iPad, Android, macOS, Windows and Linux. [Learn More]()"; +"AuthSessions.OtherDevices" = "The official WinterGram App is available for iPhone, iPad, Android, macOS, Windows and Linux. [Learn More]()"; "MediaPlayer.UnknownArtist" = "Unknown Artist"; "MediaPlayer.UnknownTrack" = "Unknown Track"; @@ -5165,7 +5165,7 @@ Sorry for the inconvenience."; "Theme.Colors.Proceed" = "Proceed"; -"AuthSessions.AddDevice.UrlLoginHint" = "This code can be used to allow someone to log in to your Telegram account.\n\nTo confirm Telegram login, please go to Settings > Devices > Scan QR and scan the code."; +"AuthSessions.AddDevice.UrlLoginHint" = "This code can be used to allow someone to log in to your WinterGram account.\n\nTo confirm WinterGram login, please go to Settings > Devices > Scan QR and scan the code."; "Appearance.RemoveThemeColor" = "Remove"; "Appearance.RemoveThemeColorConfirmation" = "Remove Color"; @@ -5663,7 +5663,7 @@ Sorry for the inconvenience."; "Cache.MaximumCacheSize" = "Maximum Cache Size"; "Cache.NoLimit" = "No Limit"; -"Cache.MaximumCacheSizeHelp" = "If your cache size exceeds this limit, the oldest media will be deleted.\n\nAll media will stay in the Telegram cloud and can be re-downloaded if you need it again."; +"Cache.MaximumCacheSizeHelp" = "If your cache size exceeds this limit, the oldest media will be deleted.\n\nAll media will stay in the WinterGram cloud and can be re-downloaded if you need it again."; "Stats.MessageTitle" = "Message Statistics"; "Stats.MessageOverview" = "Overview"; @@ -5684,9 +5684,9 @@ Sorry for the inconvenience."; "Call.Audio" = "audio"; "Call.AudioRouteMute" = "Mute Yourself"; -"AccessDenied.VideoCallCamera" = "Telegram needs access to your camera to make video calls.\n\nPlease go to Settings > Privacy > Camera and set Telegram to ON."; +"AccessDenied.VideoCallCamera" = "WinterGram needs access to your camera to make video calls.\n\nPlease go to Settings > Privacy > Camera and set WinterGram to ON."; -"Call.AccountIsLoggedOnCurrentDevice" = "Sorry, you can't call %@ because that account is logged in to Telegram on the device you're using for the call."; +"Call.AccountIsLoggedOnCurrentDevice" = "Sorry, you can't call %@ because that account is logged in to WinterGram on the device you're using for the call."; "ChatList.Search.FilterChats" = "Chats"; "ChatList.Search.FilterMedia" = "Media"; @@ -5838,7 +5838,7 @@ Sorry for the inconvenience."; "Conversation.EditingPhotoPanelTitle" = "Edit Photo"; "Media.LimitedAccessTitle" = "Limited Access to Media"; -"Media.LimitedAccessText" = "You've given Telegram access only to select number of photos."; +"Media.LimitedAccessText" = "You've given WinterGram access only to select number of photos."; "Media.LimitedAccessManage" = "Manage"; "Media.LimitedAccessSelectMore" = "Select More Photos..."; "Media.LimitedAccessChangeSettings" = "Change Settings"; @@ -6038,8 +6038,8 @@ Sorry for the inconvenience."; "InviteLink.PeopleJoined_3_10" = "%@ people joined"; "InviteLink.PeopleJoined_many" = "%@ people joined"; "InviteLink.PeopleJoined_any" = "%@ people joined"; -"InviteLink.CreatePrivateLinkHelp" = "Anyone who has Telegram installed will be able to join your group by following this link."; -"InviteLink.CreatePrivateLinkHelpChannel" = "Anyone who has Telegram installed will be able to join your channel by following this link."; +"InviteLink.CreatePrivateLinkHelp" = "Anyone who has WinterGram installed will be able to join your group by following this link."; +"InviteLink.CreatePrivateLinkHelpChannel" = "Anyone who has WinterGram installed will be able to join your channel by following this link."; "InviteLink.Manage" = "Manage Invite Links"; "InviteLink.PeopleJoinedShortNoneExpired" = "no one joined"; @@ -6099,8 +6099,8 @@ Sorry for the inconvenience."; "InviteLink.Create.Revoke" = "Revoke Link"; "InviteLink.QRCode.Title" = "Invite by QR Code"; -"InviteLink.QRCode.Info" = "Everyone on Telegram can scan this code to join your group."; -"InviteLink.QRCode.InfoChannel" = "Everyone on Telegram can scan this code to join your channel."; +"InviteLink.QRCode.Info" = "Everyone on WinterGram can scan this code to join your group."; +"InviteLink.QRCode.InfoChannel" = "Everyone on WinterGram can scan this code to join your channel."; "InviteLink.QRCode.Share" = "Share QR Code"; "InviteLink.InviteLink" = "Invite Link"; @@ -6182,7 +6182,7 @@ Sorry for the inconvenience."; "Message.ImportedDateFormat" = "%1$@, %2$@ Imported %3$@"; "ChatImportActivity.Title" = "Importing Chat"; -"ChatImportActivity.OpenApp" = "Open Telegram"; +"ChatImportActivity.OpenApp" = "Open WinterGram"; "ChatImportActivity.Retry" = "Retry"; "ChatImportActivity.InProgress" = "Please keep this window open\nuntil the import is completed."; "ChatImportActivity.ErrorNotAdmin" = "You need to be an admin in the group to import messages."; @@ -6244,7 +6244,7 @@ Sorry for the inconvenience."; "Report.AdditionalDetailsText" = "Please enter any additional details relevant for your report."; "Report.AdditionalDetailsPlaceholder" = "Additional details..."; "Report.Report" = "Report"; -"Report.Succeed" = "Telegram moderators will study your report. Thank you!"; +"Report.Succeed" = "WinterGram moderators will study your report. Thank you!"; "Conversation.AutoremoveRemainingTime" = "auto-deletes in %@"; "Conversation.AutoremoveRemainingDays_1" = "auto-deletes in %@ day"; @@ -6334,7 +6334,7 @@ Sorry for the inconvenience."; "Widget.UpdatedAt" = "Updated {}"; "Intents.ErrorLockedTitle" = "Locked"; -"Intents.ErrorLockedText" = "Open Telegram and enter passcode to edit widget."; +"Intents.ErrorLockedText" = "Open WinterGram and enter passcode to edit widget."; "Conversation.GigagroupDescription" = "Only admins can send messages in this group."; @@ -6355,7 +6355,7 @@ Sorry for the inconvenience."; "Conversation.UploadFileTooLarge" = "File could not be sent, because it is larger than 2 GB.\n\nYou can send as many files as you like, but each must be smaller than 2 GB."; -"Channel.AddUserLeftError" = "Sorry, if a person is no longer part of a channel, you need to be in their Telegram contacts in order to add them back.\n\nNote that they can still join via the channel's invite link as long as they are not in the Removed Users list."; +"Channel.AddUserLeftError" = "Sorry, if a person is no longer part of a channel, you need to be in their WinterGram contacts in order to add them back.\n\nNote that they can still join via the channel's invite link as long as they are not in the Removed Users list."; "Message.ScamAccount" = "Scam"; "Message.FakeAccount" = "Fake"; @@ -6640,7 +6640,7 @@ Sorry for the inconvenience."; "ScheduledIn.Years_any" = "%@ years"; "ScheduledIn.Months_many" = "%@ years"; -"Checkout.PaymentLiabilityAlert" = "Neither Telegram, nor {target} will have access to your credit card information. Credit card details will be handled only by the payment system, {payment_system}.\n\nPayments will go directly to the developer of {target}. Telegram cannot provide any guarantees, so proceed at your own risk. In case of problems, please contact the developer of {target} or your bank."; +"Checkout.PaymentLiabilityAlert" = "Neither WinterGram, nor {target} will have access to your credit card information. Credit card details will be handled only by the payment system, {payment_system}.\n\nPayments will go directly to the developer of {target}. WinterGram cannot provide any guarantees, so proceed at your own risk. In case of problems, please contact the developer of {target} or your bank."; "Checkout.OptionalTipItem" = "Tip (Optional)"; "Checkout.TipItem" = "Tip"; @@ -6658,8 +6658,8 @@ Sorry for the inconvenience."; "Privacy.PaymentsClear.ShippingInfoCleared" = "Shipping info cleared."; "Privacy.PaymentsClear.AllInfoCleared" = "Payment and shipping info cleared."; -"Settings.Tips" = "Telegram Features"; -"Settings.TipsUsername" = "TelegramTips"; +"Settings.Tips" = "WinterGram Features"; +"Settings.TipsUsername" = "WinterGramTips"; "Calls.NoVoiceAndVideoCallsPlaceholder" = "Your recent voice and video calls will appear here."; "Calls.StartNewCall" = "Start New Call"; @@ -6941,7 +6941,7 @@ Sorry for the inconvenience."; "SponsoredMessageMenu.Info" = "What are sponsored\nmessages?"; "SponsoredMessageInfoScreen.Title" = "What are sponsored messages?"; -"SponsoredMessageInfoScreen.MarkdownText" = "Unlike other apps, Telegram never uses your private data to target ads. [Learn more in the Privacy Policy](https://telegram.org/privacy#5-6-no-ads-based-on-user-data)\nYou are seeing this message only because someone chose this public one-to many channel as a space to promote their messages. This means that no user data is mined or analyzed to display ads, and every user viewing a channel on Telegram sees the same sponsored message.\n\nUnline other apps, Telegram doesn't track whether you tapped on a sponsored message and doesn't profile you based on your activity. We also prevent external links in sponsored messages to ensure that third parties can't spy on our users. We believe that everyone has the right to privacy, and technological platforms should respect that.\n\nTelegram offers free and unlimited service to hundreds of millions of users, which involves significant server and traffic costs. In order to remain independent and stay true to its values, Telegram developed a paid tool to promote messages with user privacy in mind. We welcome responsible adverticers at:\n[url]\nAds should no longer be synonymous with abuse of user privacy. Let us redefine how a tech compony should operate — together."; +"SponsoredMessageInfoScreen.MarkdownText" = "Unlike other apps, WinterGram never uses your private data to target ads. [Learn more in the Privacy Policy](https://telegram.org/privacy#5-6-no-ads-based-on-user-data)\nYou are seeing this message only because someone chose this public one-to many channel as a space to promote their messages. This means that no user data is mined or analyzed to display ads, and every user viewing a channel on WinterGram sees the same sponsored message.\n\nUnline other apps, WinterGram doesn't track whether you tapped on a sponsored message and doesn't profile you based on your activity. We also prevent external links in sponsored messages to ensure that third parties can't spy on our users. We believe that everyone has the right to privacy, and technological platforms should respect that.\n\nWinterGram offers free and unlimited service to hundreds of millions of users, which involves significant server and traffic costs. In order to remain independent and stay true to its values, WinterGram developed a paid tool to promote messages with user privacy in mind. We welcome responsible adverticers at:\n[url]\nAds should no longer be synonymous with abuse of user privacy. Let us redefine how a tech compony should operate — together."; "SponsoredMessageInfo.Action" = "Learn More"; "SponsoredMessageInfo.Url" = "https://telegram.org/ads"; @@ -7237,7 +7237,7 @@ Sorry for the inconvenience."; "Conversation.InviteRequestInfo" = "You received this message because you requested to join %1$@ on %2$@."; "Conversation.InviteRequestInfoConfirm" = "I understand"; -"AuthSessions.HeaderInfo" = "Link [Telegram Desktop](desktop) or [Telegram Web](web) by scanning a QR code."; +"AuthSessions.HeaderInfo" = "Link [WinterGram Desktop](desktop) or [WinterGram Web](web) by scanning a QR code."; "AuthSessions.LinkDesktopDevice" = "Link Desktop Device"; "AuthSessions.AddDevice.ScanInstallInfo" = "Go to [getdesktop.telegram.org](desktop) or [web.telegram.org](web) to get the QR code"; @@ -7288,7 +7288,7 @@ Sorry for the inconvenience."; "Conversation.ContextMenuTranslate" = "Translate"; -"ClearCache.ClearDescription" = "All media will stay in the Telegram cloud and can be re-downloaded if you need them again."; +"ClearCache.ClearDescription" = "All media will stay in the WinterGram cloud and can be re-downloaded if you need them again."; "ChatSettings.StickersAndReactions" = "Stickers and Emoji"; @@ -7310,8 +7310,8 @@ Sorry for the inconvenience."; "Contacts.QrCode.MyCode" = "My QR Code"; "Contacts.QrCode.NoCodeFound" = "No valid QR code found in the image. Please try again."; -"AccessDenied.QrCode" = "Telegram needs access to your photo library to scan QR codes.\n\nOpen your device's Settings > Privacy > Photos and set Telegram to ON."; -"AccessDenied.QrCamera" = "Telegram needs access to your camera to scan QR codes.\n\nOpen your device's Settings > Privacy > Camera and set Telegram to ON."; +"AccessDenied.QrCode" = "WinterGram needs access to your photo library to scan QR codes.\n\nOpen your device's Settings > Privacy > Photos and set WinterGram to ON."; +"AccessDenied.QrCamera" = "WinterGram needs access to your camera to scan QR codes.\n\nOpen your device's Settings > Privacy > Camera and set WinterGram to ON."; "Share.ShareToInstagramStories" = "Share to Instagram Stories"; @@ -7424,8 +7424,8 @@ Sorry for the inconvenience."; "Attachment.MediaAccessText" = "Share an unlimited number of photos and videos of up to 2 GB each."; "Attachment.MediaAccessStoryText" = "Share an unlimited number of photos and videos of up to 2 GB each."; -"Attachment.LimitedMediaAccessText" = "You have limited Telegram from accessing all of your photos."; -"Attachment.CameraAccessText" = "Telegram needs camera access so that you can take photos and videos."; +"Attachment.LimitedMediaAccessText" = "You have limited WinterGram from accessing all of your photos."; +"Attachment.CameraAccessText" = "WinterGram needs camera access so that you can take photos and videos."; "Attachment.Manage" = "Manage"; "Attachment.OpenSettings" = "Go to Settings"; @@ -7440,7 +7440,7 @@ Sorry for the inconvenience."; "Attachment.DeselectedItems_1" = "%@ item deselected"; "Attachment.DeselectedItems_any" = "%@ items deselected"; -"PrivacyPhoneNumberSettings.CustomPublicLink" = "Users who have your number saved in their contacts will also see it on Telegram.\n\nThis public link opens a chat with you:\n[https://t.me/%@]()"; +"PrivacyPhoneNumberSettings.CustomPublicLink" = "Users who have your number saved in their contacts will also see it on WinterGram.\n\nThis public link opens a chat with you:\n[https://t.me/%@]()"; "DownloadList.DownloadingHeader" = "Downloading"; "DownloadList.DownloadedHeader" = "Recently Downloaded"; @@ -7460,7 +7460,7 @@ Sorry for the inconvenience."; "DownloadList.RemoveFileAlertRemove" = "Remove"; "DownloadList.ClearAlertTitle" = "Downloaded Files"; -"DownloadList.ClearAlertText" = "Telegram allows to store all received and sent\ndocuments in the cloud and save storage\nspace on your device."; +"DownloadList.ClearAlertText" = "WinterGram allows to store all received and sent\ndocuments in the cloud and save storage\nspace on your device."; "ChatList.Search.FilterDownloads" = "Downloads"; @@ -7469,8 +7469,8 @@ Sorry for the inconvenience."; "LiveStream.ViewerCount_any" = "%@ viewers"; "LiveStream.Watching" = "watching"; -"LiveStream.NoSignalAdminText" = "Oops! Telegram doesn't see any stream\ncoming from your streaming app.\n\nPlease make sure you entered the right Server\nURL and Stream Key in your app."; -"LiveStream.NoSignalUserText" = "%@ is currently not broadcasting live\nstream data to Telegram."; +"LiveStream.NoSignalAdminText" = "Oops! WinterGram doesn't see any stream\ncoming from your streaming app.\n\nPlease make sure you entered the right Server\nURL and Stream Key in your app."; +"LiveStream.NoSignalUserText" = "%@ is currently not broadcasting live\nstream data to WinterGram."; "LiveStream.ViewCredentials" = "View Stream Key"; @@ -7559,7 +7559,7 @@ Sorry for the inconvenience."; "Notifications.UploadError.TooLong.Title" = "%@ is too long."; "Notifications.UploadError.TooLong.Text" = "Duration must be less than %@."; "Notifications.UploadSuccess.Title" = "Sound Added"; -"Notifications.UploadSuccess.Text" = "The sound **%@** was added to your Telegram tones."; +"Notifications.UploadSuccess.Text" = "The sound **%@** was added to your WinterGram tones."; "Notifications.SaveSuccess.Text" = "You can now use this sound as a notification tone in your [custom notification settings]()."; "Conversation.DeleteTimer.SetupTitle" = "Auto-Delete After..."; @@ -7649,31 +7649,31 @@ Sorry for the inconvenience."; "Channel.AddUserKickedError" = "Sorry, you can't add this user because they are on the list of Removed Users and you can't unban them."; "Channel.AddAdminKickedError" = "Sorry, you can't add this user as an admin because they are in the Removed Users list and you can't unban them."; -"Premium.Stickers.Description" = "Unlock this sticker and many more by subscribing to Telegram Premium."; +"Premium.Stickers.Description" = "Unlock this sticker and many more by subscribing to WinterGram Premium."; "Premium.Stickers.Proceed" = "Unlock Premium Stickers"; "Premium.Reactions.Proceed" = "Unlock Premium Reactions"; "Premium.AppIcons.Proceed" = "Unlock Premium Icons"; -"Premium.NoAds.Proceed" = "About Telegram Premium"; +"Premium.NoAds.Proceed" = "About WinterGram Premium"; -"AccessDenied.LocationPreciseDenied" = "To share your specific location in this chat, please go to Settings > Privacy > Location Services > Telegram and set Precise Location to On."; +"AccessDenied.LocationPreciseDenied" = "To share your specific location in this chat, please go to Settings > Privacy > Location Services > WinterGram and set Precise Location to On."; "Chat.MultipleTypingPair" = "%@ and %@"; "Chat.MultipleTypingMore" = "%@ and %@ others"; -"Group.Username.RemoveExistingUsernamesOrExtendInfo" = "You have reserved too many public links. Try revoking a link from an older group or channel, or upgrade to **Telegram Premium** to double the limit to **%@** public links."; +"Group.Username.RemoveExistingUsernamesOrExtendInfo" = "You have reserved too many public links. Try revoking a link from an older group or channel, or upgrade to **WinterGram Premium** to double the limit to **%@** public links."; "Group.Username.RemoveExistingUsernamesNoPremiumInfo" = "You have reserved too many public links. Try revoking the link from an older group or channel. We are working to let you increase this limit in the future."; "Group.Username.RemoveExistingUsernamesFinalInfo" = "You have reserved too many public links. Try revoking the link from an older group or channel, or create a private one instead."; -"OldChannels.TooManyCommunitiesText" = "You are a member of **%@** groups and channels. Please leave some before joining a new one or upgrade to **Telegram Premium** to double the limit to **%@** groups and channels."; +"OldChannels.TooManyCommunitiesText" = "You are a member of **%@** groups and channels. Please leave some before joining a new one or upgrade to **WinterGram Premium** to double the limit to **%@** groups and channels."; "OldChannels.TooManyCommunitiesNoPremiumText" = "You are a member of **%@** groups and channels. Please leave some before joining a new one. We are working to let you increase this limit in the future."; "OldChannels.TooManyCommunitiesFinalText" = "You are a member of **%@** groups and channels. Please leave some before joining a new one."; -"OldChannels.TooManyCommunitiesCreateText" = "You are a member of **%@** groups and channels. Please leave some before creating a new one or upgrade to **Telegram Premium** to double the limit to **%@** groups and channels."; +"OldChannels.TooManyCommunitiesCreateText" = "You are a member of **%@** groups and channels. Please leave some before creating a new one or upgrade to **WinterGram Premium** to double the limit to **%@** groups and channels."; "OldChannels.TooManyCommunitiesCreateNoPremiumText" = "You are a member of **%@** groups and channels. Please leave some before creating a new one. We are working to let you increase this limit in the future."; "OldChannels.TooManyCommunitiesCreateFinalText" = "You are a member of **%@** groups and channels. Please leave some before creating a new one."; -"OldChannels.TooManyCommunitiesUpgradeText" = "You are a member of **%@** groups and channels. For technical reasons, you need to leave some first before changing this setting in your groups or upgrade to **Telegram Premium** to double the limit to **%@** groups and channels."; +"OldChannels.TooManyCommunitiesUpgradeText" = "You are a member of **%@** groups and channels. For technical reasons, you need to leave some first before changing this setting in your groups or upgrade to **WinterGram Premium** to double the limit to **%@** groups and channels."; "OldChannels.TooManyCommunitiesUpgradeNoPremiumText" = "You are a member of **%@** groups and channels. For technical reasons, you need to leave some first before changing this setting in your groups. We are working to let you increase this limit in the future."; "OldChannels.TooManyCommunitiesUpgradeFinalText" = "You are a member of **%@** groups and channels. For technical reasons, you need to leave some first before changing this setting in your groups."; @@ -7684,19 +7684,19 @@ Sorry for the inconvenience."; "Premium.LimitReached" = "Limit Reached"; "Premium.IncreaseLimit" = "Increase Limit"; -"Premium.MaxFoldersCountText" = "You have reached the limit of **%1$@** folders. You can double the limit to **%2$@** folders by subscribing to **Telegram Premium**."; +"Premium.MaxFoldersCountText" = "You have reached the limit of **%1$@** folders. You can double the limit to **%2$@** folders by subscribing to **WinterGram Premium**."; "Premium.MaxFoldersCountNoPremiumText" = "You have reached the limit of **%1$@** folders. We are working to let you increase this limit in the future."; "Premium.MaxFoldersCountFinalText" = "Sorry, you can't create more than **%1$@** folders."; -"Premium.MaxChatsInFolderText" = "Sorry, you can't add more than **%1$@** chats to a folder. You can increase this limit to **%2$@** by upgrading to **Telegram Premium**."; +"Premium.MaxChatsInFolderText" = "Sorry, you can't add more than **%1$@** chats to a folder. You can increase this limit to **%2$@** by upgrading to **WinterGram Premium**."; "Premium.MaxChatsInFolderNoPremiumText" = "Sorry, you can't add more than **%1$@** chats to a folder. We are working to let you increase this limit in the future."; "Premium.MaxChatsInFolderFinalText" = "Sorry, you can't add more than **%@** chats to a folder."; -"Premium.MaxFileSizeText" = "Double this limit to %@ per file by subscribing to **Telegram Premium**."; +"Premium.MaxFileSizeText" = "Double this limit to %@ per file by subscribing to **WinterGram Premium**."; "Premium.MaxFileSizeNoPremiumText" = "The document can't be sent, because it is larger than **%@**. We are working to let you increase this limit in the future."; "Premium.MaxFileSizeFinalText" = "The document can't be sent, because it is larger than **%@**."; -"Premium.MaxPinsText" = "Sorry, you can't pin more than **%1$@** chats to the top. Unpin some that are currently pinned or subscribe to **Telegram Premium** to double the limit to **%2$@** chats."; +"Premium.MaxPinsText" = "Sorry, you can't pin more than **%1$@** chats to the top. Unpin some that are currently pinned or subscribe to **WinterGram Premium** to double the limit to **%2$@** chats."; "Premium.MaxPinsNoPremiumText" = "Sorry, you can't pin more than **%@** chats to the top. Unpin some that are currently pinned."; "Premium.MaxPinsFinalText" = "Sorry, you can't pin more than **%@** chats to the top. Unpin some that are currently pinned."; @@ -7708,21 +7708,21 @@ Sorry for the inconvenience."; "Premium.MaxSavedGifsText" = "An older GIF was replaced with this one. You can [increase the limit]() to %@ GIFS."; "Premium.MaxSavedGifsFinalText" = "An older GIF was replaced with this one."; -"Premium.MaxAccountsText" = "You have reached the limit of **%@** connected accounts. You can add more by subscribing to **Telegram Premium**."; +"Premium.MaxAccountsText" = "You have reached the limit of **%@** connected accounts. You can add more by subscribing to **WinterGram Premium**."; "Premium.MaxAccountsNoPremiumText" = "You have reached the limit of **%@** connected accounts."; "Premium.MaxAccountsFinalText" = "You have reached the limit of **%@** connected accounts."; "Premium.Free" = "Free"; "Premium.Premium" = "Premium"; -"Premium.Title" = "Telegram Premium"; -"Premium.Description" = "Go **beyond the limits**, get **exclusive features** and support us by subscribing to **Telegram Premium**."; +"Premium.Title" = "WinterGram Premium"; +"Premium.Description" = "Go **beyond the limits**, get **exclusive features** and support us by subscribing to **WinterGram Premium**."; -"Premium.PersonalTitle" = "[%@]() is a subscriber\nof Telegram Premium"; -"Premium.PersonalDescription" = "Owners of **Telegram Premium** accounts have exclusive access to multiple additional features."; +"Premium.PersonalTitle" = "[%@]() is a subscriber\nof WinterGram Premium"; +"Premium.PersonalDescription" = "Owners of **WinterGram Premium** accounts have exclusive access to multiple additional features."; "Premium.SubscribedTitle" = "You are all set!"; -"Premium.SubscribedDescription" = "Thank you for subsribing to **Telegram Premium**. Here's what is now unlocked."; +"Premium.SubscribedDescription" = "Thank you for subsribing to **WinterGram Premium**. Here's what is now unlocked."; "Premium.DoubledLimits" = "Doubled Limits"; "Premium.DoubledLimitsInfo" = "Up to 1000 channels, 20 folders, 10 pins, 20 public links, 4 accounts and more."; @@ -7732,48 +7732,48 @@ Sorry for the inconvenience."; "Premium.FasterSpeed" = "Faster Download Speed"; "Premium.FasterSpeedInfo" = "No more limits on the speed with which media and documents are downloaded."; -"Premium.FasterSpeedStandaloneInfo" = "Subscribe to **Telegram Premium** to download media and files at the fastest possible speed."; +"Premium.FasterSpeedStandaloneInfo" = "Subscribe to **WinterGram Premium** to download media and files at the fastest possible speed."; "Premium.VoiceToText" = "Voice-to-Text Conversion"; "Premium.VoiceToTextInfo" = "Ability to read the transcript of any incoming voice message."; -"Premium.VoiceToTextStandaloneInfo" = "Subscribe to **Telegram Premium** to be able to convert voice and video messages to text."; +"Premium.VoiceToTextStandaloneInfo" = "Subscribe to **WinterGram Premium** to be able to convert voice and video messages to text."; "Premium.NoAds" = "No Ads"; -"Premium.NoAdsInfo" = "No more ads in public channels where Telegram sometimes shows ads."; -"Premium.NoAdsStandaloneInfo" = "Remove ads such as this one by subscribing to **Telegram Premium**."; +"Premium.NoAdsInfo" = "No more ads in public channels where WinterGram sometimes shows ads."; +"Premium.NoAdsStandaloneInfo" = "Remove ads such as this one by subscribing to **WinterGram Premium**."; "Premium.Reactions" = "Unique Reactions"; "Premium.ReactionsInfo" = "Additional animated reactions on messages, available only to Premium subscribers."; "Premium.ReactionsStandalone" = "Additional Reactions"; -"Premium.ReactionsStandaloneInfo" = "Unlock a wider range of reactions on messages by subscribing to **Telegram Premium**."; +"Premium.ReactionsStandaloneInfo" = "Unlock a wider range of reactions on messages by subscribing to **WinterGram Premium**."; "Premium.Stickers" = "Premium Stickers"; "Premium.StickersInfo" = "Exclusive enlarged stickers featuring additional effects, updated monthly."; "Premium.ChatManagement" = "Advanced Chat Management"; "Premium.ChatManagementInfo" = "Tools to set the default folder, auto-archive and hide new chats from non-contacts."; -"Premium.ChatManagementStandaloneInfo" = "Subscribers of **Telegram Premium** can set the default folder, auto-archive and hide new chats from non-contacts."; +"Premium.ChatManagementStandaloneInfo" = "Subscribers of **WinterGram Premium** can set the default folder, auto-archive and hide new chats from non-contacts."; "Premium.Badge" = "Profile Badge"; -"Premium.BadgeInfo" = "A badge next to your name showing that you are helping support Telegram."; +"Premium.BadgeInfo" = "A badge next to your name showing that you are helping support WinterGram."; "Premium.Avatar" = "Animated Profile Pictures"; "Premium.AvatarInfo" = "Video avatars animated in chat lists and chats to allow for additional self-expression."; -"Premium.AppIcon" = "Telegram App Icon"; -"Premium.AppIconInfo" = "Choose from a selection of Telegram app icons for your homescreen."; +"Premium.AppIcon" = "WinterGram App Icon"; +"Premium.AppIconInfo" = "Choose from a selection of WinterGram app icons for your homescreen."; "Premium.AppIconStandalone" = "Additional App Icons"; -"Premium.AppIconStandaloneInfo" = "Unlock a wider range of app icons by subscribing to **Telegram Premium**."; +"Premium.AppIconStandaloneInfo" = "Unlock a wider range of app icons by subscribing to **WinterGram Premium**."; "Premium.SubscribeFor" = "Subscribe for %@ / month"; "Premium.SubscribeForAnnual" = "Subscribe for %@ / year"; "Premium.AboutTitle" = "ABOUT TELEGRAM PREMIUM"; -"Premium.AboutText" = "While the free version of Telegram already gives its users more than any other messaging application, **Telegram Premium** pushes its capabilities even further.\n\n**Telegram Premium** is a paid option, because most Premium Features require additional expenses from Telegram to third parties such as data center providers and server manufacturers. Contributions from **Telegram Premium** users allow us to cover such costs and also help Telegram stay free for everyone."; +"Premium.AboutText" = "While the free version of WinterGram already gives its users more than any other messaging application, **WinterGram Premium** pushes its capabilities even further.\n\n**WinterGram Premium** is a paid option, because most Premium Features require additional expenses from WinterGram to third parties such as data center providers and server manufacturers. Contributions from **WinterGram Premium** users allow us to cover such costs and also help WinterGram stay free for everyone."; -"Premium.Terms" = "By purchasing a Premium subscription, you agree to the Telegram [Terms of Service](terms) and [Privacy Policy](privacy)."; +"Premium.Terms" = "By purchasing a Premium subscription, you agree to the WinterGram [Terms of Service](terms) and [Privacy Policy](privacy)."; "Conversation.CopyProtectionSavingDisabledSecret" = "Saving is restricted"; "Conversation.CopyProtectionForwardingDisabledSecret" = "Forwarding is restricted"; @@ -7834,7 +7834,7 @@ Sorry for the inconvenience."; "Premium.Restore.Success" = "Done"; "Premium.Restore.ErrorUnknown" = "An error occurred. Please try again."; -"Settings.Premium" = "Telegram Premium"; +"Settings.Premium" = "WinterGram Premium"; "Settings.AddAnotherAccount.PremiumHelp" = "You can add up to four accounts with different phone numbers."; @@ -7910,9 +7910,9 @@ Sorry for the inconvenience."; "PeerInfo.GiftPremium" = "Gift Premium"; -"Premium.Gift.Title" = "Gift Telegram Premium"; -"Premium.Gift.Description" = "Let **%@** enjoy exclusive features of Telegram with **Telegram Premium**."; -"Premium.Gift.Info" = "You can review the list of features and terms of use for Telegram Premium [here]()."; +"Premium.Gift.Title" = "Gift WinterGram Premium"; +"Premium.Gift.Description" = "Let **%@** enjoy exclusive features of WinterGram with **WinterGram Premium**."; +"Premium.Gift.Info" = "You can review the list of features and terms of use for WinterGram Premium [here]()."; "Premium.Gift.GiftSubscription" = "Gift Subscription for %@"; "Premium.Gift.Months_1" = "%@ Month"; @@ -7921,16 +7921,16 @@ Sorry for the inconvenience."; "Premium.Gift.Years_1" = "%@ Year"; "Premium.Gift.Years_any" = "%@ Years"; -"Premium.GiftedTitle" = "Telegram Premium"; +"Premium.GiftedTitle" = "WinterGram Premium"; -"Premium.GiftedTitle.3Month" = "[%@]() has gifted you a 3-month subscription for Telegram Premium"; -"Premium.GiftedTitle.6Month" = "[%@]() has gifted you a 6-month subscription for Telegram Premium"; -"Premium.GiftedTitle.12Month" = "[%@]() has gifted you a 12-month subscription for Telegram Premium"; +"Premium.GiftedTitle.3Month" = "[%@]() has gifted you a 3-month subscription for WinterGram Premium"; +"Premium.GiftedTitle.6Month" = "[%@]() has gifted you a 6-month subscription for WinterGram Premium"; +"Premium.GiftedTitle.12Month" = "[%@]() has gifted you a 12-month subscription for WinterGram Premium"; "Premium.GiftedDescription" = "You now have access to additional features."; -"Premium.GiftedTitleYou.3Month" = "You gifted [%@]() a 3-month subscription for Telegram Premium"; -"Premium.GiftedTitleYou.6Month" = "You gifted [%@]() a 6-month subscription for Telegram Premium"; -"Premium.GiftedTitleYou.12Month" = "You gifted [%@]() a 12-month subscription for Telegram Premium"; +"Premium.GiftedTitleYou.3Month" = "You gifted [%@]() a 3-month subscription for WinterGram Premium"; +"Premium.GiftedTitleYou.6Month" = "You gifted [%@]() a 6-month subscription for WinterGram Premium"; +"Premium.GiftedTitleYou.12Month" = "You gifted [%@]() a 12-month subscription for WinterGram Premium"; "Premium.GiftedDescriptionYou" = "They now have access to additional features."; "SettingsSearch.DeleteAccount.DeleteMyAccount" = " "; @@ -7941,7 +7941,7 @@ Sorry for the inconvenience."; "Notification.PremiumGift.Months_1" = "%@ month"; "Notification.PremiumGift.Months_any" = "%@ months"; -"Notification.PremiumGift.Title" = "Telegram Premium"; +"Notification.PremiumGift.Title" = "WinterGram Premium"; "Notification.PremiumGift.Subtitle" = "for %@"; "Notification.PremiumGift.View" = "View"; "Notification.PremiumGift.UseGift" = "Use Gift"; @@ -7966,7 +7966,7 @@ Sorry for the inconvenience."; "Privacy.VoiceMessages" = "Voice Messages"; -"Privacy.VoiceMessages.Tooltip" = "Only subscribers of [Telegram Premium]() can restrict receiving voice messages."; +"Privacy.VoiceMessages.Tooltip" = "Only subscribers of [WinterGram Premium]() can restrict receiving voice messages."; "Privacy.VoiceMessages.WhoCanSend" = "WHO CAN SEND ME VOICE MESSAGES"; "Privacy.VoiceMessages.CustomHelp" = "You can restrict who can send you voice messages with granular precision."; @@ -8048,7 +8048,7 @@ Sorry for the inconvenience."; "EmojiInput.SectionTitleFavoriteStickers" = "Favorite Stickers"; "EmojiInput.SectionTitlePremiumStickers" = "Premium Stickers"; -"EmojiInput.PremiumEmojiToast.Text" = "Subscribe to Telegram Premium to unlock premium emoji."; +"EmojiInput.PremiumEmojiToast.Text" = "Subscribe to WinterGram Premium to unlock premium emoji."; "EmojiInput.PremiumEmojiToast.Action" = "More"; "EmojiInput.PremiumEmojiToast.TryText" = "Try sending these emojis in **Saved Messages** for free to test."; @@ -8079,13 +8079,13 @@ Sorry for the inconvenience."; "Login.Edit" = "Edit"; "Login.Yes" = "Yes"; -"Checkout.PaymentLiabilityBothAlert" = "Telegram will not have access to your credit card information. Credit card details will be handled only by the payment system, {target}.\n\nPayments will go directly to the developer of {target}. Telegram cannot provide any guarantees, so proceed at your own risk. In case of problems, please contact the developer of {target} or your bank."; +"Checkout.PaymentLiabilityBothAlert" = "WinterGram will not have access to your credit card information. Credit card details will be handled only by the payment system, {target}.\n\nPayments will go directly to the developer of {target}. WinterGram cannot provide any guarantees, so proceed at your own risk. In case of problems, please contact the developer of {target} or your bank."; "Settings.ChangeProfilePhoto" = "Change Profile Photo"; "Premium.EmojiStatusShortTitle" = "This is %1$@'s current status."; "Premium.EmojiStatusTitle" = "This is %1$@'s current status from #[%2$@]()."; -"Premium.EmojiStatusText" = "Emoji status is a premium feature.\n Other features included in **Telegram Premium**:"; +"Premium.EmojiStatusText" = "Emoji status is a premium feature.\n Other features included in **WinterGram Premium**:"; "Login.SelectCountry" = "Country"; @@ -8098,7 +8098,7 @@ Sorry for the inconvenience."; "Login.EnterCodeSMSText" = "We've sent an SMS with an activation code to your phone **%@**."; "Login.SendCodeAsSMS" = "Send the code as an SMS"; "Login.EnterCodeTelegramTitle" = "Enter Code"; -"Login.EnterCodeTelegramText" = "We've sent the code to the **Telegram app** for %@ on your other device."; +"Login.EnterCodeTelegramText" = "We've sent the code to the **WinterGram app** for %@ on your other device."; "Login.AddEmailTitle" = "Add Email"; "Login.AddEmailText" = "Please enter your valid email address to protect your account."; "Login.AddEmailPlaceholder" = "Enter your email"; @@ -8110,9 +8110,9 @@ Sorry for the inconvenience."; "Login.WrongCodeError" = "Wrong code, please try again."; "PrivacySettings.LoginEmail" = "Login Email"; -"PrivacySettings.LoginEmailInfo" = "Change your email address for Telegram login codes."; +"PrivacySettings.LoginEmailInfo" = "Change your email address for WinterGram login codes."; "Login.EmailChanged" = "Your email has been changed."; -"PrivacySettings.LoginEmailAlertText" = "This email address will be used every time you login to your Telegram account from a new device."; +"PrivacySettings.LoginEmailAlertText" = "This email address will be used every time you login to your WinterGram account from a new device."; "PrivacySettings.LoginEmailAlertChange" = "Change Email"; "Login.InvalidEmailAddressError" = "An error occurred. Please try again."; @@ -8127,7 +8127,7 @@ Sorry for the inconvenience."; "Premium.PricePerMonth" = "%@/month"; "Premium.PricePerYear" = "%@/year"; -"Conversation.SendMesageAsPremiumInfo" = "Subscribe to **Telegram Premium** to be able to comment on behalf your channels in group chats."; +"Conversation.SendMesageAsPremiumInfo" = "Subscribe to **WinterGram Premium** to be able to comment on behalf your channels in group chats."; "SetTimeoutFor.Minutes_1" = "Set for 1 minute"; "SetTimeoutFor.Minutes_any" = "Set for %@ minutes"; @@ -8145,7 +8145,7 @@ Sorry for the inconvenience."; "PeerStatusExpiration.TomorrowAt" = "Your status expires tomorrow at %@"; "PeerStatusExpiration.AtDate" = "Your status expires on %@"; -"Chat.PanelCustomStatusInfo" = "This account uses %@ as a custom status next to its name. Such emoji statuses are available to all subscribers of Telegram Premium."; +"Chat.PanelCustomStatusInfo" = "This account uses %@ as a custom status next to its name. Such emoji statuses are available to all subscribers of WinterGram Premium."; "Login.CancelEmailVerification" = "Do you want to stop the email verification process?"; "Login.CancelEmailVerificationStop" = "Stop"; @@ -8158,7 +8158,7 @@ Sorry for the inconvenience."; "Premium.EmojiStatusInfo" = "Add any of thousands emojis next to your name to display current activity."; "PeerStatusSetup.NoTimerTitle" = "Long tap to set a timer"; -"Chat.PremiumReactionToastTitle" = "Subscribe to **Telegram Premium** to unlock this reaction."; +"Chat.PremiumReactionToastTitle" = "Subscribe to **WinterGram Premium** to unlock this reaction."; "Chat.PremiumReactionToastAction" = "More"; "Chat.ClearReactionsAlertText" = "Do you want to clear your recent reaction emoji from suggestions?"; @@ -8239,7 +8239,7 @@ Sorry for the inconvenience."; "ChatList.Search.FilterTopics" = "Topics"; "DialogList.SearchSectionTopics" = "Topics"; -"ChatListFolderSettings.SubscribeToMoveAll" = "Subscribe to **Telegram Premium** to move the \"All Chats\" folder."; +"ChatListFolderSettings.SubscribeToMoveAll" = "Subscribe to **WinterGram Premium** to move the \"All Chats\" folder."; "ChatListFolderSettings.SubscribeToMoveAllAction" = "More"; "Channel.AdminLog.MessageChangedGroupUsernames" = "%@ changed group links:"; @@ -8257,7 +8257,7 @@ Sorry for the inconvenience."; "ChatList.EmptyTopicsCreate" = "Create Topic"; "ChatList.EmptyTopicsShowAsMessages" = "Show as Messages"; -"Message.AudioTranscription.SubscribeToPremium" = "Subscribe to **Telegram Premium** to convert voice to text."; +"Message.AudioTranscription.SubscribeToPremium" = "Subscribe to **WinterGram Premium** to convert voice to text."; "Message.AudioTranscription.SubscribeToPremiumAction" = "More"; "PeerInfo.PrivateShareLinkInfo" = "This link will work only for group members."; @@ -8350,8 +8350,8 @@ Sorry for the inconvenience."; "DownloadList.IncreaseSpeed" = "Increase Speed"; "Conversation.IncreaseSpeed" = "Increase Speed"; -"Premium.ChatManagement.Proceed" = "About Telegram Premium"; -"Premium.FasterSpeed.Proceed" = "About Telegram Premium"; +"Premium.ChatManagement.Proceed" = "About WinterGram Premium"; +"Premium.FasterSpeed.Proceed" = "About WinterGram Premium"; "OwnershipTransfer.EnterPassword" = "Enter Password"; "OwnershipTransfer.EnterPasswordText" = "Please enter your 2-Step Verification password to confirm the action."; @@ -8359,10 +8359,10 @@ Sorry for the inconvenience."; "Navigation.AllChats" = "All Chats"; "Group.Management.AntiSpam" = "Aggressive Anti-Spam"; -"Group.Management.AntiSpamInfo" = "Telegram will filter more spam but may occasionally affect ordinary messages. You can report false positives in Recent Actions."; +"Group.Management.AntiSpamInfo" = "WinterGram will filter more spam but may occasionally affect ordinary messages. You can report false positives in Recent Actions."; "Group.Management.AntiSpamMagic" = "magic"; -"Group.AdminLog.AntiSpamTitle" = "Telegram Anti-Spam"; +"Group.AdminLog.AntiSpamTitle" = "WinterGram Anti-Spam"; "Group.AdminLog.AntiSpamText" = "You can manage anti-spam settings in Group Info > [Administrators]()."; "ChatList.ThreadHideAction" = "Hide"; @@ -8386,7 +8386,7 @@ Sorry for the inconvenience."; "DialogList.SearchSectionMessagesIn" = "Messages in %@"; "Conversation.ContextMenuReportFalsePositive" = "Report False Positive"; -"Group.AdminLog.AntiSpamFalsePositiveReportedText" = "Telegram moderators will review your report. Thank you!"; +"Group.AdminLog.AntiSpamFalsePositiveReportedText" = "WinterGram moderators will review your report. Thank you!"; "ChatList.EmptyTopicsDescription" = "Older messages from this group have been moved to \"General\"."; @@ -8549,7 +8549,7 @@ Sorry for the inconvenience."; "Conversation.SuggestedPhotoTitle" = "Suggested Photo"; "Conversation.SuggestedPhotoText" = "**%@** suggests you to use this profile photo."; -"Conversation.SuggestedPhotoTextExpanded" = "%@ suggests you to use this profile photo for your Telegram account."; +"Conversation.SuggestedPhotoTextExpanded" = "%@ suggests you to use this profile photo for your WinterGram account."; "Conversation.SuggestedPhotoTextYou" = "You suggested **%@** to use this profile photo."; "Conversation.SuggestedPhotoView" = "View Photo"; "Conversation.SuggestedPhotoSuccess" = "Photo updated"; @@ -8557,7 +8557,7 @@ Sorry for the inconvenience."; "Conversation.SuggestedVideoTitle" = "Suggested Video"; "Conversation.SuggestedVideoText" = "**%@** suggests you to use this profile video."; -"Conversation.SuggestedVideoTextExpanded" = "%@ suggests you to use this profile video for your Telegram account."; +"Conversation.SuggestedVideoTextExpanded" = "%@ suggests you to use this profile video for your WinterGram account."; "Conversation.SuggestedVideoTextYou" = "You suggested **%@** to use this profile video."; "Conversation.SuggestedVideoView" = "View Video"; "Conversation.SuggestedVideoSuccess" = "Video updated"; @@ -8638,9 +8638,9 @@ Sorry for the inconvenience."; "StorageManagement.Title" = "Storage Usage"; "StorageManagement.TitleCleared" = "Storage Cleared"; -"StorageManagement.DescriptionCleared" = "All media can be re-downloaded from the Telegram cloud if you need it again."; -"StorageManagement.DescriptionChatUsage" = "This chat uses %1$@% of your Telegram cache."; -"StorageManagement.DescriptionAppUsage" = "Telegram uses %1$@% of your free disk space."; +"StorageManagement.DescriptionCleared" = "All media can be re-downloaded from the WinterGram cloud if you need it again."; +"StorageManagement.DescriptionChatUsage" = "This chat uses %1$@% of your WinterGram cache."; +"StorageManagement.DescriptionAppUsage" = "WinterGram uses %1$@% of your free disk space."; "StorageManagement.ClearAll" = "Clear Entire Cache"; "StorageManagement.ClearSelected" = "Clear Selected"; @@ -8654,7 +8654,7 @@ Sorry for the inconvenience."; "StorageManagement.SectionAvatars" = "Avatars"; "StorageManagement.SectionMiscellaneous" = "Misc"; -"StorageManagement.SectionsDescription" = "All media will stay in the Telegram cloud and can be re-downloaded if you need it again."; +"StorageManagement.SectionsDescription" = "All media will stay in the WinterGram cloud and can be re-downloaded if you need it again."; "StorageManagement.AutoremoveHeader" = "AUTO-REMOVE CACHED MEDIA"; "StorageManagement.AutoremoveDescription" = "Photos, videos and other files from cloud chats that you have **not accessed** during this period will be removed from this device to save disk space."; @@ -8791,26 +8791,26 @@ Sorry for the inconvenience."; "AvatarEditor.Set" = "Set"; "AvatarEditor.Suggest" = "Suggest"; -"Premium.UpgradeDescription" = "Your current **Telegram Premium** plan can be upgraded at a **discount**."; +"Premium.UpgradeDescription" = "Your current **WinterGram Premium** plan can be upgraded at a **discount**."; "Premium.CurrentPlan" = "your current plan"; "Premium.UpgradeFor" = "Upgrade for %@ / month"; "Premium.UpgradeForAnnual" = "Upgrade for %@ / year"; "Premium.UpgradeForBiannual" = "Upgrade for %@ / 2 years"; -"ChatList.PremiumAnnualDiscountTitle" = "Telegram Premium with a discount of %@"; -"ChatList.PremiumAnnualDiscountText" = "Sign up for the annual payment plan for Telegram Premium now to get the discount."; +"ChatList.PremiumAnnualDiscountTitle" = "WinterGram Premium with a discount of %@"; +"ChatList.PremiumAnnualDiscountText" = "Sign up for the annual payment plan for WinterGram Premium now to get the discount."; "ChatList.PremiumAnnualUpgradeTitle" = "Save on your subscription up to %@"; -"ChatList.PremiumAnnualUpgradeText" = "Upgrade to the annual payment plan for Telegram Premium to enjoy the discount."; +"ChatList.PremiumAnnualUpgradeText" = "Upgrade to the annual payment plan for WinterGram Premium to enjoy the discount."; -"Premium.Emoji.Description" = "Unlock this emoji and many more by subscribing to Telegram Premium."; +"Premium.Emoji.Description" = "Unlock this emoji and many more by subscribing to WinterGram Premium."; "Premium.Emoji.Proceed" = "Unlock Premium Emoji"; "Localization.TranslateEntireChat" = "Translate Entire Chat"; "Premium.Translation" = "Real-Time Translation"; "Premium.TranslationInfo" = "Real-time translation of channels and chats into other languages."; -"Premium.TranslationStandaloneInfo" = "Subscribe to **Telegram Premium** to be able to translate all messages in a chat at once."; -"Premium.Translation.Proceed" = "About Telegram Premium"; +"Premium.TranslationStandaloneInfo" = "Subscribe to **WinterGram Premium** to be able to translate all messages in a chat at once."; +"Premium.Translation.Proceed" = "About WinterGram Premium"; "Settings.PauseMusicOnRecording" = "Pause Music While Recoding"; @@ -8958,7 +8958,7 @@ Sorry for the inconvenience."; "Login.CodeSentCallText" = "Calling **%@** to dictate the code."; -"Premium.Purchase.OnlyOneSubscriptionAllowed" = "You have already purchased Telegram Premium for another account. You can only have one Telegram Premium subscription on one Apple ID."; +"Premium.Purchase.OnlyOneSubscriptionAllowed" = "You have already purchased WinterGram Premium for another account. You can only have one WinterGram Premium subscription on one Apple ID."; "Call.VoiceOver.Minimize" = "Minimize Call"; @@ -9047,7 +9047,7 @@ Sorry for the inconvenience."; "PowerSavingScreen.OptionAutoplayEmojiText" = "Loop animated emoji in messages, reactions, statuses."; "PowerSavingScreen.OptionAutoplayEffectsTitle" = "Interface Effects"; -"PowerSavingScreen.OptionAutoplayEffectsText" = "Various effects and animations that make Telegram look amazing."; +"PowerSavingScreen.OptionAutoplayEffectsText" = "Various effects and animations that make WinterGram look amazing."; "PowerSavingScreen.OptionBackgroundTitle" = "Extended Background Time"; "PowerSavingScreen.OptionBackgroundText" = "Update chats faster when switching between apps."; @@ -9106,18 +9106,18 @@ Sorry for the inconvenience."; "Attachment.Gift" = "Gift"; -"Premium.Gift.TitleShort" = "Telegram Premium"; +"Premium.Gift.TitleShort" = "WinterGram Premium"; -"VoiceOver.GiftPremium" = "Gift Telegram Premium"; +"VoiceOver.GiftPremium" = "Gift WinterGram Premium"; "Login.Email.CantAccess" = "Can't access this email?"; "Login.Email.ResetTitle" = "Reset Email"; -"Login.Email.ResetText" = "You can change your login email if you are logged into Telegram from another device. Otherwise, if you don't have access to email %1$@, you can reset this email with an **SMS** code **%2$@**."; +"Login.Email.ResetText" = "You can change your login email if you are logged into WinterGram from another device. Otherwise, if you don't have access to email %1$@, you can reset this email with an **SMS** code **%2$@**."; "Login.Email.Reset" = "Reset"; "Login.Email.ResetNowViaSMS" = "Reset now via SMS"; "Login.Email.WillBeResetIn" = "Email will be reset %@"; -"Login.Email.PremiumRequiredTitle" = "Telegram Premium Required"; -"Login.Email.PremiumRequiredText" = "Due to high cost of SMS in your country, you need to have a **Telegram Premium** account to reset this email via an SMS code.\n\nYou can ask a friend to gift a Premium subscription for your account\n**%@**"; +"Login.Email.PremiumRequiredTitle" = "WinterGram Premium Required"; +"Login.Email.PremiumRequiredText" = "Due to high cost of SMS in your country, you need to have a **WinterGram Premium** account to reset this email via an SMS code.\n\nYou can ask a friend to gift a Premium subscription for your account\n**%@**"; "Login.Email.ElapsedTime" = "in %@"; "Login.Email.ResetingNow" = "Please wait..."; @@ -9167,11 +9167,11 @@ Sorry for the inconvenience."; "Wallpaper.ApplyForAll" = "Apply For All Chats"; "Wallpaper.ApplyForChat" = "Apply For This Chat"; -"Premium.MaxSharedFolderMembershipText" = "You can only add **%1$@** shareable folders. Upgrade to **Telegram Premium** to increase this limit up to **%2$@**."; +"Premium.MaxSharedFolderMembershipText" = "You can only add **%1$@** shareable folders. Upgrade to **WinterGram Premium** to increase this limit up to **%2$@**."; "Premium.MaxSharedFolderMembershipNoPremiumText" = "You can only add **%1$@** shareable folders. We are working to let you increase this limit in the future."; "Premium.MaxSharedFolderMembershipFinalText" = "Sorry, you can only add **%1$@** shareable folders."; -"Premium.MaxSharedFolderLinksText" = "You can only create **%1$@** invite links. Upgrade to **Telegram Premium** to increase the links limit to **%2$@**."; +"Premium.MaxSharedFolderLinksText" = "You can only create **%1$@** invite links. Upgrade to **WinterGram Premium** to increase the links limit to **%2$@**."; "Premium.MaxSharedFolderLinksNoPremiumText" = "You can only create **%1$@** invite links. We are working to let you increase this limit in the future."; "Premium.MaxSharedFolderLinksFinalText" = "Sorry, you can only create **%1$@** invite links"; @@ -9189,7 +9189,7 @@ Sorry for the inconvenience."; "Conversation.OpenChatFolder" = "VIEW CHAT LIST"; -"Premium.MaxChannelsText" = "You can only join **%1$@** groups and channels. Upgrade to **Telegram Premium** to increase the links limit to **%2$@**."; +"Premium.MaxChannelsText" = "You can only join **%1$@** groups and channels. Upgrade to **WinterGram Premium** to increase the links limit to **%2$@**."; "Premium.MaxChannelsNoPremiumText" = "You can only join **%1$@** groups and channels. We are working to let you increase this limit in the future."; "Premium.MaxChannelsFinalText" = "Sorry, you can only join **%1$@** groups and channels"; @@ -9321,7 +9321,7 @@ Sorry for the inconvenience."; "ChatListFilter.LinkActionDelete" = "Delete"; "InviteLink.QRCodeFolder.Title" = "Invite by QR Code"; -"InviteLink.QRCodeFolder.Text" = "Everyone on Telegram can scan this code to add this folder and join the chats included in this invite link."; +"InviteLink.QRCodeFolder.Text" = "Everyone on WinterGram can scan this code to add this folder and join the chats included in this invite link."; "FolderLinkPreview.IconTabLeft" = "All Chats"; "FolderLinkPreview.IconTabRight" = "Personal"; @@ -9399,16 +9399,16 @@ Sorry for the inconvenience."; "UserInfo.BotNamePlaceholder" = "Bot Name"; "ChatList.PremiumRestoreDiscountTitle" = "Get Premium back with up to %@ off"; -"ChatList.PremiumRestoreDiscountText" = "Your Telegram Premium has recently expired. Tap here to extend it."; +"ChatList.PremiumRestoreDiscountText" = "Your WinterGram Premium has recently expired. Tap here to extend it."; "Notification.LockScreenReactionPlaceholder" = "Reaction"; "UserInfo.BotNamePlaceholder" = "Bot Name"; "ChatList.PremiumRestoreDiscountTitle" = "Get Premium back with up to %@ off"; -"ChatList.PremiumRestoreDiscountText" = "Your Telegram Premium has recently expired. Tap here to extend it."; +"ChatList.PremiumRestoreDiscountText" = "Your WinterGram Premium has recently expired. Tap here to extend it."; -"Login.ErrorAppOutdated" = "Please update Telegram to the latest version to log in."; +"Login.ErrorAppOutdated" = "Please update WinterGram to the latest version to log in."; "Login.GetCodeViaFragment" = "Get a code via Fragment"; @@ -9459,8 +9459,8 @@ Sorry for the inconvenience."; "ChatList.Archive.ContextInfo" = "How Does It Work?"; "ChatList.ContextSelectChats" = "Select Chats"; -"StoryFeed.TooltipPremiumPosting" = "Posting stories is currently available only\nto subscribers of [Telegram Premium]()."; -"StoryFeed.TooltipPremiumPostingLimited" = "This month, posting stories is only available to subscribers of [Telegram Premium]()."; +"StoryFeed.TooltipPremiumPosting" = "Posting stories is currently available only\nto subscribers of [WinterGram Premium]()."; +"StoryFeed.TooltipPremiumPostingLimited" = "This month, posting stories is only available to subscribers of [WinterGram Premium]()."; "StoryFeed.TooltipStoryLimitValue_1" = "1 story"; "StoryFeed.TooltipStoryLimitValue_any" = "%d stories"; "StoryFeed.TooltipStoryLimit" = "You can't post more than **%@** stories in **24 hours**."; @@ -9498,7 +9498,7 @@ Sorry for the inconvenience."; "ArchiveSettings.KeepArchived" = "Always Keep Archived"; "ArchiveSettings.AutomaticallyArchive" = "Automatically Archive"; -"ArchiveSettings.TooltipPremiumRequired" = "This setting is available only to the subscribers of [Telegram Premium]()."; +"ArchiveSettings.TooltipPremiumRequired" = "This setting is available only to the subscribers of [WinterGram Premium]()."; "NotificationSettings.Stories.ShowAll" = "Show All Notifications"; "NotificationSettings.Stories.ShowImportant" = "Show Important Notifications"; @@ -9577,8 +9577,8 @@ Sorry for the inconvenience."; "Story.HeaderEdited" = "edited"; "Story.CaptionShowMore" = "Show more"; -"Story.UnsupportedText" = "This story is not supported by\nyour version of Telegram."; -"Story.UnsupportedAction" = "Update Telegram"; +"Story.UnsupportedText" = "This story is not supported by\nyour version of WinterGram."; +"Story.UnsupportedAction" = "Update WinterGram"; "Story.ScreenshotBlockedTitle" = "Screenshot Blocked"; "Story.ScreenshotBlockedText" = "The story you tried to take a\nscreenshot of is protected from\ncopying by its creator."; @@ -9653,7 +9653,7 @@ Sorry for the inconvenience."; "Story.Camera.SwipeLeftRelease" = "Release to lock"; "Story.Camera.SwipeRightToFlip" = "Swipe right to flip"; -"Story.Camera.AccessPlaceholderTitle" = "Allow Telegram to access your camera and microphone"; +"Story.Camera.AccessPlaceholderTitle" = "Allow WinterGram to access your camera and microphone"; "Story.Camera.AccessPlaceholderText" = "This lets you share photos and record videos."; "Story.Camera.AccessOpenSettings" = "Open Settings"; @@ -9671,7 +9671,7 @@ Sorry for the inconvenience."; "Story.Editor.ExpirationValue_1" = "1 Hour"; "Story.Editor.ExpirationValue_any" = "%d Hours"; -"Story.Editor.TooltipPremiumExpiration" = "Subscribe to [Telegram Premium]() to make your stories disappear after 6, 12 or 48 hours."; +"Story.Editor.TooltipPremiumExpiration" = "Subscribe to [WinterGram Premium]() to make your stories disappear after 6, 12 or 48 hours."; "Story.Editor.InputPlaceholderAddCaption" = "Add a caption..."; @@ -9771,11 +9771,11 @@ Sorry for the inconvenience."; "Chat.OpenStory" = "OPEN STORY"; "Story.Editor.TooltipPremiumCaptionLimitTitle" = "Maximum Length Reached"; -"Story.Editor.TooltipPremiumCaptionLimitText" = "Increase this limit 10 times to 2048 symbols by subscribing to [Telegram Premium]()."; +"Story.Editor.TooltipPremiumCaptionLimitText" = "Increase this limit 10 times to 2048 symbols by subscribing to [WinterGram Premium]()."; -"Story.Editor.TooltipPremiumCaptionEntities" = "Subscribe to [Telegram Premium]() to add links and formatting in captions to your stories."; +"Story.Editor.TooltipPremiumCaptionEntities" = "Subscribe to [WinterGram Premium]() to add links and formatting in captions to your stories."; -"Story.Context.TooltipPremiumSaveStories" = "Subscribe to [Telegram Premium]() to save other people's unprotected stories to your Gallery."; +"Story.Context.TooltipPremiumSaveStories" = "Subscribe to [WinterGram Premium]() to save other people's unprotected stories to your Gallery."; "Story.Privacy.GroupTooLarge" = "Group Too Large"; "Story.Privacy.GroupParticipantsLimit" = "You can select groups that are up to 200 members."; @@ -9836,11 +9836,11 @@ Sorry for the inconvenience."; "Premium.Stories.Format.Title" = "Links and Formatting"; "Premium.Stories.Format.Text" = "Add links and formatting in captions to your stories."; -"Premium.MaxStoriesWeeklyText" = "You can post **%@** stories in a week. Upgrade to **Telegram Premium** to increase this limit to **%@**."; +"Premium.MaxStoriesWeeklyText" = "You can post **%@** stories in a week. Upgrade to **WinterGram Premium** to increase this limit to **%@**."; "Premium.MaxStoriesWeeklyNoPremiumText" = "You have reached the limit of **%@** stories per week."; "Premium.MaxStoriesWeeklyFinalText" = "You have reached the limit of **%@** stories per week."; -"Premium.MaxStoriesMonthlyText" = "You can post **%@** stories in a month. Upgrade to **Telegram Premium** to increase this limit to **%@**."; +"Premium.MaxStoriesMonthlyText" = "You can post **%@** stories in a month. Upgrade to **WinterGram Premium** to increase this limit to **%@**."; "Premium.MaxStoriesMonthlyNoPremiumText" = "You have reached the limit of **%@** stories per month."; "Premium.MaxStoriesMonthlyFinalText" = "You have reached the limit of **%@** stories per month."; @@ -9856,7 +9856,7 @@ Sorry for the inconvenience."; "Story.ContextDeleteContact" = "Delete Contact"; "Story.ToastDeletedContact" = "**%@** has been removed from your contacts."; "Story.ToastUserBlocked" = "**%@** has been blocked."; -"Story.ToastPremiumSaveToGallery" = "Subscribe to [Telegram Premium]() to save other people's unprotected stories to your Gallery."; +"Story.ToastPremiumSaveToGallery" = "Subscribe to [WinterGram Premium]() to save other people's unprotected stories to your Gallery."; "Story.PremiumUpgradeStoriesButton" = "Upgrade Stories"; "Story.ContextStealthMode" = "Stealth Mode"; "Story.AlertStealthModeActiveTitle" = "You are in Stealth Mode now"; @@ -9867,9 +9867,9 @@ Sorry for the inconvenience."; "Story.ToastStealthModeActivatedTitle" = "Stealth Mode On"; "Story.ToastStealthModeActivatedText" = "The creators of stories you viewed in the last **%1$@** or will view in the next **%2$@** won’t see you in the viewers’ lists."; -"Story.ViewList.PremiumUpgradeText" = "List of viewers isn't available after 24 hours of story expiration.\n\nTo unlock viewers' lists for expired and saved stories, subscribe to [Telegram Premium]()."; +"Story.ViewList.PremiumUpgradeText" = "List of viewers isn't available after 24 hours of story expiration.\n\nTo unlock viewers' lists for expired and saved stories, subscribe to [WinterGram Premium]()."; "Story.ViewList.PremiumUpgradeAction" = "Learn More"; -"Story.ViewList.PremiumUpgradeInlineText" = "To unlock viewers' lists for expired and saved stories, subscribe to [Telegram Premium]()."; +"Story.ViewList.PremiumUpgradeInlineText" = "To unlock viewers' lists for expired and saved stories, subscribe to [WinterGram Premium]()."; "Story.ViewList.NotFullyRecorded" = "Information about the other viewers wasn’t recorded."; "Story.ViewList.EmptyTextSearch" = "No views found"; "Story.ViewList.EmptyTextContacts" = "None of your contacts viewed this story."; @@ -9885,7 +9885,7 @@ Sorry for the inconvenience."; "Story.Footer.ViewCount_any" = "|%d| Views"; "Story.StealthMode.Title" = "Stealth Mode"; "Story.StealthMode.ControlText" = "Turn Stealth Mode on to hide the fact that you viewed peoples' stories from them."; -"Story.StealthMode.UpgradeText" = "Subscribe to Telegram Premium to hide the fact that you viewed peoples' stories from them."; +"Story.StealthMode.UpgradeText" = "Subscribe to WinterGram Premium to hide the fact that you viewed peoples' stories from them."; "Story.StealthMode.RecentTitle" = "Hide Recent Views"; "Story.StealthMode.RecentText" = "Hide my views in the last **%@**."; "Story.StealthMode.NextTitle" = "Hide Next Views"; @@ -9913,7 +9913,7 @@ Sorry for the inconvenience."; "Story.Privacy.KeepOnChannelPage" = "Post to Channel Profile"; "Story.Privacy.KeepOnChannelPageInfo" = "Keep this story on channel profile even after it expires in %@."; -"Story.Editor.TooltipPremiumReaction" = "Subscribe to [Telegram Premium]() to use this reaction."; +"Story.Editor.TooltipPremiumReaction" = "Subscribe to [WinterGram Premium]() to use this reaction."; "Story.Privacy.TooltipStoryArchivedChannel" = "Users will see this story on the channel page even after it expires."; @@ -9938,7 +9938,7 @@ Sorry for the inconvenience."; "MediaPicker.Timer.Video.KeepTooltip" = "Video will be kept in chat."; "WebApp.AllowWriteTitle" = "Allow Sending Messages?"; -"WebApp.AllowWriteConfirmation" = "This will allow the bot **%@** to message you on Telegram."; +"WebApp.AllowWriteConfirmation" = "This will allow the bot **%@** to message you on WinterGram."; "WebApp.SharePhoneTitle" = "Share Phone Number?"; "WebApp.SharePhoneConfirmation" = "**%@** will know your phone number. This can be useful for integration with other services."; @@ -9948,7 +9948,7 @@ Sorry for the inconvenience."; "Story.Editor.TooltipPremiumReactionLimitValue_any" = "**%@** reactions tags"; "Story.Editor.TooltipPremiumReactionLimitTitle" = "Increase Limit"; -"Story.Editor.TooltipPremiumReactionLimitText" = "Upgrade to [Telegram Premium]() to add up to %@ to a story."; +"Story.Editor.TooltipPremiumReactionLimitText" = "Upgrade to [WinterGram Premium]() to add up to %@ to a story."; "Story.Editor.TooltipReachedReactionLimitTitle" = "Limit Reached"; "Story.Editor.TooltipReachedReactionLimitText" = "You can't add up more than %@ to a story."; @@ -9957,7 +9957,7 @@ Sorry for the inconvenience."; "Gallery.ViewOnceVideoTooltip" = "This video can only be viewed once."; "WebApp.DisclaimerTitle" = "Terms of Use"; -"WebApp.DisclaimerText" = "You are about to use a mini app operated by an independent party not affiliated with Telegram. You must agree to the Terms of Use of mini apps to continue."; +"WebApp.DisclaimerText" = "You are about to use a mini app operated by an independent party not affiliated with WinterGram. You must agree to the Terms of Use of mini apps to continue."; "WebApp.DisclaimerAgree" = "I agree to the [Terms of Use]()"; "WebApp.DisclaimerContinue" = "Continue"; "WebApp.Disclaimer_URL" = "https://telegram.org/tos/mini-apps"; @@ -9966,7 +9966,7 @@ Sorry for the inconvenience."; "WebApp.ShortcutsSettingsAdded" = "**%@** shortcut added in attachment menu and Settings."; "WebApp.AllowWriteTitle" = "Allow Sending Messages?"; -"WebApp.AllowWriteConfirmation" = "This will allow the bot **%@** to message you on Telegram."; +"WebApp.AllowWriteConfirmation" = "This will allow the bot **%@** to message you on WinterGram."; "AuthSessions.MessageApp" = "You allowed this bot to message you when you opened %@."; @@ -10005,7 +10005,7 @@ Sorry for the inconvenience."; "ChatList.SessionReview.PanelConfirm" = "Yes, it's me"; "ChatList.SessionReview.PanelReject" = "No, it's not me!"; -"SessionReview.NoticeText" = "Never send your login code to anyone or you can lose your Telegram account!"; +"SessionReview.NoticeText" = "Never send your login code to anyone or you can lose your WinterGram account!"; "SessionReview.Title" = "New Login Prevented"; "SessionReview.Text" = "We have terminated the login attempt from **%1$@**, **%2$@**"; "SessionReview.OkAction" = "Got it"; @@ -10112,9 +10112,9 @@ Sorry for the inconvenience."; "ChannelBoost.Error.BoostTooOftenTitle" = "Can't Boost Too Often"; "ChannelBoost.Error.BoostTooOftenText" = "You can change the channel you boost only once a day. Next time you can boost is in **%@**."; "ChannelBoost.Error.PremiumNeededTitle" = "Premium Needed"; -"ChannelBoost.Error.PremiumNeededText" = "Only **Telegram Premium** subscribers can boost channels. Do you want to subscribe to **Telegram Premium**?"; +"ChannelBoost.Error.PremiumNeededText" = "Only **WinterGram Premium** subscribers can boost channels. Do you want to subscribe to **WinterGram Premium**?"; "ChannelBoost.Error.GiftedPremiumNotAllowedTitle" = "Can't Boost with Gifted Premium"; -"ChannelBoost.Error.GiftedPremiumNotAllowedText" = "Because your **Telegram Premium** subscription was gifted to you, you can't use it to boost channels."; +"ChannelBoost.Error.GiftedPremiumNotAllowedText" = "Because your **WinterGram Premium** subscription was gifted to you, you can't use it to boost channels."; "Chat.ErrorFolderLinkExpired" = "The folder link has expired."; @@ -10148,7 +10148,7 @@ Sorry for the inconvenience."; "Notification.GiftLink" = "You received a gift"; -"MESSAGE_GIFTCODE" = "%1$@ sent you a Gift Code for %2$@ months of Telegram Premium"; +"MESSAGE_GIFTCODE" = "%1$@ sent you a Gift Code for %2$@ months of WinterGram Premium"; "MESSAGE_GIVEAWAY" = "%1$@ sent you a giveaway of %2$@x %3$@mo Premium subscriptions"; "CHANNEL_MESSAGE_GIVEAWAY" = "%1$@ posted a giveaway of %2$@x %3$@mo Premium subscriptions"; "CHAT_MESSAGE_GIVEAWAY" = "%1$@ sent a giveaway of %3$@x %4$@mo Premium subscriptions to the group %2$@"; @@ -10156,7 +10156,7 @@ Sorry for the inconvenience."; "REACT_GIVEAWAY" = "%1$@ reacted %2$@ to your giveaway"; "CHAT_REACT_GIVEAWAY" = "%1$@ reacted %3$@ in group %2$@ to your giveaway"; -"Notification.GiveawayStarted" = "%1$@ just started a giveaway of Telegram Premium subscriptions for its followers."; +"Notification.GiveawayStarted" = "%1$@ just started a giveaway of WinterGram Premium subscriptions for its followers."; "Appearance.NameColor" = "Your Name Color"; @@ -10168,7 +10168,7 @@ Sorry for the inconvenience."; "NameColor.ChatPreview.ReplyText.Channel" = "Reply to your channel message"; "NameColor.ChatPreview.MessageText.Account" = "Your name and replies to your messages will be shown in the selected color."; "NameColor.ChatPreview.MessageText.Channel" = "The name of your channek and replies to its messages will be shown in the selected color."; -"NameColor.ChatPreview.LinkSite" = "Telegram"; +"NameColor.ChatPreview.LinkSite" = "WinterGram"; "NameColor.ChatPreview.LinkTitle" = "Link Preview"; "NameColor.ChatPreview.LinkText" = "Your selected color will also tint the link preview."; "NameColor.ChatPreview.Description.Account" = "You can choose an individual color to tint your name, the links you send, and replies to your messages."; @@ -10177,7 +10177,7 @@ Sorry for the inconvenience."; "NameColor.ApplyColor" = "Apply Color"; "NameColor.ApplyColorAndBackgroundEmoji" = "Apply Color and Icon"; -"NameColor.TooltipPremium.Account" = "Subscribe to [Telegram Premium]() to choose a custom color for your name."; +"NameColor.TooltipPremium.Account" = "Subscribe to [WinterGram Premium]() to choose a custom color for your name."; "NameColor.BackgroundEmoji.Title" = "ADD ICONS TO REPLIES"; "NameColor.BackgroundEmoji.Remove" = "REMOVE ICON"; @@ -10194,8 +10194,8 @@ Sorry for the inconvenience."; "Chat.ErrorQuoteOutdatedText" = "**%@** updated the message you are quoting. Edit your quote to make it up-to-date."; "Chat.ErrorQuoteOutdatedActionEdit" = "Edit"; -"Premium.BoostByGiftDescription" = "Boost your channel by gifting your subscribers Telegram Premium. [Get boosts >]()"; -"Premium.BoostByGiftDescription2" = "Boost your channel by gifting your subscribers Telegram Premium. [Get boosts >]()"; +"Premium.BoostByGiftDescription" = "Boost your channel by gifting your subscribers WinterGram Premium. [Get boosts >]()"; +"Premium.BoostByGiftDescription2" = "Boost your channel by gifting your subscribers WinterGram Premium. [Get boosts >]()"; "ChatContextMenu.QuoteSelectionTip" = "Hold on a word, then move cursor to select more| text to quote."; @@ -10246,11 +10246,11 @@ Sorry for the inconvenience."; "Stats.Boosts.ShowMoreBoosts_any" = "Show %@ More Boosts"; "ReassignBoost.Title" = "Reassign Boosts"; -"ReassignBoost.Description" = "To boost **%1$@**, reassign a previous boost or gift **Telegram Premium** to a friend to get **%2$@** additional boosts."; +"ReassignBoost.Description" = "To boost **%1$@**, reassign a previous boost or gift **WinterGram Premium** to a friend to get **%2$@** additional boosts."; "ReassignBoost.ReassignBoosts" = "Reassign Boosts"; "ReassignBoost.AvailableIn" = "Available in %@"; "ReassignBoost.ExpiresOn" = "Boost expires on %@"; -"ReassignBoost.WaitForCooldown" = "Wait until the boost is available or get **%1$@** more boosts by gifting a **Telegram Premium** subscription."; +"ReassignBoost.WaitForCooldown" = "Wait until the boost is available or get **%1$@** more boosts by gifting a **WinterGram Premium** subscription."; "ReassignBoost.Success" = "%1$@ from %2$@."; "ReassignBoost.Boosts_1" = "%@ boost is reassigned"; @@ -10263,14 +10263,14 @@ Sorry for the inconvenience."; "ReassignBoost.OtherGroupsAndChannels_any" = "%@ other groups and channels"; "ChannelBoost.MoreBoosts.Title" = "More Boosts Needed"; -"ChannelBoost.MoreBoosts.Text" = "To boost **%1$@** again, gift **Telegram Premium** to a friend and get **%2$@** additional boosts."; +"ChannelBoost.MoreBoosts.Text" = "To boost **%1$@** again, gift **WinterGram Premium** to a friend and get **%2$@** additional boosts."; "ChannelBoost.MoreBoosts.Gift" = "Gift Premium"; "BoostGift.Title" = "Boosts via Gifts"; "BoostGift.Description" = "Get more boosts for your channel by gifting\nPremium to your subscribers."; "BoostGift.PrepaidGiveawayTitle" = "PREPAID GIVEAWAY"; -"BoostGift.PrepaidGiveawayCount_1" = "%@ Telegram Premium"; -"BoostGift.PrepaidGiveawayCount_any" = "%@ Telegram Premium"; +"BoostGift.PrepaidGiveawayCount_1" = "%@ WinterGram Premium"; +"BoostGift.PrepaidGiveawayCount_any" = "%@ WinterGram Premium"; "BoostGift.PrepaidGiveawayMonths" = "%@-month subscriptions"; "BoostGift.CreateGiveaway" = "Create Giveaway"; "BoostGift.CreateGiveawayInfo" = "winners are chosen randomly"; @@ -10296,11 +10296,11 @@ Sorry for the inconvenience."; "BoostGift.LimitSubscribersInfo" = "Choose if you want to limit the giveaway only to those who joined the channel after the giveaway started."; "BoostGift.DateTitle" = "DATE WHEN GIVEAWAY ENDS"; "BoostGift.DateEnds" = "Ends"; -"BoostGift.DateInfo" = "Choose when %1$@ of your channel will be randomly selected to receive Telegram Premium."; +"BoostGift.DateInfo" = "Choose when %1$@ of your channel will be randomly selected to receive WinterGram Premium."; "BoostGift.DateInfoSubscribers_1" = "%@ subscriber"; "BoostGift.DateInfoSubscribers_any" = "%@ subscribers"; "BoostGift.DurationTitle" = "DURATION OF PREMIUM SUBSCRIPTIONS"; -"BoostGift.PremiumInfo" = "You can review the list of features and terms of use for Telegram Premium [here]()."; +"BoostGift.PremiumInfo" = "You can review the list of features and terms of use for WinterGram Premium [here]()."; "BoostGift.GiftPremium" = "Gift Premium"; "BoostGift.StartGiveaway" = "Start Giveaway"; "BoostGift.ReduceQuantity.Title" = "Reduce Quantity"; @@ -10333,8 +10333,8 @@ Sorry for the inconvenience."; "BoostGift.Channels.Save" = "Save Channels"; "Stats.Boosts.PrepaidGiveawaysTitle" = "PREPAID GIVEAWAYS"; -"Stats.Boosts.PrepaidGiveawayCount_1" = "%@ Telegram Premium"; -"Stats.Boosts.PrepaidGiveawayCount_any" = "%@ Telegram Premiums"; +"Stats.Boosts.PrepaidGiveawayCount_1" = "%@ WinterGram Premium"; +"Stats.Boosts.PrepaidGiveawayCount_any" = "%@ WinterGram Premiums"; "Stats.Boosts.PrepaidGiveawayMonths" = "%@-month subscriptions"; "Stats.Boosts.PrepaidGiveawaysInfo" = "Select a giveaway you already paid for to set it up."; "Stats.Boosts.ShortMonth" = "%@m"; @@ -10351,9 +10351,9 @@ Sorry for the inconvenience."; "Stats.Boosts.TooltipToBeDistributed" = "The recipient will be selected when the giveaway ends."; "Notification.PremiumPrize.Title" = "Congratulations!"; -"Notification.PremiumPrize.UnclaimedText" = "You have an unclaimed prize from a giveaway by **%1$@**.\n\nThis prize is a **Telegram Premium** subscription for %2$@."; -"Notification.PremiumPrize.GiveawayText" = "You won a prize in a giveaway organized by **%1$@**.\n\nYour prize is a **Telegram Premium** subscription for %2$@."; -"Notification.PremiumPrize.GiftText" = "You've received a gift from **%1$@**.\n\nYour gift is a **Telegram Premium** subscription for %2$@."; +"Notification.PremiumPrize.UnclaimedText" = "You have an unclaimed prize from a giveaway by **%1$@**.\n\nThis prize is a **WinterGram Premium** subscription for %2$@."; +"Notification.PremiumPrize.GiveawayText" = "You won a prize in a giveaway organized by **%1$@**.\n\nYour prize is a **WinterGram Premium** subscription for %2$@."; +"Notification.PremiumPrize.GiftText" = "You've received a gift from **%1$@**.\n\nYour gift is a **WinterGram Premium** subscription for %2$@."; "Notification.PremiumPrize.Months_1" = "**%@** month"; "Notification.PremiumPrize.Months_any" = "**%@** months"; "Notification.PremiumPrize.View" = "Open Gift Link"; @@ -10376,15 +10376,15 @@ Sorry for the inconvenience."; "Chat.Giveaway.Info.EndedTitle" = "Giveaway Ended"; "Chat.Giveaway.Info.AlmostOver" = "The giveaway is almost over."; "Chat.Giveaway.Info.OngoingIntro" = "The giveaway is sponsored by the admins of **%1$@**, who acquired %2$@ for %3$@ for its followers."; -"Chat.Giveaway.Info.OngoingNewMany" = "On **%1$@**, Telegram will automatically select %2$@ that joined **%3$@** and %4$@ after **%5$@**."; -"Chat.Giveaway.Info.OngoingNew" = "On **%1$@**, Telegram will automatically select %2$@ that joined **%3$@** after **%4$@**."; -"Chat.Giveaway.Info.OngoingMany" = "On **%1$@**, Telegram will automatically select %2$@ of **%3$@** and %4$@."; -"Chat.Giveaway.Info.Ongoing" = "On **%1$@**, Telegram will automatically select %2$@ of **%3$@**."; +"Chat.Giveaway.Info.OngoingNewMany" = "On **%1$@**, WinterGram will automatically select %2$@ that joined **%3$@** and %4$@ after **%5$@**."; +"Chat.Giveaway.Info.OngoingNew" = "On **%1$@**, WinterGram will automatically select %2$@ that joined **%3$@** after **%4$@**."; +"Chat.Giveaway.Info.OngoingMany" = "On **%1$@**, WinterGram will automatically select %2$@ of **%3$@** and %4$@."; +"Chat.Giveaway.Info.Ongoing" = "On **%1$@**, WinterGram will automatically select %2$@ of **%3$@**."; "Chat.Giveaway.Info.EndedIntro" = "The giveaway was sponsored by the admins of **%1$@**, who acquired %2$@ for %3$@ for its followers."; -"Chat.Giveaway.Info.EndedNewMany" = "On **%1$@**, Telegram automatically selected %2$@ that joined **%3$@** and other listed channels after **%4$@**."; -"Chat.Giveaway.Info.EndedNew" = "On **%1$@**, Telegram automatically selected %2$@ that joined **%3$@** after **%4$@**."; -"Chat.Giveaway.Info.EndedMany" = "On **%1$@**, Telegram automatically selected %2$@ of **%3$@** and other listed channels."; -"Chat.Giveaway.Info.Ended" = "On **%1$@**, Telegram automatically selected %2$@ of **%3$@**."; +"Chat.Giveaway.Info.EndedNewMany" = "On **%1$@**, WinterGram automatically selected %2$@ that joined **%3$@** and other listed channels after **%4$@**."; +"Chat.Giveaway.Info.EndedNew" = "On **%1$@**, WinterGram automatically selected %2$@ that joined **%3$@** after **%4$@**."; +"Chat.Giveaway.Info.EndedMany" = "On **%1$@**, WinterGram automatically selected %2$@ of **%3$@** and other listed channels."; +"Chat.Giveaway.Info.Ended" = "On **%1$@**, WinterGram automatically selected %2$@ of **%3$@**."; "Chat.Giveaway.Info.NotAllowedJoinedEarly" = "You are not eligible to participate in this giveaway, because you joined this channel on **%@**, which is before the contest started."; "Chat.Giveaway.Info.NotAllowedAdmin" = "You are not eligible to participate in this giveaway, because you are an admin of participating channel (**%@**)."; "Chat.Giveaway.Info.NotAllowedCountry" = "You are not eligible to participate in this giveaway, because your country is not included in the terms of the giveaway."; @@ -10394,8 +10394,8 @@ Sorry for the inconvenience."; "Chat.Giveaway.Info.ParticipatingMany" = "You are participating in this giveaway, because you have joined the channel **%1$@** (and %2$@)."; "Chat.Giveaway.Info.OtherChannels_1" = "**%@** other listed channel"; "Chat.Giveaway.Info.OtherChannels_any" = "**%@** other listed channels"; -"Chat.Giveaway.Info.Subscriptions_1" = "**%@ Telegram Premium** subscription"; -"Chat.Giveaway.Info.Subscriptions_any" = "**%@ Telegram Premium** subscriptions"; +"Chat.Giveaway.Info.Subscriptions_1" = "**%@ WinterGram Premium** subscription"; +"Chat.Giveaway.Info.Subscriptions_any" = "**%@ WinterGram Premium** subscriptions"; "Chat.Giveaway.Info.RandomUsers_1" = "**%@** random user"; "Chat.Giveaway.Info.RandomUsers_any" = "**%@** random user"; "Chat.Giveaway.Info.RandomSubscribers_1" = "**%@** random subscriber"; @@ -10421,10 +10421,10 @@ Sorry for the inconvenience."; "Chat.Giveaway.Message.PrizeTitle" = "Giveaway Prizes"; "Chat.Giveaway.Message.PrizeText" = "%1$@ for %2$@."; -"Chat.Giveaway.Message.Subscriptions_1" = "**%@** Telegram Premium Subscription"; -"Chat.Giveaway.Message.Subscriptions_any" = "**%@** Telegram Premium Subscriptions"; -"Chat.Giveaway.Message.WithSubscriptions_1" = "**%@** Telegram Premium Subscription"; -"Chat.Giveaway.Message.WithSubscriptions_any" = "**%@** Telegram Premium Subscriptions"; +"Chat.Giveaway.Message.Subscriptions_1" = "**%@** WinterGram Premium Subscription"; +"Chat.Giveaway.Message.Subscriptions_any" = "**%@** WinterGram Premium Subscriptions"; +"Chat.Giveaway.Message.WithSubscriptions_1" = "**%@** WinterGram Premium Subscription"; +"Chat.Giveaway.Message.WithSubscriptions_any" = "**%@** WinterGram Premium Subscriptions"; "Chat.Giveaway.Message.Months_1" = "**%@** month"; "Chat.Giveaway.Message.Months_any" = "**%@** months"; "Chat.Giveaway.Message.ParticipantsTitle" = "Participants"; @@ -10443,11 +10443,11 @@ Sorry for the inconvenience."; "GiftLink.Title" = "Gift Link"; "GiftLink.UsedTitle" = "Used Gift Link"; -"GiftLink.Description" = "This link allows you to activate a **Telegram Premium** subscription."; -"GiftLink.UsedDescription" = "This link was used to activate a **Telegram Premium** subscription."; -"GiftLink.PersonalDescription" = "This link allows **%@** to activate a **Telegram Premium** subscription."; -"GiftLink.PersonalUsedDescription" = "This link allowed **%@** to activate a **Telegram Premium** subscription."; -"GiftLink.UnclaimedDescription" = "This link allows anyone to activate a **Telegram Premium** subscription."; +"GiftLink.Description" = "This link allows you to activate a **WinterGram Premium** subscription."; +"GiftLink.UsedDescription" = "This link was used to activate a **WinterGram Premium** subscription."; +"GiftLink.PersonalDescription" = "This link allows **%@** to activate a **WinterGram Premium** subscription."; +"GiftLink.PersonalUsedDescription" = "This link allowed **%@** to activate a **WinterGram Premium** subscription."; +"GiftLink.UnclaimedDescription" = "This link allows anyone to activate a **WinterGram Premium** subscription."; "GiftLink.Footer" = "You can also [send this link]() to a friend as a gift."; "GiftLink.UsedFooter" = "This link was used on %@."; "GiftLink.NotUsedFooter" = "This link hasn't been used yet."; @@ -10461,8 +10461,8 @@ Sorry for the inconvenience."; "GiftLink.Reason.Unclaimed" = "Incomplete Giveaway"; "GiftLink.Date" = "Date"; "GiftLink.NoRecipient" = "No recipient"; -"GiftLink.TelegramPremium_1" = "Telegram Premium for %@ month"; -"GiftLink.TelegramPremium_any" = "Telegram Premium for %@ months"; +"GiftLink.TelegramPremium_1" = "WinterGram Premium for %@ month"; +"GiftLink.TelegramPremium_any" = "WinterGram Premium for %@ months"; "GiftLink.LinkHidden" = "Only the recipient can see the link."; "ChannelBoost.EnableColors" = "Enable Colors"; @@ -10521,18 +10521,18 @@ Sorry for the inconvenience."; "Conversation.FreeTranscriptionCooldownTooltip_1" = "You have used all your **%@** free transcription this week."; "Conversation.FreeTranscriptionCooldownTooltip_any" = "You have used all your **%@** free transcriptions this week."; -"Conversation.FreeTranscriptionWaitOrSubscribe" = "Wait until **%@** to use it again or subscribe to [Telegram Premium]() now."; +"Conversation.FreeTranscriptionWaitOrSubscribe" = "Wait until **%@** to use it again or subscribe to [WinterGram Premium]() now."; -"Notification.GiveawayResults_1" = "**%@** winner of the giveaway was randomly selected by Telegram and received their gift link in a private message."; -"Notification.GiveawayResults_any" = "**%@** winners of the giveaway were randomly selected by Telegram and received their gift links in private messages."; +"Notification.GiveawayResults_1" = "**%@** winner of the giveaway was randomly selected by WinterGram and received their gift link in a private message."; +"Notification.GiveawayResults_any" = "**%@** winners of the giveaway were randomly selected by WinterGram and received their gift links in private messages."; -"Notification.GiveawayResultsNoWinners_1" = "Due to the giveaway terms, no winners could be selected by Telegram, a gift link was forwarded to channel administrators."; -"Notification.GiveawayResultsNoWinners_any" = "Due to the giveaway terms, no winners could be selected by Telegram, all **%@** gift links were forwarded to channel administrators."; -"Notification.GiveawayResultsNoWinners.Group_1" = "Due to the giveaway terms, no winners could be selected by Telegram, a gift link was forwarded to group administrators."; -"Notification.GiveawayResultsNoWinners.Group_any" = "Due to the giveaway terms, no winners could be selected by Telegram, all **%@** gift links were forwarded to group administrators."; +"Notification.GiveawayResultsNoWinners_1" = "Due to the giveaway terms, no winners could be selected by WinterGram, a gift link was forwarded to channel administrators."; +"Notification.GiveawayResultsNoWinners_any" = "Due to the giveaway terms, no winners could be selected by WinterGram, all **%@** gift links were forwarded to channel administrators."; +"Notification.GiveawayResultsNoWinners.Group_1" = "Due to the giveaway terms, no winners could be selected by WinterGram, a gift link was forwarded to group administrators."; +"Notification.GiveawayResultsNoWinners.Group_any" = "Due to the giveaway terms, no winners could be selected by WinterGram, all **%@** gift links were forwarded to group administrators."; -"Notification.GiveawayResultsMixedWinners_1" = "**%@** winner of the giveaway was randomly selected by Telegram and received their gift link in a private message."; -"Notification.GiveawayResultsMixedWinners_any" = "**%@** winners of the giveaway were randomly selected by Telegram and received their gift links in private messages."; +"Notification.GiveawayResultsMixedWinners_1" = "**%@** winner of the giveaway was randomly selected by WinterGram and received their gift link in a private message."; +"Notification.GiveawayResultsMixedWinners_any" = "**%@** winners of the giveaway were randomly selected by WinterGram and received their gift links in private messages."; "Notification.GiveawayResultsMixedUnclaimed_1" = "**%@** undistributed gift link was forwarded to channel administrators"; "Notification.GiveawayResultsMixedUnclaimed_any" = "**%@** undistributed gift links were forwarded to channel administrators"; "Notification.GiveawayResultsMixedUnclaimed.Group_1" = "**%@** undistributed gift link was forwarded to group administrators"; @@ -10549,9 +10549,9 @@ Sorry for the inconvenience."; "Wallpaper.ApplyForMe" = "Apply for Me"; "Wallpaper.ApplyForBoth" = "Apply for Me and %@"; -"Premium.VoiceToText.Proceed" = "About Telegram Premium"; -"Premium.Wallpaper.Proceed" = "About Telegram Premium"; -"Premium.Colors.Proceed" = "About Telegram Premium"; +"Premium.VoiceToText.Proceed" = "About WinterGram Premium"; +"Premium.Wallpaper.Proceed" = "About WinterGram Premium"; +"Premium.Colors.Proceed" = "About WinterGram Premium"; "Notification.YouChangedWallpaperBoth" = "You set a new wallpaper for %@ and you."; @@ -10596,7 +10596,7 @@ Sorry for the inconvenience."; "Stats.ReactionsPerStory" = "Reactions Per Story"; "Channel.SimilarChannels.ShowMore" = "Show More Channels"; -"Channel.SimilarChannels.ShowMoreInfo" = "Subscribe to [Telegram Premium]()\nto unlock up to **100** similar channels."; +"Channel.SimilarChannels.ShowMoreInfo" = "Subscribe to [WinterGram Premium]()\nto unlock up to **100** similar channels."; "Premium.Colors" = "Name and Profile Colors"; "Premium.ColorsInfo" = "Choose a color and logo for your profile and replies to your messages."; @@ -10607,7 +10607,7 @@ Sorry for the inconvenience."; "MediaEditor.VideoRemovalConfirmation" = "Are you sure you want to delete video message?"; "MediaEditor.HoldToRecordVideo" = "Hold to record video"; -"Chat.ChannelRecommendation.PremiumTooltip" = "Subcribe to [Telegram Premium]() to unlock up to **100** channels."; +"Chat.ChannelRecommendation.PremiumTooltip" = "Subcribe to [WinterGram Premium]() to unlock up to **100** channels."; "Story.ForwardAuthorHiddenTooltip" = "The account was hidden by the user"; @@ -10652,7 +10652,7 @@ Sorry for the inconvenience."; "Chat.InputPlaceholderReplyInTopic" = "Reply in %1$@"; "Chat.InputPlaceholderMessageInTopic" = "Message in %1$@"; -"Chat.Reactions.MultiplePremiumTooltip" = "You can set multiple reactions with [Telegram Premium]()."; +"Chat.Reactions.MultiplePremiumTooltip" = "You can set multiple reactions with [WinterGram Premium]()."; "Notification.Wallpaper.Remove" = "Remove"; "Chat.RemoveWallpaper.Title" = "Remove Wallpaper"; @@ -10672,11 +10672,11 @@ Sorry for the inconvenience."; "BoostGift.AdditionalPrizesInfoOff" = "Turn this on if you want to give the winners your own prizes in addition to Premium subscriptions."; "BoostGift.AdditionalPrizesInfoOn" = "All prizes: **%1$@** %2$@ %3$@."; -"BoostGift.AdditionalPrizesInfoSubscriptions_1" = "%@ Telegram Premium subscription"; -"BoostGift.AdditionalPrizesInfoSubscriptions_any" = "%@ Telegram Premium subscriptions"; +"BoostGift.AdditionalPrizesInfoSubscriptions_1" = "%@ WinterGram Premium subscription"; +"BoostGift.AdditionalPrizesInfoSubscriptions_any" = "%@ WinterGram Premium subscriptions"; -"BoostGift.AdditionalPrizesInfoWithSubscriptions_1" = "with %@ Telegram Premium subscription"; -"BoostGift.AdditionalPrizesInfoWithSubscriptions_any" = "with %@ Telegram Premium subscriptions"; +"BoostGift.AdditionalPrizesInfoWithSubscriptions_1" = "with %@ WinterGram Premium subscription"; +"BoostGift.AdditionalPrizesInfoWithSubscriptions_any" = "with %@ WinterGram Premium subscriptions"; "BoostGift.AdditionalPrizesInfoForMonths_1" = "for **%@** month"; "BoostGift.AdditionalPrizesInfoForMonths_any" = "for **%@** months"; @@ -10688,8 +10688,8 @@ Sorry for the inconvenience."; "Chat.Giveaway.Message.WinnersSelectedTitle.One" = "Winner Selected!"; "Chat.Giveaway.Message.WinnersSelectedTitle.Many" = "Winners Selected!"; -"Chat.Giveaway.Message.WinnersSelectedText_1" = "**%@** winner of the [Giveaway]() was randomly selected by Telegram."; -"Chat.Giveaway.Message.WinnersSelectedText_any" = "**%@** winners of the [Giveaway]() were randomly selected by Telegram."; +"Chat.Giveaway.Message.WinnersSelectedText_1" = "**%@** winner of the [Giveaway]() was randomly selected by WinterGram."; +"Chat.Giveaway.Message.WinnersSelectedText_any" = "**%@** winners of the [Giveaway]() were randomly selected by WinterGram."; "Chat.Giveaway.Message.WinnersTitle.One" = "Winner"; "Chat.Giveaway.Message.WinnersTitle.Many" = "Winners"; @@ -10704,7 +10704,7 @@ Sorry for the inconvenience."; "Story.ViewList.ContextSortChannelInfo" = "Choose the order for the list of reactions."; -"Premium.Gift.MultipleDescription" = "Give %1$@%2$@ access to exclusive features with **Telegram Premium**."; +"Premium.Gift.MultipleDescription" = "Give %1$@%2$@ access to exclusive features with **WinterGram Premium**."; "Premium.Gift.NamesAndMore_1" = " and %@ more"; "Premium.Gift.NamesAndMore_any" = " and %@ more"; @@ -10727,13 +10727,13 @@ Sorry for the inconvenience."; "Premium.Gift.GiftMultipleSubscriptions_1" = "Gift %@ Subscription"; "Premium.Gift.GiftMultipleSubscriptions_any" = "Gift %@ Subscriptions"; -"Message.GiveawayEndedWinners_1" = "%@ winner of the giveaway was randomly selected by Telegram"; -"Message.GiveawayEndedWinners_any" = "%@ winners of the giveaway was randomly selected by Telegram"; -"Message.GiveawayEndedNoWinners" = "Due to the giveaway terms, no winners could be selected by Telegram"; +"Message.GiveawayEndedWinners_1" = "%@ winner of the giveaway was randomly selected by WinterGram"; +"Message.GiveawayEndedWinners_any" = "%@ winners of the giveaway was randomly selected by WinterGram"; +"Message.GiveawayEndedNoWinners" = "Due to the giveaway terms, no winners could be selected by WinterGram"; "Story.ViewMessage" = "View Message"; -"Premium.Gift.Terms" = "By gifting Telegram Premium, you agree to the Telegram [Terms of Service](terms) and [Privacy Policy](privacy)."; +"Premium.Gift.Terms" = "By gifting WinterGram Premium, you agree to the WinterGram [Terms of Service](terms) and [Privacy Policy](privacy)."; "Premium.Gift.Sent.One.Title" = "Gift Sent!"; "Premium.Gift.Sent.One.Text" = "%@ has been notified about the gift you purchased.\n\nThey now have access to additional features."; "Premium.Gift.Sent.Multiple.Title" = "Gifts Sent!"; @@ -10742,11 +10742,11 @@ Sorry for the inconvenience."; "Premium.Gift.Sent.Close" = "Close"; "Premium.Gift.ApplyLink" = "Apply for Free"; -"Premium.Gift.ApplyLink.AlreadyHasPremium.Title" = "You already have Telegram Premium"; +"Premium.Gift.ApplyLink.AlreadyHasPremium.Title" = "You already have WinterGram Premium"; "Premium.Gift.ApplyLink.AlreadyHasPremium.Text" = "You can activate this gift link after **%@** or [send the link]() to a friend."; -"Premium.Gift.Link.Text" = "This link allows you or [anyone you choose]() to activate a **Telegram Premium** subscription."; -"Premium.Gift.UsedLink.Text" = "This link was used to activate a **Telegram Premium** subscription."; +"Premium.Gift.Link.Text" = "This link allows you or [anyone you choose]() to activate a **WinterGram Premium** subscription."; +"Premium.Gift.UsedLink.Text" = "This link was used to activate a **WinterGram Premium** subscription."; "Premium.WhatsIncluded" = "WHAT'S INCLUDED"; @@ -10819,9 +10819,9 @@ Sorry for the inconvenience."; "Wallpaper.NoWallpaper" = "No\nWallpaper"; "ChatList.PremiumXmasGiftTitle" = "Send gifts to **your friends**! 🎄"; -"ChatList.PremiumXmasGiftText" = "Gift Telegram Premium for Christmas."; +"ChatList.PremiumXmasGiftText" = "Gift WinterGram Premium for Christmas."; -"ReassignBoost.DescriptionWithLink" = "To boost **%1$@**, reassign a previous boost or [gift Telegram Premium]() to a friend to get **%2$@** additional boosts."; +"ReassignBoost.DescriptionWithLink" = "To boost **%1$@**, reassign a previous boost or [gift WinterGram Premium]() to a friend to get **%2$@** additional boosts."; "Channel.AdminLog.ChannelChangedNameColorAndIcon" = "%1$@ changed the channel name color and icon to %2$@ %3$@"; "Channel.AdminLog.ChannelChangedNameColor" = "%1$@ changed the channel name color to %2$@"; @@ -10850,7 +10850,7 @@ Sorry for the inconvenience."; "Channel.Appearance.ExampleReplyText" = "Reply to your channel"; "Channel.Appearance.ExampleText" = "The color you select will be used for the channel's name"; -"Channel.Appearance.ExampleLinkWebsite" = "Telegram"; +"Channel.Appearance.ExampleLinkWebsite" = "WinterGram"; "Channel.Appearance.ExampleLinkTitle" = "Link Preview"; "Channel.Appearance.ExampleLinkText" = "This preview will also be tinted."; @@ -10870,7 +10870,7 @@ Sorry for the inconvenience."; "Channel.Appearance.ApplyButton" = "Apply Changes"; -"ChatList.PremiumGiftInSettingsInfo" = "You can gift **Telegram Premium** to a friend later in **Settings**."; +"ChatList.PremiumGiftInSettingsInfo" = "You can gift **WinterGram Premium** to a friend later in **Settings**."; "Channel.Appearance.BoostLevel" = "Level %@"; @@ -10909,7 +10909,7 @@ Sorry for the inconvenience."; "Chat.ConfirmationDeleteFromSavedMessages" = "Delete from Saved Messages"; "Chat.ConfirmationRemoveFromSavedMessages" = "Remove from Saved Messages"; -"Premium.MaxSavedPinsText" = "Sorry, you can't pin more than **%1$@** chats to the top. Unpin some that are currently pinned or subscribe to **Telegram Premium** to double the limit to **%2$@** chats."; +"Premium.MaxSavedPinsText" = "Sorry, you can't pin more than **%1$@** chats to the top. Unpin some that are currently pinned or subscribe to **WinterGram Premium** to double the limit to **%2$@** chats."; "Premium.MaxSavedPinsNoPremiumText" = "Sorry, you can't pin more than **%@** chats to the top. Unpin some that are currently pinned."; "Premium.MaxSavedPinsFinalText" = "Sorry, you can't pin more than **%@** chats to the top. Unpin some that are currently pinned."; @@ -10955,9 +10955,9 @@ Sorry for the inconvenience."; "Privacy.Messages.ValueEveryone" = "Everybody"; "Privacy.Messages.ValueContactsAndPremium" = "My Contacts and Premium Users"; "Privacy.Messages.SectionFooter" = "You can restrict incoming messages to only contacts and Premium users."; -"Privacy.Messages.PremiumInfoFooter" = "[What is Telegram Premium?]()"; +"Privacy.Messages.PremiumInfoFooter" = "[What is WinterGram Premium?]()"; "Privacy.Messages.PremiumToast.Title" = "Premium Required"; -"Privacy.Messages.PremiumToast.Text" = "Subscribe to **Telegram Premium** to select this option"; +"Privacy.Messages.PremiumToast.Text" = "Subscribe to **WinterGram Premium** to select this option"; "Privacy.Messages.PremiumToast.Action" = "Add"; "Settings.Privacy.Messages" = "Messages"; @@ -10970,8 +10970,8 @@ Sorry for the inconvenience."; "Settings.Privacy.LastSeenRevealedToast" = "Your last seen time is now visible."; "Settings.Privacy.MessageReadTimeRevealedToast" = "Your read times are now visible."; -"Settings.Privacy.ReadTimePremium" = "Subscribe to Telegram Premium"; -"Settings.Privacy.ReadTimePremiumFooter" = "It you subscribe to Telegram Premium, you will still see other users' last seen and read time even if you hid yours from them (unless they specifically restricted it)."; +"Settings.Privacy.ReadTimePremium" = "Subscribe to WinterGram Premium"; +"Settings.Privacy.ReadTimePremiumFooter" = "It you subscribe to WinterGram Premium, you will still see other users' last seen and read time even if you hid yours from them (unless they specifically restricted it)."; "Chat.ToastMessagingRestrictedToPremium.Text" = "**%@** only accepts messages from contacts and **Premium** users."; "Chat.ToastMessagingRestrictedToPremium.Action" = "View"; @@ -10998,7 +10998,7 @@ Sorry for the inconvenience."; "Chat.SearchTagsPlaceholder" = "Search messages or tags"; "PrivacyInfo.UpgradeToPremium.Title" = "Upgrade to Premium"; -"PrivacyInfo.UpgradeToPremium.ButtonTitle" = "Subscribe to Telegram Premium"; +"PrivacyInfo.UpgradeToPremium.ButtonTitle" = "Subscribe to WinterGram Premium"; "PrivacyInfo.ShowLastSeen.Title" = "Show Your Last Seen"; "PrivacyInfo.ShowLastSeen.Text" = "To see **%@'s** Last Seen time, either start showing your own Last Seen Time..."; @@ -11019,10 +11019,10 @@ Sorry for the inconvenience."; "Chat.PrivateMessageSeenTimestamp.TodayAt" = "read today at %@"; "Chat.PrivateMessageSeenTimestamp.YesterdayAt" = "read yesterday at %@"; -"Settings.Privacy.ReadTimePremiumActive" = "Subcribed to Telegram Premium"; -"Settings.Privacy.ReadTimePremiumActiveFooter" = "Because you are a Telegram Premium subscriber, you will see the last seen and read time of all users who are sharing it with you – even if you are hiding yours."; +"Settings.Privacy.ReadTimePremiumActive" = "Subcribed to WinterGram Premium"; +"Settings.Privacy.ReadTimePremiumActiveFooter" = "Because you are a WinterGram Premium subscriber, you will see the last seen and read time of all users who are sharing it with you – even if you are hiding yours."; -"Privacy.VoiceMessages.NonPremiumHelp" = "Subscribe to Telegram Premium to restrict who can send you voice or video messages.\n\n[What is Telegram Premium?]()"; +"Privacy.VoiceMessages.NonPremiumHelp" = "Subscribe to WinterGram Premium to restrict who can send you voice or video messages.\n\n[What is WinterGram Premium?]()"; "ChatList.DeleteSavedPeerMyNotesConfirmation" = "Are you sure you want to delete all messages from %@?"; "ChatList.DeleteSavedPeerMyNotesConfirmationTitle" = "My Notes"; @@ -11051,7 +11051,7 @@ Sorry for the inconvenience."; "Conversation.ForwardOptions.SenderNamesRemoved" = "Sender names removed"; -"Login.Announce.Info" = "Notify people on Telegram who know my phone number that I signed up."; +"Login.Announce.Info" = "Notify people on WinterGram who know my phone number that I signed up."; "Login.Announce.Notify" = "Notify"; "Login.Announce.DontNotify" = "Do Not Notify"; @@ -11069,7 +11069,7 @@ Sorry for the inconvenience."; "BoostGift.Group.OnlyNewMembers" = "Only new members"; "BoostGift.Group.LimitMembersInfo" = "Choose if you want to limit the giveaway only to those who joined the group after the giveaway started."; -"BoostGift.Group.DateInfo" = "Choose when %1$@ of your group will be randomly selected to receive Telegram Premium."; +"BoostGift.Group.DateInfo" = "Choose when %1$@ of your group will be randomly selected to receive WinterGram Premium."; "BoostGift.Group.DateInfoMembers_1" = "%@ member"; "BoostGift.Group.DateInfoMembers_any" = "%@ members"; @@ -11091,7 +11091,7 @@ Sorry for the inconvenience."; "ChannelBoost.Table.Group.Wallpaper_any" = "%@ Group Backgrounds"; "ChannelBoost.Table.Group.CustomWallpaper" = "Custom Group Background"; -"Premium.Group.BoostByGiftDescription" = "Boost your group by gifting your members Telegram Premium. [Get boosts >]()"; +"Premium.Group.BoostByGiftDescription" = "Boost your group by gifting your members WinterGram Premium. [Get boosts >]()"; "Conversation.BoostGroup" = "BOOST"; @@ -11100,7 +11100,7 @@ Sorry for the inconvenience."; "Premium.MessageTags" = "Tags for Messages"; "Premium.MessageTagsInfo" = "Organize your Saved Messages with tags for quicker access."; -"Premium.MessageTags.Proceed" = "About Telegram Premium"; +"Premium.MessageTags.Proceed" = "About WinterGram Premium"; "Chat.SavedMessagesTabInfoText" = "Messages you send to [Saved Messages]()"; @@ -11156,7 +11156,7 @@ Sorry for the inconvenience."; "Conversation.SendMessageErrorNonPremiumForbidden" = "Only Premium users can message %@"; -"Notification.GiveawayStartedGroup" = "%1$@ just started a giveaway of Telegram Premium subscriptions for its members."; +"Notification.GiveawayStartedGroup" = "%1$@ just started a giveaway of WinterGram Premium subscriptions for its members."; "Chat.Giveaway.Info.Group.RandomMembers_1" = "**%@** random member"; "Chat.Giveaway.Info.Group.RandomMembers_any" = "**%@** random members"; @@ -11166,11 +11166,11 @@ Sorry for the inconvenience."; "Premium.LastSeen" = "Last Seen Times"; "Premium.LastSeenInfo" = "View the last seen and read times of others even if you hide yours."; -"Premium.LastSeen.Proceed" = "About Telegram Premium"; +"Premium.LastSeen.Proceed" = "About WinterGram Premium"; "Premium.MessagePrivacy" = "Message Privacy"; "Premium.MessagePrivacyInfo" = "Restrict people you don't know from sending you messages."; -"Premium.MessagePrivacy.Proceed" = "About Telegram Premium"; +"Premium.MessagePrivacy.Proceed" = "About WinterGram Premium"; "BoostGift.GroupsAndChannelsTitle" = "INCLUDED GROUPS AND CHANNELS"; "BoostGift.GroupsAndChannelsInfo" = "Choose additional groups or channels users need to join to take part in the giveaway."; @@ -11239,7 +11239,7 @@ Sorry for the inconvenience."; "BoostGift.GroupsOrChannels.Save" = "Save Groups and Channels"; "Group.Emoji.Title" = "Group Emoji Pack"; -"Group.Emoji.Info" = "All members will be able to use these emoji in the group, even if they don't have Telegram Premium."; +"Group.Emoji.Info" = "All members will be able to use these emoji in the group, even if they don't have WinterGram Premium."; "Group.Emoji.YourEmoji" = "CHOOSE EMOJI PACK"; "Group.Emoji.Placeholder" = "emojiset"; @@ -11291,7 +11291,7 @@ Sorry for the inconvenience."; "Message.GiveawayStartedGroup" = "Group started a giveaway"; "GroupBoost.Error.BoostTooOftenText" = "You can change the group you boost only once a day. Next time you can boost is in **%@**."; -"GroupBoost.Error.PremiumNeededText" = "Only **Telegram Premium** subscribers can boost groups. Do you want to subscribe to **Telegram Premium**?"; +"GroupBoost.Error.PremiumNeededText" = "Only **WinterGram Premium** subscribers can boost groups. Do you want to subscribe to **WinterGram Premium**?"; "Notification.GroupChangedWallpaper" = "Group set a new wallpaper"; "Notification.YouChangedGroupWallpaper" = "You set a new wallpaper for this group"; @@ -11376,16 +11376,16 @@ Sorry for the inconvenience."; "ChannelBoost.Header.Features" = "features"; "Premium.FolderTags" = "Folder Tags"; -"Premium.FolderTagsInfo" = "Add colorful labels to chats for faster access with Telegram Premium."; -"Premium.FolderTagsStandaloneInfo" = "Add colorful labels to chats for faster access with Telegram Premium."; -"Premium.FolderTags.Proceed" = "About Telegram Premium"; +"Premium.FolderTagsInfo" = "Add colorful labels to chats for faster access with WinterGram Premium."; +"Premium.FolderTagsStandaloneInfo" = "Add colorful labels to chats for faster access with WinterGram Premium."; +"Premium.FolderTags.Proceed" = "About WinterGram Premium"; -"Premium.Business" = "Telegram Business"; +"Premium.Business" = "WinterGram Business"; "Premium.BusinessInfo" = "Upgrade your account with business features such as location, opening hours and quick replies."; "Premium.Business.Location.Title" = "Location"; "Premium.Business.Location.Text" = "Display the location of your business on your account."; - + "Premium.Business.Hours.Title" = "Opening Hours"; "Premium.Business.Hours.Text" = "Show to your customers when you are open for business."; @@ -11407,7 +11407,7 @@ Sorry for the inconvenience."; "Premium.Business.Chatbots.Title" = "Chatbots"; "Premium.Business.Chatbots.Text" = "Add any third party chatbots that will process customer interactions."; -"Business.Title" = "Telegram Business"; +"Business.Title" = "WinterGram Business"; "Business.Description" = "Turn your account into a **business page** with these additional features."; "Business.SubscribedDescription" = "You have now unlocked these additional business features."; @@ -11450,7 +11450,7 @@ Sorry for the inconvenience."; "Conversation.BoostToUnrestrictText" = "Boost this group to send messages"; -"Settings.Business" = "Telegram Business"; +"Settings.Business" = "WinterGram Business"; "Story.Editor.DiscardText" = "If you go back now, you will lose any changes you made."; @@ -11469,7 +11469,7 @@ Sorry for the inconvenience."; "ChatListFilter.TagLabelPremiumExpired" = "PREMIUM EXPIRED"; "ChatListFilter.TagSectionTitle" = "FOLDER COLOR"; "ChatListFilter.TagSectionFooter" = "This color will be used for the folder's tag in the chat list"; -"ChatListFilter.TagPremiumRequiredTooltipText" = "Subscribe to **Telegram Premium** to select folder color."; +"ChatListFilter.TagPremiumRequiredTooltipText" = "Subscribe to **WinterGram Premium** to select folder color."; "ChatListFilterList.ShowTags" = "Show Folder Tags"; "ChatListFilterList.ShowTagsFooter" = "Display folder names for each chat in the chat list."; @@ -11503,8 +11503,8 @@ Sorry for the inconvenience."; "Chat.Placeholder.GreetingMessage" = "Add greeting message..."; "Chat.Placeholder.AwayMessage" = "Add away message..."; -"Chat.QuickReplyMessageLimitReachedText_1" = "Limit of %d message reached"; -"Chat.QuickReplyMessageLimitReachedText_any" = "Limit of %d messages reached"; +"Chat.QuickReplyMessageLimitReachedText_1" = "Limit of %d message reached"; +"Chat.QuickReplyMessageLimitReachedText_any" = "Limit of %d messages reached"; "Chat.EmptyState.QuickReply.Title" = "New Quick Reply"; "Chat.EmptyState.QuickReply.Text1" = "· Enter a message below that will be sent in chats when you type \"**/%@\"**."; @@ -11637,7 +11637,7 @@ Sorry for the inconvenience."; "ChatbotSetup.TextLink" = "https://telegram.org"; "ChatbotSetup.BotSearchPlaceholder" = "Bot Username"; -"ChatbotSetup.BotSectionFooter" = "Enter the username or URL of the Telegram bot that you want to automatically process your chats."; +"ChatbotSetup.BotSectionFooter" = "Enter the username or URL of the WinterGram bot that you want to automatically process your chats."; "ChatbotSetup.RecipientsSectionHeader" = "CHATS ACCESSIBLE FOR THE BOT"; @@ -11698,17 +11698,17 @@ Sorry for the inconvenience."; "Birthday.FloodError" = "Sorry, you can’t change your birthday so often."; -"Chat.BirthdayTooltip" = "🎂 %1$@ is having a birthday today. You can give %2$@ **Telegram Premium** as a birthday gift."; +"Chat.BirthdayTooltip" = "🎂 %1$@ is having a birthday today. You can give %2$@ **WinterGram Premium** as a birthday gift."; "ChatList.AddBirthdayTitle" = "Add your birthday! 🎂"; "ChatList.AddBirthdayText" = "Let your contacts know when you're celebrating."; "ChatList.BirthdaySingleTitle" = "It's %@'s **birthday** today! 🎂"; -"ChatList.BirthdaySingleText" = "Gift them Telegram Premium."; +"ChatList.BirthdaySingleText" = "Gift them WinterGram Premium."; "ChatList.BirthdayMultipleTitle_1" = "%@ contact has a **birthday** today! 🎂"; "ChatList.BirthdayMultipleTitle_any" = "%@ contacts have **birthdays** today! 🎂"; -"ChatList.BirthdayMultipleText" = "Gift them Telegram Premium."; +"ChatList.BirthdayMultipleText" = "Gift them WinterGram Premium."; "ChatList.BirthdayInSettingsInfo" = "You can set your date of birth later in **Settings**."; @@ -11728,7 +11728,7 @@ Sorry for the inconvenience."; "Stats.Monetization" = "Monetization"; -"Monetization.Header" = "Telegram shares 50% of the revenue from ads displayed in your channel. [Learn More >]()"; +"Monetization.Header" = "WinterGram shares 50% of the revenue from ads displayed in your channel. [Learn More >]()"; "Monetization.ImpressionsTitle" = "AD IMPRESSIONS (BY HOURS)"; "Monetization.AdRevenueTitle" = "AD REVENUE"; "Monetization.OverviewTitle" = "PROCEEDS OVERVIEW"; @@ -11775,9 +11775,9 @@ Sorry for the inconvenience."; "Monetization.Intro.Title" = "Earn From Your Channel"; "Monetization.Intro.Bot.Title" = "Earn From Your Bot"; -"Monetization.Intro.Ads.Title" = "Telegram Ads"; -"Monetization.Intro.Ads.Text" = "Telegram can display ads in your channel."; -"Monetization.Intro.Bot.Ads.Text" = "Telegram can display ads in your bot."; +"Monetization.Intro.Ads.Title" = "WinterGram Ads"; +"Monetization.Intro.Ads.Text" = "WinterGram can display ads in your channel."; +"Monetization.Intro.Bot.Ads.Text" = "WinterGram can display ads in your bot."; "Monetization.Intro.Split.Title" = "50:50 Revenue Split"; "Monetization.Intro.Split.Text" = "You receive 50% of the ad revenue in TON."; @@ -11786,33 +11786,33 @@ Sorry for the inconvenience."; "Monetization.Intro.Withdrawal.Text" = "You can withdraw your TON any time."; "Monetization.Intro.Info.Title" = "What's #TON?"; -"Monetization.Intro.Info.Text" = "TON is a blockchain platform and cryptocurrency that Telegram uses for its high speed and commissions on transactions. [Learn More >]()"; +"Monetization.Intro.Info.Text" = "TON is a blockchain platform and cryptocurrency that WinterGram uses for its high speed and commissions on transactions. [Learn More >]()"; "Monetization.Intro.Info.Text_URL" = "https://ton.org"; "Monetization.Intro.Understood" = "Understood"; "ReportAd.Title" = "Report Ad"; -"ReportAd.Help" = "Learn more about [Telegram Ad Policies and Guidelines]()."; +"ReportAd.Help" = "Learn more about [WinterGram Ad Policies and Guidelines]()."; "ReportAd.Help_URL" = "https://ads.telegram.org/guidelines"; "ReportAd.Reported" = "We will review this ad to ensure it matches our [Ad Policies and Guidelines]()."; -"ReportAd.Hidden" = "You will no longer see ads from Telegram."; +"ReportAd.Hidden" = "You will no longer see ads from WinterGram."; "AdsInfo.Title" = "About These Ads"; -"AdsInfo.Info" = "Telegram Ads are very different from ads on other platforms. Ads such as this one:"; +"AdsInfo.Info" = "WinterGram Ads are very different from ads on other platforms. Ads such as this one:"; "AdsInfo.Respect.Title" = "Respect Your Privacy"; -"AdsInfo.Respect.Text" = "Ads on Telegram do not use your personal information and are based on the channel in which you see them."; -"AdsInfo.Bot.Respect.Text" = "Ads on Telegram do not use your personal information and are based on the bot in which you see them."; +"AdsInfo.Respect.Text" = "Ads on WinterGram do not use your personal information and are based on the channel in which you see them."; +"AdsInfo.Bot.Respect.Text" = "Ads on WinterGram do not use your personal information and are based on the bot in which you see them."; "AdsInfo.Split.Title" = "Help the Channel Creator"; -"AdsInfo.Split.Text" = "50% of the revenue from Telegram Ads goes to the owner of the channel where they are displayed."; +"AdsInfo.Split.Text" = "50% of the revenue from WinterGram Ads goes to the owner of the channel where they are displayed."; "AdsInfo.Bot.Split.Title" = "Help the Bot Creator"; -"AdsInfo.Bot.Split.Text" = "50% of the revenue from Telegram Ads goes to the owner of the bot where they are displayed."; +"AdsInfo.Bot.Split.Text" = "50% of the revenue from WinterGram Ads goes to the owner of the bot where they are displayed."; "AdsInfo.Ads.Title" = "Can Be Removed"; -"AdsInfo.Ads.Text" = "You can turn off ads by subscribing to [Telegram Premium](), and Level %@ channels can remove them for their subscribers."; -"AdsInfo.Bot.Ads.Text" = "You can turn off ads by subscribing to [Telegram Premium]()."; +"AdsInfo.Ads.Text" = "You can turn off ads by subscribing to [WinterGram Premium](), and Level %@ channels can remove them for their subscribers."; +"AdsInfo.Bot.Ads.Text" = "You can turn off ads by subscribing to [WinterGram Premium]()."; "AdsInfo.Launch.Title" = "Can I Launch an Ad?"; -"AdsInfo.Launch.Text" = "Anyone can create ads to display in this channel – with minimal budgets. Check out the Telegram Ad Platform for details. [Learn More >]()"; -"AdsInfo.Bot.Launch.Text" = "Anyone can create ads to display in this bot – with minimal budgets. Check out the Telegram Ad Platform for details. [Learn More >]()"; +"AdsInfo.Launch.Text" = "Anyone can create ads to display in this channel – with minimal budgets. Check out the WinterGram Ad Platform for details. [Learn More >]()"; +"AdsInfo.Bot.Launch.Text" = "Anyone can create ads to display in this bot – with minimal budgets. Check out the WinterGram Ad Platform for details. [Learn More >]()"; "AdsInfo.Launch.Text_URL" = "https://promote.telegram.org"; "AdsInfo.Understood" = "Understood"; @@ -11865,7 +11865,7 @@ Sorry for the inconvenience."; "SendInviteLink.ChannelTextContactsAndPremiumMultipleUsers_1" = "{user_list}, and **%d** more person only accept invitations to channels from contacts and **Premium** users."; "SendInviteLink.ChannelTextContactsAndPremiumMultipleUsers_any" = "{user_list}, and **%d** more people only accept invitations to channels from contacts and **Premium** users."; -"SendInviteLink.SubscribeToPremiumButton" = "Subscribe to Telegram Premium"; +"SendInviteLink.SubscribeToPremiumButton" = "Subscribe to WinterGram Premium"; "SendInviteLink.PremiumOrSendSectionSeparator" = "or"; "SendInviteLink.TextSendInviteLink" = "You can try to send an invite link instead."; @@ -11913,7 +11913,7 @@ Sorry for the inconvenience."; "ChatbotSetup.Recipients.ExcludedListTitle" = "Excluded Chats"; "ChatbotSetup.Recipients.IncludedListTitle" = "Included Chats"; -"ChatbotSetup.ErrorBotNotBusinessCapable" = "This bot doesn't support Telegram Business yet."; +"ChatbotSetup.ErrorBotNotBusinessCapable" = "This bot doesn't support WinterGram Business yet."; "ChatbotSetup.RecipientSummary.ValueEmpty" = "Add"; "ChatbotSetup.RecipientSummary.ValueItems_1" = "1 item"; @@ -11925,10 +11925,10 @@ Sorry for the inconvenience."; "Chat.ToastPhoneNumberCopied" = "Phone number copied to clipboard."; "Chat.SpeedLimitAlert.Download.Title" = "Download speed limited"; -"Chat.SpeedLimitAlert.Download.Text" = "Subscribe to [Telegram Premium]() and increase download speeds %@ times."; +"Chat.SpeedLimitAlert.Download.Text" = "Subscribe to [WinterGram Premium]() and increase download speeds %@ times."; "Chat.SpeedLimitAlert.Upload.Title" = "Upload speed limited"; -"Chat.SpeedLimitAlert.Upload.Text" = "Subscribe to [Telegram Premium]() and increase upload speeds %@ times."; +"Chat.SpeedLimitAlert.Upload.Text" = "Subscribe to [WinterGram Premium]() and increase upload speeds %@ times."; "Chat.BusinessBotMessageTooltip" = "Only you can see that this\nmessage was sent by the bot."; @@ -11952,10 +11952,10 @@ Sorry for the inconvenience."; "Business.AdsTitle" = "ADS IN CHANNELS"; "Business.DontHideAds" = "Do Not Hide Ads"; -"Business.AdsInfo" = "As a Premium subscriber, you don't see any ads on Telegram, but you can turn them on, for example, to view your own ads that you launched on the [Telegram Ad Platform >]()"; +"Business.AdsInfo" = "As a Premium subscriber, you don't see any ads on WinterGram, but you can turn them on, for example, to view your own ads that you launched on the [WinterGram Ad Platform >]()"; "Business.AdsInfo_URL" = "https://promote.telegram.org"; -"ChatList.PremiumGraceTitle" = "⚠️ Don't lose access to Telegram Premium!"; +"ChatList.PremiumGraceTitle" = "⚠️ Don't lose access to WinterGram Premium!"; "ChatList.PremiumGraceText" = "Your exclusive benefits are about to expire."; "Stickers.RemoveFromRecent" = "Remove from Recents"; @@ -12256,19 +12256,19 @@ Sorry for the inconvenience."; "Chat.Context.Phone.CreateNewContact" = "Create New Contact"; "Chat.Context.Phone.AddToExistingContact" = "Add to Existing Contact"; "Chat.Context.Phone.SendMessage" = "Send Message"; -"Chat.Context.Phone.TelegramVoiceCall" = "Telegram Voice Call"; -"Chat.Context.Phone.TelegramVideoCall" = "Telegram Video Call"; -"Chat.Context.Phone.InviteToTelegram" = "Invite to Telegram"; +"Chat.Context.Phone.TelegramVoiceCall" = "WinterGram Voice Call"; +"Chat.Context.Phone.TelegramVideoCall" = "WinterGram Video Call"; +"Chat.Context.Phone.InviteToTelegram" = "Invite to WinterGram"; "Chat.Context.Phone.CallViaCarrier" = "Call via Carrier"; "Chat.Context.Phone.CopyNumber" = "Copy Number"; -"Chat.Context.Phone.NotOnTelegram" = "This number is not on Telegram."; +"Chat.Context.Phone.NotOnTelegram" = "This number is not on WinterGram."; "Chat.Context.Phone.ViewProfile" = "View Profile"; "Chat.Context.Username.SendMessage" = "Send Message"; "Chat.Context.Username.OpenGroup" = "Open Group"; "Chat.Context.Username.OpenChannel" = "Open Channel"; "Chat.Context.Username.Copy" = "Copy Username"; -"Chat.Context.Username.NotOnTelegram" = "This user doesn't exist on Telegram."; +"Chat.Context.Username.NotOnTelegram" = "This user doesn't exist on WinterGram."; "Chat.Context.Hashtag.Search" = "Search"; "Chat.Context.Hashtag.Copy" = "Copy Hashtag"; @@ -12292,8 +12292,8 @@ Sorry for the inconvenience."; "Conversation.ContextMenuAddFactCheck" = "Add Fact Check"; "Conversation.ContextMenuEditFactCheck" = "Edit Fact Check"; -"Stars.Intro.Title" = "Telegram Stars"; -"Stars.Intro.Description" = "Buy Stars to unlock content and services in miniapps on Telegram."; +"Stars.Intro.Title" = "WinterGram Stars"; +"Stars.Intro.Description" = "Buy Stars to unlock content and services in miniapps on WinterGram."; "Stars.Intro.Balance" = "Balance"; "Stars.Intro.YourBalance" = "your balance"; "Stars.Intro.Buy" = "Buy More Stars"; @@ -12314,7 +12314,7 @@ Sorry for the inconvenience."; "Stars.Intro.Transaction.FragmentWithdrawal.Title" = "Withdrawal"; "Stars.Intro.Transaction.FragmentWithdrawal.Subtitle" = "via Fragment"; "Stars.Intro.Transaction.TelegramAds.Title" = "Withdrawal"; -"Stars.Intro.Transaction.TelegramAds.Subtitle" = "via Telegram Ads"; +"Stars.Intro.Transaction.TelegramAds.Subtitle" = "via WinterGram Ads"; "Stars.Intro.Transaction.Gift" = "Gift"; "Stars.Intro.Transaction.ConvertedGift" = "Converted Gift"; "Stars.Intro.Transaction.Unsupported.Title" = "Unsupported"; @@ -12364,7 +12364,7 @@ Sorry for the inconvenience."; "Stars.Transaction.FragmentWithdrawal.Title" = "Stars Withdrawal"; "Stars.Transaction.FragmentWithdrawal.Subtitle" = "Fragment"; "Stars.Transaction.TelegramAds.Title" = "Stars Withdrawal"; -"Stars.Transaction.TelegramAds.Subtitle" = "Telegram Ads"; +"Stars.Transaction.TelegramAds.Subtitle" = "WinterGram Ads"; "Stars.Transaction.Unsupported.Title" = "Unsupported"; "Stars.Transaction.Refund" = "Refund"; @@ -12407,7 +12407,7 @@ Sorry for the inconvenience."; "Chat.MessageEffectMenu.SectionMessageEffects" = "Message Effects"; "Chat.SendMessageMenu.MoveCaptionUp" = "Move Caption Up"; "Chat.SendMessageMenu.MoveCaptionDown" = "Move Caption Down"; -"Chat.SendMessageMenu.ToastPremiumRequired.Text" = "Subscribe to [Telegram Premium]() to add this animated effect."; +"Chat.SendMessageMenu.ToastPremiumRequired.Text" = "Subscribe to [WinterGram Premium]() to add this animated effect."; "BusinessLink.AlertTextLimitText" = "The message text limit is 4096 characters"; @@ -12478,7 +12478,7 @@ Sorry for the inconvenience."; "MediaEditor.Link.LinkName.Title" = "LINK NAME (OPTIONAL)"; "MediaEditor.Link.LinkName.Placeholder" = "Enter a Name"; -"Story.Editor.TooltipLinkPremium" = "Subscribe to [Telegram Premium]() to add links."; +"Story.Editor.TooltipLinkPremium" = "Subscribe to [WinterGram Premium]() to add links."; "Story.Editor.TooltipLinkLimitValue_1" = "**%@** link"; "Story.Editor.TooltipLinkLimitValue_any" = "**%@** links"; @@ -12547,7 +12547,7 @@ Sorry for the inconvenience."; "WebApp.Share" = "Share"; "Stars.Purchase.GiftStars" = "Gift Stars"; -"Stars.Purchase.GiftInfo" = "With Stars, **%1$@** will be able to unlock content and services on Telegram. [See Examples >]()"; +"Stars.Purchase.GiftInfo" = "With Stars, **%1$@** will be able to unlock content and services on WinterGram. [See Examples >]()"; "Stars.Purchase.SubscriptionInfo" = "Buy Stars to subscribe for **%@**."; "Stars.Purchase.SubscriptionRenewInfo" = "Buy Stars to keep your subscription for **%@**."; @@ -12568,8 +12568,8 @@ Sorry for the inconvenience."; "Notification.StarsGift.Title_1" = "%@ Star"; "Notification.StarsGift.Title_any" = "%@ Stars"; -"Notification.StarsGift.Subtitle" = "Use Stars to unlock content and services on Telegram."; -"Notification.StarsGift.SubtitleYou" = "With Stars, %@ will be able to unlock content and services on Telegram."; +"Notification.StarsGift.Subtitle" = "Use Stars to unlock content and services on WinterGram."; +"Notification.StarsGift.SubtitleYou" = "With Stars, %@ will be able to unlock content and services on WinterGram."; "Notification.StarsGift.UnknownUser" = "Unknown user"; "Bot.Settings" = "Bot Settings"; @@ -12582,7 +12582,7 @@ Sorry for the inconvenience."; "Browser.ContextMenu.CopyLink" = "Copy Link"; "Browser.ContextMenu.Share" = "Share"; -"WebBrowser.Telegram" = "Telegram"; +"WebBrowser.Telegram" = "WinterGram"; "Monetization.Proceeds.Ton.Info" = "TON from your total balance can be used for ads or withdrawn as rewards 3 days after they are earned."; "Monetization.Proceeds.Stars.Info" = "Stars from your total balance can be used for ads or withdrawn as rewards 21 days after they are earned."; @@ -12609,10 +12609,10 @@ Sorry for the inconvenience."; "Stars.Intro.StarsSent.ViewChat" = "View Chat"; "Stars.Gift.Received.Title" = "Received Gift"; -"Stars.Gift.Received.Text" = "Use Stars to unlock content and services on Telegram. [See Examples >]()"; +"Stars.Gift.Received.Text" = "Use Stars to unlock content and services on WinterGram. [See Examples >]()"; "Stars.Gift.Sent.Title" = "Sent Gift"; -"Stars.Gift.Sent.Text" = "With Stars, %@ will be able to unlock content and services on Telegram. [See Examples >]()"; +"Stars.Gift.Sent.Text" = "With Stars, %@ will be able to unlock content and services on WinterGram. [See Examples >]()"; "WebBrowser.Reload" = "Reload"; "WebBrowser.Share" = "Share"; @@ -12627,11 +12627,11 @@ Sorry for the inconvenience."; "WebBrowser.AddressBar.ShowMore" = "Show More"; "WebBrowser.OpenLinksIn.Title" = "OPEN LINKS IN"; -"WebBrowser.AutoLogin" = "Auto-Login via Telegram"; -"WebBrowser.AutoLogin.Info" = "Use your Telegram account to automatically log in to websites opened in the in-app browser."; +"WebBrowser.AutoLogin" = "Auto-Login via WinterGram"; +"WebBrowser.AutoLogin.Info" = "Use your WinterGram account to automatically log in to websites opened in the in-app browser."; "WebBrowser.ClearCookies" = "Clear Cookies"; -"WebBrowser.ClearCookies.Info" = "Delete all cookies in the Telegram in-app browser. This action will sign you out of most websites."; +"WebBrowser.ClearCookies.Info" = "Delete all cookies in the WinterGram in-app browser. This action will sign you out of most websites."; "WebBrowser.ClearCookies.Succeed" = "Cookies cleared."; "WebBrowser.Exceptions.Title" = "NEVER OPEN IN THE IN-APP BROWSER"; @@ -12657,7 +12657,7 @@ Sorry for the inconvenience."; "WebBrowser.Done" = "Done"; -"AccessDenied.LocationWeather" = "Telegram needs access to your location so that you can add the weather widget to your stories.\n\nPlease go to Settings > Privacy > Location Services and set Telegram to ON."; +"AccessDenied.LocationWeather" = "WinterGram needs access to your location so that you can add the weather widget to your stories.\n\nPlease go to Settings > Privacy > Location Services and set WinterGram to ON."; "Story.Editor.TooltipWeatherLimitText" = "You can't add more than one weather sticker to a story."; @@ -12683,7 +12683,7 @@ Sorry for the inconvenience."; "PeerInfo.PaneBotPreviews" = "Preview"; "PeerInfo.OpenAppButton" = "Open App"; -"PeerInfo.AppFooterAdmin" = "By publishing this mini app, you agree to the [Telegram Terms of Service for Developers](https://telegram.org/privacy)."; +"PeerInfo.AppFooterAdmin" = "By publishing this mini app, you agree to the [WinterGram Terms of Service for Developers](https://telegram.org/privacy)."; "PeerInfo.AppFooter" = "By launching this mini app, you agree to the [Terms of Service for Mini Apps](https://telegram.org/privacy)."; "BotPreviews.MenuAddPreview" = "Add Preview"; "BotPreviews.MenuReorder" = "Reorder"; @@ -12787,7 +12787,7 @@ Sorry for the inconvenience."; "Stars.Intro.Transaction.SubscriptionFee.Title" = "Monthly Subscription Fee"; "Stars.Intro.Transaction.Reaction.Title" = "Star Reaction"; -"Stars.Purchase.GenericPurchasePurpose" = "Buy Stars to unlock content and services on Telegram."; +"Stars.Purchase.GenericPurchasePurpose" = "Buy Stars to unlock content and services on WinterGram."; "Stars.Purchase.PurchasePurpose.subs" = "Buy Stars to keep all your subscriptions."; "Stars.Transfer.Subscribe.Channel.Title" = "Subscribe"; @@ -12858,7 +12858,7 @@ Sorry for the inconvenience."; "SendStarReactions.TermsOfServiceFooter" = "By sending Stars you agree to the [Terms of Service](https://telegram.org/tos/stars)"; "PeerInfo.AllowedReactions.StarReactions" = "Enable Paid Reactions"; -"PeerInfo.AllowedReactions.StarReactionsFooter" = "Switch this on to let your subscribers set paid reactions with Telegram Stars, which you will be able to withdraw later as TON. [Learn More >](https://telegram.org/privacy)"; +"PeerInfo.AllowedReactions.StarReactionsFooter" = "Switch this on to let your subscribers set paid reactions with WinterGram Stars, which you will be able to withdraw later as TON. [Learn More >](https://telegram.org/privacy)"; "Chat.ToastStarsSent.Title_1" = "Star sent!"; "Chat.ToastStarsSent.Title_any" = "Stars sent!"; @@ -12881,13 +12881,13 @@ Sorry for the inconvenience."; "Notification.StarsPrize" = "You received a gift"; -"Notification.GiveawayStartedStars" = "%1$@ just started a giveaway of %2$@ Telegram Stars for its followers."; -"Notification.GiveawayStartedStarsGroup" = "%1$@ just started a giveaway of %2$@ Telegram Stars for its members."; +"Notification.GiveawayStartedStars" = "%1$@ just started a giveaway of %2$@ WinterGram Stars for its followers."; +"Notification.GiveawayStartedStarsGroup" = "%1$@ just started a giveaway of %2$@ WinterGram Stars for its members."; "Notification.StarsGiveawayStarted" = "%1$@ just started a giveaway of %2$@ for its followers."; "Notification.StarsGiveawayStartedGroup" = "%1$@ just started a giveaway of %2$@ for its members."; -"Notification.StarsGiveawayStarted.Stars_1" = "%@ Telegram Star"; -"Notification.StarsGiveawayStarted.Stars_any" = "%@ Telegram Stars"; +"Notification.StarsGiveawayStarted.Stars_1" = "%@ WinterGram Star"; +"Notification.StarsGiveawayStarted.Stars_any" = "%@ WinterGram Stars"; "Chat.Giveaway.Message.Stars.PrizeText" = "%1$@ will be distributed %2$@."; "Chat.Giveaway.Message.Stars.Stars_1" = "**%@** Stars"; @@ -12916,8 +12916,8 @@ Sorry for the inconvenience."; "BoostGift.NewDescriptionGroup" = "Get more boosts and members for\nyour group by giving away prizes."; "BoostGift.NewDescription" = "Get more boosts and subscribers for\nyour channel by giving away prizes."; "BoostGift.Prize" = "PRIZE"; -"BoostGift.Prize.Premium" = "Telegram Premium"; -"BoostGift.Prize.Stars" = "Telegram Stars"; +"BoostGift.Prize.Premium" = "WinterGram Premium"; +"BoostGift.Prize.Stars" = "WinterGram Stars"; "BoostGift.Stars.Title" = "STARS TO DISTRIBUTE"; "BoostGift.Stars.Boosts_1" = "%@ BOOST"; "BoostGift.Stars.Boosts_any" = "%@ BOOSTS"; @@ -12939,8 +12939,8 @@ Sorry for the inconvenience."; "BoostGift.Group.StarsDateInfo" = "Choose when %@ of your group will be randomly selected to receive Stars."; "BoostGift.StarsDateInfo" = "Choose when %@ of your channel will be randomly selected to receive Stars."; -"BoostGift.PrepaidGiveawayStarsCount_1" = "%@ Telegram Premium"; -"BoostGift.PrepaidGiveawayStarsCount_any" = "%@ Telegram Premium"; +"BoostGift.PrepaidGiveawayStarsCount_1" = "%@ WinterGram Premium"; +"BoostGift.PrepaidGiveawayStarsCount_any" = "%@ WinterGram Premium"; "BoostGift.PrepaidGiveawayStarsMonths" = "%@-month subscriptions"; "WebBrowser.ShowInstantView" = "Show Instant View"; @@ -12975,8 +12975,8 @@ Sorry for the inconvenience."; "Premium.BoostByGiveawayDescription" = "Get more boosts and subscribers for your channel by giving away prizes. [Get boosts >]()"; "Premium.Group.BoostByGiveawayDescription" = "Get more boosts and members for your group by giving away prizes. [Get boosts >]()"; -"Notification.StarsGiveawayResultsNoWinners" = "Due to the giveaway terms, no winners could be selected by Telegram, all stars were credited to channel administrators."; -"Notification.StarsGiveawayResultsNoWinners.Group" = "Due to the giveaway terms, no winners could be selected by Telegram, all stars were credited to group administrators."; +"Notification.StarsGiveawayResultsNoWinners" = "Due to the giveaway terms, no winners could be selected by WinterGram, all stars were credited to channel administrators."; +"Notification.StarsGiveawayResultsNoWinners.Group" = "Due to the giveaway terms, no winners could be selected by WinterGram, all stars were credited to group administrators."; "Notification.StarsGiveaway.Title" = "Congratulations!"; "Notification.StarsGiveaway.Subtitle" = "You won a prize in a giveaway organized by **%1$@**.\n\nYour prize is **%2$@**."; @@ -12993,13 +12993,13 @@ Sorry for the inconvenience."; "SharedMedia.GiftCount_any" = "%@ gifts"; "Stars.Info.Title" = "What are Stars?"; -"Stars.Info.Description" = "Telegram Stars allow users to:"; +"Stars.Info.Description" = "WinterGram Stars allow users to:"; "Stars.Info.Gift.Title" = "Send Gifts to Friends"; "Stars.Info.Gift.Text" = "Give your friends gifts that can be kept on their profiles or converted to Stars."; "Stars.Info.Miniapp.Title" = "Use Stars in Miniapps"; -"Stars.Info.Miniapp.Text" = "Buy additional content and services in Telegram miniapps. [See Examples >]()"; +"Stars.Info.Miniapp.Text" = "Buy additional content and services in WinterGram miniapps. [See Examples >]()"; "Stars.Info.Media.Title" = "Unlock Content in Channels"; -"Stars.Info.Media.Text" = "Get access to paid content and services in Telegram channels."; +"Stars.Info.Media.Text" = "Get access to paid content and services in WinterGram channels."; "Stars.Info.Reaction.Title" = "Send Star Reactions"; "Stars.Info.Reaction.Text" = "Support your favorite channels by sending Star reactions to their posts."; "Stars.Info.Done" = "Got It"; @@ -13063,7 +13063,7 @@ Sorry for the inconvenience."; "Gift.Convert.Success.Text.Stars_any" = "%@ Stars"; "Gift.Options.Premium.Title" = "Gift Premium"; -"Gift.Options.Premium.Text" = "Give **%@** access to exclusive features with Telegram Premium. [See Features >]()"; +"Gift.Options.Premium.Text" = "Give **%@** access to exclusive features with WinterGram Premium. [See Features >]()"; "Gift.Options.Premium.Months_1" = "%@ month"; "Gift.Options.Premium.Months_any" = "%@ months"; "Gift.Options.Premium.Years_1" = "%@ year"; @@ -13144,7 +13144,7 @@ Sorry for the inconvenience."; "Notification.PremiumGift.YearsTitle_any" = "%@ Years Premium"; "Notification.PremiumGift.More" = "more"; -"Notification.PremiumGift.SubscriptionDescription" = "Subscription for exclusive Telegram features."; +"Notification.PremiumGift.SubscriptionDescription" = "Subscription for exclusive WinterGram features."; "Notification.StarsGift.Stars_1" = "%@ Star"; "Notification.StarsGift.Stars_any" = "%@ Stars"; @@ -13185,7 +13185,7 @@ Sorry for the inconvenience."; "Stars.Transaction.TelegramBotApi.Messages_1" = "%@ Message"; "Stars.Transaction.TelegramBotApi.Messages_any" = "%@ Messages"; -"Monetization.Bot.Header" = "Telegram shares 50% of the revenue from ads displayed in your bot. [Learn More >]()"; +"Monetization.Bot.Header" = "WinterGram shares 50% of the revenue from ads displayed in your bot. [Learn More >]()"; "Monetization.Bot.BalanceTitle" = "AVAILABLE BALANCE"; "Resolve.ChannelErrorNotFound" = "Sorry, this channel doesn't seem to exist."; @@ -13395,7 +13395,7 @@ Sorry for the inconvenience."; "AffiliateSetup.IntroNew.Title1" = "Share revenue with affiliates"; "AffiliateSetup.IntroNew.Text1" = "Set the commission for revenue generated by users referred to you."; "AffiliateSetup.IntroNew.Title2" = "Launch your affiliate program"; -"AffiliateSetup.IntroNew.Text2" = "Telegram will feature your program for millions of potential affiliates."; +"AffiliateSetup.IntroNew.Text2" = "WinterGram will feature your program for millions of potential affiliates."; "AffiliateSetup.IntroNew.Title3" = "Let affiliates promote you"; "AffiliateSetup.IntroNew.Text3" = "Affiliates will share your referral link with their audience."; @@ -13510,8 +13510,8 @@ Sorry for the inconvenience."; "Gift.Upgrade.Unique.Description" = "Get a unique number, model, backdrop, and symbol for your gift."; "Gift.Upgrade.Unique.IncludeDescription" = "The recipient will get a unique number, model, backdrop, and symbol for the gift."; "Gift.Upgrade.Transferable.Title" = "Transferable"; -"Gift.Upgrade.Transferable.Description" = "Send your upgraded gift to any of your friends on Telegram."; -"Gift.Upgrade.Transferable.IncludeDescription" = "The recipient will be able to send the gift to anyone Telegram."; +"Gift.Upgrade.Transferable.Description" = "Send your upgraded gift to any of your friends on WinterGram."; +"Gift.Upgrade.Transferable.IncludeDescription" = "The recipient will be able to send the gift to anyone WinterGram."; "Gift.Upgrade.Tradable.Title" = "Tradable"; "Gift.Upgrade.Tradable.Description" = "Sell or auction your gift on third-party NFT marketplaces."; "Gift.Upgrade.Tradable.IncludeDescription" = "The recipient will be able to auction the gift on third-party NFT marketplaces."; @@ -13547,12 +13547,12 @@ Sorry for the inconvenience."; "Gift.Transfer.SendUnlocks.Days_any" = "%@ days"; "Gift.Transfer.UnlockPending.Title" = "Unlocking In Progress"; -"Gift.Transfer.UnlockPending.Text" = "In %@, you'll be able to send this collectible to any TON blockchain address outside Telegram for sale or auction."; +"Gift.Transfer.UnlockPending.Text" = "In %@, you'll be able to send this collectible to any TON blockchain address outside WinterGram for sale or auction."; "Gift.Transfer.UnlockPending.Text.Days_1" = "%@ day"; "Gift.Transfer.UnlockPending.Text.Days_any" = "%@ days"; "Gift.Transfer.UpdateRequired.Title" = "Update Required"; -"Gift.Transfer.UpdateRequired.Text" = "Please update your Telegram application to the latest version."; +"Gift.Transfer.UpdateRequired.Text" = "Please update your WinterGram application to the latest version."; "Gift.Transfer.Confirmation.Title" = "Confirm Transfer"; "Gift.Transfer.Confirmation.TextFree" = "Dou you want to transfer ownership of **%1$@** to **%2$@**?"; @@ -13700,7 +13700,7 @@ Sorry for the inconvenience."; "Gift.View.PutOn" = "You put on %@"; "Gift.View.TookOff" = "You took off %@"; -"Gift.View.TooltipPremiumWearing" = "Subscribe to [Telegram Premium]() to wear collectibles."; +"Gift.View.TooltipPremiumWearing" = "Subscribe to [WinterGram Premium]() to wear collectibles."; "Conversation.AddToContactsLong" = "Add to Contacts"; @@ -13717,7 +13717,7 @@ Sorry for the inconvenience."; "SharedMedia.SimilarBot.Users_any" = "%@ monthly users"; "PeerInfo.SimilarBots.ShowMore" = "Show More Bots"; -"PeerInfo.SimilarBots.ShowMoreInfo" = "Subscribe to [Telegram Premium]()\nto unlock up to **100** similar bots."; +"PeerInfo.SimilarBots.ShowMoreInfo" = "Subscribe to [WinterGram Premium]()\nto unlock up to **100** similar bots."; "PeerInfo.VerifyAccounts" = "Verify Accounts"; @@ -13726,7 +13726,7 @@ Sorry for the inconvenience."; "Gift.View.Context.Transfer" = "Transfer"; "Gift.Withdraw.Title" = "Manage with Fragment"; -"Gift.Withdraw.Text" = "You can use Fragment, a third-party service, to transfer **%@** to your TON account. After that, you can manage it as an NFT with any TON wallet outside Telegram.\n\nYou can also move such NFTs back to your Telegram account via Fragment."; +"Gift.Withdraw.Text" = "You can use Fragment, a third-party service, to transfer **%@** to your TON account. After that, you can manage it as an NFT with any TON wallet outside WinterGram.\n\nYou can also move such NFTs back to your WinterGram account via Fragment."; "Gift.Withdraw.Proceed" = "Open Fragment"; "ChatListFilter.NameEnableAnimations" = "Enable Animations"; @@ -13741,8 +13741,8 @@ Sorry for the inconvenience."; "PeerInfo.Gifts.SendGift" = "Send Gift"; "PeerInfo.Gifts.ChannelNotify" = "Notify About New Gifts"; -"PeerInfo.Gifts.ChannelNotifyTooltip" = "You will receive a message from Telegram when your channel receives a gift."; -"PeerInfo.Gifts.ChannelNotifyDisabledTooltip" = "You will not receive a message from Telegram when your channel receives a gift."; +"PeerInfo.Gifts.ChannelNotifyTooltip" = "You will receive a message from WinterGram when your channel receives a gift."; +"PeerInfo.Gifts.ChannelNotifyDisabledTooltip" = "You will not receive a message from WinterGram when your channel receives a gift."; "Notification.StarsGift.Channel.Sent" = "%1$@ sent a gift to %2$@ for %3$@"; @@ -13830,9 +13830,9 @@ Sorry for the inconvenience."; "Stars.Transfer.Terms" = "By purchasing you agree to the [Terms of Service]()."; "Stars.Transfer.Terms_URL" = "https://telegram.org/tos/stars"; -"AvatarEditor.PremiumNeeded.Background" = "Subscribe to Telegram Premium to choose this background."; -"AvatarEditor.PremiumNeeded.Emoji" = "Subscribe to Telegram Premium to choose this emoji."; -"AvatarEditor.PremiumNeeded.Sticker" = "Subscribe to Telegram Premium to choose this sticker."; +"AvatarEditor.PremiumNeeded.Background" = "Subscribe to WinterGram Premium to choose this background."; +"AvatarEditor.PremiumNeeded.Emoji" = "Subscribe to WinterGram Premium to choose this emoji."; +"AvatarEditor.PremiumNeeded.Sticker" = "Subscribe to WinterGram Premium to choose this sticker."; "PremiumNeeded.Title" = "Premium Needed"; "PremiumNeeded.Subscribe" = "Subscribe"; @@ -13931,11 +13931,11 @@ Sorry for the inconvenience."; "Privacy.Messages.Stars_1" = "%@ Star"; "Privacy.Messages.Stars_any" = "%@ Stars"; -"Privacy.Messages.Unlock" = "Unlock with Telegram Premium"; +"Privacy.Messages.Unlock" = "Unlock with WinterGram Premium"; "Premium.PaidMessages" = "Paid Messages"; "Premium.PaidMessagesInfo" = "Charge a fee for messages from non-contacts or new senders."; -"Premium.PaidMessages.Proceed" = "About Telegram Premium"; +"Premium.PaidMessages.Proceed" = "About WinterGram Premium"; "PeerInfo.Gifts.Reorder" = "Reorder"; "PeerInfo.Gifts.Context.Pin" = "Pin"; @@ -13949,7 +13949,7 @@ Sorry for the inconvenience."; "PeerInfo.Gifts.Context.Transfer" = "Transfer"; "Gift.Send.Premium.Confirmation.Title" = "Send a Gift"; -"Gift.Send.Premium.Confirmation.Text" = "Are you sure you want to gift **Telegram Premium** to **%1$@** for **%2$@**?"; +"Gift.Send.Premium.Confirmation.Text" = "Are you sure you want to gift **WinterGram Premium** to **%1$@** for **%2$@**?"; "Gift.Send.Premium.Confirmation.Text.Stars_1" = "%@ Star"; "Gift.Send.Premium.Confirmation.Text.Stars_any" = "%@ Stars"; "Gift.Send.Premium.Confirmation.Confirm" = "Confirm"; @@ -14038,7 +14038,7 @@ Sorry for the inconvenience."; "Premium.MaxExpiringStoriesTextNumberFormat_any" = "**%d** stories"; "Premium.MaxExpiringStoriesTextPremiumNumberFormat_1" = "**%d** story"; "Premium.MaxExpiringStoriesTextPremiumNumberFormat_any" = "**%d** stories"; -"Premium.MaxExpiringStoriesTextFormat" = "You can post %@ in **24** hours. Subscribe to **Telegram Premium** to increase this limit to **%@**."; +"Premium.MaxExpiringStoriesTextFormat" = "You can post %@ in **24** hours. Subscribe to **WinterGram Premium** to increase this limit to **%@**."; "Premium.MaxExpiringStoriesNoPremiumTextNumberFormat_1" = "**%d** story"; "Premium.MaxExpiringStoriesNoPremiumTextNumberFormat_any" = "**%d** stories"; @@ -14051,7 +14051,7 @@ Sorry for the inconvenience."; "Stars.AccountRevenue.Proceeds.Info" = "Stars from your total balance can be withdrawn as rewards 21 days after they are earned."; "Stars.AccountRevenue.Withdraw.Info" = "You can collect rewards for Stars using Fragment. You cannot withdraw less than 1000 stars. [Learn More >]()"; -"Privacy.Gifts.PremiumToast.Text" = "Subscribe to **Telegram Premium** to use this setting."; +"Privacy.Gifts.PremiumToast.Text" = "Subscribe to **WinterGram Premium** to use this setting."; "Privacy.Gifts.PremiumToast.Action" = "Open"; "Gift.Send.ErrorDisallowed" = "**%@** doesn't accept this kind of gifts."; @@ -14096,7 +14096,7 @@ Sorry for the inconvenience."; "FrozenAccount.Title" = "Your Account is Frozen"; "FrozenAccount.Violation.Title" = "Violation of Terms"; -"FrozenAccount.Violation.Text" = "Your account was frozen for breaking Telegram's Terms and Conditions."; +"FrozenAccount.Violation.Text" = "Your account was frozen for breaking WinterGram's Terms and Conditions."; "FrozenAccount.ReadOnly.Title" = "Read-Only Mode"; "FrozenAccount.ReadOnly.Text" = "You can access your account but can't send messages or take actions."; "FrozenAccount.Appeal.Title" = "Appeal Before Deactivation"; @@ -14105,8 +14105,8 @@ Sorry for the inconvenience."; "FrozenAccount.Understood" = "Understood"; "AdsInfo.Search.Respect.Text" = "Ads like this do not use your personal information and are based on the search query you entered."; -"AdsInfo.Search.Ads.Text" = "You can turn off ads by subscribing to [Telegram Premium]()."; -"AdsInfo.Search.Launch.Text" = "Anyone can create an ad to display in search results for any query. Check out the Telegram Ad Platform for details. [Learn More >]()"; +"AdsInfo.Search.Ads.Text" = "You can turn off ads by subscribing to [WinterGram Premium]()."; +"AdsInfo.Search.Launch.Text" = "Anyone can create an ad to display in search results for any query. Check out the WinterGram Ad Platform for details. [Learn More >]()"; "ChatList.FrozenAccount.Title" = "Your account is frozen"; "ChatList.FrozenAccount.Text" = "Tap to view details and submit an appeal."; @@ -14120,18 +14120,18 @@ Sorry for the inconvenience."; "Login.Fee.Title" = "SMS Fee"; "Login.Fee.SmsCost.Title" = "High SMS Costs"; -"Login.Fee.SmsCost.Text" = "Telecom providers in your country (%@) charge Telegram very high prices for SMS."; +"Login.Fee.SmsCost.Text" = "Telecom providers in your country (%@) charge WinterGram very high prices for SMS."; "Login.Fee.Verification.Title" = "Verification Required"; -"Login.Fee.Verification.Text" = "Telegram needs to send you an SMS with a verification code to confirm your phone number."; -"Login.Fee.Support.Title" = "Support via [Telegram Premium >]()"; -"Login.Fee.Support.Text" = "Sign up for a 1-week Telegram Premium subscription to help cover the SMS costs."; +"Login.Fee.Verification.Text" = "WinterGram needs to send you an SMS with a verification code to confirm your phone number."; +"Login.Fee.Support.Title" = "Support via [WinterGram Premium >]()"; +"Login.Fee.Support.Text" = "Sign up for a 1-week WinterGram Premium subscription to help cover the SMS costs."; "Login.Fee.SignUp" = "Sign Up for %@"; -"Login.Fee.GetPremiumForAWeek" = "Get Telegram Premium for 1 week"; +"Login.Fee.GetPremiumForAWeek" = "Get WinterGram Premium for 1 week"; "StoryFeed.AddStory" = "Add Story"; "WebApp.ImportData.Title" = "Import Data"; -"WebApp.ImportData.Description" = "**%@** is requesting permission to import data from a previous Telegram account used on this device."; +"WebApp.ImportData.Description" = "**%@** is requesting permission to import data from a previous WinterGram account used on this device."; "WebApp.ImportData.AccountHeader" = "ACCOUNT TO IMPORT DATA FROM"; "WebApp.ImportData.CreatedOn" = "created on %@"; "WebApp.ImportData.Import" = "Import"; @@ -14145,10 +14145,10 @@ Sorry for the inconvenience."; "VideoChat.RevokeLink" = "Revoke Link"; -"InviteLink.GroupCallLinkHelp" = "Anyone on Telegram can join your call by following the link below."; +"InviteLink.GroupCallLinkHelp" = "Anyone on WinterGram can join your call by following the link below."; "InviteLink.CallLinkTitle" = "Call Link"; "InviteLink.CreatedGroupCallFooter" = "Be the first to join the call and add people from there. [Open Call >](open_call)"; -"InviteLink.QRCode.InfoGroupCall" = "Everyone on Telegram can scan this code to join your group call."; +"InviteLink.QRCode.InfoGroupCall" = "Everyone on WinterGram can scan this code to join your group call."; "Call.GenericGroupCallTitle" = "Group Call"; @@ -14320,7 +14320,7 @@ Sorry for the inconvenience."; "Premium.CreateMultipleStories" = "Create Multiple Stories"; -"FrozenAccount.Violation.TextNew" = "Your account was frozen for breaking Telegram's [Terms and Conditions]()."; +"FrozenAccount.Violation.TextNew" = "Your account was frozen for breaking WinterGram's [Terms and Conditions]()."; "FrozenAccount.Violation.TextNew_URL" = "https://telegram.org/tos"; "Stars.Purchase.BuyStarGiftInfo" = "Buy Stars to acquire a unique collectible."; @@ -14364,7 +14364,7 @@ Sorry for the inconvenience."; "Chat.TrimVoiceMessageToResume.Text" = "Audio outside that range will be discarded, and recording will start immediately."; "Chat.TrimVoiceMessageToResume.Proceed" = "Proceed"; -"Contacts.LimitedAccess.Text" = "You have limited Telegram from accessing all of your contacts."; +"Contacts.LimitedAccess.Text" = "You have limited WinterGram from accessing all of your contacts."; "Contacts.LimitedAccess.Manage" = "MANAGE"; "Media.PhotoHdOn" = "The photo will be sent in high quality."; @@ -14517,7 +14517,7 @@ Sorry for the inconvenience."; "Chat.Todo.ContextMenu.SectionList" = "List"; "Chat.Todo.ContextMenu.SectionsInfo" = "You're viewing actions for one task.\nYou can switch to actions for the list."; -"Chat.Todo.PremiumRequired" = "Only [Telegram Premium]() subscribers can mark tasks as done."; +"Chat.Todo.PremiumRequired" = "Only [WinterGram Premium]() subscribers can mark tasks as done."; "Chat.Todo.CompletionLimited" = "%@ has restricted others from editing this checklist."; "Chat.Todo.CompletionLimitedForward" = "You can’t make changes to forwarded checklists."; @@ -14608,10 +14608,10 @@ Sorry for the inconvenience."; "BalanceNeeded.FragmentSubtitle" = "You can add funds to your balance via the third-party platform Fragment."; "BalanceNeeded.FragmentAction" = "Add Funds via Fragment"; -"Settings.Ton.Description" = "Use TON to submit post suggestions to channels on Telegram."; +"Settings.Ton.Description" = "Use TON to submit post suggestions to channels on WinterGram."; "Settings.TransactionsTon.Title" = "TON"; -"Settings.TransactionsTon.Subtitle" = "Use TON to submit post suggestions to channels on Telegram."; +"Settings.TransactionsTon.Subtitle" = "Use TON to submit post suggestions to channels on WinterGram."; "Chat.DeletePaidMessageStars.Title" = "Stars Will Be Lost"; "Chat.DeletePaidMessageStars.Text" = "You won't receive **Stars** for this post if you delete it now. The post must remain visible for at least **24 hours** after publication."; @@ -14675,13 +14675,13 @@ Sorry for the inconvenience."; "Stars.SellGiftMinAmountToast.Text" = "You cannot sell gift for less than %@"; "Premium.Week.SignUp" = "Sign up for %@"; -"Premium.Week.SignUpInfo" = "Get Telegram Premium for 1 week"; +"Premium.Week.SignUpInfo" = "Get WinterGram Premium for 1 week"; "Settings.MyTon" = "My TON"; -"Stars.Gift.Ton.Text" = "Use TON to submit post suggestions to channels on Telegram."; +"Stars.Gift.Ton.Text" = "Use TON to submit post suggestions to channels on WinterGram."; "Stars.Ton.Title" = "TON"; -"Stars.Ton.Description" = "Use TON to submit post suggestions to channels on Telegram."; +"Stars.Ton.Description" = "Use TON to submit post suggestions to channels on WinterGram."; "Notification.Ton.Subtitle" = "Use TON to submit post suggestions to channels."; "Notification.Ton.SubtitleYou" = "With TON, %@ will be able to submit post suggestions to channels."; @@ -14709,7 +14709,7 @@ Sorry for the inconvenience."; "Chat.SensitiveContentShort" = "18+"; -"AccessDenied.AgeVerificationCamera" = "Telegram needs access to your camera for age verification.\n\nOpen your device's Settings > Privacy > Camera and set Telegram to ON."; +"AccessDenied.AgeVerificationCamera" = "WinterGram needs access to your camera for age verification.\n\nOpen your device's Settings > Privacy > Camera and set WinterGram to ON."; "PeerInfo.Gifts.Collections.All" = "All Gifts"; "PeerInfo.Gifts.Collections.Add" = "Add Collection"; @@ -14756,8 +14756,8 @@ Sorry for the inconvenience."; "Gift.Options.Gift.BuyLimitReached_any" = "You've already sent %@ of these gifts, and it's the limit."; "AgeVerification.Title" = "Age Verification"; -"AgeVerification.Text" = "To access this content, you must confirm you are at least **18** years old.\n\nThis is a one-time process using your phone's camera. Your selfie will not be stored by Telegram."; -"AgeVerification.Text.GB" = "To access this content, you must confirm you are at least **18** years old as required by UK law.\n\nThis is a one-time process using your phone's camera. Your selfie will not be stored by Telegram."; +"AgeVerification.Text" = "To access this content, you must confirm you are at least **18** years old.\n\nThis is a one-time process using your phone's camera. Your selfie will not be stored by WinterGram."; +"AgeVerification.Text.GB" = "To access this content, you must confirm you are at least **18** years old as required by UK law.\n\nThis is a one-time process using your phone's camera. Your selfie will not be stored by WinterGram."; "AgeVerification.Verify" = "Verify My Age"; "AgeVerification.Unavailable.Title" = "18+"; @@ -14793,7 +14793,7 @@ Sorry for the inconvenience."; "Gift.Buy.PayInTon.Tooltip" = "Pay with TON to skip the 21-day wait before transferring the gift again."; "Premium.PremiumGift.Title" = "Premium Gift"; -"Premium.PremiumGift.Description" = "Subscribe to **Telegram Premium** to send up to **5** of these gifts and unlock access to multiple additional features."; +"Premium.PremiumGift.Description" = "Subscribe to **WinterGram Premium** to send up to **5** of these gifts and unlock access to multiple additional features."; "Stars.SellGift.TonAmountTitle" = "PRICE IN TON"; "Stars.SellGift.OnlyTon" = "Only Accept TON"; @@ -14849,7 +14849,7 @@ Sorry for the inconvenience."; "Stories.DeleteAlbum.Confirmation" = "Delete %@?"; "ProfileLevelInfo.Title" = "Rating"; -"ProfileLevelInfo.MyText" = "The rating reflects your activity on Telegram. What affects it:"; +"ProfileLevelInfo.MyText" = "The rating reflects your activity on WinterGram. What affects it:"; "ProfileLevelInfo.MyDescriptionToday_1" = "The rating updates today.\n\%@ point is pending."; "ProfileLevelInfo.MyDescriptionToday_any" = "The rating updates today.\n\%@ points are pending."; "ProfileLevelInfo.MyDescriptionDays_1" = "%@ day"; @@ -14866,13 +14866,13 @@ Sorry for the inconvenience."; "ProfileLevelInfo.MyDescriptionInPreviewToday_1" = "This will be your rating today,\n after %@ is added. [Back >]()"; "ProfileLevelInfo.MyDescriptionInPreviewToday_any" = "This will be your rating today,\n after %@ added. [Back >]()"; -"ProfileLevelInfo.OtherDescription" = "The rating reflects **%@'s** activity on Telegram. What affects it:"; +"ProfileLevelInfo.OtherDescription" = "The rating reflects **%@'s** activity on WinterGram. What affects it:"; "ProfileLevelInfo.LevelIndex_1" = "Level %@"; "ProfileLevelInfo.LevelIndex_any" = "Level %@"; "ProfileLevelInfo.CloseButton" = "Understood"; -"ProfileLevelInfo.Item0.Title" = "Gifts from Telegram"; -"ProfileLevelInfo.Item0.Text" = "100% of the Stars spent on gifts purchased from Telegram."; +"ProfileLevelInfo.Item0.Title" = "Gifts from WinterGram"; +"ProfileLevelInfo.Item0.Text" = "100% of the Stars spent on gifts purchased from WinterGram."; "ProfileLevelInfo.Item0.Badge" = "ADDED"; "ProfileLevelInfo.Item1.Title" = "Gifts and Posts from Users"; "ProfileLevelInfo.Item1.Text" = "20% of the Stars spent on gifts or posts from users and channels."; @@ -14906,7 +14906,7 @@ Sorry for the inconvenience."; "Gift.Upgrade.GiftTitle" = "Make Unique"; "Gift.Upgrade.GiftDescription" = "Let %@ turn this gift into a unique collectible."; "Gift.Upgrade.Unique.GiftDescription" = "%@ will get a unique number, model, backdrop, and symbol for the gift."; -"Gift.Upgrade.Transferable.GiftDescription" = "%@ will be able to send the gift to anyone Telegram."; +"Gift.Upgrade.Transferable.GiftDescription" = "%@ will be able to send the gift to anyone WinterGram."; "Gift.Upgrade.Tradable.GiftDescription" = "%@ will be able to auction the gift on third-party NFT marketplaces."; "Notification.StarGift.Subtitle.Upgrade.Prepaid" = "%@ can now turn this gift to a unique collectible"; @@ -14988,13 +14988,13 @@ Sorry for the inconvenience."; "Gift.Upgrade.Skip" = "Skip"; "Gift.Upgrade.UpgradeNext" = "Upgrade Next Gift"; -"Gift.Value.DescriptionAveragePrice" = "This is the average sale price of **%@** on Telegram and Fragment over the past month."; +"Gift.Value.DescriptionAveragePrice" = "This is the average sale price of **%@** on WinterGram and Fragment over the past month."; "Gift.Value.DescriptionLastPriceFragment" = "This is the last price at which **%@** was last sold on Fragment."; -"Gift.Value.DescriptionLastPriceTelegram" = "This is the last price at which **%@** was last sold on Telegram."; +"Gift.Value.DescriptionLastPriceTelegram" = "This is the last price at which **%@** was last sold on WinterGram."; -"Gift.Value.LastPriceInfo" = "**%1$@** is the last price for %2$@ gifts listed on Telegram and Fragment."; -"Gift.Value.MinimumPriceInfo" = "**%1$@** is the floor price for %2$@ gifts listed on Telegram and Fragment."; -"Gift.Value.AveragePriceInfo" = "**%1$@** is the average sale price for %2$@ gifts listed on Telegram and Fragment over the past month."; +"Gift.Value.LastPriceInfo" = "**%1$@** is the last price for %2$@ gifts listed on WinterGram and Fragment."; +"Gift.Value.MinimumPriceInfo" = "**%1$@** is the floor price for %2$@ gifts listed on WinterGram and Fragment."; +"Gift.Value.AveragePriceInfo" = "**%1$@** is the average sale price for %2$@ gifts listed on WinterGram and Fragment over the past month."; "Gift.Value.InitialSale" = "Initial Sale"; "Gift.Value.InitialPrice" = "Initial Price"; @@ -15002,7 +15002,7 @@ Sorry for the inconvenience."; "Gift.Value.LastPrice" = "Last Price"; "Gift.Value.MinimumPrice" = "Minimum Price"; "Gift.Value.AveragePrice" = "Average Price"; -"Gift.Value.ForSaleOnTelegram" = "for sale on Telegram"; +"Gift.Value.ForSaleOnTelegram" = "for sale on WinterGram"; "Gift.Value.ForSaleOnFragment" = "for sale on Fragment"; "Gift.View.Context.SetAsTheme" = "Set as Theme in..."; @@ -15036,7 +15036,7 @@ Locale: %3$@ Target phone: %4$@ App: %5$@ -App version: %6$@ +App version: %6$@ Issue: %7$@ Error: %8$@"; @@ -15129,10 +15129,10 @@ Error: %8$@"; "MESSAGE_SUGGEST_BIRTHDAY" = "%@ suggested you your birthday"; "Gift.UnavailableAction.Title" = "Action Locked"; -"Gift.UnavailableAction.Text" = "Transfer this gift to your Telegram account on Fragment to unlock this action."; +"Gift.UnavailableAction.Text" = "Transfer this gift to your WinterGram account on Fragment to unlock this action."; "Gift.UnavailableAction.OpenFragment" = "Open Fragment"; -"Gift.Unique.Telegram" = "Telegram"; +"Gift.Unique.Telegram" = "WinterGram"; "VoiceChat.ContextEnableMessages" = "Enable Messages"; "VoiceChat.ContextDisableMessages" = "Disable Messages"; @@ -15151,7 +15151,7 @@ Error: %8$@"; "ProfileLevelInfo.NegativeRating" = "Negative rating"; -"Notification.StarsGift.Assigned" = "You started displaying %@ on your Telegram profile page."; +"Notification.StarsGift.Assigned" = "You started displaying %@ on your WinterGram profile page."; "Gift.View.SenderInfo" = "**%@** sent you this gift on **%@**."; @@ -15196,7 +15196,7 @@ Error: %8$@"; "Gift.Setup.AuctionInfo.Bidders_1" = "%@ bidder"; "Gift.Setup.AuctionInfo.Bidders_any" = "%@ bidders"; -"PrivacySettings.LoginEmailSetupInfo" = "Setup your email address for Telegram login codes."; +"PrivacySettings.LoginEmailSetupInfo" = "Setup your email address for WinterGram login codes."; "LoginEmail.Title" = "Add Email"; "LoginEmail.Description" = "Please add your email address to keep access to your account."; @@ -15223,7 +15223,7 @@ Error: %8$@"; "Gift.Auction.TimeLeftMinutes" = "{m}:{s} left"; "Gift.Auction.ItemsBought_1" = "item bought"; "Gift.Auction.ItemsBought_any" = "items bought"; -"Gift.Auction.ForSaleOnTelegram" = "for sale on Telegram"; +"Gift.Auction.ForSaleOnTelegram" = "for sale on WinterGram"; "Gift.Auction.ForSaleOnFragment" = "for sale on Fragment"; "Gift.Auction.AveragePriceInfo" = "**%1$@** is the average sale price for %2$@ gifts."; "Gift.Auction.Stars_1" = "%@ Star"; @@ -15363,9 +15363,9 @@ Error: %8$@"; "LiveStreamSettings.SaveSettings" = "Save Settings"; "AddContact.Title" = "New Contact"; -"AddContact.PhoneNumber.IsContact"= "This phone number is already in your contacts. [View >]()"; -"AddContact.PhoneNumber.Registered"= "This phone number is on Telegram."; -"AddContact.PhoneNumber.NotRegistered"= "This phone number is not on Telegram. [Invite >]()"; +"AddContact.PhoneNumber.IsContact" = "This phone number is already in your contacts. [View >]()"; +"AddContact.PhoneNumber.Registered" = "This phone number is on WinterGram."; +"AddContact.PhoneNumber.NotRegistered" = "This phone number is not on WinterGram. [Invite >]()"; "AddContact.SyncToPhone" = "Sync Contact to Phone"; "AddContact.NotePlaceholder" = "Add notes only visible to you"; "AddContact.AddQR" = "Add via QR Code"; @@ -15382,7 +15382,7 @@ Error: %8$@"; "ScheduleMessage.RepeatPeriod.Yearly" = "Yearly"; "ScheduleMessage.PremiumRequired.Title" = "Premium Required"; -"ScheduleMessage.PremiumRequired.Text" = "Subscribe to **Telegram Premium** to schedule repeating messages."; +"ScheduleMessage.PremiumRequired.Text" = "Subscribe to **WinterGram Premium** to schedule repeating messages."; "ScheduleMessage.PremiumRequired.Add" = "Add"; "Stars.Transaction.GiftAuctionBid" = "Gift Auction Bid"; @@ -15567,11 +15567,11 @@ Error: %8$@"; "Passkeys.ListFooter" = "Your passkeys are stored securely in your password manager."; "Gift.Demo.Title" = "Unique Collectibles"; -"Gift.Demo.Description" = "Telegram Gifts are collectible items you can trade or showcase on your profile."; +"Gift.Demo.Description" = "WinterGram Gifts are collectible items you can trade or showcase on your profile."; "Gift.Demo.Unique.Title" = "Unique"; "Gift.Demo.Unique.Text" = "Upgrade your gifts to get a unique number, model, backdrop and symbol."; "Gift.Demo.Tradable.Title" = "Tradable"; -"Gift.Demo.Tradable.Text" = "Sell your gift on Telegram or on third-party NFT marketplaces."; +"Gift.Demo.Tradable.Text" = "Sell your gift on WinterGram or on third-party NFT marketplaces."; "Gift.Demo.Wearable.Title" = "Wearable"; "Gift.Demo.Wearable.Text" = "Display gifts on your page and set them as profile covers or statuses."; "Gift.Demo.Understood" = "Understood"; @@ -15596,7 +15596,7 @@ Error: %8$@"; "Gift.Auction.GiftAuction" = "Gift Auction"; "Gift.Auction.UpcomingAuction" = "Upcoming Auction"; -"Gift.Auction.LearnMore" = "Learn more about Telegram Gifts >"; +"Gift.Auction.LearnMore" = "Learn more about WinterGram Gifts >"; "Gift.AuctionBid.UpcomingTitle" = "Place an Early Bid"; @@ -15621,7 +15621,7 @@ Error: %8$@"; "Gift.WearPreview.Limited" = "limited"; "Gift.WearPreview.Upgraded" = "upgraded"; "Gift.WearPreview.FreeUpgrade" = "Free\nUpgrade"; -"Gift.WearPreview.LearnMore" = "Learn more about wearing Telegram Gifts >"; +"Gift.WearPreview.LearnMore" = "Learn more about wearing WinterGram Gifts >"; "Notification.StarGift.Sold" = "sold"; "Notification.StarGiftOffer.Offer" = "**%1$@** offered you **%2$@** for your gift **%3$@**."; @@ -15665,7 +15665,7 @@ Error: %8$@"; "CocoonInfo.Private.Title" = "Private"; "CocoonInfo.Private.Text" = "No third party can access any data, such as translations, inside Cocoon."; "CocoonInfo.Efficient.Title" = "Efficient"; -"CocoonInfo.Efficient.Text" = "Cocoon has allowed Telegram to reduce translation costs by 6x."; +"CocoonInfo.Efficient.Text" = "Cocoon has allowed WinterGram to reduce translation costs by 6x."; "CocoonInfo.ForEveryone.Title" = "For Everyone"; "CocoonInfo.ForEveryone.Text" = "Any developer can use Cocoon for AI features. Learn more at [@cocoon](telegram) or [cocoon.org](web)."; "CocoonInfo.IntergrateInfo" = "Want to integrate Cocoon into your projects?\nReach out at [t.me/cocoon?direct]()"; @@ -15682,7 +15682,7 @@ Error: %8$@"; "Conversation.Dice.Change" = "change"; "EmojiStake.Title" = "Emoji Stake"; -"EmojiStake.Description" = "An experimental feature for Telegram Premium users."; +"EmojiStake.Description" = "An experimental feature for WinterGram Premium users."; "EmojiStake.ResultsTitle" = "RESULTS AND RETURNS"; "EmojiStake.StreakInfo" = "A streak resets after 3 # or a stake change."; "EmojiStake.StakeTitle" = "STAKE"; @@ -15693,7 +15693,7 @@ Error: %8$@"; "Conversation.Summary.Text" = "Tap to see original text"; "Conversation.Summary.Limit.Title" = "AI Summary"; -"Conversation.Summary.Limit.Text" = "Summarize large messages with AI – unlimited with Telegram Premium."; +"Conversation.Summary.Limit.Text" = "Summarize large messages with AI – unlimited with WinterGram Premium."; "Appearance.SendWithCmdEnter" = "Send Messages with ⌘+Enter"; @@ -15760,8 +15760,8 @@ Error: %8$@"; "AuthConfirmation.Description" = "This site will receive your **name**,\n**username** and **profile photo**."; "AuthConfirmation.DescriptionApp" = "This app will receive your **name**,\n**username** and **profile photo**."; "AuthConfirmation.Device" = "Device"; -"AuthConfirmation.IpAddress" ="IP Address"; -"AuthConfirmation.Info" ="This login attempt came from the device above."; +"AuthConfirmation.IpAddress" = "IP Address"; +"AuthConfirmation.Info" = "This login attempt came from the device above."; "AuthConfirmation.AllowMessages" = "Allow Messages"; "AuthConfirmation.AllowMessagesInfo" = "This will allow %@ to message you."; "AuthConfirmation.Cancel" = "Cancel"; @@ -15871,8 +15871,8 @@ Error: %8$@"; "Gift.Craft.UnavailableBlockchain.Text" = "This gift can't be used as the first one in crafting because it has been recorded on the blockchain."; "AuthConfirmation.Emoji.Title" = "Tap the emoji shown\non your other device"; -"AuthConfirmation.Emoji.Description" = "Telegram wants to make sure it's really you."; -"AuthConfirmation.Emoji.DescriptionFirst" = "Telegram wants to make sure it's really you trying to log in to **%@**."; +"AuthConfirmation.Emoji.Description" = "WinterGram wants to make sure it's really you."; +"AuthConfirmation.Emoji.DescriptionFirst" = "WinterGram wants to make sure it's really you trying to log in to **%@**."; "Chat.TagPlaceholder" = "Tag"; @@ -16167,7 +16167,7 @@ Error: %8$@"; "TextProcessing.ActionApply" = "Apply"; "TextProcessing.ActionClose" = "Close"; "TextProcessing.LimitToast.Title" = "Daily limit reached"; -"TextProcessing.LimitToast.Text" = "Get **Telegram Premium** for **50x** more text edits per day."; +"TextProcessing.LimitToast.Text" = "Get **WinterGram Premium** for **50x** more text edits per day."; "TextProcessing.Emojify" = "Emojify"; "TextProcessing.TextExpand" = "more"; "TextProcessing.TextCollapse" = "less"; @@ -16225,7 +16225,7 @@ Error: %8$@"; "Attachment.ChatsMusic" = "YOUR CHATS"; "Attachment.PublicMusic" = "PUBLIC CHATS"; -"PeerInfo.UnofficialSecurityRisk" = "%@ uses an unofficial Telegram client – messages to this user may be less secure."; +"PeerInfo.UnofficialSecurityRisk" = "%@ uses an unofficial WinterGram client – messages to this user may be less secure."; "Gallery.Live" = "LIVE"; @@ -16277,18 +16277,18 @@ Error: %8$@"; "Chat.AdminAction.ToastReactionsDeletedTextMultiple" = "Reactions Deleted."; "Chat.AdminAction.ToastMessagesAndReactionsDeletedText" = "Messages and reactions deleted."; -"Premium.SignUp.SignUpNewInfo" = "Get Telegram Premium for %@"; +"Premium.SignUp.SignUpNewInfo" = "Get WinterGram Premium for %@"; "Premium.SignUp.SignUpNewInfo.Days_1" = "%@ day"; "Premium.SignUp.SignUpNewInfo.Days_any" = "%@ days"; -"Premium.SignUp.SignUpNewInfoNone" = "Get Telegram Premium"; +"Premium.SignUp.SignUpNewInfoNone" = "Get WinterGram Premium"; "Login.Fee.Support.NewText.Days_1" = "%@ day"; "Login.Fee.Support.NewText.Days_any" = "%@ days"; -"Login.Fee.Support.NewText" = "Sign up for a %@ Telegram Premium subscription to help cover the SMS costs."; -"Login.Fee.Support.NewTextNone" = "Sign up for Telegram Premium subscription to help cover the SMS costs."; +"Login.Fee.Support.NewText" = "Sign up for a %@ WinterGram Premium subscription to help cover the SMS costs."; +"Login.Fee.Support.NewTextNone" = "Sign up for WinterGram Premium subscription to help cover the SMS costs."; -"Login.Fee.GetPremiumNone" = "Get Telegram Premium"; -"Login.Fee.GetPremiumForDays" = "Get Telegram Premium for %@"; +"Login.Fee.GetPremiumNone" = "Get WinterGram Premium"; +"Login.Fee.GetPremiumForDays" = "Get WinterGram Premium for %@"; "Login.Fee.GetPremiumForDays.Days_1" = "%@ day"; "Login.Fee.GetPremiumForDays.Days_any" = "%@ days"; @@ -16364,7 +16364,7 @@ Error: %8$@"; "SocksProxySetup.QrCode.TgLink" = "tg:// link"; "SocksProxySetup.QrCode.TMeLink" = "t.me link"; -"WebBrowser.OpenLinksInfo" = "Open links inside Telegram instead of your default browser for more privacy."; +"WebBrowser.OpenLinksInfo" = "Open links inside WinterGram instead of your default browser for more privacy."; "WebBrowser.Exceptions.OpenInApp" = "OPEN IN-APP"; "WebBrowser.Exceptions.DontOpenInApp" = "DON'T OPEN IN-APP"; "WebBrowser.Exceptions.InAppInfo" = "These sites will still be opened in-app."; @@ -16373,3 +16373,201 @@ Error: %8$@"; "RichTextPreview.Formula" = "[formula]"; "RichTextPreview.Table" = "[table]"; "RichTextPreview.Music" = "Music"; + +/* WinterGram fork strings (auto-managed) */ +"WinterGram.APIHash" = "API Hash"; +"WinterGram.APIID" = "API ID"; +"WinterGram.APPEARANCE" = "APPEARANCE"; +"WinterGram.AddVisualGift" = "Add Visual Gift"; +"WinterGram.AddVisuallyToProfile" = "Add visually to profile"; +"WinterGram.AllowSavingRestrictedContent" = "Allow Saving Restricted Content"; +"WinterGram.AllowScreenshotsEverywhere" = "Allow Screenshots Everywhere"; +"WinterGram.Always" = "Always"; +"WinterGram.Android" = "Android"; +"WinterGram.Appearance" = "Appearance"; +"WinterGram.ApplyToBubbles" = "Apply to Bubbles"; +"WinterGram.ApplyToChatList" = "Apply to Chat List"; +"WinterGram.ApplyToNavigationBars" = "Apply to Navigation Bars"; +"WinterGram.ApplyToTabBar" = "Apply to Tab Bar"; +"WinterGram.AutoMarkAsRead" = "Auto Mark as Read"; +"WinterGram.AutoPrivacy" = "Auto Privacy"; +"WinterGram.StashPrivacy.ProfilePhoto" = "Profile Photo"; +"WinterGram.StashPrivacy.PhoneNumber" = "Phone Number"; +"WinterGram.StashPrivacy.Presence" = "Last Seen & Online"; +"WinterGram.StashPrivacy.Forwards" = "Forwarded Messages"; +"WinterGram.StashPrivacy.VoiceCalls" = "Calls"; +"WinterGram.StashPrivacy.Birthday" = "Birthday"; +"WinterGram.StashPrivacy.GiftsAutoSave" = "Gifts"; +"WinterGram.StashPrivacy.Bio" = "Bio"; +"WinterGram.StashPrivacy.SavedMusic" = "Saved Music"; +"WinterGram.StashPrivacy.GroupInvitations" = "Group Invitations"; +"WinterGram.Automatic" = "Automatic"; +"WinterGram.AvatarShape" = "Avatar Shape"; +"WinterGram.AyuGram" = "Ayu"; +"WinterGram.CoreMenu" = "Core"; +"WinterGram.Core" = "Core"; +"WinterGram.BadgeDeveloperRole" = "is a WinterGram developer."; +"WinterGram.BadgeDeveloperSuffix" = "supported WinterGram development and received a unique badge."; +"WinterGram.BadgeOfficialChannelSuffix" = "is the official WinterGram resource."; +"WinterGram.Beta" = "Beta"; +"WinterGram.BotAPI" = "Bot API"; +"WinterGram.BubbleRadius" = "Bubble Radius"; +"WinterGram.CHAT" = "CHAT"; +"WinterGram.AddTemplate" = "Add Template"; +"WinterGram.Channel" = "Channel"; +"WinterGram.Chat" = "Chat"; +"WinterGram.MutualContact" = "Mutual contact"; +"WinterGram.NotMutualContact" = "Not a mutual contact"; +"WinterGram.Other" = "Other"; +"WinterGram.ClearSavedDeletedMessages" = "Clear Saved Deleted Messages"; +"WinterGram.ConfirmGIFs" = "Confirm GIFs"; +"WinterGram.ConfirmStickers" = "Confirm Stickers"; +"WinterGram.ConfirmVoiceMessages" = "Confirm Voice Messages"; +"WinterGram.ConfirmStoryView" = "Confirm Story View"; +"WinterGram.Custom" = "Custom..."; +"WinterGram.CustomFont" = "Custom Font"; +"WinterGram.Default" = "Default"; +"WinterGram.Disabled" = "Disabled"; +"WinterGram.DefaultReaction" = "Default Reaction"; +"WinterGram.DefaultRealDevice" = "Default (Real Device)"; +"WinterGram.DeletedAndEditedMessagesAreKeptLocallyOnThisDeviceOnly" = "Deleted and edited messages are kept locally on this device only."; +"WinterGram.DeletedMark" = "Deleted Marker"; +"WinterGram.NFTGift" = "NFT"; +"WinterGram.RegularGift" = "Regular Gift"; +"WinterGram.Desktop" = "Desktop"; +"WinterGram.DimDeletedMessages" = "Dim Deleted Messages"; +"WinterGram.ShowDeletionTime" = "Show Deletion Time"; +"WinterGram.TopBanner" = "Top Banner"; +"WinterGram.Solid" = "Solid"; +"WinterGram.Glass" = "Glass"; +"WinterGram.Gradient" = "Gradient"; +"WinterGram.Outline" = "Outline"; +"WinterGram.DisableAds" = "Disable Ads"; +"WinterGram.DisableOpenLinkWarning" = "Disable Open Link Warning"; +"WinterGram.DoNotChange" = "Do Not Change"; +"WinterGram.EmptyNoPasscode" = "Empty = no passcode"; +"WinterGram.EnterNFTLinkEGHttpsTMeNftSlug" = "Enter NFT link (e.g. https://t.me/nft/slug)"; +"WinterGram.EnterPasscode" = "Enter Passcode"; +"WinterGram.Error" = "Error"; +"WinterGram.InvalidNFTLink" = "Invalid NFT link"; +"WinterGram.Everything" = "Everything"; +"WinterGram.ExteraGram" = "exteraGram"; +"WinterGram.FEATURES" = "FEATURES"; +"WinterGram.Features" = "Features"; +"WinterGram.ForwardWithoutAuthor" = "Forward Without Author"; +"WinterGram.GHOSTMODE" = "GHOST MODE"; +"WinterGram.GhostMode" = "Ghost Mode"; +"WinterGram.GiftAddedVisuallyToProfile" = "Gift added visually to profile"; +"WinterGram.GitHub" = "GitHub"; +"WinterGram.Google" = "Google"; +"WinterGram.HIDDENARCHIVE" = "HIDDEN ARCHIVE"; +"WinterGram.HISTORY" = "HISTORY"; +"WinterGram.Hidden" = "Hidden"; +"WinterGram.HiddenArchive" = "Hidden Archive"; +"WinterGram.HideEditedMark" = "Hide \"edited\" Mark"; +"WinterGram.HideFromStashedPeers" = "Hide From Stashed Peers"; +"WinterGram.HidePremiumStatuses" = "Hide Premium Statuses"; +"WinterGram.HideStories" = "Hide Stories"; +"WinterGram.History" = "History"; +"WinterGram.INFORMATION" = "INFORMATION"; +"WinterGram.IOS" = "iOS"; +"WinterGram.IconPack" = "Icon Pack"; +"WinterGram.InGhostMode" = "In Ghost Mode"; +"WinterGram.IncreaseWebViewHeight" = "Increase WebView Height"; +"WinterGram.Information" = "Information"; +"WinterGram.InfoFooter" = "WinterGram · independent iOS client · GPLv2 © reekeer\n\nWntGram Beta · v1.0.0"; +"WinterGram.LIQUIDGLASS" = "LIQUID GLASS"; +"WinterGram.LiquidGlass" = "Liquid Glass"; +"WinterGram.LinksFooter" = "WinterGram\nVersion: Beta v1.0.0"; +"WinterGram.LocalPremium" = "Local Premium"; +"WinterGram.LocalPremiumUnlocksPremiumOnlyUIOnThisDeviceItDoesNotGrantServerSidePremium" = "Local Premium unlocks Premium-only UI on this device; it does not grant server-side Premium."; +"WinterGram.MacOS" = "macOS"; +"WinterGram.MaterialDesign" = "Material Design"; +"WinterGram.MessageTranslation" = "Message Translation"; +"WinterGram.Messages" = "Messages"; +"WinterGram.MonospaceFont" = "Monospace Font"; +"WinterGram.MuteNotifications" = "Mute Notifications"; +"WinterGram.Never" = "Never"; +"WinterGram.Off" = "Off"; +"WinterGram.OnlineTracker" = "Online Tracker"; +"WinterGram.OnlyAddedEmojiStickers" = "Only Added Emoji & Stickers"; +"WinterGram.PrivacyFirstMessagingClient" = "Privacy-first messaging client"; +"WinterGram.ProfileName" = "Profile Name"; +"WinterGram.Plugins" = "Plugins"; +"WinterGram.Restart" = "Restart"; +"WinterGram.RestartRequired" = "Restart Required"; +"WinterGram.Round" = "Round"; +"WinterGram.Rounded" = "Rounded"; +"WinterGram.Rounding" = "Rounding"; +"WinterGram.SPOOFING" = "SPOOFING"; +"WinterGram.SaveCurrentAsProfile" = "Save Current as Profile +"; +"WinterGram.SaveDeletedFromBots" = "Save Deleted from Bots"; +"WinterGram.SaveDeletedMessages" = "Save Deleted Messages"; +"WinterGram.SaveEditHistory" = "Save Edit History"; +"WinterGram.SaveSelfDestructMessages" = "Save Self-Destruct Messages"; +"WinterGram.SendWithoutSound" = "Send Without Sound"; +"WinterGram.ShowMessageSeconds" = "Show Message Seconds"; +"WinterGram.ShowPeerID" = "Show Peer ID"; +"WinterGram.ShowRegistrationDate" = "Show Registration Date"; +"WinterGram.SingleCornerRadius" = "Single Corner Radius"; +"WinterGram.SomeSettingsWillTakeEffectAfterRestart" = "Some settings will take effect after restart."; +"WinterGram.SpoofAppVersion" = "App Version"; +"WinterGram.SpoofDeviceModel" = "Device Model"; +"WinterGram.SpoofTheDeviceModelAppVersionAndWebViewPlatformReportedToTelegramAndMiniAppsAPIIDHashUseYourOwnCredentialsFromMyTelegramOrgChangingThemRequiresReLogin" = "Spoof the device model, app version and WebView platform reported to Telegram and Mini Apps. API ID/Hash use your own credentials from my.telegram.org — changing them requires re-login."; +"WinterGram.Spoofing" = "Spoofing"; +"WinterGram.Square" = "Square"; +"WinterGram.Squircle" = "Squircle"; +"WinterGram.StashPasscode" = "Stash Passcode"; +"WinterGram.StashedChats" = "Stashed Chats"; +"WinterGram.StashedChatsAreHiddenFromTheMainListAndAccessibleOnlyHere" = "Stashed chats are hidden from the main list and accessible only here."; +"WinterGram.HiddenArchiveInfo" = "Chats in the hidden archive are not shown in the chat list. Long-press a chat in the list to stash or unstash it."; +"WinterGram.HiddenArchiveEmpty" = "The hidden archive is empty."; +"WinterGram.Stories" = "Stories"; +"WinterGram.System" = "System"; +"WinterGram.Telegram" = "Telegram"; +"WinterGram.TelegramAPI" = "Telegram API"; +"WinterGram.Templates" = "Templates"; +"WinterGram.TheseOptionsChangeHowMessageMetadataAndActionsAreShownInChats" = "These options change how message metadata and actions are shown in chats."; +"WinterGram.TrackOnlineStatus" = "Track Online Status"; +"WinterGram.TranslationProvider" = "Translation Provider"; +"WinterGram.UseScheduledMessages" = "Use Scheduled Messages"; +"WinterGram.TransparencyBlurAndTintCanBeFineTunedPerSurfaceTurnLiquidGlassOffForTheStandardOpaqueLook" = "Transparency, blur and tint can be fine-tuned per surface. Turn Liquid Glass off for the standard opaque look."; +"WinterGram.UseDefaultTelegramBranding" = "Use Default Branding"; +"WinterGram.Version" = "Version"; +"WinterGram.Vibrancy" = "Vibrancy"; +"WinterGram.VisualGift" = "Visual Gift"; +"WinterGram.WebViewPlatform" = "WebView Platform"; +"WinterGram.WhenGhostModeIsOnWinterGramStopsSendingReadReceiptsOnlineStatusAndTypingActivity" = "When Ghost Mode is on, WinterGram stops sending read receipts, online status and typing activity."; +"WinterGram.WinterGram" = "WinterGram"; +"WinterGram.WinterGramWntIsAPrivacyFocusedMessagingClientForIPhoneANativePortOfTheAyuGramExperienceItAddsGhostModeSavedDeletedMessagesAndEditHistoryAHiddenArchiveLocalPremiumAdRemovalDeepCustomizationAndLiquidGlass" = "Modified Telegram client."; +"WinterGram.Yandex" = "Yandex"; +"WinterGram.None" = "None"; +"WinterGram.AppearanceFooter" = "Avatar and bubble shapes, fonts and the app icon pack apply across the whole app."; +"WinterGram.Categories" = "Categories"; +"WinterGram.LearnMore" = "Learn More"; +"WinterGram.Links" = "Links"; +"WinterGram.Releases" = "Releases"; +"WinterGram.DeletedMessages.Title" = "Deleted Messages"; +"WinterGram.DeletedMessages.Total" = "Total"; +"WinterGram.DeletedMessages.SelectTypes" = "Select Types"; +"WinterGram.DeletedMessages.DeleteSelected" = "Delete Selected"; +"WinterGram.DeletedMessages.ConfirmDelete" = "Delete the selected saved deleted messages? This cannot be undone."; +"WinterGram.DeletedMessages.Deleted" = "%@ freed"; +"WinterGram.DeletedMessages.Text" = "Messages"; +"WinterGram.DeletedMessages.Photos" = "Photos"; +"WinterGram.DeletedMessages.Videos" = "Videos"; +"WinterGram.DeletedMessages.Voice" = "Voice Messages"; +"WinterGram.DeletedMessages.VideoMessages" = "Video Messages"; +"WinterGram.DeletedMessages.Music" = "Music"; +"WinterGram.DeletedMessages.Stickers" = "Stickers"; +"WinterGram.DeletedMessages.Other" = "Other"; +"WinterGram.DeletedMessages.TopChats" = "Top Chats"; +"WinterGram.ReadAfterAction" = "Read after action"; +"WinterGram.DontReadMessages" = "Don't read messages"; +"WinterGram.DontReadStories" = "Don't read stories"; +"WinterGram.DontSendOnline" = "Don't send «online»"; +"WinterGram.DontSendTyping" = "Don't send «typing»"; +"WinterGram.AutoOffline" = "Auto offline"; +"WinterGram.SPYMODE" = "SPY MODE"; +"WinterGram.SpyMode" = "Spy mode"; +"WinterGram.DeleteSelected" = "Delete Selected"; diff --git a/Telegram/Telegram-iOS/es.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/es.lproj/InfoPlist.strings index 672fbd1a9c..09af1ca6de 100644 --- a/Telegram/Telegram-iOS/es.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/es.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ /* Localized versions of Info.plist keys */ -"NSContactsUsageDescription" = "Telegram subirá continuamente tus contactos a sus servidores fuertemente cifrados, para permitirte interactuar con tus amigos en todos tus dispositivos."; -"NSLocationWhenInUseUsageDescription" = "Cuando envías tu ubicación a tus amigos, Telegram necesita acceso para mostrarles un mapa."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "Cuando eliges compartir tu ubicación en tiempo real con amigos en un chat, Telegram necesita acceso en segundo plano a tu ubicación para mantenerla actualizada mientras la función esté en uso."; -"NSLocationAlwaysUsageDescription" = "Cuando envías tu ubicación a tus amigos, Telegram necesita acceso para mostrarles un mapa. También es requerido para enviar ubicaciones desde un Apple Watch."; +"NSContactsUsageDescription" = "WinterGram subirá continuamente tus contactos a sus servidores fuertemente cifrados, para permitirte interactuar con tus amigos en todos tus dispositivos."; +"NSLocationWhenInUseUsageDescription" = "Cuando envías tu ubicación a tus amigos, WinterGram necesita acceso para mostrarles un mapa."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "Cuando eliges compartir tu ubicación en tiempo real con amigos en un chat, WinterGram necesita acceso en segundo plano a tu ubicación para mantenerla actualizada mientras la función esté en uso."; +"NSLocationAlwaysUsageDescription" = "Cuando envías tu ubicación a tus amigos, WinterGram necesita acceso para mostrarles un mapa. También es requerido para enviar ubicaciones desde un Apple Watch."; "NSCameraUsageDescription" = "Necesitamos esto para que puedas tomar y compartir fotos y videos, así como para realizar videollamadas."; "NSPhotoLibraryUsageDescription" = "Es requerido para que puedas compartir fotos y vídeos desde tu biblioteca de fotos."; "NSPhotoLibraryAddUsageDescription" = "Necesitamos esto para que puedas guardar fotos y videos en tu biblioteca de fotos."; diff --git a/Telegram/Telegram-iOS/fr.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/fr.lproj/InfoPlist.strings index fa15291c28..f960d40a3e 100644 --- a/Telegram/Telegram-iOS/fr.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/fr.lproj/InfoPlist.strings @@ -1,12 +1,12 @@ /* Localized versions of Info.plist keys */ -"NSContactsUsageDescription" = "Telegram va synchroniser en continu vos contacts sur ses serveurs chiffrés pour vous permettre de joindre vos amis sur tous vos appareils."; -"NSLocationWhenInUseUsageDescription" = "Quand vous envoyez votre position à vos amis, Telegram doit accéder à votre position pour leur montrer une carte."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "Quand vous décidez de partager votre position en temps réel dans un échange, Telegram doit y accéder en arrière-plan pour l'actualiser pendant la durée du partage."; -"NSLocationAlwaysUsageDescription" = "Quand vous décidez de partager votre position en temps réel dans un échange, Telegram doit y accéder en arrière-plan pour l'actualiser pendant la durée du partage. Cela sert aussi à envoyer une position depuis l'Apple Watch."; +"NSContactsUsageDescription" = "WinterGram va synchroniser en continu vos contacts sur ses serveurs chiffrés pour vous permettre de joindre vos amis sur tous vos appareils."; +"NSLocationWhenInUseUsageDescription" = "Quand vous envoyez votre position à vos amis, WinterGram doit accéder à votre position pour leur montrer une carte."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "Quand vous décidez de partager votre position en temps réel dans un échange, WinterGram doit y accéder en arrière-plan pour l'actualiser pendant la durée du partage."; +"NSLocationAlwaysUsageDescription" = "Quand vous décidez de partager votre position en temps réel dans un échange, WinterGram doit y accéder en arrière-plan pour l'actualiser pendant la durée du partage. Cela sert aussi à envoyer une position depuis l'Apple Watch."; "NSCameraUsageDescription" = "Nous en avons besoin pour que vous puissiez prendre et partager des photos et des vidéos."; "NSPhotoLibraryUsageDescription" = "Nous en avons besoin pour que vous puissiez partager des photos et des vidéos de votre photothèque."; "NSPhotoLibraryAddUsageDescription" = "Nous en avons besoin pour que vous puissiez enregistrer des photos et des vidéos dans votre photothèque."; "NSMicrophoneUsageDescription" = "Nous en avons besoin pour que vous puissiez enregistrer et partager des messages vocaux et des vidéos avec du son."; "NSSiriUsageDescription" = "Vous pouvez utiliser Siri pour envoyer des messages."; -"NSFaceIDUsageDescription" = "Vous pouvez déverrouiller Telegram avec Face ID."; +"NSFaceIDUsageDescription" = "Vous pouvez déverrouiller WinterGram avec Face ID."; diff --git a/Telegram/Telegram-iOS/id.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/id.lproj/InfoPlist.strings index 8291c51d7f..325e026f04 100644 --- a/Telegram/Telegram-iOS/id.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/id.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ /* Localized versions of Info.plist keys */ -"NSContactsUsageDescription" = "Telegram akan terus mengunggah kontak Anda ke penyimpanan awan yang dienkripsi penuh, sehingga Anda dapat terhubung dengan teman Anda di semua perangkat Anda."; -"NSLocationWhenInUseUsageDescription" = "Ketika Anda mengirim lokasi untuk teman, Telegram membutuhkan akses untuk berbagi peta."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "Jika Anda memilih untuk membagikan Live Location dengan teman dalam obrolan, Telegram memerlukan akses latar belakang ke lokasi Anda agar lokasi tetap diperbarui selama lokasi langsung dibagikan."; -"NSLocationAlwaysUsageDescription" = "Jika Anda ingin berbagi Live Location dengan teman dalam chat, Telegram perlu akses latar belakang ke lokasi Anda agar lokasi tetap diperbarui selama lokasi langsung dibagikan. Hal yang sama perlu dilakukan untuk berbagi lokasi dari Apple Watch."; +"NSContactsUsageDescription" = "WinterGram akan terus mengunggah kontak Anda ke penyimpanan awan yang dienkripsi penuh, sehingga Anda dapat terhubung dengan teman Anda di semua perangkat Anda."; +"NSLocationWhenInUseUsageDescription" = "Ketika Anda mengirim lokasi untuk teman, WinterGram membutuhkan akses untuk berbagi peta."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "Jika Anda memilih untuk membagikan Live Location dengan teman dalam obrolan, WinterGram memerlukan akses latar belakang ke lokasi Anda agar lokasi tetap diperbarui selama lokasi langsung dibagikan."; +"NSLocationAlwaysUsageDescription" = "Jika Anda ingin berbagi Live Location dengan teman dalam chat, WinterGram perlu akses latar belakang ke lokasi Anda agar lokasi tetap diperbarui selama lokasi langsung dibagikan. Hal yang sama perlu dilakukan untuk berbagi lokasi dari Apple Watch."; "NSCameraUsageDescription" = "Kami membutuhkan hal ini agar Anda dapat mengambil dan membagikan foto dan video."; "NSPhotoLibraryUsageDescription" = "Kami membutuhkan hal ini agar Anda dapat berbagi foto dan video dari galeri foto Anda."; "NSPhotoLibraryAddUsageDescription" = "Kami membutuhkan hal ini agar Anda dapat menyimpan foto dan video ke galeri foto."; diff --git a/Telegram/Telegram-iOS/it.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/it.lproj/InfoPlist.strings index a9a5ed1752..40c8b16dfb 100644 --- a/Telegram/Telegram-iOS/it.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/it.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ /* Localized versions of Info.plist keys */ -"NSContactsUsageDescription" = "Telegram caricherà continuamente i tuoi contatti sui suoi server cloud altamente criptati per farti connettere con i tuoi amici da tutti i tuoi dispositivi."; -"NSLocationWhenInUseUsageDescription" = "Quando invii la tua posizione ai tuoi amici, Telegram ha bisogno di accedere per mostrare loro la mappa."; -"NSLocationAlwaysUsageDescription" = "Quando invii la tua posizione ai tuoi amici, Telegram ha bisogno di accedere per mostrare loro la mappa. Ti serve anche per inviare posizioni da Apple Watch."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "Quando scegli di condividere la tua Posizione Attuale con gli amici in una chat, Telegram ha bisogno dell'accesso in background alla tua posizione per tenerli aggiornati durante la durata della condivisione della posizione."; +"NSContactsUsageDescription" = "WinterGram caricherà continuamente i tuoi contatti sui suoi server cloud altamente criptati per farti connettere con i tuoi amici da tutti i tuoi dispositivi."; +"NSLocationWhenInUseUsageDescription" = "Quando invii la tua posizione ai tuoi amici, WinterGram ha bisogno di accedere per mostrare loro la mappa."; +"NSLocationAlwaysUsageDescription" = "Quando invii la tua posizione ai tuoi amici, WinterGram ha bisogno di accedere per mostrare loro la mappa. Ti serve anche per inviare posizioni da Apple Watch."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "Quando scegli di condividere la tua Posizione Attuale con gli amici in una chat, WinterGram ha bisogno dell'accesso in background alla tua posizione per tenerli aggiornati durante la durata della condivisione della posizione."; "NSCameraUsageDescription" = "Ci serve per farti scattare, registrare e condividere foto e video, oltre che per fare videochiamate."; "NSPhotoLibraryUsageDescription" = "Ci serve per farti condividere foto e video dalla tua libreria foto."; "NSPhotoLibraryAddUsageDescription" = "Ci serve per farti salvare foto e video nella tua libreria foto."; diff --git a/Telegram/Telegram-iOS/ko.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/ko.lproj/InfoPlist.strings index b692e0af6c..2e9fdcbfda 100644 --- a/Telegram/Telegram-iOS/ko.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/ko.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ /* Localized versions of Info.plist keys */ -"CFBundleDisplayName" = "텔레그램"; -"NSContactsUsageDescription" = "Telegram will continuously upload your contacts to its heavily encrypted cloud servers to let you connect with your friends across all your devices."; -"NSLocationWhenInUseUsageDescription" = "친구에게 회원님의 위치를 전송할 경우 위치를 지도에 표시하기 위해 텔레그램이 위치 정보에 접근할 수 있어야 합니다."; -"NSLocationAlwaysUsageDescription" = "친구에게 회원님의 위치를 전송할 경우 위치를 지도에 표시하기 위해 텔레그램이 위치 정보에 접근할 수 있어야 합니다. 애플워치에 위치 전송을 위해서도 필요합니다."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "When you choose to share your Live Location with friends in a chat, Telegram needs background access to your location to keep them updated for the duration of the live sharing."; +"CFBundleDisplayName" = "WinterGram"; +"NSContactsUsageDescription" = "WinterGram will continuously upload your contacts to its heavily encrypted cloud servers to let you connect with your friends across all your devices."; +"NSLocationWhenInUseUsageDescription" = "친구에게 회원님의 위치를 전송할 경우 위치를 지도에 표시하기 위해 WinterGram이 위치 정보에 접근할 수 있어야 합니다."; +"NSLocationAlwaysUsageDescription" = "친구에게 회원님의 위치를 전송할 경우 위치를 지도에 표시하기 위해 WinterGram이 위치 정보에 접근할 수 있어야 합니다. 애플워치에 위치 전송을 위해서도 필요합니다."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "When you choose to share your Live Location with friends in a chat, WinterGram needs background access to your location to keep them updated for the duration of the live sharing."; "NSCameraUsageDescription" = "사진과 비디오 촬영을 위하여 필요합니다."; "NSPhotoLibraryUsageDescription" = "촬영한 사진과 비디오를 공유하기 위하여 필요합니다."; "NSPhotoLibraryAddUsageDescription" = "사진과 동영상을 갤러리에 저장하기 위해 이 권한이 필요합니다."; diff --git a/Telegram/Telegram-iOS/ms.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/ms.lproj/InfoPlist.strings index c6d7bf72f9..880821efd5 100644 --- a/Telegram/Telegram-iOS/ms.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/ms.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ /* Localized versions of Info.plist keys */ -"NSContactsUsageDescription" = "Telegram akan sentiasa muat naik kenalan anda ke pelayan awan yang berenkripsi tinggi agar anda boleh berhubung dengan rakan anda di semua peranti anda."; -"NSLocationWhenInUseUsageDescription" = "Bila anda hantar lokasi anda kepada rakan anda, Telegram perlukan akses untuk tunjuk peta."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "Bila anda pilih untuk kongsi Lokasi Langsung anda dengan rakan dalam chat, Telegram perlu akses latar belakang ke lokasi anda agar lokasi anda sentiasa dikemaskini ketika perkongsian."; -"NSLocationAlwaysUsageDescription" = "Bila anda pilih untuk kongsi lokasi langsung anda dengan rakan dalam chat, Telegram perlu akses latar belakang agar lokasi anda sentiasa dikemaskini. Anda juga harus hantar lokasi anda ke Jam Apple."; +"NSContactsUsageDescription" = "WinterGram akan sentiasa muat naik kenalan anda ke pelayan awan yang berenkripsi tinggi agar anda boleh berhubung dengan rakan anda di semua peranti anda."; +"NSLocationWhenInUseUsageDescription" = "Bila anda hantar lokasi anda kepada rakan anda, WinterGram perlukan akses untuk tunjuk peta."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "Bila anda pilih untuk kongsi Lokasi Langsung anda dengan rakan dalam chat, WinterGram perlu akses latar belakang ke lokasi anda agar lokasi anda sentiasa dikemaskini ketika perkongsian."; +"NSLocationAlwaysUsageDescription" = "Bila anda pilih untuk kongsi lokasi langsung anda dengan rakan dalam chat, WinterGram perlu akses latar belakang agar lokasi anda sentiasa dikemaskini. Anda juga harus hantar lokasi anda ke Jam Apple."; "NSCameraUsageDescription" = "Kita perlukan ini agar anda boleh ambil dan kongsi foto dan video, dan juga buat panggilan video."; "NSPhotoLibraryUsageDescription" = "Kita perlu ini agar anda boleh kongsi foto dan video dari librari foto anda."; "NSPhotoLibraryAddUsageDescription" = "Kita perlu ini agar anda boleh simpan foto dan video ke librari foto anda."; diff --git a/Telegram/Telegram-iOS/nl.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/nl.lproj/InfoPlist.strings index 1bddf64f04..cdc03c0cce 100644 --- a/Telegram/Telegram-iOS/nl.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/nl.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ /* Localized versions of Info.plist keys */ -"NSContactsUsageDescription" = "Telegram synchroniseert je contacten continu naar onze zwaar versleutelde Cloud-servers, zodat je contact kunt opnemen met je vrienden vanaf al je apparaten."; -"NSLocationWhenInUseUsageDescription" = "Als je je locatie met vrienden wilt delen heeft Telegram toegang nodig om ze een kaart te kunnen tonen."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "Als je ervoor kiest om je huidige locatie te delen met vrienden in een chat heeft Telegram achtergrondtoegang tot je locatie nodig om deze bij te werken tijdens het live-delen."; -"NSLocationAlwaysUsageDescription" = "Telegram heeft toegang nodig om een kaart aan je vrienden te tonen als je jouw locatie met ze deelt. Je hebt dit ook nodig om locaties te sturen vanaf een Apple Watch."; +"NSContactsUsageDescription" = "WinterGram synchroniseert je contacten continu naar onze zwaar versleutelde Cloud-servers, zodat je contact kunt opnemen met je vrienden vanaf al je apparaten."; +"NSLocationWhenInUseUsageDescription" = "Als je je locatie met vrienden wilt delen heeft WinterGram toegang nodig om ze een kaart te kunnen tonen."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "Als je ervoor kiest om je huidige locatie te delen met vrienden in een chat heeft WinterGram achtergrondtoegang tot je locatie nodig om deze bij te werken tijdens het live-delen."; +"NSLocationAlwaysUsageDescription" = "WinterGram heeft toegang nodig om een kaart aan je vrienden te tonen als je jouw locatie met ze deelt. Je hebt dit ook nodig om locaties te sturen vanaf een Apple Watch."; "NSCameraUsageDescription" = "We hebben dit nodig zodat je foto's en video's kunt maken en delen."; "NSPhotoLibraryUsageDescription" = "We hebben dit nodig zodat je foto's en video's kunt delen vanuit je fotobibliotheek."; "NSPhotoLibraryAddUsageDescription" = "We hebben dit nodig zodat je foto's en video's kunt opslaan in je fotobibliotheek."; diff --git a/Telegram/Telegram-iOS/pl.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/pl.lproj/InfoPlist.strings index e2edd5f990..7d426c24dc 100644 --- a/Telegram/Telegram-iOS/pl.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/pl.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ /* Localized versions of Info.plist keys */ -"NSContactsUsageDescription" = "Telegram będzie nieprzerwanie przesyłać kontakty do silnie zaszyfrowanych serwerów w chmurze, aby umożliwić ci połączenie się ze znajomymi na wszystkich urządzeniach."; -"NSLocationWhenInUseUsageDescription" = "Gdy wysyłasz swoją lokalizację znajomym, Telegram potrzebuje dostępu, aby pokazać im mapę."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "Gdy zdecydujesz się udostępnić swoją „lokalizację na żywo” znajomym podczas czatu, Telegram potrzebuje dostępu w tle do twojej lokalizacji, aby zapewnić jej aktualizację przez cały czas udostępniania „na żywo”."; -"NSLocationAlwaysUsageDescription" = "Gdy zdecydujesz się udostępnić swoją „lokalizację na żywo” znajomym podczas czatu, Telegram potrzebuje dostępu w tle do twojej lokalizacji, aby zapewnić jej aktualizację przez cały czas udostępniania „na żywo”. Jest to również potrzebne do wysyłania lokalizacji z Apple Watch."; +"NSContactsUsageDescription" = "WinterGram będzie nieprzerwanie przesyłać kontakty do silnie zaszyfrowanych serwerów w chmurze, aby umożliwić ci połączenie się ze znajomymi na wszystkich urządzeniach."; +"NSLocationWhenInUseUsageDescription" = "Gdy wysyłasz swoją lokalizację znajomym, WinterGram potrzebuje dostępu, aby pokazać im mapę."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "Gdy zdecydujesz się udostępnić swoją „lokalizację na żywo” znajomym podczas czatu, WinterGram potrzebuje dostępu w tle do twojej lokalizacji, aby zapewnić jej aktualizację przez cały czas udostępniania „na żywo”."; +"NSLocationAlwaysUsageDescription" = "Gdy zdecydujesz się udostępnić swoją „lokalizację na żywo” znajomym podczas czatu, WinterGram potrzebuje dostępu w tle do twojej lokalizacji, aby zapewnić jej aktualizację przez cały czas udostępniania „na żywo”. Jest to również potrzebne do wysyłania lokalizacji z Apple Watch."; "NSCameraUsageDescription" = "Potrzebujemy tego, aby można było robić i udostępniać zdjęcia i wideo, a także prowadzić rozmowy wideo."; "NSPhotoLibraryUsageDescription" = "Potrzebujemy tego, aby można było udostępniać zdjęcia i wideo ze swojej biblioteki zdjęć."; "NSPhotoLibraryAddUsageDescription" = "Potrzebujemy tego, aby można było zapisywać zdjęcia i wideo w swojej bibliotece zdjęć."; diff --git a/Telegram/Telegram-iOS/pt.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/pt.lproj/InfoPlist.strings index ddc65bbd02..aa75fe0c66 100644 --- a/Telegram/Telegram-iOS/pt.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/pt.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ /* Localized versions of Info.plist keys */ -"NSContactsUsageDescription" = "Telegram atualizará continuamente os seus contatos em servidores na nuvem fortemente criptografados para que você se conecte com seus amigos através de todos os seus dispositivos."; -"NSLocationWhenInUseUsageDescription" = "Para enviar sua localização para seus amigos o Telegram precisa de permissão para mostrá-los o mapa."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "Quando você escolhe compartilhar sua Localização em Tempo Real com amigos no chat, o Telegram precisa de acesso à sua localização em segundo plano para mantê-los atualizados durante o compartilhamento."; -"NSLocationAlwaysUsageDescription" = "Para enviar sua localização para seus amigos, o Telegram precisa de permissão para mostrá-los o mapa. Você também precisará disso para enviar localizações de um Apple Watch."; +"NSContactsUsageDescription" = "WinterGram atualizará continuamente os seus contatos em servidores na nuvem fortemente criptografados para que você se conecte com seus amigos através de todos os seus dispositivos."; +"NSLocationWhenInUseUsageDescription" = "Para enviar sua localização para seus amigos o WinterGram precisa de permissão para mostrá-los o mapa."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "Quando você escolhe compartilhar sua Localização em Tempo Real com amigos no chat, o WinterGram precisa de acesso à sua localização em segundo plano para mantê-los atualizados durante o compartilhamento."; +"NSLocationAlwaysUsageDescription" = "Para enviar sua localização para seus amigos, o WinterGram precisa de permissão para mostrá-los o mapa. Você também precisará disso para enviar localizações de um Apple Watch."; "NSCameraUsageDescription" = "Precisamos acessar sua câmera para que você possa capturar fotos e vídeos."; "NSPhotoLibraryUsageDescription" = "Precisamos disso para que você possa compartilhar fotos e vídeos de sua galeria."; "NSPhotoLibraryAddUsageDescription" = "Precisamos disso para que você possa salvar fotos e vídeos em sua galeria de fotos."; diff --git a/Telegram/Telegram-iOS/ru.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/ru.lproj/InfoPlist.strings index 08c349a3c3..4c2cdbf0ba 100644 --- a/Telegram/Telegram-iOS/ru.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/ru.lproj/InfoPlist.strings @@ -1,7 +1,7 @@ /* Localized versions of Info.plist keys */ -"NSContactsUsageDescription" = "Актуальная информация о ваших контактах будет храниться зашифрованной в облаке Telegram, чтобы вы могли связаться с друзьями с любого устройства."; -"NSLocationWhenInUseUsageDescription" = "Когда вы отправляете друзьям геопозицию, Telegram нужно разрешение, чтобы показать им карту."; +"NSContactsUsageDescription" = "Актуальная информация о ваших контактах будет храниться зашифрованной в облаке WinterGram, чтобы вы могли связаться с друзьями с любого устройства."; +"NSLocationWhenInUseUsageDescription" = "Когда вы отправляете друзьям геопозицию, WinterGram нужно разрешение, чтобы показать им карту."; "NSLocationAlwaysAndWhenInUseUsageDescription" = "Фоновый доступ к геопозиции требуется, чтобы обновлять вашу геопозицию, когда вы транслируете её в чат с друзьями."; "NSLocationAlwaysUsageDescription" = "Фоновый доступ к геопозиции требуется, чтобы обновлять вашу геопозицию, когда вы транслируете её в чат с друзьями. Он также необходим для отправки геопозиции с Apple Watch."; "NSCameraUsageDescription" = "Это необходимо, чтобы вы могли делиться снятыми фотографиями и видео."; diff --git a/Telegram/Telegram-iOS/tr.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/tr.lproj/InfoPlist.strings index 283b1f2a25..cb8d5cc3c1 100644 --- a/Telegram/Telegram-iOS/tr.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/tr.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ /* Localized versions of Info.plist keys */ -"NSContactsUsageDescription" = "Telegram, arkadaşlarınızla tüm cihazlarınız arasında bağlantı kurmanızı sağlamak için kişilerinizi sürekli olarak şifreli bulut sunucularına yükleyecek."; -"NSLocationWhenInUseUsageDescription" = "Konumunuzu arkadaşlarınıza gönderdiğinizde, Telegram'ın onlara bir harita göstermesi için erişmesi gerekiyor."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "Canlı Konumunuzu arkadaşlarınızla bir sohbette paylaşmayı seçtiğinizde, Telegram'ın canlı paylaşım süresince onları güncel tutmak için konumunuza arka plan erişimi olması gerekir."; -"NSLocationAlwaysUsageDescription" = "Canlı konumunuzu bir sohbette arkadaşlarınızla paylaşmayı seçtiğinizde, Telegram'ın canlı paylaşım süresince konumunuzu güncel tutması için bir arka plan erişimi gerekir. Ayrıca Apple Watch'dan konum göndermek için de buna ihtiyacınız var."; +"NSContactsUsageDescription" = "WinterGram, arkadaşlarınızla tüm cihazlarınız arasında bağlantı kurmanızı sağlamak için kişilerinizi sürekli olarak şifreli bulut sunucularına yükleyecek."; +"NSLocationWhenInUseUsageDescription" = "Konumunuzu arkadaşlarınıza gönderdiğinizde, WinterGram'ın onlara bir harita göstermesi için erişmesi gerekiyor."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "Canlı Konumunuzu arkadaşlarınızla bir sohbette paylaşmayı seçtiğinizde, WinterGram'ın canlı paylaşım süresince onları güncel tutmak için konumunuza arka plan erişimi olması gerekir."; +"NSLocationAlwaysUsageDescription" = "Canlı konumunuzu bir sohbette arkadaşlarınızla paylaşmayı seçtiğinizde, WinterGram'ın canlı paylaşım süresince konumunuzu güncel tutması için bir arka plan erişimi gerekir. Ayrıca Apple Watch'dan konum göndermek için de buna ihtiyacınız var."; "NSCameraUsageDescription" = "Fotoğraf ve video çekip paylaşabilmeniz ve görüntülü arama yapabilmeniz için buna ihtiyacımız var."; "NSPhotoLibraryUsageDescription" = "Fotoğraf arşivinizdeki fotoğraf ve videoları paylaşabilmeniz için buna ihtiyacımız var."; "NSPhotoLibraryAddUsageDescription" = "Fotoğraf arşivine fotoğraf ve video kaydedebilmeniz için buna ihtiyacımız var."; diff --git a/Telegram/Telegram-iOS/uk.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/uk.lproj/InfoPlist.strings index f9e8f94199..ec88017fdb 100644 --- a/Telegram/Telegram-iOS/uk.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/uk.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ /* Localized versions of Info.plist keys */ -"NSContactsUsageDescription" = "Telegram завантажуватиме ваші контакти до зашифрованих хмарних серверів, щоб ви могли зʼєднуватися з друзями на всіх пристроях."; -"NSLocationWhenInUseUsageDescription" = "Коли ви надсилаєте розташування друзям, Telegram потребує доступу, щоб показати їм мапу."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "Коли ви захочете поділитися Маячком із друзями у чаті, Telegram потребує фонового доступу до розташування, щоб воно оновлювалося на час трансляції маячка."; -"NSLocationAlwaysUsageDescription" = "Коли ви захочете поділитися Маячком із друзями у чаті, Telegram потребує фонового доступу до розташування, щоб воно оновлювалося на час трансляції маячка. Також вам це потрібно для надсилання розташувань з Apple Watch."; +"NSContactsUsageDescription" = "WinterGram завантажуватиме ваші контакти до зашифрованих хмарних серверів, щоб ви могли зʼєднуватися з друзями на всіх пристроях."; +"NSLocationWhenInUseUsageDescription" = "Коли ви надсилаєте розташування друзям, WinterGram потребує доступу, щоб показати їм мапу."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "Коли ви захочете поділитися Маячком із друзями у чаті, WinterGram потребує фонового доступу до розташування, щоб воно оновлювалося на час трансляції маячка."; +"NSLocationAlwaysUsageDescription" = "Коли ви захочете поділитися Маячком із друзями у чаті, WinterGram потребує фонового доступу до розташування, щоб воно оновлювалося на час трансляції маячка. Також вам це потрібно для надсилання розташувань з Apple Watch."; "NSCameraUsageDescription" = "Нам це потрібно, щоб ви могли знімати та ділитися фото й відео."; "NSPhotoLibraryUsageDescription" = "Нам це потрібно, щоб ви могли ділитися фото та відео з вашої бібліотеки фото."; "NSPhotoLibraryAddUsageDescription" = "Нам це потрібно, щоб ви могли зберігати фото та відео до вашої бібліотеки фото."; diff --git a/Telegram/Telegram-iOS/uz.lproj/InfoPlist.strings b/Telegram/Telegram-iOS/uz.lproj/InfoPlist.strings index ec5ffb6584..dddd13279b 100644 --- a/Telegram/Telegram-iOS/uz.lproj/InfoPlist.strings +++ b/Telegram/Telegram-iOS/uz.lproj/InfoPlist.strings @@ -1,9 +1,9 @@ /* Localized versions of Info.plist keys */ -"NSContactsUsageDescription" = "Telegram barcha qurilmalaringizdan doʻstlaringiz bilan bogʻlana olishingiz uchun muntazam ravishda kontaktlaringizni kuchli shifrlanadigan bulut serverlariga yuklaydi."; -"NSLocationWhenInUseUsageDescription" = "Joylashuvingizni doʻstlaringizga yuborganingizda Telegram ularga xaritani koʻrsatishi uchun ruxsat kerak boʻladi."; -"NSLocationAlwaysAndWhenInUseUsageDescription" = "Chatda doʻstlarga Jonli joylashuvingizni ulashishni tanlaganingizda, Telegram joylashuvingizni jonli ulashish davomida yangilab turishi uchun undan fonda foydalanishi kerak."; -"NSLocationAlwaysUsageDescription" = "Chatda doʻstlarga Jonli joylashuvingizni ulashishni tanlaganingizda, Telegram joylashuvingizni jonli ulashish davomida yangilab turishi uchun undan fonda foydalanishi kerak. Sizga bu Apple Watchdan joylashuvlarni yuborish uchun ham kerak."; +"NSContactsUsageDescription" = "WinterGram barcha qurilmalaringizdan doʻstlaringiz bilan bogʻlana olishingiz uchun muntazam ravishda kontaktlaringizni kuchli shifrlanadigan bulut serverlariga yuklaydi."; +"NSLocationWhenInUseUsageDescription" = "Joylashuvingizni doʻstlaringizga yuborganingizda WinterGram ularga xaritani koʻrsatishi uchun ruxsat kerak boʻladi."; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "Chatda doʻstlarga Jonli joylashuvingizni ulashishni tanlaganingizda, WinterGram joylashuvingizni jonli ulashish davomida yangilab turishi uchun undan fonda foydalanishi kerak."; +"NSLocationAlwaysUsageDescription" = "Chatda doʻstlarga Jonli joylashuvingizni ulashishni tanlaganingizda, WinterGram joylashuvingizni jonli ulashish davomida yangilab turishi uchun undan fonda foydalanishi kerak. Sizga bu Apple Watchdan joylashuvlarni yuborish uchun ham kerak."; "NSCameraUsageDescription" = "Bu bizga rasm va videolarga olish, shuningdek, video chaqiruvlar qilish va ularni ulashishingiz uchun kerak."; "NSPhotoLibraryUsageDescription" = "Bu bizga galereyangizdan rasm va videolaringizni ulasha olishingiz uchun kerak."; "NSPhotoLibraryAddUsageDescription" = "Bu bizga rasm va videolaringizni galereyangizga saqlay olishingiz uchun kerak."; diff --git a/Telegram/WatchApp/project.yml b/Telegram/WatchApp/project.yml index bf08b9408a..7add64f47a 100644 --- a/Telegram/WatchApp/project.yml +++ b/Telegram/WatchApp/project.yml @@ -11,7 +11,7 @@ # with project.yml. Do NOT add the container target or SnapshotPreviews here. name: tgwatch options: - bundleIdPrefix: ph.telegra # ph.telegra.Telegraph.watchkitapp, child of the main Telegram app id + bundleIdPrefix: dev.reekeer # dev.reekeer.wintergram.watchkitapp, child of the main WinterGram app id developmentLanguage: en deploymentTarget: watchOS: "26.0" @@ -51,12 +51,12 @@ targets: info: path: tgwatch Watch App/Info.plist properties: - CFBundleDisplayName: Telegram + CFBundleDisplayName: WinterGram CFBundleIconName: AppIcon CFBundleShortVersionString: $(MARKETING_VERSION) CFBundleVersion: $(CURRENT_PROJECT_VERSION) WKApplication: true - WKCompanionAppBundleIdentifier: ph.telegra.Telegraph + WKCompanionAppBundleIdentifier: dev.reekeer.wintergram # Intentional: keeps the watch app independently installable on the watch # while still shipping inside the Telegram IPA. Do not change to false. WKRunsIndependentlyOfCompanionApp: true @@ -68,8 +68,8 @@ targets: NSLocationWhenInUseUsageDescription: Share your current location in chats. settings: base: - PRODUCT_BUNDLE_IDENTIFIER: ph.telegra.Telegraph.watchkitapp - DEVELOPMENT_TEAM: C67CF9S4VU + PRODUCT_BUNDLE_IDENTIFIER: dev.reekeer.wintergram.watchkitapp + DEVELOPMENT_TEAM: REPLACE_WITH_YOUR_TEAM_ID TARGETED_DEVICE_FAMILY: "4" GENERATE_INFOPLIST_FILE: "NO" LD_RUNPATH_SEARCH_PATHS: $(inherited) @executable_path/Frameworks diff --git a/Telegram/WatchApp/tgwatch Watch App/Info.plist b/Telegram/WatchApp/tgwatch Watch App/Info.plist index ca62526111..8efc1b7f04 100644 --- a/Telegram/WatchApp/tgwatch Watch App/Info.plist +++ b/Telegram/WatchApp/tgwatch Watch App/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Telegram + WinterGram CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIconName diff --git a/Telegram/WatchApp/tgwatch.xcodeproj/project.pbxproj b/Telegram/WatchApp/tgwatch.xcodeproj/project.pbxproj index ac34cf16c3..3c96c16f7b 100644 --- a/Telegram/WatchApp/tgwatch.xcodeproj/project.pbxproj +++ b/Telegram/WatchApp/tgwatch.xcodeproj/project.pbxproj @@ -467,7 +467,7 @@ LastUpgradeCheck = 1430; TargetAttributes = { F8E880AF01A492CCA4C21162 = { - DevelopmentTeam = C67CF9S4VU; + DevelopmentTeam = REPLACE_WITH_YOUR_TEAM_ID; }; }; }; @@ -631,11 +631,11 @@ CODE_SIGNING_ALLOWED = NO; CODE_SIGNING_REQUIRED = NO; CODE_SIGN_IDENTITY = ""; - DEVELOPMENT_TEAM = C67CF9S4VU; + DEVELOPMENT_TEAM = REPLACE_WITH_YOUR_TEAM_ID; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "tgwatch Watch App/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = ph.telegra.Telegraph.watchkitapp; + PRODUCT_BUNDLE_IDENTIFIER = dev.reekeer.wintergram.watchkitapp; SDKROOT = watchos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = 4; @@ -653,11 +653,11 @@ CODE_SIGN_IDENTITY = ""; DEAD_CODE_STRIPPING = YES; DEPLOYMENT_POSTPROCESSING = YES; - DEVELOPMENT_TEAM = C67CF9S4VU; + DEVELOPMENT_TEAM = REPLACE_WITH_YOUR_TEAM_ID; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "tgwatch Watch App/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = ph.telegra.Telegraph.watchkitapp; + PRODUCT_BUNDLE_IDENTIFIER = dev.reekeer.wintergram.watchkitapp; SDKROOT = watchos; SKIP_INSTALL = YES; STRIP_INSTALLED_PRODUCT = YES; diff --git a/Tests/WinterGram/WinterGramGhostLogicTests.swift b/Tests/WinterGram/WinterGramGhostLogicTests.swift new file mode 100644 index 0000000000..d591734539 --- /dev/null +++ b/Tests/WinterGram/WinterGramGhostLogicTests.swift @@ -0,0 +1,70 @@ +import Foundation + +// Standalone unit tests for the ghost-mode decision logic. +// +// This project has no Bazel test harness, but WinterGramGhostLogic.swift is +// dependency-free, so it can be compiled and run directly: +// +// swiftc \ +// submodules/TelegramUIPreferences/Sources/WinterGramGhostLogic.swift \ +// Tests/WinterGram/WinterGramGhostLogicTests.swift \ +// -o /tmp/wnt_tests && /tmp/wnt_tests +// +// The functions tested here are the exact ones WinterGramSettings forwards to, +// so a regression in the shipping rules fails these tests. + +@main +struct WinterGramGhostLogicTests { + static var failures = 0 + static var checks = 0 + + static func expect(_ actual: Bool, _ expected: Bool, _ label: String) { + checks += 1 + if actual != expected { + failures += 1 + print("FAIL: \(label) — expected \(expected), got \(actual)") + } + } + + static func main() { + let G = WinterGramGhostLogic.self + + // suppressesReadReceipts: only when ghost ON and receipts OFF. + expect(G.suppressesReadReceipts(ghostModeEnabled: true, sendReadReceipts: false), true, "readReceipts ghost+off") + expect(G.suppressesReadReceipts(ghostModeEnabled: true, sendReadReceipts: true), false, "readReceipts ghost+on") + expect(G.suppressesReadReceipts(ghostModeEnabled: false, sendReadReceipts: false), false, "readReceipts noghost+off") + expect(G.suppressesReadReceipts(ghostModeEnabled: false, sendReadReceipts: true), false, "readReceipts noghost+on") + + // suppressesOnlinePresence + expect(G.suppressesOnlinePresence(ghostModeEnabled: true, sendOnlineStatus: false), true, "online ghost+off") + expect(G.suppressesOnlinePresence(ghostModeEnabled: true, sendOnlineStatus: true), false, "online ghost+on") + expect(G.suppressesOnlinePresence(ghostModeEnabled: false, sendOnlineStatus: false), false, "online noghost+off") + + // suppressesTypingStatus + expect(G.suppressesTypingStatus(ghostModeEnabled: true, sendUploadProgress: false), true, "typing ghost+off") + expect(G.suppressesTypingStatus(ghostModeEnabled: false, sendUploadProgress: false), false, "typing noghost+off") + + // suppressesStoryViews + expect(G.suppressesStoryViews(ghostModeEnabled: true, sendReadStories: false), true, "story ghost+off") + expect(G.suppressesStoryViews(ghostModeEnabled: true, sendReadStories: true), false, "story ghost+on") + + // shouldMarkReadAfterAction: requires reads suppressed AND the toggle on. + expect(G.shouldMarkReadAfterAction(ghostModeEnabled: true, sendReadReceipts: false, markReadAfterAction: true), true, "markRead suppressed+toggle") + expect(G.shouldMarkReadAfterAction(ghostModeEnabled: true, sendReadReceipts: false, markReadAfterAction: false), false, "markRead suppressed+notoggle") + expect(G.shouldMarkReadAfterAction(ghostModeEnabled: true, sendReadReceipts: true, markReadAfterAction: true), false, "markRead notsuppressed+toggle") + expect(G.shouldMarkReadAfterAction(ghostModeEnabled: false, sendReadReceipts: false, markReadAfterAction: true), false, "markRead noghost+toggle") + + // shouldGoOfflineAfterAction: requires ghost ON and the toggle on. + expect(G.shouldGoOfflineAfterAction(ghostModeEnabled: true, sendOfflineAfterOnline: true), true, "offline ghost+toggle") + expect(G.shouldGoOfflineAfterAction(ghostModeEnabled: true, sendOfflineAfterOnline: false), false, "offline ghost+notoggle") + expect(G.shouldGoOfflineAfterAction(ghostModeEnabled: false, sendOfflineAfterOnline: true), false, "offline noghost+toggle") + + if failures == 0 { + print("OK: all \(checks) ghost-logic checks passed") + exit(0) + } else { + print("\(failures)/\(checks) checks FAILED") + exit(1) + } + } +} diff --git a/branding/backplate_badge.png b/branding/backplate_badge.png new file mode 100644 index 0000000000..4d0a214d0f Binary files /dev/null and b/branding/backplate_badge.png differ diff --git a/branding/banner-default.png b/branding/banner-default.png new file mode 100644 index 0000000000..d2d76eac80 Binary files /dev/null and b/branding/banner-default.png differ diff --git a/branding/icon-app-dark.png b/branding/icon-app-dark.png new file mode 100644 index 0000000000..7812dcdab4 Binary files /dev/null and b/branding/icon-app-dark.png differ diff --git a/branding/icon-app-developer.png b/branding/icon-app-developer.png new file mode 100644 index 0000000000..c074a3b02b Binary files /dev/null and b/branding/icon-app-developer.png differ diff --git a/branding/icon-app-house-dark.png b/branding/icon-app-house-dark.png new file mode 100644 index 0000000000..32e7f91b3c Binary files /dev/null and b/branding/icon-app-house-dark.png differ diff --git a/branding/icon-app-house-light.png b/branding/icon-app-house-light.png new file mode 100644 index 0000000000..955a7862f0 Binary files /dev/null and b/branding/icon-app-house-light.png differ diff --git a/branding/icon-app-light.png b/branding/icon-app-light.png new file mode 100644 index 0000000000..3ea9401bb8 Binary files /dev/null and b/branding/icon-app-light.png differ diff --git a/branding/snowflake_colored.png b/branding/snowflake_colored.png new file mode 100644 index 0000000000..f5d9c1d962 Binary files /dev/null and b/branding/snowflake_colored.png differ diff --git a/branding/snowflake_monochrome.png b/branding/snowflake_monochrome.png new file mode 100644 index 0000000000..1e0ad19808 Binary files /dev/null and b/branding/snowflake_monochrome.png differ diff --git a/branding/wintergram-icon.png b/branding/wintergram-icon.png deleted file mode 100644 index 0cf3146a0a..0000000000 Binary files a/branding/wintergram-icon.png and /dev/null differ diff --git a/branding/wintergram-icon.svg b/branding/wintergram-icon.svg deleted file mode 100644 index ef7e7dae2a..0000000000 --- a/branding/wintergram-icon.svg +++ /dev/null @@ -1,54931 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/build-system/Make/Make.py b/build-system/Make/Make.py index 57416703f8..66071f8186 100644 --- a/build-system/Make/Make.py +++ b/build-system/Make/Make.py @@ -46,6 +46,8 @@ class BazelCommandLine: self.show_actions = False self.enable_sandbox = False self.disable_provisioning_profiles = False + self.disable_extensions = False + self.bazel_arguments = [] self.profile_swift = False self.embed_watch_app = False self.watch_api_id = None @@ -138,6 +140,13 @@ class BazelCommandLine: def set_disable_provisioning_profiles(self): self.disable_provisioning_profiles = True + def set_disable_extensions(self): + self.disable_extensions = True + + def add_bazel_arguments(self, arguments): + if arguments: + self.bazel_arguments.extend(arguments.split()) + def set_profile_swift(self, value): self.profile_swift = value @@ -299,6 +308,11 @@ class BazelCommandLine: if self.disable_provisioning_profiles: combined_arguments += ['--//Telegram:disableProvisioningProfiles'] + if self.disable_extensions: + combined_arguments += ['--//Telegram:disableExtensions'] + + combined_arguments += self.bazel_arguments + combined_arguments += self.common_args combined_arguments += self.common_build_args combined_arguments += self.get_define_arguments() @@ -517,12 +531,21 @@ def resolve_configuration(base_path, bazel_command_line: BazelCommandLine, argum provisioning_profiles_path=provisioning_path, additional_codesigning_output_path=additional_codesigning_output_path ) - if codesigning_data.aps_environment is None: - print('Could not find a valid aps-environment entitlement in the provided provisioning profiles') - sys.exit(1) + 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' + else: + print('Could not find a valid aps-environment entitlement in the provided provisioning profiles') + sys.exit(1) if bazel_command_line is not None: - build_configuration.write_to_variables_file(bazel_path=bazel_command_line.bazel, use_xcode_managed_codesigning=codesigning_data.use_xcode_managed_codesigning, aps_environment=codesigning_data.aps_environment, path=configuration_repository_path + '/variables.bzl') + build_configuration.write_to_variables_file(bazel_path=bazel_command_line.bazel, use_xcode_managed_codesigning=codesigning_data.use_xcode_managed_codesigning, aps_environment=aps_environment, path=configuration_repository_path + '/variables.bzl') provisioning_profile_files = [] for file_name in os.listdir(provisioning_path): @@ -578,7 +601,7 @@ def generate_project(bazel, arguments): generate_dsym = arguments.generateDsym if arguments.target is not None: target_name = arguments.target - + call_executable(['killall', 'Xcode'], check_result=False) xcodeproj_path = generate( @@ -701,6 +724,13 @@ def build(bazel, arguments): bazel_command_line.set_split_swiftmodules(arguments.enableParallelSwiftmoduleGeneration) + if arguments.disableExtensions: + bazel_command_line.set_disable_extensions() + if arguments.disableProvisioningProfiles: + bazel_command_line.set_disable_provisioning_profiles() + if arguments.bazelArguments is not None: + bazel_command_line.add_bazel_arguments(arguments.bazelArguments) + bazel_command_line.invoke_build() if arguments.outputBuildArtifactsPath is not None: @@ -949,7 +979,7 @@ if __name__ == '__main__': cleanParser = subparsers.add_parser( 'clean', help=''' - Clean local bazel cache. Does not affect files cached remotely (via --cacheHost=...) or + Clean local bazel cache. Does not affect files cached remotely (via --cacheHost=...) or locally in an external directory ('--cacheDir=...') ''' ) @@ -1043,6 +1073,30 @@ if __name__ == '__main__': required=True, help='Build configuration' ) + buildParser.add_argument( + '--disableExtensions', + action='store_true', + default=False, + help=''' + Do not build app extensions. Useful for simulator builds or when + provisioning profiles for extensions are not available. + ''' + ) + buildParser.add_argument( + '--disableProvisioningProfiles', + action='store_true', + default=False, + help=''' + Do not use provisioning profiles. Useful for simulator builds or + when only fake/self-signed codesigning material is available. + ''' + ) + buildParser.add_argument( + '--bazelArguments', + required=False, + help='Add additional arguments to the bazel build invocation.', + metavar='arguments' + ) buildParser.add_argument( '--enableParallelSwiftmoduleGeneration', action='store_true', @@ -1406,7 +1460,7 @@ if __name__ == '__main__': arguments=args, additional_codesigning_output_path=remote_input_path ) - + shutil.copyfile(args.configurationPath, remote_input_path + '/configuration.json') watch_provisioning_profile_remote_path = None @@ -1448,7 +1502,7 @@ if __name__ == '__main__': arguments=args, additional_codesigning_output_path=remote_input_path ) - + shutil.copyfile(args.configurationPath, remote_input_path + '/configuration.json') TartBuild.remote_build_tart( diff --git a/build-system/appcenter-configuration.json b/build-system/appcenter-configuration.json deleted file mode 100755 index b86e141cab..0000000000 --- a/build-system/appcenter-configuration.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "bundle_id": "ph.telegra.Telegraph", - "api_id": "8", - "api_hash": "7245de8e747a0d6fbe11f7cc14fcc0bb", - "team_id": "C67CF9S4VU", - "app_center_id": "4c816ed0-df83-423c-846b-a0a8467dc7d2", - "is_internal_build": "false", - "is_appstore_build": "true", - "appstore_id": "686449807", - "app_specific_url_scheme": "tg", - "premium_iap_product_id": "org.telegram.telegramPremium.monthly", - "enable_siri": true, - "enable_icloud": true -} \ No newline at end of file diff --git a/build-system/wintergram-development-configuration.example.json b/build-system/wintergram-development-configuration.example.json new file mode 100644 index 0000000000..6c33309cc8 --- /dev/null +++ b/build-system/wintergram-development-configuration.example.json @@ -0,0 +1,14 @@ +{ + "bundle_id": "dev.reekeer.wintergram", + "api_id": "2040", + "api_hash": "b18441a1ff607e10a989891a5462e627", + "team_id": "0000000000", + "app_center_id": "0", + "is_internal_build": "true", + "is_appstore_build": "false", + "appstore_id": "0", + "app_specific_url_scheme": "wnt", + "premium_iap_product_id": "", + "enable_siri": false, + "enable_icloud": false +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md new file mode 100644 index 0000000000..5edba15da1 --- /dev/null +++ b/docs/FEATURES.md @@ -0,0 +1,153 @@ +# WinterGram Features + +This document describes every user-facing WinterGram capability. Settings are grouped under the **WinterGram** tab in the app (Settings → WinterGram). + +--- + +## Ghost Mode + +Control what activity signals you send to Telegram servers. + +| Feature | Description | +| :-- | :-- | +| Ghost Mode presets | Off, Messages only, Stories only, or Everything | +| Send without sound | Never, only in Ghost Mode, or always | +| Scheduled send trick | Route ghost-mode sends through the scheduled path so the chat stays unread | +| Track online status | Local log of when contacts go online (Online Tracker screen) | +| Story ghost | View stories without marking them seen | +| Mark read after action | Mark chats read locally after you reply or react, without sending receipts | +| Go offline after online | Send a one-shot offline presence after appearing online | + +--- + +## History and Recovery + +Keep message content on this device even when it is removed or changed on the server. + +| Feature | Description | +| :-- | :-- | +| Save deleted messages | Retain messages after the other side deletes them | +| Save edit history | Store every revision of edited messages | +| Save from bots | Include bot messages in the deleted-message cache | +| Save self-destruct | Keep disappearing messages before they vanish | +| Dim deleted messages | Render saved deletions at reduced opacity | +| Clear saved deletions | Remove all locally cached deleted messages | +| Deleted messages browser | Data & Storage → Deleted Messages: pie chart by type, per-category cleanup, **top chats** by deletion count | + +--- + +## Hidden Archive + +A separate stash for chats that should not appear in the main list. + +| Feature | Description | +| :-- | :-- | +| Stash chats | Move chats to a settings-only archive via context menu | +| Stashed chats list | Browse and unstash from WinterGram → Core → Hidden Archive | +| Mute notifications | Drop in-app banners for stashed peers | +| Auto mark as read | Mark incoming stashed messages read locally | +| Passcode | Optional passcode gate before opening the stash | + +--- + +## Features (anti-features and conveniences) + +| Feature | Description | +| :-- | :-- | +| Disable ads | Hide sponsored messages in channels | +| Local Premium | Unlock Premium-gated UI locally (no server-side subscription) | +| Hide stories | Remove stories from the chat list header | +| Hide Premium statuses | Conceal Premium badges and statuses | +| Disable open-link warning | Skip the concealed-URL confirmation dialog | +| Allow saving restricted content | Bypass copy-protection on protected media | +| Allow screenshots everywhere | Disable screenshot blocking in supported views | +| Confirm stickers / GIFs / voice | Ask before sending each media type | + +--- + +## Chat + +| Feature | Description | +| :-- | :-- | +| Show message seconds | Display seconds in message timestamps | +| Show peer ID | Hidden, Telegram API form, or Bot API form in profiles | +| Show registration date | Display account creation date where available | +| Hide edited mark | Suppress the "edited" label on messages | +| Message translation | Context-menu translation with selectable provider | +| Translation provider | Telegram, Google, Yandex, or system | +| Increase WebView height | Taller viewport for mini-apps | +| Only added emoji and stickers | Limit picker to your custom sets | +| Forward without author | Omit original author on forwards | +| Default reaction | Custom emoji reaction used by default | + +--- + +## Appearance + +| Feature | Description | +| :-- | :-- | +| Material Design | Material-styled switches and controls | +| Single corner radius | Apply bubble radius to one corner only | +| Avatar shape | Slider from square through squircle to round | +| Bubble radius | Adjustable message bubble corner radius | +| Custom font | Override the UI typeface | +| Monospace font | Override the code/monospace typeface | +| Icon pack | WinterGram, Ayu, exteraGram, or Telegram alternate icons | +| Liquid Glass | Frosted translucent surfaces with per-area toggles | +| Liquid Glass vibrancy | Extra vibrancy on glass layers | +| Apply to chat list / nav bars / tab bar / bubbles | Per-surface glass control | + +--- + +## Spoofing + +Report different device metadata to Telegram and mini-apps. Changing API credentials requires re-login. + +| Feature | Description | +| :-- | :-- | +| Spoof device model | Override the hardware identifier | +| Spoof app version | Override the client version string | +| WebView platform | Automatic, iOS, Android, macOS, or desktop | +| Custom API ID / Hash | Use your own credentials from my.telegram.org | +| Spoof presets | Save and recall device/version profiles | + +--- + +## Badges and Branding + +| Feature | Description | +| :-- | :-- | +| WinterGram badge | Composed at runtime from white backplate + snowflake shapes, tinted to the theme | +| Developer badge | Backplated snowflake for contributors | +| Official channel badge | Snowflake for official WinterGram resources | +| Use default branding | Restore standard Telegram naming in the UI | + +--- + +## Accounts and Data + +| Feature | Description | +| :-- | :-- | +| Multiple accounts | Up to 100 accounts on one device | +| Deep links | `wnt://` scheme mirrors `tg://`; `wnt://wintergram/
` opens a settings category | +| Local-only storage | Deleted and edited history never leaves the device | +| iCloud backup | Account data is included in device backups | + +--- + +## Localization + +| Language | Coverage | +| :-- | :-- | +| English | All WinterGram settings strings | +| Russian | Full WinterGram UI via bundled seed translations | + +--- + +## Channels and Links + +| Resource | URL | +| :-- | :-- | +| Channel | https://t.me/wntgram | +| Beta | https://t.me/wntbeta | +| GitHub | https://github.com/reekeer/WinterGram | diff --git a/docs/wintergram-features.md b/docs/wintergram-features.md index dcbe4aff27..ffeb2539e8 100644 --- a/docs/wintergram-features.md +++ b/docs/wintergram-features.md @@ -1,86 +1,111 @@ # WinterGram — feature → implementation map -This document maps every WinterGram option to where it is (or needs to be) wired into the -Telegram-iOS codebase. The settings themselves live in a single store: +> Developer reference. User-facing overview: [`docs/FEATURES.md`](FEATURES.md). + +This document maps every WinterGram option to where it is wired into the +codebase. The settings themselves live in a single store: - `submodules/TelegramUIPreferences/Sources/WinterGramSettings.swift` — the `WinterGramSettings` `Codable` struct, its sub-structs (`WinterGramLiquidGlass`) and enums, plus - `updateWinterGramSettingsInteractively(...)` and `winterGramSettings(accountManager:)`. + `updateWinterGramSettingsInteractively(...)`, `winterGramSettings(accountManager:)`, and the + synchronous snapshot `currentWinterGramSettings` (kept fresh by + `observeWinterGramSettings(accountManager:)`, started from `AppDelegate`). - `submodules/TelegramUIPreferences/Sources/PostboxKeys.swift` — the shared-data key `ApplicationSpecificSharedDataKeys.winterGramSettings` (value `23`). +- `submodules/TelegramCore/Sources/WinterGram/WinterGramCoreSettings.swift` — a minimal + mirror for hooks inside `TelegramCore` (which cannot import `TelegramUIPreferences`); + fed by the same observer. Read the store anywhere that already has an `AccountContext` / `AccountManager` via -`winterGramSettings(accountManager:)`, and write it via `updateWinterGramSettingsInteractively`. +`winterGramSettings(accountManager:)` (reactive) or `currentWinterGramSettings` (sync), +and write it via `updateWinterGramSettingsInteractively`. ## Status legend -- **Store** — the setting exists and persists (done in this repo). -- **Hook** — the place in the app where behavior must read the setting. +- ✅ — setting persists and the behavior hook is in place. +- ⏳ — setting persists, behavior not fully hooked yet. ## Privacy & Ghost Mode -| Setting | Hook | -| :-- | :-- | -| `ghostModeEnabled` | Master switch read by the read-receipt / online-status / typing senders below. | -| `sendReadReceipts` | `TelegramCore` history read — gate `_internal_applyMaxReadIndex` / outgoing `messages.readHistory` calls. | -| `sendReadStories` | Story view reporting — gate `markStoryAsSeen` network calls. | -| `sendOnlineStatus` | Online presence — gate `account.updatePresence` / `updateStatus`. | -| `sendUploadProgress` | Typing/upload activity — gate `ChatActivity` / `setTyping` reporting. | -| `sendOfflineAfterOnline` | Emit a one-shot offline presence packet after the app goes online. | -| `markReadAfterAction` | After replying/reacting, locally mark the chat read without sending receipts. | -| `useScheduledMessages` | "Отложка" — when ghosting, send via the scheduled-messages path. | -| `sendWithoutSound` | Outgoing message flags — set `silent` per `shouldSendWithoutSound`. | -| `suggestGhostBeforeStory` | Story viewer — present the ghost confirmation before opening. | +| Setting | Status | Hook | +| :-- | :-- | :-- | +| `ghostModeEnabled` | ✅ | Master switch read by the gates below. | +| `sendReadReceipts` | ✅ | `AccountContext.applyMaxReadIndex` (`submodules/TelegramUI/Sources/AccountContext.swift`) returns early in ghost mode. | +| `sendReadStories` | ✅ | All four `markAsSeen` implementations in `StoryChatContent.swift` return early in ghost mode. | +| `sendOnlineStatus` | ✅ | `SharedWakeupManager` forces `shouldKeepOnlinePresence` to false in ghost mode. | +| `sendUploadProgress` | ✅ | Typing-activity subscription in `ChatController.swift` skips `updateLocalInputActivity` in ghost mode. | +| `sendOfflineAfterOnline` | ⏳ | Emit a one-shot offline presence packet after going online. | +| `markReadAfterAction` | ⏳ | After replying/reacting, locally mark read without sending receipts. | +| `useScheduledMessages` | ✅ | "Отложка": `transformEnqueueMessages` in `ChatController.swift` routes ghost-mode sends through the scheduled path (now + 12 s) so sending doesn't mark the chat read. | +| `sendWithoutSound` | ✅ | `transformEnqueueMessages` computes `effectiveSilentPosting` from never / in-ghost / always. | +| `suggestGhostBeforeStory` | ⏳ | Story viewer — present the ghost confirmation before opening. | ## History & Recovery -| Setting | Hook | -| :-- | :-- | -| `saveDeletedMessages` | Hook the deletion path in `Postbox` history removal; mirror messages into a local store before they are purged. | -| `saveMessagesHistory` | On `EditMessage` updates, append the previous version to a local edit-history store. | -| `semiTransparentDeletedMessages` | `ChatMessageItemView` — render saved-deleted bubbles at reduced alpha. | -| `deletedMark` / `editedMark` | Message footer rendering in the bubble content nodes. | +| Setting | Status | Hook | +| :-- | :-- | :-- | +| `saveDeletedMessages` | ✅ | Remote deletions (`.DeleteMessages` / `.DeleteMessagesWithGlobalIds`) skipped in `AccountStateManagementUtils.swift` via `currentWinterGramCoreSettings`. | +| `saveMessagesHistory` | ✅ | On remote `.EditMessage`, the previous text/entities/timestamp are appended to `WinterGramEditHistoryAttribute` (`submodules/TelegramCore/Sources/WinterGram/`); registered in `AccountManager.swift`. Viewing UI: ⏳. | +| `semiTransparentDeletedMessages` | ⏳ | Render kept-deleted bubbles at reduced alpha. | -## Hidden Archive ("AАrchive") +## Hidden Archive ("ААрхив") -| Setting | Hook | -| :-- | :-- | -| `stashedPeerIds` | Filter the chat list to hide these peers from the main list; show them only in the dedicated WinterGram archive screen. | -| `stashMuteNotifications` | Notification service extension — suppress notifications for stashed peers. | -| `stashAutoMarkRead` | On receiving from a stashed peer, locally mark read (respecting Ghost Mode). | +| Setting | Status | Hook | +| :-- | :-- | :-- | +| `stashedPeerIds` | ✅ | Hidden from the main tab in `ChatListNodeEntries.swift`; stash/unstash via chat-list context menu (`ChatContextMenus.swift`); browse via Settings → WinterGram → Stashed Chats (`WinterGramStashController.swift`). | +| `stashMuteNotifications` | ✅ | In-app notification pipeline in `ApplicationContext.swift` drops banners for stashed peers. (APNs pushes need the NotificationService extension: ⏳.) | +| `stashAutoMarkRead` | ✅ | Same pipeline calls `applyMaxReadIndexInteractively` for stashed peers. | ## Anti-Features -| Setting | Hook | -| :-- | :-- | -| `disableAds` | Sponsored-messages fetch in `TelegramCore` (`getAdMessages`) — return empty when disabled. | -| `localPremium` | `isPremium` resolution in the UI layer — treat as Premium locally for gated UI. | -| `shadowBanIds` | Chat history filtering — drop incoming messages from these peers from the rendered list. | -| `disableStories` | Story list assembly — hide the stories strip. | -| `hidePremiumStatuses` | Peer title rendering — drop Premium/emoji-status badges. | -| `disableOpenLinkWarning` | URL open path — skip the "open this link?" confirmation. | +| Setting | Status | Hook | +| :-- | :-- | :-- | +| `disableAds` | ✅ | Ad insertion gate in `ChatHistoryEntriesForView.swift`. | +| `localPremium` | ✅ | `isPremium` resolution in `submodules/TelegramUI/Sources/AccountContext.swift`. | +| `shadowBanIds` | ✅ | Entry filter by author in `ChatHistoryEntriesForView.swift`. | +| `disableStories` | ✅ | `shouldDisplayStoriesInChatListHeader` in `ChatListControllerNode.swift` returns false. | +| `hidePremiumStatuses` | ✅ | `ChatTitleView` / `ChatTitleComponent` / `ChatListItem` / `ItemListPeerItem`. | +| `disableOpenLinkWarning` | ✅ | Concealed-link alert gate in `OpenUserGeneratedUrl.swift`. | + +## In-app purchases + +Fully disabled: `InAppPurchaseManager.buyProduct` fails immediately with `.cantMakePayments` +(every purchase screen maps that to a localized error), and `PremiumIntroScreen.buy()` shows a +"subscribe via the official Telegram app" alert before reaching the manager. Redeeming gift +codes still works (not an IAP). ## Chat Conveniences -| Setting | Hook | -| :-- | :-- | -| `stickerConfirmation` / `gifConfirmation` / `voiceConfirmation` | Send paths in the chat input panel — present a confirm alert before sending. | -| `showMessageSeconds` | Timestamp formatting in the bubble footer. | -| `showPeerId` | Peer info / chat title — append the ID in Telegram or Bot API form. | -| `translateMessages` / `translationProvider` | Message context menu translate action + provider selection. | -| `webviewSpoofPlatform` / `increaseWebviewHeight` | WebApp controller — set the spoofed `tg_platform` / viewport height. | +| Setting | Status | Hook | +| :-- | :-- | :-- | +| `stickerConfirmation` / `gifConfirmation` | ✅ | `ChatController.swift` send paths. | +| `voiceConfirmation` | ✅ | `ChatControllerMediaRecording.swift`. | +| `showMessageSeconds` | ✅ | `StringForMessageTimestampStatus.swift`. | +| `showPeerId` | ✅ | ID row (long-press copies) in `PeerInfoProfileItems.swift` for users and channels/groups, honoring Telegram/Bot-API format. | +| `translateMessages` / `translationProvider` | ⏳ | Message context-menu translate + provider. | +| `webviewSpoofPlatform` | ✅ | `BotWebView.swift` in TelegramCore reads `currentWinterGramCoreSettings.webviewPlatform` (ios / android / macos / tdesktop), fed by the settings observer. | +| `increaseWebviewHeight` | ⏳ | WebApp controller viewport. | ## Appearance & Customization -| Setting | Hook | -| :-- | :-- | -| `liquidGlass.*` | `Display` blur/material layers behind the chat list, nav bar, and tab bar; read `enabled`, `transparency`, `blurRadius`, `tintColor`, per-surface flags. | -| `materialDesign` | Switch/control styling in `ItemListUI` components. | -| `avatarCornerRadius` / `singleCornerRadius` | Avatar node corner rounding in `AvatarNode`. | -| `messageBubbleRadius` / `removeMessageTail` | Bubble background drawing in the chat message backgrounds. | -| `customFont` / `monoFont` | `PresentationData` font resolution. | -| `appIcon` / `iconPack` | Alternate-icon switching via `UIApplication.setAlternateIconName`; see `Telegram/Telegram-iOS/DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset`. | -| `showOnlyAddedEmojisAndStickers` | Emoji/sticker panel data sources — filter to installed packs. | +| Setting | Status | Hook | +| :-- | :-- | :-- | +| `liquidGlass.*` | ⏳ | Blur/material layers behind chat list, nav bar, tab bar. | +| `materialDesign` | ⏳ | Switch/control styling. | +| `avatarCornerRadius` / `singleCornerRadius` | ⏳ | `AvatarNode` corner rounding. Note: photos are circle-clipped inside the bitmap (`PeerAvatar.swift` `roundCorners` mask), so a real implementation must touch every render path, not just `imageNode.cornerRadius`. | +| `messageBubbleRadius` / `removeMessageTail` | ⏳ | Bubble background drawing. | +| `customFont` / `monoFont` | ⏳ | `PresentationData` font resolution. | +| `appIcon` / `iconPack` | ⏳ | Alternate-icon switching; assets in `DefaultAppIcon.xcassets/WinterGramDarkIcon.appiconset`. | +| `showOnlyAddedEmojisAndStickers` | ⏳ | Emoji/sticker panel data sources. | + +## Accounts + +- Unlimited accounts: `maximumNumberOfAccounts` / `maximumPremiumNumberOfAccounts` = 100 in + `submodules/AccountUtils/Sources/AccountUtils.swift`; the add-account flow in + `PeerInfoScreenSettingsActions.swift` uses the same constants. +- Account data is included in iCloud/iTunes device backups (`isExcludedFromBackup = false` in + `TelegramCore/Sources/Account/AccountManager.swift`). Tradeoff: session auth keys become part + of the backup. ## Deep links — `wnt://` @@ -90,7 +115,7 @@ Normalized to `tg://` at the app entry by `normalizeWinterGramUrlScheme(_:)` in ## Settings UI -A dedicated **WinterGram** entry should be added to the settings list -(`submodules/SettingsUI` / the PeerInfo settings screen) that opens an `ItemListController` -backed by `winterGramSettings(accountManager:)` and writing through -`updateWinterGramSettingsInteractively`. Group the rows by the sections above. +The **WinterGram** entry is the first row of Settings (snowball icon, +`PresentationResourcesSettings.winterGram`), opening +`submodules/SettingsUI/Sources/WinterGramSettingsController.swift`. The Hidden Archive browser +lives in `WinterGramStashController.swift` next to it. diff --git a/docs/wintergram-setup.md b/docs/wintergram-setup.md new file mode 100644 index 0000000000..3c0b2e6785 --- /dev/null +++ b/docs/wintergram-setup.md @@ -0,0 +1,176 @@ +# WinterGram Build & Install Guide + +This guide covers how to build WinterGram from source, run it in the iOS Simulator, install it on a physical iPhone without a paid Apple Developer Program, and sideload the resulting IPA. + +## Requirements + +- macOS with Xcode (the project is tested on Xcode 26.x) +- Python 3 +- ~60 GB of free disk space (Bazel cache + build artifacts) +- A free Apple ID (for on-device installation) + +## 1. Clone the repository + +```sh +git clone --recursive https://github.com/reekeer/WinterGram.git +cd WinterGram +``` + +If you cloned without `--recursive`: + +```sh +git submodule update --init --recursive +``` + +## 2. Configure build credentials + +WinterGram needs a development configuration with your own credentials. + +1. Copy the example file: + + ```sh + cp build-system/wintergram-development-configuration.example.json \ + build-system/wintergram-development-configuration.json + ``` + +2. Edit `build-system/wintergram-development-configuration.json`: + - `bundle_id` — a unique bundle identifier (e.g. `dev.you.wintergram`). + - `api_id` / `api_hash` — obtain from . + - `team_id` — your Apple Developer Team ID (a 10-character string). For a free Apple ID, create an Apple Development certificate in Xcode → Settings → Accounts, then find the Team ID in Keychain Access under the certificate's **Organizational Unit** field. + - `app_specific_url_scheme` is `wnt` by default. + +> **Important:** `api_id` and `api_hash` are baked into the app at build time. You cannot change them after installation because your Telegram session is tied to the `api_id` used when logging in. + +The committed example uses public test values (`api_id: 2040`). Replace them with your own before installing on a personal device. + +## 3. Build an unsigned IPA + +### Convenience wrapper (recommended) + +`scripts/build-wintergram.sh` wraps the Bazel/Make.py invocations and emits WinterGram-named IPAs in `build/`: + +```sh +./scripts/build-wintergram.sh sim # simulator -> build/WinterGram-Simulator.ipa +./scripts/build-wintergram.sh --install # build sim + install into the booted Simulator (sim only) +./scripts/build-wintergram.sh --install --run # also launch it +./scripts/build-wintergram.sh sideload # device IPA -> build/WinterGram.ipa +./scripts/build-wintergram.sh livecontainer # unsigned IPA -> build/WinterGram-LiveContainer.ipa +./scripts/build-wintergram.sh all # all of the above +``` + +`--clean` wipes `build/` first; `--open-build-dir` reveals the output in Finder; `--help` lists everything. + +The raw commands below are equivalent if you prefer to invoke Make.py directly. + +The fastest path to a device IPA is a terminal build: + +```sh +python3 build-system/Make/Make.py --overrideXcodeVersion \ + --cacheDir ~/telegram-bazel-cache \ + build \ + --configurationPath build-system/wintergram-development-configuration.json \ + --xcodeManagedCodesigning \ + --buildNumber=1 \ + --configuration=debug_arm64 +``` + +The finished `.ipa` is written to `bazel-bin/Telegram/`. + +For a simulator build, use `debug_sim_arm64` and fake codesigning files (the Simulator does not validate signatures, but Bazel still requires provisioning profiles): + +```sh +python3 build-system/Make/Make.py --overrideXcodeVersion \ + --cacheDir ~/telegram-bazel-cache \ + build \ + --configurationPath build-system/wintergram-development-configuration.json \ + --codesigningInformationPath build-system/fake-codesigning \ + --buildNumber=1 \ + --configuration=debug_sim_arm64 +``` + +Add `--continueOnError` after `build` to see every error in one pass. `--xcodeManagedCodesigning` is intended for `generateProject`; for terminal builds with extensions, use `--codesigningInformationPath build-system/fake-codesigning`. + +## 4. Install on a physical iPhone (free Apple ID) + +### With AltStore / SideStore + +1. Build a device IPA (`debug_arm64` or `release_arm64`) with your Team ID in the config. +2. Install AltStore on your phone (via AltServer on your Mac). +3. In AltStore, tap **+**, select the built `.ipa`, and let AltStore re-sign and install it. +4. A free Apple ID must re-sign the app every 7 days. AltStore and SideStore can refresh this automatically in the background. + +### With Xcode directly + +1. Generate the Xcode project (see below). +2. Select the **Telegram** scheme and your connected iPhone as the destination. +3. In the target's **Signing & Capabilities**, choose your Personal Team. +4. Press ⌘R. The first time, trust the developer on the device in Settings → General → VPN & Device Management. + +Free provisioning profiles last 7 days and are limited to 3 apps per Apple ID. Push notifications via APNs, Siri, and iCloud are unavailable with a free account, so the dev config has `enable_siri` and `enable_icloud` disabled. + +## 5. Generate an Xcode project + +```sh +python3 build-system/Make/Make.py --overrideXcodeVersion \ + --cacheDir ~/telegram-bazel-cache \ + generateProject \ + --configurationPath build-system/wintergram-development-configuration.json \ + --xcodeManagedCodesigning +``` + +To generate a simulator-only project without provisioning profiles, add `--disableProvisioningProfiles`. + +The command opens the generated `Telegram.xcodeproj` automatically. If it does not, the project is in the repository root. + +## 6. Run in the Simulator + +1. Open the generated `Telegram.xcodeproj`. +2. Select the **Telegram** scheme and any iPhone Simulator. +3. Press ⌘R. + +The first build is slow (40–90 minutes) because WebRTC and ffmpeg are compiled from source. Incremental builds are much faster. If Xcode hangs on "build-request.json not updated yet", cancel and run again; this is a known rules_xcodeproj quirk. + +## App icons + +All WinterGram app-icon assets are generated from source PNGs in `branding/` by a single script: + +```sh +./scripts/generate-app-icons.sh +``` + +Each source named `branding/wnt-app-icon-.png` (square, ≥1024×1024) becomes an alternate +icon `WinterGram`. The two shipping icons are `wnt-app-icon-dark.png` (also the primary +home-screen icon) and `wnt-app-icon-light.png`. The script regenerates the `.alticon` folders, the +primary `WinterGramDarkIcon.appiconset`, and the in-app preview imagesets in one pass. + +A brand-new variant is generated but must still be registered manually — the script prints the three +spots: `Telegram/BUILD` (`alternate_icon_folders`), `AppDelegate.getAvailableAlternateIcons()`, and the +preview-imageset mapping in `applicationBindings`. + +## Repository layout + +``` +WinterGram/ +├── Telegram/ ← App entry point and extensions +│ ├── Telegram-iOS/ ← Info.plist, assets, xcconfig +│ ├── Share / SiriIntents / NotificationService / ... +│ └── WatchApp/ ← watchOS client snapshot +├── submodules/ ← Functionality split into Bazel modules +│ ├── TelegramUIPreferences/Sources/WinterGramSettings.swift +│ └── SettingsUI/Sources/WinterGramSettingsController.swift +├── branding/ ← Source art: app-icon PNGs (wnt-app-icon-*.png) + badge/snowflake shapes +├── scripts/ ← Build + tooling +│ ├── build-wintergram.sh ← Convenience build wrapper (sim / sideload / livecontainer) +│ └── generate-app-icons.sh ← App-icon generator (reads branding/wnt-app-icon-*.png) +├── docs/ ← Documentation +├── build-system/ ← Bazel wrapper and configs +│ └── wintergram-development-configuration.json ← your dev config +├── third-party/ ← External dependencies +└── Tests/ ← Bazel test app targets +``` + +## Troubleshooting + +- **"No .mobileprovision targets for extensions"** when using `--xcodeManagedCodesigning` in a terminal build: use `--codesigningInformationPath build-system/fake-codesigning` instead, or use `generateProject` for Xcode-managed signing. +- **Build fails on the first try:** ensure submodules are fully initialized and you have enough disk space. +- **App crashes on launch after re-signing:** make sure the `bundle_id` in your config is unique and the `team_id` matches the certificate used to sign. diff --git a/scripts/build-wintergram.sh b/scripts/build-wintergram.sh new file mode 100755 index 0000000000..18f1f1cf2b --- /dev/null +++ b/scripts/build-wintergram.sh @@ -0,0 +1,300 @@ +#!/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 + +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" +LC_NAME="WinterGram-LiveContainer.ipa" +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" + +MODE="all" +INSTALL_SIM=0 +RUN_SIM=0 +CLEAN=0 +OPEN_BUILD_DIR=0 + +usage() { + cat < 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). + --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 --install + $0 --install --run + $0 sideload --clean +EOF +} + +die() { + echo "ERROR: $*" >&2 + exit 1 +} + +ipa_size() { + du -h "$1" | cut -f1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing command: $1" +} + +# --- args ------------------------------------------------------------------ + +MODE_WAS_EXPLICIT=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + all|sideload|device|livecontainer|lc|sim|simulator) + MODE="$1" + MODE_WAS_EXPLICIT=1 + ;; + --install) + INSTALL_SIM=1 + ;; + --run) + RUN_SIM=1 + INSTALL_SIM=1 + ;; + --clean) + CLEAN=1 + ;; + --open-build-dir) + OPEN_BUILD_DIR=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown argument: $1" + ;; + esac + 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" +fi + +if [ "$CLEAN" -eq 1 ]; then + echo "==> Cleaning ./$OUT_DIR ..." + rm -rf "$OUT_DIR" +fi + +mkdir -p "$OUT_DIR" + +# --- simulator ------------------------------------------------------------- + +build_sim() { + echo "==> [Simulator] build (debug_sim_arm64) ..." + 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 \ + --disableExtensions \ + --buildNumber=1 --configuration=debug_sim_arm64 + + [ -f "$DEVICE_SRC" ] || die "simulator artifact not found at $DEVICE_SRC" + + cp -f "$DEVICE_SRC" "$OUT_DIR/$SIM_NAME" + echo "==> [Simulator] done: $OUT_DIR/$SIM_NAME ($(ipa_size "$OUT_DIR/$SIM_NAME"))" +} + +ensure_booted_simulator() { + require_cmd xcrun + + if ! xcrun simctl list devices booted | grep -q "(Booted)"; then + die "no active booted Simulator found. Open Simulator.app and boot a device first." + fi +} + +install_sim() { + ensure_booted_simulator + + local IPA="$OUT_DIR/$SIM_NAME" + [ -f "$IPA" ] || die "simulator IPA not found at $IPA" + + echo "==> [Simulator] installing into active booted Simulator ..." + + 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" + } + + xcrun simctl install booted "$APP_PATH" + + local INSTALLED_BUNDLE_ID + INSTALLED_BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$APP_PATH/Info.plist" 2>/dev/null || true)" + + echo "==> [Simulator] installed: $(basename "$APP_PATH")" + + if [ "$RUN_SIM" -eq 1 ]; then + [ -n "$INSTALLED_BUNDLE_ID" ] || { + rm -rf "$TMP_DIR" + die "could not read CFBundleIdentifier from app Info.plist" + } + + echo "==> [Simulator] launching $INSTALLED_BUNDLE_ID ..." + xcrun simctl launch booted "$INSTALLED_BUNDLE_ID" || true + fi + + rm -rf "$TMP_DIR" +} + +# --- device build shared by sideload + livecontainer ----------------------- + +ensure_cert() { + # Device builds need a codesigning identity in the keychain, even a throwaway one. + if ! security find-certificate -c "Apple Distribution: Telegram FZ-LLC (C67CF9S4VU)" >/dev/null 2>&1; then + echo "==> Importing throwaway signing cert into login keychain ..." + security import build-system/fake-codesigning/certs/SelfSigned.p12 -P "" -A >/dev/null 2>&1 || true + fi +} + +build_device() { + ensure_cert + + 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 \ + --disableExtensions \ + --buildNumber=1 --configuration=debug_arm64 + + [ -f "$DEVICE_SRC" ] || die "device artifact not found at $DEVICE_SRC" +} + +make_sideload() { + cp -f "$DEVICE_SRC" "$OUT_DIR/$SIDELOAD_NAME" + echo "==> [Sideload] done: $OUT_DIR/$SIDELOAD_NAME ($(ipa_size "$OUT_DIR/$SIDELOAD_NAME"))" +} + +make_livecontainer() { + echo "==> [LiveContainer] repackaging unsigned ..." + + local TMP_DIR + TMP_DIR="$(mktemp -d)" + + unzip -q "$DEVICE_SRC" -d "$TMP_DIR" + + find "$TMP_DIR/Payload/Telegram.app" -type f \( -name Telegram -o -name "*.dylib" \) -print0 2>/dev/null \ + | xargs -0 codesign --remove-signature 2>/dev/null || true + + find "$TMP_DIR/Payload/Telegram.app" -type d -name "*.framework" -print0 2>/dev/null | while IFS= read -r -d '' fw; do + local bin + bin="$fw/$(basename "$fw" .framework)" + [ -f "$bin" ] && codesign --remove-signature "$bin" 2>/dev/null || true + done + + find "$TMP_DIR" -type d \( -name _CodeSignature -o -name SC_Info \) -exec rm -rf {} + 2>/dev/null || true + find "$TMP_DIR/Payload/Telegram.app" -name "embedded.mobileprovision" -delete 2>/dev/null || true + + rm -f "$OUT_DIR/$LC_NAME" + + local ZIP_ITEMS="Payload" + [ -d "$TMP_DIR/SwiftSupport" ] && ZIP_ITEMS="$ZIP_ITEMS SwiftSupport" + + ( + cd "$TMP_DIR" + zip -qr "$REPO/$OUT_DIR/$LC_NAME" $ZIP_ITEMS + ) + + rm -rf "$TMP_DIR" + + echo "==> [LiveContainer] done: $OUT_DIR/$LC_NAME ($(ipa_size "$OUT_DIR/$LC_NAME"))" +} + +# --- main ------------------------------------------------------------------ + +case "$MODE" in + sim|simulator) + build_sim + ;; + sideload|device) + build_device + make_sideload + ;; + livecontainer|lc) + build_device + 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. + build_device + make_sideload + make_livecontainer + build_sim + ;; + *) + die "unknown mode: $MODE" + ;; +esac + +if [ "$INSTALL_SIM" -eq 1 ]; then + # --install forced MODE=sim above, so the simulator IPA was just built — install it. + install_sim +fi + +echo +echo "==> WinterGram deliverables in ./$OUT_DIR/:" +ls -1 "$OUT_DIR"/WinterGram*.ipa 2>/dev/null | sed 's#^# #' || true + +if [ "$OPEN_BUILD_DIR" -eq 1 ]; then + open "$OUT_DIR" +fi \ No newline at end of file diff --git a/scripts/generate-branding.sh b/scripts/generate-branding.sh new file mode 100755 index 0000000000..4d46ae6180 --- /dev/null +++ b/scripts/generate-branding.sh @@ -0,0 +1,153 @@ +#!/bin/bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { echo "ERROR: missing command: $1" >&2; exit 1; } +} + +require_cmd sips +require_cmd python3 + +BRANDING_DIR="branding" +ALTICON_BASE="Telegram/Telegram-iOS" +IMAGESET_BASE="Telegram/Telegram-iOS/AppIcons.xcassets" +DEFAULT_ASSET_BASE="Telegram/Telegram-iOS/DefaultAppIcon.xcassets" +PRIMARY_APPICONSET="$DEFAULT_ASSET_BASE/WinterGramDarkIcon.appiconset" + +resize_square() { + sips -s format png -z "$3" "$3" "$1" --out "$2" >/dev/null +} + +resize_rect() { + sips -s format png -z "$4" "$3" "$1" --out "$2" >/dev/null +} + +capitalize_words() { + python3 - "$1" <<'PY' +import re, sys +value = sys.argv[1] +print("".join(part[:1].upper() + part[1:] for part in re.split(r"[-_]+", value) if part)) +PY +} + +write_imageset_contents() { + local dir="$1" name="$2" + cat > "$dir/Contents.json" <.png" >&2 + exit 1 +fi + +wintergram_icons=() +for src in "${icon_sources[@]}"; do + base="$(basename "$src" .png)" + variant="${base#icon-app-}" + setname="WinterGram$(capitalize_words "$variant")" + wintergram_icons+=("$setname") + echo "==> icon $setname from $src" + generate_alticon "$src" "$setname" + generate_icon_imageset "$src" "$setname" + if [ "$variant" = "dark" ]; then + generate_primary_appiconset "$src" + fi +done + +banner_assets=() +for src in "${banner_sources[@]}"; do + base="$(basename "$src" .png)" + variant="${base#banner-}" + if [ "$variant" = "wintergram" ]; then + asset="WntGramBanner" + else + asset="WntGramBanner$(capitalize_words "$variant")" + fi + banner_assets+=("$asset") + echo "==> banner $asset from $src" + generate_banner_imageset "$src" "$asset" +done + +python3 - "${wintergram_icons[@]}" <<'PY' +import pathlib, re, sys +icons = sys.argv[1:] + +build = pathlib.Path("Telegram/BUILD") +text = build.read_text() +match = re.search(r"alternate_icon_folders = \[\n(.*?)\n\]", text, re.S) +if match: + existing = re.findall(r'"([^"]+)"', match.group(1)) + non_wintergram = [name for name in existing if not name.startswith("WinterGram") and pathlib.Path(f"Telegram/Telegram-iOS/{name}.alticon").is_dir()] + names = sorted(non_wintergram + icons) + block = "alternate_icon_folders = [\n" + "".join(f' "{name}",\n' for name in names) + "]" + text = text[:match.start()] + block + text[match.end():] + build.write_text(text) + +app_delegate = pathlib.Path("submodules/TelegramUI/Sources/AppDelegate.swift") +text = app_delegate.read_text() +pattern = r'var icons = \[\n(.*?)PresentationAppIcon\(name: "BlueIcon"' +match = re.search(pattern, text, re.S) +if match: + wintergram_lines = "".join(f' PresentationAppIcon(name: "{name}", imageName: "{name}"),\n' for name in icons) + replacement = "var icons = [\n" + wintergram_lines + ' PresentationAppIcon(name: "BlueIcon"' + text = text[:match.start()] + replacement + text[match.end():] + app_delegate.write_text(text) +PY + +echo +echo "==> Generated WinterGram icons: ${wintergram_icons[*]}" +if [ "${#banner_assets[@]}" -gt 0 ]; then + echo "==> Generated banners: ${banner_assets[*]}" +fi diff --git a/scripts/generate_wintergram_icon.swift b/scripts/generate_wintergram_icon.swift new file mode 100644 index 0000000000..05d7e7aafa --- /dev/null +++ b/scripts/generate_wintergram_icon.swift @@ -0,0 +1,70 @@ +#!/usr/bin/env swift +import Foundation +import CoreGraphics +import ImageIO + +let projectRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) +let backplateURL = projectRoot.appendingPathComponent("branding/backplate_badge.png") +let snowflakeURL = projectRoot.appendingPathComponent("branding/snowflake_monochrome.png") +let outputDir = projectRoot.appendingPathComponent("submodules/TelegramUI/Images.xcassets/Item List/Icons/WinterGram.imageset") + +func loadImage(url: URL) -> CGImage? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + return CGImageSourceCreateImageAtIndex(source, 0, nil) +} + +func saveImage(_ image: CGImage, url: URL) { + guard let destination = CGImageDestinationCreateWithURL(url as CFURL, kUTTypePNG, 1, nil) else { + fatalError("Cannot create PNG destination for \(url.path)") + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + fatalError("Failed to write \(url.path)") + } +} + +func composedBadge(backplate: CGImage, snowflake: CGImage, pixelSize: CGFloat) -> CGImage? { + let size = CGSize(width: pixelSize, height: pixelSize) + guard let context = CGContext( + data: nil, + width: Int(pixelSize), + height: Int(pixelSize), + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + + context.clear(CGRect(origin: .zero, size: size)) + + // Backplate fills the badge canvas (matches the runtime badge composition). + context.draw(backplate, in: CGRect(origin: .zero, size: size)) + + // Snowflake is 756/1024 of the badge, centred. + let snowflakeSize = pixelSize * 756.0 / 1024.0 + let snowflakeOffset = (pixelSize - snowflakeSize) / 2.0 + context.draw(snowflake, in: CGRect(x: snowflakeOffset, y: snowflakeOffset, width: snowflakeSize, height: snowflakeSize)) + + return context.makeImage() +} + +guard let backplate = loadImage(url: backplateURL) else { + fatalError("Cannot load backplate at \(backplateURL.path)") +} +guard let snowflake = loadImage(url: snowflakeURL) else { + fatalError("Cannot load snowflake at \(snowflakeURL.path)") +} + +let sizes: [(name: String, px: CGFloat)] = [ + ("wintergram_snowflake_30@2x.png", 60.0), + ("wintergram_snowflake_30@3x.png", 90.0) +] + +for (name, px) in sizes { + guard let image = composedBadge(backplate: backplate, snowflake: snowflake, pixelSize: px) else { + fatalError("Failed to compose \(name)") + } + let url = outputDir.appendingPathComponent(name) + saveImage(image, url: url) + print("Wrote \(url.path)") +} diff --git a/submodules/AvatarNode/BUILD b/submodules/AvatarNode/BUILD index 3aad72c509..c366835308 100644 --- a/submodules/AvatarNode/BUILD +++ b/submodules/AvatarNode/BUILD @@ -14,6 +14,7 @@ swift_library( "//submodules/Display:Display", "//submodules/TelegramCore:TelegramCore", "//submodules/Postbox", + "//submodules/TelegramUIPreferences:TelegramUIPreferences", "//submodules/TelegramPresentationData:TelegramPresentationData", "//submodules/AnimationUI:AnimationUI", "//submodules/AppBundle:AppBundle", diff --git a/submodules/AvatarNode/Sources/AvatarNode.swift b/submodules/AvatarNode/Sources/AvatarNode.swift index e690da30ad..fc3ae5828f 100644 --- a/submodules/AvatarNode/Sources/AvatarNode.swift +++ b/submodules/AvatarNode/Sources/AvatarNode.swift @@ -6,6 +6,7 @@ import TelegramCore import Postbox import SwiftSignalKit import TelegramPresentationData +import TelegramUIPreferences import AnimationUI import AppBundle import AccountContext @@ -50,7 +51,7 @@ private class AvatarNodeParameters: NSObject { let hasImage: Bool let clipStyle: AvatarNodeClipStyle let cutoutRect: CGRect? - + init(theme: PresentationTheme?, accountPeerId: EnginePeer.Id?, peerId: EnginePeer.Id?, colors: [UIColor], letters: [String], font: UIFont, icon: AvatarNodeIcon, explicitColorIndex: Int?, hasImage: Bool, clipStyle: AvatarNodeClipStyle, cutoutRect: CGRect?) { self.theme = theme self.accountPeerId = accountPeerId @@ -63,10 +64,10 @@ private class AvatarNodeParameters: NSObject { self.hasImage = hasImage self.clipStyle = clipStyle self.cutoutRect = cutoutRect - + super.init() } - + func withUpdatedHasImage(_ hasImage: Bool) -> AvatarNodeParameters { return AvatarNodeParameters(theme: self.theme, accountPeerId: self.accountPeerId, peerId: self.peerId, colors: self.colors, letters: self.letters, font: self.font, icon: self.icon, explicitColorIndex: self.explicitColorIndex, hasImage: hasImage, clipStyle: self.clipStyle, cutoutRect: self.cutoutRect) } @@ -87,7 +88,7 @@ public func calculateAvatarColors(context: AccountContext?, explicitColorIndex: colorIndex = -1 } } - + let colors: [UIColor] if icon != .none { if case .deletedIcon = icon { @@ -158,7 +159,7 @@ public func calculateAvatarColors(context: AccountContext?, explicitColorIndex: } return index } - + switch nameColor { case let .preset(nameColor): if let context, nameColor.rawValue > 13 { @@ -173,12 +174,12 @@ public func calculateAvatarColors(context: AccountContext?, explicitColorIndex: let index = colorIndexFromColor(color: color) colors = AvatarNode.gradientColors[index % AvatarNode.gradientColors.count] } - + } else { colors = AvatarNode.gradientColors[colorIndex % AvatarNode.gradientColors.count] } } - + return colors } @@ -243,36 +244,36 @@ public enum AvatarNodeColorOverride { public final class AvatarEditOverlayNode: ASDisplayNode { override public init() { super.init() - + self.isOpaque = false self.displaysAsynchronously = true } - + @objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) { assertNotOnMainThread() - + let context = UIGraphicsGetCurrentContext()! - + if !isRasterizing { context.setBlendMode(.copy) context.setFillColor(UIColor.clear.cgColor) context.fill(bounds) } - + context.beginPath() context.addEllipse(in: CGRect(x: 0.0, y: 0.0, width: bounds.size.width, height: bounds.size.height)) context.clip() - + context.setFillColor(UIColor(rgb: 0x000000, alpha: 0.4).cgColor) context.fill(bounds) - + context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: 1.0, y: -1.0) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - + context.setBlendMode(.normal) - + if bounds.width > 90.0 { if let editAvatarIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/EditAvatarIconLarge"), color: .white) { context.draw(editAvatarIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - editAvatarIcon.size.width) / 2.0) + 0.5, y: floor((bounds.size.height - editAvatarIcon.size.height) / 2.0) + 1.0), size: editAvatarIcon.size)) @@ -298,9 +299,9 @@ public final class AvatarNode: ASDisplayNode { context.fillPath() }) } - + public static let avatarBubblePath: CGPath = generateAvatarBubblePath() - + public static func addAvatarBubblePath(context: CGContext, rect: CGRect) { let path = AvatarNode.avatarBubblePath let sx = rect.width / 60.0 @@ -314,7 +315,7 @@ public final class AvatarNode: ASDisplayNode { let transformedPath = path.copy(using: &transform)! context.addPath(transformedPath) } - + public static let gradientColors: [[UIColor]] = [ [UIColor(rgb: 0xff516a), UIColor(rgb: 0xff885e)], [UIColor(rgb: 0xffa85c), UIColor(rgb: 0xffcd6a)], @@ -324,30 +325,30 @@ public final class AvatarNode: ASDisplayNode { [UIColor(rgb: 0x2a9ef1), UIColor(rgb: 0x72d5fd)], [UIColor(rgb: 0xd669ed), UIColor(rgb: 0xe0a2f3)], ] - + static let grayscaleColors: [UIColor] = [ UIColor(rgb: 0xb1b1b1), UIColor(rgb: 0xcdcdcd) ] - + static let grayscaleDarkColors: [UIColor] = [ UIColor(white: 1.0, alpha: 0.22), UIColor(white: 1.0, alpha: 0.18) ] - + static let savedMessagesColors: [UIColor] = [ UIColor(rgb: 0x2a9ef1), UIColor(rgb: 0x72d5fd) ] - + static let repostColors: [UIColor] = [ UIColor(rgb: 0x3DA1FD), UIColor(rgb: 0x34C76F) ] - + public final class ContentNode: ASDisplayNode { private struct Params: Equatable { let peerId: EnginePeer.Id? let resourceId: String? let displayDimensions: CGSize let clipStyle: AvatarNodeClipStyle - + init( peerId: EnginePeer.Id?, resourceId: String?, @@ -360,14 +361,14 @@ public final class AvatarNode: ASDisplayNode { self.clipStyle = clipStyle } } - + public var font: UIFont { didSet { if oldValue.pointSize != font.pointSize { if let parameters = self.parameters { self.parameters = AvatarNodeParameters(theme: parameters.theme, accountPeerId: parameters.accountPeerId, peerId: parameters.peerId, colors: parameters.colors, letters: parameters.letters, font: self.font, icon: parameters.icon, explicitColorIndex: parameters.explicitColorIndex, hasImage: parameters.hasImage, clipStyle: parameters.clipStyle, cutoutRect: parameters.cutoutRect) } - + if !self.displaySuspended { self.setNeedsDisplay() } @@ -380,16 +381,16 @@ public final class AvatarNode: ASDisplayNode { public let imageNode: ImageNode private var imageNodeMask: UIImageView? public var editOverlayNode: AvatarEditOverlayNode? - + private let imageReadyDisposable = MetaDisposable() fileprivate var state: AvatarNodeState = .empty - + public var unroundedImage: UIImage? private var currentImage: UIImage? - + private var params: Params? private var loadDisposable = MetaDisposable() - + var clipStyle: AvatarNodeClipStyle { if let params = self.params { return params.clipStyle @@ -398,7 +399,7 @@ public final class AvatarNode: ASDisplayNode { } return .none } - + public var badgeView: AvatarBadgeView? { didSet { if self.badgeView !== oldValue { @@ -420,7 +421,7 @@ public final class AvatarNode: ASDisplayNode { } } } - + private let imageReady = Promise(false) public var ready: Signal { let imageReady = self.imageReady @@ -432,49 +433,49 @@ public final class AvatarNode: ASDisplayNode { }) } } - + public init(font: UIFont) { self.font = font self.imageNode = ImageNode(enableHasImage: true, enableAnimatedTransition: true) - + super.init() - + self.isOpaque = false self.displaysAsynchronously = true self.disableClearContentsOnHide = true - + self.imageNode.isUserInteractionEnabled = false self.addSubnode(self.imageNode) - + self.imageNode.contentUpdated = { [weak self] image in guard let self else { return } - + self.currentImage = image - + guard let badgeView = self.badgeView, let parameters = self.parameters else { return } - + if parameters.hasImage, let image { badgeView.update(content: .image(image)) } } } - + deinit { self.loadDisposable.dispose() } - + override public func didLoad() { super.didLoad() - + if #available(iOSApplicationExtension 11.0, iOS 11.0, *), !self.isLayerBacked { self.view.accessibilityIgnoresInvertColors = true } } - + public func updateSize(size: CGSize) { self.imageNode.frame = CGRect(origin: CGPoint(), size: size) self.editOverlayNode?.frame = self.imageNode.frame @@ -486,12 +487,12 @@ public final class AvatarNode: ASDisplayNode { self.editOverlayNode?.setNeedsDisplay() } } - + public func playArchiveAnimation() { guard let theme = self.theme else { return } - + var iconColor = theme.chatList.unpinnedArchiveAvatarColor.foregroundColor var backgroundColor = theme.chatList.unpinnedArchiveAvatarColor.backgroundColors.topColor let animationBackgroundNode = ASImageNode() @@ -510,9 +511,9 @@ public final class AvatarNode: ASDisplayNode { backgroundColor = backgroundColors.1.mixedWith(backgroundColors.0, alpha: 0.5) animationBackgroundNode.image = generateGradientFilledCircleImage(diameter: self.imageNode.frame.width, colors: colors) } - + self.addSubnode(animationBackgroundNode) - + let animationNode = AnimationNode(animation: "anim_archiveAvatar", colors: ["box1.box1.Fill 1": iconColor, "box3.box3.Fill 1": iconColor, "box2.box2.Fill 1": backgroundColor], scale: 0.1653828) animationNode.isUserInteractionEnabled = false animationNode.completion = { [weak animationBackgroundNode, weak self] in @@ -520,11 +521,11 @@ public final class AvatarNode: ASDisplayNode { animationBackgroundNode?.removeFromSupernode() } animationBackgroundNode.addSubnode(animationNode) - + animationBackgroundNode.layer.animateScale(from: 1.0, to: 1.07, duration: 0.12, removeOnCompletion: false, completion: { [weak animationBackgroundNode] finished in animationBackgroundNode?.layer.animateScale(from: 1.07, to: 1.0, duration: 0.12, removeOnCompletion: false) }) - + if var size = animationNode.preferredSize() { size = CGSize(width: ceil(size.width), height: ceil(size.height)) animationNode.frame = CGRect(x: floor((self.bounds.width - size.width) / 2.0), y: floor((self.bounds.height - size.height) / 2.0) + 1.0, width: size.width, height: size.height) @@ -532,12 +533,12 @@ public final class AvatarNode: ASDisplayNode { } self.imageNode.isHidden = true } - + public func playRepostAnimation() { let animationNode = AnimationNode(animation: "anim_storyrepost", colors: [:], scale: 0.11) animationNode.isUserInteractionEnabled = false self.addSubnode(animationNode) - + if var size = animationNode.preferredSize() { size = CGSize(width: ceil(size.width), height: ceil(size.height)) animationNode.frame = CGRect(x: floor((self.bounds.width - size.width) / 2.0), y: floor((self.bounds.height - size.height) / 2.0) + 1.0, width: size.width, height: size.height) @@ -546,18 +547,18 @@ public final class AvatarNode: ASDisplayNode { }) } } - + public func playCameraAnimation() { let animationBackgroundNode = ASImageNode() animationBackgroundNode.isUserInteractionEnabled = false animationBackgroundNode.frame = self.imageNode.frame animationBackgroundNode.image = generateGradientFilledCircleImage(diameter: self.imageNode.frame.width, colors: AvatarNode.repostColors.map { $0.cgColor } as NSArray) self.addSubnode(animationBackgroundNode) - + let animationNode = AnimationNode(animation: "anim_camera", colors: [:], scale: 0.082) animationNode.isUserInteractionEnabled = false self.addSubnode(animationNode) - + if var size = animationNode.preferredSize() { size = CGSize(width: ceil(size.width), height: ceil(size.height)) animationNode.frame = CGRect(x: floor((self.bounds.width - size.width) / 2.0) + 1.0, y: floor((self.bounds.height - size.height) / 2.0), width: size.width, height: size.height) @@ -570,7 +571,7 @@ public final class AvatarNode: ASDisplayNode { }) } } - + public func setPeer( accountPeerId: EnginePeer.Id, postbox: Postbox, @@ -634,15 +635,14 @@ public final class AvatarNode: ASDisplayNode { } else if peer?.restrictionText(platform: "ios", contentSettings: contentSettings) == nil { representation = peer?.smallProfileImage } - let updatedState: AvatarNodeState = .peerAvatar(peer?.id ?? EnginePeer.Id(0), peer?.nameColor, peer?.displayLetters ?? [], representation, clipStyle, cutoutRect) if updatedState != self.state || overrideImage != self.overrideImage || theme !== self.theme { self.state = updatedState self.overrideImage = overrideImage self.theme = theme - + let parameters: AvatarNodeParameters - + if let peer = peer, let signal = peerAvatarImage(postbox: postbox, peerReference: PeerReference(peer), authorOfMessage: authorOfMessage, representation: representation, displayDimensions: displayDimensions, clipStyle: clipStyle, emptyColor: emptyColor, synchronousLoad: synchronousLoad, provideUnrounded: storeUnrounded, cutoutRect: cutoutRect) { self.contents = nil self.displaySuspended = true @@ -655,21 +655,21 @@ public final class AvatarNode: ASDisplayNode { |> map { next -> UIImage? in return next?.0 }) - + if case .editAvatarIcon = icon { if self.editOverlayNode == nil { let editOverlayNode = AvatarEditOverlayNode() editOverlayNode.frame = self.imageNode.frame editOverlayNode.isUserInteractionEnabled = false self.addSubnode(editOverlayNode) - + self.editOverlayNode = editOverlayNode } self.editOverlayNode?.isHidden = false } else { self.editOverlayNode?.isHidden = true } - + parameters = AvatarNodeParameters(theme: theme, accountPeerId: accountPeerId, peerId: peer.id, colors: calculateAvatarColors(context: nil, explicitColorIndex: nil, peerId: peer.id, nameColor: peer.nameColor, icon: icon, theme: theme), letters: peer.displayLetters, font: self.font, icon: icon, explicitColorIndex: nil, hasImage: true, clipStyle: clipStyle, cutoutRect: cutoutRect) } else { self.imageReady.set(.single(true)) @@ -677,11 +677,11 @@ public final class AvatarNode: ASDisplayNode { if self.isNodeLoaded { self.imageNode.contents = nil } - + self.editOverlayNode?.isHidden = true let colors = calculateAvatarColors(context: nil, explicitColorIndex: nil, peerId: peer?.id ?? EnginePeer.Id(0), nameColor: peer?.nameColor, icon: icon, theme: theme) parameters = AvatarNodeParameters(theme: theme, accountPeerId: accountPeerId, peerId: peer?.id ?? EnginePeer.Id(0), colors: colors, letters: peer?.displayLetters ?? [], font: self.font, icon: icon, explicitColorIndex: nil, hasImage: false, clipStyle: clipStyle, cutoutRect: cutoutRect) - + if let badgeView = self.badgeView { let badgeColor: UIColor if colors.isEmpty { @@ -701,7 +701,7 @@ public final class AvatarNode: ASDisplayNode { } } } - + func setPeerV2( context genericContext: AccountContext, account: Account? = nil, @@ -727,21 +727,22 @@ public final class AvatarNode: ASDisplayNode { } let previousSize = self.params?.displayDimensions self.params = params - + switch clipStyle { case .none: self.imageNode.clipsToBounds = false self.imageNode.cornerRadius = 0.0 case .round: self.imageNode.clipsToBounds = true - self.imageNode.cornerRadius = displayDimensions.height * 0.5 + // WinterGram: avatarCornerRadius (0=square ... 50=fully round) as a percentage of height. + self.imageNode.cornerRadius = displayDimensions.height * (CGFloat(currentWinterGramSettings.avatarCornerRadius) / 100.0) case .roundedRect: self.imageNode.clipsToBounds = true self.imageNode.cornerRadius = displayDimensions.height * 0.25 case .bubble: break } - + if case .bubble = clipStyle { var updateMask = false let imageNodeMask: UIImageView @@ -762,7 +763,7 @@ public final class AvatarNode: ASDisplayNode { self.imageNodeMask = nil self.imageNode.view.mask = nil } - + if let imageCache = genericContext.imageCache as? DirectMediaImageCache, let peer, let smallProfileImage = peer.smallProfileImage, let peerReference = PeerReference(peer) { if let result = imageCache.getAvatarImage(peer: peerReference, resource: MediaResourceReference.avatar(peer: peerReference, resource: smallProfileImage.resource), immediateThumbnail: peer.profileImageRepresentations.first?.immediateThumbnailData, size: Int(displayDimensions.width * UIScreenScale), synchronous: synchronousLoad) { if let image = result.image { @@ -779,7 +780,7 @@ public final class AvatarNode: ASDisplayNode { } } } - + public func setPeer( context genericContext: AccountContext, account: Account? = nil, @@ -841,17 +842,16 @@ public final class AvatarNode: ASDisplayNode { } else if peer?.restrictionText(platform: "ios", contentSettings: genericContext.currentContentSettings.with { $0 }) == nil { representation = peer?.smallProfileImage } - let updatedState: AvatarNodeState = .peerAvatar(peer?.id ?? EnginePeer.Id(0), peer?.nameColor, peer?.displayLetters ?? [], representation, clipStyle, cutoutRect) if updatedState != self.state || overrideImage != self.overrideImage || theme !== self.theme { self.state = updatedState self.overrideImage = overrideImage self.theme = theme - + let parameters: AvatarNodeParameters - + let account = account ?? genericContext.account - + if let peer = peer, let signal = peerAvatarImage(account: account, peerReference: PeerReference(peer), authorOfMessage: authorOfMessage, representation: representation, displayDimensions: displayDimensions, clipStyle: clipStyle, emptyColor: emptyColor, synchronousLoad: synchronousLoad, provideUnrounded: storeUnrounded, cutoutRect: cutoutRect) { self.contents = nil self.displaySuspended = true @@ -864,21 +864,21 @@ public final class AvatarNode: ASDisplayNode { |> map { next -> UIImage? in return next?.0 }) - + if case .editAvatarIcon = icon { if self.editOverlayNode == nil { let editOverlayNode = AvatarEditOverlayNode() editOverlayNode.frame = self.imageNode.frame editOverlayNode.isUserInteractionEnabled = false self.addSubnode(editOverlayNode) - + self.editOverlayNode = editOverlayNode } self.editOverlayNode?.isHidden = false } else { self.editOverlayNode?.isHidden = true } - + parameters = AvatarNodeParameters(theme: theme, accountPeerId: account.peerId, peerId: peer.id, colors: calculateAvatarColors(context: genericContext, explicitColorIndex: nil, peerId: peer.id, nameColor: peer.nameColor, icon: icon, theme: theme), letters: peer.displayLetters, font: self.font, icon: icon, explicitColorIndex: nil, hasImage: true, clipStyle: clipStyle, cutoutRect: cutoutRect) } else { self.imageReady.set(.single(true)) @@ -886,11 +886,11 @@ public final class AvatarNode: ASDisplayNode { if self.isNodeLoaded { self.imageNode.contents = nil } - + self.editOverlayNode?.isHidden = true let colors = calculateAvatarColors(context: genericContext, explicitColorIndex: nil, peerId: peer?.id ?? EnginePeer.Id(0), nameColor: peer?.nameColor, icon: icon, theme: theme) parameters = AvatarNodeParameters(theme: theme, accountPeerId: account.peerId, peerId: peer?.id ?? EnginePeer.Id(0), colors: colors, letters: peer?.displayLetters ?? [], font: self.font, icon: icon, explicitColorIndex: nil, hasImage: false, clipStyle: clipStyle, cutoutRect: cutoutRect) - + if let badgeView = self.badgeView { let badgeColor: UIColor if colors.isEmpty { @@ -910,7 +910,7 @@ public final class AvatarNode: ASDisplayNode { } } } - + public func setCustomLetters(_ letters: [String], explicitColor: AvatarNodeColorOverride? = nil, icon: AvatarNodeExplicitIcon? = nil, cutoutRect: CGRect? = nil) { var explicitIndex: Int? if let explicitColor = explicitColor { @@ -922,52 +922,57 @@ public final class AvatarNode: ASDisplayNode { let updatedState: AvatarNodeState = .custom(letter: letters, explicitColorIndex: explicitIndex, explicitIcon: icon) if updatedState != self.state { self.state = updatedState - + let parameters: AvatarNodeParameters if let icon = icon, case .phone = icon { parameters = AvatarNodeParameters(theme: nil, accountPeerId: nil, peerId: nil, colors: calculateAvatarColors(context: nil, explicitColorIndex: explicitIndex, peerId: nil, nameColor: nil, icon: .phoneIcon, theme: nil), letters: [], font: self.font, icon: .phoneIcon, explicitColorIndex: explicitIndex, hasImage: false, clipStyle: .round, cutoutRect: cutoutRect) } else { parameters = AvatarNodeParameters(theme: nil, accountPeerId: nil, peerId: nil, colors: calculateAvatarColors(context: nil, explicitColorIndex: explicitIndex, peerId: nil, nameColor: nil, icon: .none, theme: nil), letters: letters, font: self.font, icon: .none, explicitColorIndex: explicitIndex, hasImage: false, clipStyle: .round, cutoutRect: cutoutRect) } - + self.displaySuspended = true self.contents = nil - + self.imageReady.set(.single(true)) self.displaySuspended = false - + if self.parameters == nil || self.parameters != parameters { self.parameters = parameters self.setNeedsDisplay() } } } - + override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol { return parameters ?? NSObject() } - + @objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) { let context = UIGraphicsGetCurrentContext()! - + if !isRasterizing { context.setBlendMode(.copy) context.setFillColor(UIColor.clear.cgColor) context.fill(bounds) } - + if !(parameters is AvatarNodeParameters) { return } - + let colors: [UIColor] if let parameters = parameters as? AvatarNodeParameters { colors = parameters.colors - + if case .round = parameters.clipStyle { context.beginPath() - context.addEllipse(in: CGRect(x: 0.0, y: 0.0, width: bounds.size.width, height: - bounds.size.height)) + // WinterGram: honor avatarCornerRadius for letter-placeholder avatars too. + let wntRadiusFraction = CGFloat(currentWinterGramSettings.avatarCornerRadius) / 100.0 + if wntRadiusFraction >= 0.5 { + context.addEllipse(in: CGRect(x: 0.0, y: 0.0, width: bounds.size.width, height: bounds.size.height)) + } else { + context.addPath(UIBezierPath(roundedRect: CGRect(x: 0.0, y: 0.0, width: bounds.size.width, height: bounds.size.height), cornerRadius: floor(bounds.size.height * wntRadiusFraction)).cgPath) + } context.clip() } else if case .roundedRect = parameters.clipStyle { context.beginPath() @@ -981,9 +986,9 @@ public final class AvatarNode: ASDisplayNode { } else { colors = grayscaleColors } - + let colorsArray: NSArray = colors.map(\.cgColor) as NSArray - + var iconColor = UIColor.white var diagonal = false if let parameters = parameters as? AvatarNodeParameters, parameters.icon != .none { @@ -998,27 +1003,27 @@ public final class AvatarNode: ASDisplayNode { } } } - + var locations: [CGFloat] = [1.0, 0.0] - + let colorSpace = CGColorSpaceCreateDeviceRGB() let gradient = CGGradient(colorsSpace: colorSpace, colors: colorsArray, locations: &locations)! - + if diagonal { context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: bounds.size.height), end: CGPoint(x: bounds.size.width, y: 0.0), options: CGGradientDrawingOptions()) } else { context.drawLinearGradient(gradient, start: CGPoint(), end: CGPoint(x: 0.0, y: bounds.size.height), options: CGGradientDrawingOptions()) } - + context.setBlendMode(.normal) - + if let parameters = parameters as? AvatarNodeParameters { if case .deletedIcon = parameters.icon { let factor = bounds.size.width / 60.0 context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - + if let deletedIcon = deletedIcon { context.draw(deletedIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - deletedIcon.size.width) / 2.0), y: floor((bounds.size.height - deletedIcon.size.height) / 2.0)), size: deletedIcon.size)) } @@ -1027,7 +1032,7 @@ public final class AvatarNode: ASDisplayNode { context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - + if let phoneIcon = phoneIcon { context.draw(phoneIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - phoneIcon.size.width) / 2.0), y: floor((bounds.size.height - phoneIcon.size.height) / 2.0)), size: phoneIcon.size)) } @@ -1036,7 +1041,7 @@ public final class AvatarNode: ASDisplayNode { context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - + if let savedMessagesIcon = savedMessagesIcon { context.draw(savedMessagesIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - savedMessagesIcon.size.width) / 2.0), y: floor((bounds.size.height - savedMessagesIcon.size.height) / 2.0)), size: savedMessagesIcon.size)) } @@ -1046,7 +1051,7 @@ public final class AvatarNode: ASDisplayNode { context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - + if let repostStoryIcon = repostStoryIcon { context.draw(repostStoryIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - repostStoryIcon.size.width) / 2.0), y: floor((bounds.size.height - repostStoryIcon.size.height) / 2.0)), size: repostStoryIcon.size)) } @@ -1056,7 +1061,7 @@ public final class AvatarNode: ASDisplayNode { context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - + if let repliesIcon = repliesIcon { context.draw(repliesIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - repliesIcon.size.width) / 2.0), y: floor((bounds.size.height - repliesIcon.size.height) / 2.0)), size: repliesIcon.size)) } @@ -1065,7 +1070,7 @@ public final class AvatarNode: ASDisplayNode { context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - + if let theme = parameters.theme, theme.overallDarkAppearance, !isColored { if let anonymousSavedMessagesDarkIcon = anonymousSavedMessagesDarkIcon { context.draw(anonymousSavedMessagesDarkIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - anonymousSavedMessagesDarkIcon.size.width) / 2.0), y: floor((bounds.size.height - anonymousSavedMessagesDarkIcon.size.height) / 2.0)), size: anonymousSavedMessagesDarkIcon.size)) @@ -1080,7 +1085,7 @@ public final class AvatarNode: ASDisplayNode { context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - + if let myNotesIcon = myNotesIcon { context.draw(myNotesIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - myNotesIcon.size.width) / 2.0), y: floor((bounds.size.height - myNotesIcon.size.height) / 2.0)), size: myNotesIcon.size)) } @@ -1089,7 +1094,7 @@ public final class AvatarNode: ASDisplayNode { context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - + if let cameraIcon = cameraIcon { context.draw(cameraIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - cameraIcon.size.width) / 2.0), y: floor((bounds.size.height - cameraIcon.size.height) / 2.0)), size: cameraIcon.size)) } @@ -1098,7 +1103,7 @@ public final class AvatarNode: ASDisplayNode { context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - + if let storyIcon = storyIcon { context.draw(storyIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - storyIcon.size.width) / 2.0), y: floor((bounds.size.height - storyIcon.size.height) / 2.0)), size: storyIcon.size)) } @@ -1106,7 +1111,7 @@ public final class AvatarNode: ASDisplayNode { context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: 1.0, y: -1.0) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - + if bounds.width > 90.0, let editAvatarIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/EditAvatarIconLarge"), color: theme.list.itemAccentColor) { context.draw(editAvatarIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - editAvatarIcon.size.width) / 2.0) + 0.5, y: floor((bounds.size.height - editAvatarIcon.size.height) / 2.0) + 1.0), size: editAvatarIcon.size)) } else if let editAvatarIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/EditAvatarIcon"), color: theme.list.itemAccentColor) { @@ -1117,7 +1122,7 @@ public final class AvatarNode: ASDisplayNode { context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - + if let archivedChatsIcon = generateTintedImage(image: archivedChatsIcon, color: iconColor) { context.draw(archivedChatsIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - archivedChatsIcon.size.width) / 2.0), y: floor((bounds.size.height - archivedChatsIcon.size.height) / 2.0)), size: archivedChatsIcon.size)) } @@ -1126,26 +1131,26 @@ public final class AvatarNode: ASDisplayNode { if letters.count == 2 && letters[0].isSingleEmoji && letters[1].isSingleEmoji { letters = [letters[0]] } - + let string = letters.count == 0 ? "" : (letters[0] + (letters.count == 1 ? "" : letters[1])) let attributedString = NSAttributedString(string: string, attributes: [NSAttributedString.Key.font: parameters.font, NSAttributedString.Key.foregroundColor: UIColor.white]) - + let line = CTLineCreateWithAttributedString(attributedString) let lineBounds = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds) - + let lineOffset = CGPoint(x: string == "B" ? 1.0 : 0.0, y: 0.0) let lineOrigin = CGPoint(x: floorToScreenPixels(-lineBounds.origin.x + (bounds.size.width - lineBounds.size.width) / 2.0) + lineOffset.x, y: floorToScreenPixels(-lineBounds.origin.y + (bounds.size.height - lineBounds.size.height) / 2.0)) - + context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: 1.0, y: -1.0) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - + context.translateBy(x: lineOrigin.x, y: lineOrigin.y) CTLineDraw(line, context) context.translateBy(x: -lineOrigin.x, y: -lineOrigin.y) } } - + if let parameters = parameters as? AvatarNodeParameters, let cutoutRect = parameters.cutoutRect { context.setBlendMode(.copy) context.setFillColor(UIColor.clear.cgColor) @@ -1153,20 +1158,20 @@ public final class AvatarNode: ASDisplayNode { } } } - + public let contentNode: ContentNode private var storyIndicator: ComponentView? public private(set) var storyPresentationParams: StoryPresentationParams? - + private var loadingStatuses = Bag() - + public struct StoryStats: Equatable { public var totalCount: Int public var unseenCount: Int public var hasUnseenCloseFriendsItems: Bool public var hasLiveItems: Bool public var progress: Float? - + public init( totalCount: Int, unseenCount: Int, @@ -1181,9 +1186,9 @@ public final class AvatarNode: ASDisplayNode { self.progress = progress } } - + public private(set) var storyStats: StoryStats? - + public var font: UIFont { get { return self.contentNode.font @@ -1191,7 +1196,7 @@ public final class AvatarNode: ASDisplayNode { self.contentNode.font = value } } - + public var editOverlayNode: AvatarEditOverlayNode? { get { return self.contentNode.editOverlayNode @@ -1199,7 +1204,7 @@ public final class AvatarNode: ASDisplayNode { self.contentNode.editOverlayNode = value } } - + public var unroundedImage: UIImage? { get { return self.contentNode.unroundedImage @@ -1207,7 +1212,7 @@ public final class AvatarNode: ASDisplayNode { self.contentNode.unroundedImage = value } } - + public var badgeView: AvatarBadgeView? { get { return self.contentNode.badgeView @@ -1215,72 +1220,72 @@ public final class AvatarNode: ASDisplayNode { self.contentNode.badgeView = value } } - + public var ready: Signal { return self.contentNode.ready } - + public var imageNode: ImageNode { return self.contentNode.imageNode } - + public init(font: UIFont) { self.contentNode = ContentNode(font: font) - + super.init() - + self.onDidLoad { [weak self] _ in guard let self else { return } self.updateStoryIndicator(transition: .immediate) } - + self.addSubnode(self.contentNode) } - + deinit { self.cancelLoading() } - + override public var frame: CGRect { get { return super.frame } set(value) { let updateImage = !value.size.equalTo(super.frame.size) super.frame = value - + if updateImage { self.updateSize(size: value.size) } } } - + override public func nodeDidLoad() { super.nodeDidLoad() } - + public func updateSize(size: CGSize) { self.contentNode.position = CGRect(origin: CGPoint(), size: size).center self.contentNode.bounds = CGRect(origin: CGPoint(), size: size) - + self.contentNode.updateSize(size: size) - + self.updateStoryIndicator(transition: .immediate) } - + public func playArchiveAnimation() { self.contentNode.playArchiveAnimation() } - + public func playRepostAnimation() { self.contentNode.playRepostAnimation() } - + public func playCameraAnimation() { self.contentNode.playCameraAnimation () } - + public func setPeer( accountPeerId: EnginePeer.Id, postbox: Postbox, @@ -1312,7 +1317,7 @@ public final class AvatarNode: ASDisplayNode { storeUnrounded: storeUnrounded ) } - + public func setPeerV2( context genericContext: AccountContext, theme: PresentationTheme, @@ -1338,7 +1343,7 @@ public final class AvatarNode: ASDisplayNode { storeUnrounded: storeUnrounded ) } - + public func setPeer( context: AccountContext, account: Account? = nil, @@ -1368,25 +1373,25 @@ public final class AvatarNode: ASDisplayNode { cutoutRect: cutoutRect ) } - + public func setCustomLetters(_ letters: [String], explicitColor: AvatarNodeColorOverride? = nil, icon: AvatarNodeExplicitIcon? = nil) { self.contentNode.setCustomLetters(letters, explicitColor: explicitColor, icon: icon) } - + public func setStoryStats(storyStats: StoryStats?, presentationParams: StoryPresentationParams, transition: ComponentTransition) { if self.storyStats != storyStats || self.storyPresentationParams != presentationParams { self.storyStats = storyStats self.storyPresentationParams = presentationParams - + self.updateStoryIndicator(transition: transition) } } - + public struct Colors: Equatable { public var unseenColors: [UIColor] public var unseenCloseFriendsColors: [UIColor] public var seenColors: [UIColor] - + public init( unseenColors: [UIColor], unseenCloseFriendsColors: [UIColor], @@ -1396,20 +1401,20 @@ public final class AvatarNode: ASDisplayNode { self.unseenCloseFriendsColors = unseenCloseFriendsColors self.seenColors = seenColors } - + public init(theme: PresentationTheme) { self.unseenColors = [theme.chatList.storyUnseenColors.topColor, theme.chatList.storyUnseenColors.bottomColor] self.unseenCloseFriendsColors = [theme.chatList.storyUnseenPrivateColors.topColor, theme.chatList.storyUnseenPrivateColors.bottomColor] self.seenColors = [theme.chatList.storySeenColors.topColor, theme.chatList.storySeenColors.bottomColor] } } - + public struct StoryPresentationParams: Equatable { public var colors: Colors public var lineWidth: CGFloat public var inactiveLineWidth: CGFloat public var forceRoundedRect: Bool - + public init( colors: Colors, lineWidth: CGFloat, @@ -1422,7 +1427,7 @@ public final class AvatarNode: ASDisplayNode { self.forceRoundedRect = forceRoundedRect } } - + private func updateStoryIndicator(transition: ComponentTransition) { if !self.isNodeLoaded { return @@ -1433,15 +1438,15 @@ public final class AvatarNode: ASDisplayNode { guard let storyPresentationParams = self.storyPresentationParams else { return } - + let size = self.bounds.size - + if let storyStats = self.storyStats { let activeLineWidth = storyPresentationParams.lineWidth let inactiveLineWidth = storyPresentationParams.inactiveLineWidth let indicatorSize = CGSize(width: size.width - activeLineWidth * 4.0, height: size.height - activeLineWidth * 4.0) let avatarScale = (size.width - activeLineWidth * 4.0) / size.width - + let storyIndicator: ComponentView var indicatorTransition = transition if let current = self.storyIndicator { @@ -1499,7 +1504,7 @@ public final class AvatarNode: ASDisplayNode { } } } - + public func cancelLoading() { for disposable in self.loadingStatuses.copyItems() { disposable.dispose() @@ -1507,21 +1512,21 @@ public final class AvatarNode: ASDisplayNode { self.loadingStatuses.removeAll() self.updateStoryIndicator(transition: .immediate) } - + public func pushLoadingStatus(signal: Signal) -> Disposable { let disposable = MetaDisposable() - + for d in self.loadingStatuses.copyItems() { d.dispose() } self.loadingStatuses.removeAll() - + let index = self.loadingStatuses.add(disposable) - + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.2, execute: { [weak self] in self?.updateStoryIndicator(transition: .immediate) }) - + disposable.set(signal.start(completed: { [weak self] in Queue.mainQueue().async { guard let self else { @@ -1536,7 +1541,7 @@ public final class AvatarNode: ASDisplayNode { } } })) - + return ActionDisposable { [weak self] in guard let self else { return @@ -1551,4 +1556,3 @@ public final class AvatarNode: ASDisplayNode { } } } - diff --git a/submodules/ChatListUI/Sources/ChatContextMenus.swift b/submodules/ChatListUI/Sources/ChatContextMenus.swift index 0d0908f79e..3d5ebdd8af 100644 --- a/submodules/ChatListUI/Sources/ChatContextMenus.swift +++ b/submodules/ChatListUI/Sources/ChatContextMenus.swift @@ -27,7 +27,7 @@ func archiveContextMenuItems(context: AccountContext, group: EngineChatList.Grou ) |> map { [weak chatListController] unreadChatListPeerIds, chatArchiveSettingsPreference -> [ContextMenuItem] in var items: [ContextMenuItem] = [] - + if !unreadChatListPeerIds.isEmpty { items.append(.action(ContextMenuActionItem(text: strings.ChatList_Context_MarkAllAsRead, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/MarkAsRead"), color: theme.contextMenu.primaryColor) }, action: { _, f in let _ = (context.engine.messages.markAllChatsAsReadInteractively(items: [(groupId: group, filterPredicate: nil)]) @@ -36,14 +36,14 @@ func archiveContextMenuItems(context: AccountContext, group: EngineChatList.Grou }) }))) } - + let settings = chatArchiveSettingsPreference?.get(ChatArchiveSettings.self) ?? ChatArchiveSettings.default let isPinned = !settings.isHiddenByDefault items.append(.action(ContextMenuActionItem(text: isPinned ? strings.ChatList_Context_HideArchive : strings.ChatList_Context_UnhideArchive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: isPinned ? "Chat/Context Menu/Unpin": "Chat/Context Menu/Pin"), color: theme.contextMenu.primaryColor) }, action: { [weak chatListController] _, f in chatListController?.toggleArchivedFolderHiddenByDefault() f(.default) }))) - + return items } } @@ -87,9 +87,9 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI ) |> mapToSignal { filters, pinnedItemIds -> Signal<[ContextMenuItem], NoError> in let isPinned = pinnedItemIds.contains(.peer(peerId)) - + let renderedPeer = context.engine.data.get(TelegramEngine.EngineData.Item.Peer.RenderedPeer(id: peerId)) - + return renderedPeer |> mapToSignal { renderedPeer -> Signal<[ContextMenuItem], NoError> in guard let renderedPeer = renderedPeer else { @@ -98,7 +98,7 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI guard let peer = renderedPeer.chatMainPeer else { return .single([]) } - + return context.engine.data.get( TelegramEngine.EngineData.Item.Peer.IsContact(id: peer.id), TelegramEngine.EngineData.Item.Peer.NotificationSettings(id: peer.id), @@ -151,7 +151,7 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI } else if case .search(.popularApps) = source { } else { let isSavedMessages = peerId == context.account.peerId - + if !isSavedMessages, case let .user(peer) = peer, !peer.flags.contains(.isSupport), peer.botInfo == nil && !peer.isDeleted { if !isContact { items.append(.action(ContextMenuActionItem(text: strings.ChatList_Context_AddToContacts, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddUser"), color: theme.contextMenu.primaryColor) }, action: { _, f in @@ -169,7 +169,7 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI items.append(.separator) } } - + var isMuted = false if case let .muted(until) = notificationSettings.muteState, until >= Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) { isMuted = true @@ -187,17 +187,17 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI } } } - + var isUnread = false if readCounters.isUnread { isUnread = true } - + var isForum = false if case let .channel(channel) = peer, channel.isForumOrMonoForum { isForum = true } - + var hasRemoveFromFolder = false if case let .chatList(currentFilter) = source { if let currentFilter = currentFilter, case let .filter(id, title, emoticon, data) = currentFilter { @@ -225,46 +225,46 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI hasRemoveFromFolder = true } } - + if !hasRemoveFromFolder && peerGroup != nil { var hasFolders = false - + for case let .filter(_, _, _, data) in filters { let predicate = chatListFilterPredicate(filter: data, accountPeerId: context.account.peerId) if predicate.includes(peer: peer._asPeer(), groupId: .root, isRemovedFromTotalUnreadCount: isMuted, isUnread: isUnread, isContact: isContact, messageTagSummaryResult: false) { continue } - + var data = data if data.addIncludePeer(peerId: peer.id) { hasFolders = true break } } - + if hasFolders { items.append(.action(ContextMenuActionItem(text: strings.ChatList_Context_AddToFolder, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Folder"), color: theme.contextMenu.primaryColor) }, action: { c, _ in var updatedItems: [ContextMenuItem] = [] - + updatedItems.append(.action(ContextMenuActionItem(text: strings.ChatList_Context_Back, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.contextMenu.primaryColor) }, iconPosition: .left, action: { c, _ in c?.setItems(chatContextMenuItems(context: context, peerId: peerId, promoInfo: promoInfo, source: source, chatListController: chatListController, joined: joined) |> map { ContextController.Items(content: .list($0)) }, minHeight: nil, animated: true) }))) updatedItems.append(.separator) - + for filter in filters { if case let .filter(_, title, _, data) = filter { let predicate = chatListFilterPredicate(filter: data, accountPeerId: context.account.peerId) if predicate.includes(peer: peer._asPeer(), groupId: .root, isRemovedFromTotalUnreadCount: isMuted, isUnread: isUnread, isContact: isContact, messageTagSummaryResult: false) { continue } - + var data = data if !data.addIncludePeer(peerId: peer.id) { continue } - + let filterType = chatListFilterType(data) updatedItems.append(.action(ContextMenuActionItem(text: title.text, entities: title.entities, enableEntityAnimations: title.enableAnimations, icon: { theme in let imageName: String @@ -291,10 +291,10 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI c?.dismiss(completion: { let isPremium = limitsData.0?.isPremium ?? false let (_, limits, premiumLimits) = limitsData - + let limit = limits.maxFolderChatsCount let premiumLimit = premiumLimits.maxFolderChatsCount - + let count = data.includePeers.peers.count - 1 if count >= premiumLimit { let controller = PremiumLimitScreen(context: context, subject: .chatsPerFolder, count: Int32(count), action: { @@ -315,7 +315,7 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI chatListController?.push(controller) return } - + let _ = (context.engine.peers.updateChatListFiltersInteractively { filters in var filters = filters for i in 0 ..< filters.count { @@ -337,13 +337,13 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI }))) } } - + c?.setItems(.single(ContextController.Items(content: .list(updatedItems), context: context)), minHeight: nil, animated: true) }))) items.append(.separator) } } - + if isUnread { items.append(.action(ContextMenuActionItem(text: strings.ChatList_Context_MarkAsRead, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/MarkAsRead"), color: theme.contextMenu.primaryColor) }, action: { _, f in let _ = context.engine.messages.togglePeersUnreadMarkInteractively(peerIds: [peerId], setToValue: nil).startStandalone() @@ -361,7 +361,7 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI }))) } } - + let archiveEnabled = !isSavedMessages && peerId != EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(777000)) && peerId == context.account.peerId if let group = peerGroup { if archiveEnabled { @@ -385,7 +385,49 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI } }))) } - + + if !isSavedMessages && peerId != context.account.peerId { + let rawPeerId = peerId.toInt64() + let isStashed = currentWinterGramSettings.stashedPeerIds.contains(rawPeerId) + items.append(.action(ContextMenuActionItem(text: isStashed ? "Remove from Hidden Archive" : "Add to Hidden Archive", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: isStashed ? "Chat/Context Menu/Unarchive" : "Chat/Context Menu/Archive"), color: theme.contextMenu.primaryColor) }, action: { c, f in + let willStash = !isStashed + let _ = updateWinterGramSettingsInteractively(accountManager: context.sharedContext.accountManager, { settings in + var settings = settings + if isStashed { + settings.stashedPeerIds.removeAll(where: { $0 == rawPeerId }) + } else if !settings.stashedPeerIds.contains(rawPeerId) { + settings.stashedPeerIds.append(rawPeerId) + } + return settings + }).startStandalone() + // Mirror the change into the selected privacy categories' "Everybody Except..." + // lists so the stashed peer cannot see the chosen profile data. + if currentWinterGramSettings.stashPrivacy.hasAny || isStashed { + let _ = winterGramApplyStashPrivacy(engine: context.engine, peerId: peerId, stashed: willStash, privacySettings: currentWinterGramSettings.stashPrivacy).startStandalone() + } + if willStash { + let peerTitle = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + c?.dismiss(completion: { + chatListController?.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_archiveswipe", scale: 1.0, colors: [:], title: "Hidden Archive", text: "Вы добавили \(peerTitle) в скрытый архив", customUndoText: presentationData.strings.Undo_Undo, timeout: 5.0), elevatedLayout: false, animateInAsReplacement: true, action: { action in + if case .undo = action { + let _ = updateWinterGramSettingsInteractively(accountManager: context.sharedContext.accountManager, { settings in + var settings = settings + settings.stashedPeerIds.removeAll(where: { $0 == rawPeerId }) + return settings + }).startStandalone() + if currentWinterGramSettings.stashPrivacy.hasAny { + let _ = winterGramApplyStashPrivacy(engine: context.engine, peerId: peerId, stashed: false, privacySettings: currentWinterGramSettings.stashPrivacy).startStandalone() + } + } + return false + }), in: .current) + }) + } else { + f(.default) + } + }))) + } + if isPinned || chatListFilter == nil || peerId.namespace != Namespaces.Peer.SecretChat { items.append(.action(ContextMenuActionItem(text: isPinned ? strings.ChatList_Context_Unpin : strings.ChatList_Context_Pin, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: isPinned ? "Chat/Context Menu/Unpin" : "Chat/Context Menu/Pin"), color: theme.contextMenu.primaryColor) }, action: { c, f in let _ = (context.engine.peers.toggleItemPinned(location: location, itemId: .peer(peerId)) @@ -395,7 +437,7 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI f(.default) case let .limitExceeded(count, _): f(.default) - + let isPremium = limitsData.0?.isPremium ?? false if isPremium { if case .filter = location { @@ -438,7 +480,7 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI }) }))) } - + if !isSavedMessages { var isMuted = false if case let .muted(until) = notificationSettings.muteState, until >= Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) { @@ -491,7 +533,7 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI |> runOn(Queue.mainQueue()) |> delay(0.15, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() - + createSignal = createSignal |> afterDisposed { Queue.mainQueue().async { @@ -502,7 +544,7 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI cancelImpl = { joinChannelDisposable.set(nil) } - + var didJoin = false joinChannelDisposable.set((createSignal |> deliverOnMainQueue).start(next: { result in @@ -538,7 +580,7 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI } } } - + if case .chatList = source, peerGroup != nil { items.append(.action(ContextMenuActionItem(text: strings.ChatList_Context_Delete, textColor: .destructive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { _, f in if let chatListController = chatListController { @@ -566,7 +608,7 @@ func chatContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, promoI }))) } } - + if peerGroup != nil { if !items.isEmpty { if !addedSeparator { @@ -615,9 +657,9 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. guard let threadData else { return .single([]) } - + var items: [ContextMenuItem] = [] - + var isCreator = false var canManageTopics = false var canDeleteMessages = false @@ -630,7 +672,7 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. canManageTopics = true canDeleteMessages = true } - + if let isClosed, isClosed && threadId != 1 { } else { if let isPinned, canManageTopics { @@ -643,9 +685,9 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. } return } - + f(.default) - + let _ = (context.engine.peers.toggleForumChannelTopicPinned(id: peerId, threadId: threadId) |> deliverOnMainQueue).startStandalone(error: { error in switch error { @@ -660,7 +702,7 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. } }) }))) - + if isPinned, let reorder { items.append(.action(ContextMenuActionItem(text: presentationData.strings.Chat_InlineTopicMenu_Reorder, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReorderItems"), color: theme.contextMenu.primaryColor) }, action: { c, _ in c?.dismiss(completion: { @@ -670,19 +712,19 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. } } } - + var isUnread = false if threadData.incomingUnreadCount != 0 { isUnread = true } - + if isUnread { items.append(.action(ContextMenuActionItem(text: strings.ChatList_Context_MarkAsRead, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/MarkAsRead"), color: theme.contextMenu.primaryColor) }, action: { _, f in let _ = context.engine.messages.markForumThreadAsRead(peerId: peerId, threadId: threadId).startStandalone() f(.default) }))) } - + var canOpenClose = false if case .channel = peer { if isCreator { @@ -693,7 +735,7 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. canOpenClose = true } } - + if threadId != 1, canOpenClose, let customEdit { items.append(.action(ContextMenuActionItem(text: presentationData.strings.Chat_InlineTopicMenu_Edit, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor) }, action: { c, f in if let c = c as? ContextController { @@ -704,7 +746,7 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. return }))) } - + var isMuted = false switch threadData.notificationSettings.muteState { case .muted: @@ -730,53 +772,53 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. }) } else { var items: [ContextMenuItem] = [] - + items.append(.action(ContextMenuActionItem(text: presentationData.strings.PeerInfo_MuteFor, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Mute2d"), color: theme.contextMenu.primaryColor) }, action: { c, _ in var subItems: [ContextMenuItem] = [] - + /*subItems.append(.action(ContextMenuActionItem(text: presentationData.strings.Common_Back, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.contextMenu.primaryColor) }, action: { c, _ in c?.popItems() }))) subItems.append(.separator)*/ - + let presetValues: [Int32] = [ 1 * 60 * 60, 8 * 60 * 60, 1 * 24 * 60 * 60, 7 * 24 * 60 * 60 ] - + for value in presetValues { subItems.append(.action(ContextMenuActionItem(text: muteForIntervalString(strings: presentationData.strings, value: value), icon: { _ in return nil }, action: { _, f in f(.default) - + let _ = context.engine.peers.updatePeerMuteSetting(peerId: peerId, threadId: threadId, muteInterval: value).startStandalone() - + chatListController?.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_mute_for", scale: 0.066, colors: [:], title: nil, text: presentationData.strings.PeerInfo_TooltipMutedFor(mutedForTimeIntervalString(strings: presentationData.strings, value: value)).string, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) }))) } - + subItems.append(.action(ContextMenuActionItem(text: presentationData.strings.PeerInfo_MuteForCustom, icon: { _ in return nil }, action: { _, f in f(.default) - + if let chatListController = chatListController { openCustomMute(context: context, peerId: peerId, threadId: threadId, baseController: chatListController) } }))) - + c?.setItems(.single(ContextController.Items(content: .list(subItems))), minHeight: nil, animated: true) }))) - + items.append(.separator) - + var isSoundEnabled = true switch threadData.notificationSettings.messageSound { case .none: @@ -784,15 +826,15 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. default: break } - + if case .muted = threadData.notificationSettings.muteState { items.append(.action(ContextMenuActionItem(text: presentationData.strings.PeerInfo_ButtonUnmute, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/SoundOn"), color: theme.contextMenu.primaryColor) }, action: { _, f in f(.default) - + let _ = context.engine.peers.updatePeerMuteSetting(peerId: peerId, threadId: threadId, muteInterval: nil).startStandalone() - + let iconColor: UIColor = .white chatListController?.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_profileunmute", scale: 0.075, colors: [ "Middle.Group 1.Fill 1": iconColor, @@ -807,9 +849,9 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/SoundOn"), color: theme.contextMenu.primaryColor) }, action: { _, f in f(.default) - + let _ = context.engine.peers.updatePeerNotificationSoundInteractive(peerId: peerId, threadId: threadId, sound: .default).startStandalone() - + chatListController?.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_sound_on", scale: 0.056, colors: [:], title: nil, text: presentationData.strings.PeerInfo_TooltipSoundEnabled, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) }))) } else { @@ -817,18 +859,18 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/SoundOff"), color: theme.contextMenu.primaryColor) }, action: { _, f in f(.default) - + let _ = context.engine.peers.updatePeerNotificationSoundInteractive(peerId: peerId, threadId: threadId, sound: .none).startStandalone() - + chatListController?.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_sound_off", scale: 0.056, colors: [:], title: nil, text: presentationData.strings.PeerInfo_TooltipSoundDisabled, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) }))) } - + items.append(.action(ContextMenuActionItem(text: presentationData.strings.PeerInfo_NotificationsCustomize, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Customize"), color: theme.contextMenu.primaryColor) }, action: { _, f in f(.dismissWithoutContent) - + let _ = (context.engine.data.get( TelegramEngine.EngineData.Item.NotificationSettings.Global() ) @@ -836,40 +878,40 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. let updatePeerSound: (EnginePeer.Id, PeerMessageSound) -> Signal = { peerId, sound in return context.engine.peers.updatePeerNotificationSoundInteractive(peerId: peerId, threadId: threadId, sound: sound) |> deliverOnMainQueue } - + let updatePeerNotificationInterval: (EnginePeer.Id, Int32?) -> Signal = { peerId, muteInterval in return context.engine.peers.updatePeerMuteSetting(peerId: peerId, threadId: threadId, muteInterval: muteInterval) |> deliverOnMainQueue } - + let updatePeerDisplayPreviews: (EnginePeer.Id, PeerNotificationDisplayPreviews) -> Signal = { peerId, displayPreviews in return context.engine.peers.updatePeerDisplayPreviewsSetting(peerId: peerId, threadId: threadId, displayPreviews: displayPreviews) |> deliverOnMainQueue } - + let updatePeerStoriesMuted: (EnginePeer.Id, PeerStoryNotificationSettings.Mute) -> Signal = { peerId, mute in return context.engine.peers.updatePeerStoriesMutedSetting(peerId: peerId, mute: mute) |> deliverOnMainQueue } - + let updatePeerStoriesHideSender: (EnginePeer.Id, PeerStoryNotificationSettings.HideSender) -> Signal = { peerId, hideSender in return context.engine.peers.updatePeerStoriesHideSenderSetting(peerId: peerId, hideSender: hideSender) |> deliverOnMainQueue } - + let updatePeerStorySound: (EnginePeer.Id, PeerMessageSound) -> Signal = { peerId, sound in return context.engine.peers.updatePeerStorySoundInteractive(peerId: peerId, sound: sound) |> deliverOnMainQueue } - + let defaultSound: PeerMessageSound - + if case let .channel(channel) = peer, case .broadcast = channel.info { defaultSound = globalSettings.channels.sound._asMessageSound() } else { defaultSound = globalSettings.groupChats.sound._asMessageSound() } - + let canRemove = false - + let exceptionController = notificationPeerExceptionController(context: context, updatedPresentationData: nil, peer: peer, threadId: threadId, isStories: nil, canRemove: canRemove, defaultSound: defaultSound, defaultStoriesSound: defaultSound, edit: true, updatePeerSound: { peerId, sound in let _ = (updatePeerSound(peerId, sound) |> deliverOnMainQueue).startStandalone(next: { _ in @@ -891,7 +933,7 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. }, updatePeerDisplayPreviews: { peerId, displayPreviews in let _ = (updatePeerDisplayPreviews(peerId, displayPreviews) |> deliverOnMainQueue).startStandalone(next: { _ in - + }) }, updatePeerStoriesMuted: { peerId, mute in let _ = (updatePeerStoriesMuted(peerId, mute) @@ -909,14 +951,14 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. chatListController?.push(exceptionController) }) }))) - + items.append(.action(ContextMenuActionItem(text: presentationData.strings.PeerInfo_MuteForever, textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Muted"), color: theme.contextMenu.destructiveColor) }, action: { _, f in f(.default) - + let _ = context.engine.peers.updatePeerMuteSetting(peerId: peerId, threadId: threadId, muteInterval: Int32.max).startStandalone() - + let iconColor: UIColor = .white chatListController?.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_profilemute", scale: 0.075, colors: [ "Middle.Group 1.Fill 1": iconColor, @@ -926,16 +968,16 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. "Line.Group 1.Stroke 1": iconColor ], title: nil, text: presentationData.strings.PeerInfo_TooltipMutedForever, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) }))) - + c?.setItems(.single(ContextController.Items(content: .list(items))), minHeight: nil, animated: true) } }))) - + if threadId != 1 { if canOpenClose { items.append(.action(ContextMenuActionItem(text: threadData.isClosed ? presentationData.strings.ChatList_Context_ReopenTopic : presentationData.strings.ChatList_Context_CloseTopic, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: threadData.isClosed ? "Chat/Context Menu/Play": "Chat/Context Menu/Pause"), color: theme.contextMenu.primaryColor) }, action: { _, f in f(.default) - + let _ = context.engine.peers.setForumChannelTopicClosed(id: peerId, threadId: threadId, isClosed: !threadData.isClosed).startStandalone() }))) } @@ -947,7 +989,7 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. } else if let chatListController { let actionSheet = ActionSheetController(presentationData: presentationData) var items: [ActionSheetItem] = [] - + items.append(ActionSheetTextItem(title: presentationData.strings.ChatList_DeleteTopicConfirmationText, parseMarkdown: true)) items.append(ActionSheetButtonItem(title: presentationData.strings.ChatList_DeleteTopicConfirmationAction, color: .destructive, action: { [weak actionSheet] in actionSheet?.dismissAnimated() @@ -955,7 +997,7 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. let _ = context.engine.peers.removeForumChannelThread(id: peerId, threadId: threadId).startStandalone(completed: { }) })) - + actionSheet.setItemGroups([ ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ @@ -971,7 +1013,7 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. }))) } } - + if canSelect, let chatListController = chatListController as? ChatListControllerImpl { items.append(.separator) items.append(.action(ContextMenuActionItem(text: strings.ChatList_Context_Select, textColor: .primary, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) }, action: { [weak chatListController] _, f in @@ -979,7 +1021,7 @@ public func chatForumTopicMenuItems(context: AccountContext, peerId: EnginePeer. chatListController?.selectPeerThread(peerId: peerId, threadId: threadId) }))) } - + return .single(items) } } @@ -996,12 +1038,12 @@ public func savedMessagesPeerMenuItems(context: AccountContext, threadId: Int64, ) |> mapToSignal { [weak parentController] peer, pinnedThreadIds -> Signal<[ContextMenuItem], NoError> in var items: [ContextMenuItem] = [] - + let isPinned = pinnedThreadIds.contains(threadId) - + items.append(.action(ContextMenuActionItem(text: isPinned ? strings.ChatList_Context_Unpin : strings.ChatList_Context_Pin, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: isPinned ? "Chat/Context Menu/Unpin": "Chat/Context Menu/Pin"), color: theme.contextMenu.primaryColor) }, action: { _, f in f(.default) - + let _ = (context.engine.peers.toggleForumChannelTopicPinned(id: context.account.peerId, threadId: threadId) |> deliverOnMainQueue).startStandalone(error: { error in switch error { @@ -1021,12 +1063,12 @@ public func savedMessagesPeerMenuItems(context: AccountContext, threadId: Int64, } }) }))) - + items.append(.action(ContextMenuActionItem(text: strings.ChatList_Context_Delete, textColor: .destructive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { _, f in deletePeerChat(EnginePeer.Id(threadId)) f(.default) }))) - + return .single(items) } } @@ -1034,14 +1076,14 @@ public func savedMessagesPeerMenuItems(context: AccountContext, threadId: Int64, private func openCustomMute(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64, baseController: ViewController) { let controller = ChatTimerScreen(context: context, updatedPresentationData: nil, style: .default, mode: .mute, currentTime: nil, completion: { [weak baseController] value in let presentationData = context.sharedContext.currentPresentationData.with { $0 } - + if value <= 0 { let _ = context.engine.peers.updatePeerMuteSetting(peerId: peerId, threadId: threadId, muteInterval: nil).startStandalone() } else { let _ = context.engine.peers.updatePeerMuteSetting(peerId: peerId, threadId: threadId, muteInterval: value).startStandalone() - + let timeString = stringForPreciseRelativeTimestamp(strings: presentationData.strings, relativeTimestamp: Int32(Date().timeIntervalSince1970) + value, relativeTo: Int32(Date().timeIntervalSince1970), dateTimeFormat: presentationData.dateTimeFormat) - + baseController?.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_mute_for", scale: 0.056, colors: [:], title: nil, text: presentationData.strings.PeerInfo_TooltipMutedUntil(timeString).string, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) } }) diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index afb7a667c8..ecf3ff9c63 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -66,17 +66,17 @@ import AvatarComponent private final class ContextControllerContentSourceImpl: ContextControllerContentSource { let controller: ViewController weak var sourceNode: ASDisplayNode? - + let navigationController: NavigationController? - + let passthroughTouches: Bool = true - + init(controller: ViewController, sourceNode: ASDisplayNode?, navigationController: NavigationController?) { self.controller = controller self.sourceNode = sourceNode self.navigationController = navigationController } - + func transitionInfo() -> ContextControllerTakeControllerInfo? { let sourceNode = self.sourceNode return ContextControllerTakeControllerInfo(contentAreaInScreenSpace: CGRect(origin: CGPoint(), size: CGSize(width: 10.0, height: 10.0)), sourceNode: { [weak sourceNode] in @@ -87,76 +87,76 @@ private final class ContextControllerContentSourceImpl: ContextControllerContent } }) } - + func animatedIn() { } } public class ChatListControllerImpl: TelegramBaseController, ChatListController { private var validLayout: ContainerViewLayout? - + public let context: AccountContext private let controlsHistoryPreload: Bool private let hideNetworkActivityStatus: Bool - + private let animationCache: AnimationCache private let animationRenderer: MultiAnimationRenderer - + public let location: ChatListControllerLocation public let previewing: Bool - + let openMessageFromSearchDisposable: MetaDisposable = MetaDisposable() - + private var chatListDisplayNode: ChatListControllerNode { return super.displayNode as! ChatListControllerNode } - + fileprivate private(set) var primaryContext: ChatListLocationContext? private let primaryInfoReady = Promise() private let mainReady = Promise() private let storiesReady = Promise() - + private var pendingSecondaryContext: ChatListLocationContext? fileprivate private(set) var secondaryContext: ChatListLocationContext? - + fileprivate var effectiveContext: ChatListLocationContext? { return self.secondaryContext ?? self.primaryContext } - + public var effectiveLocation: ChatListControllerLocation { return self.secondaryContext?.location ?? self.location } - + private var badgeDisposable: Disposable? private var badgeIconDisposable: Disposable? - + private var didAppear = false private var dismissSearchOnDisappear = false public var onDidAppear: (() -> Void)? - + private var passcodeLockTooltipDisposable = MetaDisposable() private var didShowPasscodeLockTooltipController = false - + private var suggestLocalizationDisposable = MetaDisposable() private var didSuggestLocalization = false - + private let suggestAutoarchiveDisposable = MetaDisposable() private let dismissAutoarchiveDisposable = MetaDisposable() private var didSuggestAutoarchive = false private var didSuggestLoginEmailSetup = false private var didSuggestLoginPasskeySetup = false - + private(set) var presentationData: PresentationData private let presentationDataValue = Promise() private var presentationDataDisposable: Disposable? - + private let stateDisposable = MetaDisposable() private let filterDisposable = MetaDisposable() private let featuredFiltersDisposable = MetaDisposable() private var processedFeaturedFilters = false - + private let isReorderingTabsValue = ValuePromise(false) - + private(set) var tabContainerData: ([ChatListFilterTabEntry], Bool, Int32?)? var hasTabs: Bool { if let tabContainerData = self.tabContainerData { @@ -179,40 +179,40 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } })) } - + private var hasDownloads: Bool = false private var activeDownloadsDisposable: Disposable? private var clearUnseenDownloadsTimer: SwiftSignalKit.Timer? - + private(set) var isPremium: Bool = false private(set) var storyPostingAvailability: StoriesConfiguration.PostingAvailability = .disabled private var storiesPostingAvailabilityDisposable: Disposable? private let storyPostingAvailabilityValue = ValuePromise(.disabled) - + private var didSetupTabs = false - + private weak var emojiStatusSelectionController: ViewController? - + private var forumChannelTracker: ForumChannelTopics? - + private let selectAddMemberDisposable = MetaDisposable() private let addMemberDisposable = MetaDisposable() private let joinForumDisposable = MetaDisposable() private let actionDisposables = DisposableSet() - + private var plainTitle: String = "" - + private var powerSavingMonitoringDisposable: Disposable? - + private var rawStoryArchiveSubscriptions: EngineStorySubscriptions? private var storyArchiveSubscriptionsDisposable: Disposable? - + private var rawStorySubscriptions: EngineStorySubscriptions? private var shouldFixStorySubscriptionOrder: Bool = false private var fixedStorySubscriptionOrder: [EnginePeer.Id] = [] private(set) var orderedStorySubscriptions: EngineStorySubscriptions? private var displayedStoriesTooltip: Bool = false - + public var hasStorySubscriptions: Bool { if let rawStorySubscriptions = self.rawStorySubscriptions, !rawStorySubscriptions.items.isEmpty { return true @@ -220,45 +220,45 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return false } } - + private let hasPendingStoriesPromise = ValuePromise(false, ignoreRepeated: true) public var hasPendingStories: Signal { return self.hasPendingStoriesPromise.get() } - + private var storyProgressDisposable: Disposable? private var storySubscriptionsDisposable: Disposable? private var preloadStorySubscriptionsDisposable: Disposable? private var preloadStoryResourceDisposables: [MediaId: Disposable] = [:] - + private var sharedOpenStoryProgressDisposable = MetaDisposable() - + var currentTooltipUpdateTimer: Foundation.Timer? - + let globalControlPanelsContext: GlobalControlPanelsContext private(set) var globalControlPanelsContextState: GlobalControlPanelsContext.State? private var globalControlPanelsContextStateDisposable: Disposable? - + public override func updateNavigationCustomData(_ data: Any?, progress: CGFloat, transition: ContainedViewLayoutTransition) { if self.isNodeLoaded { self.chatListDisplayNode.effectiveContainerNode.updateSelectedChatLocation(data: data as? ChatLocation, progress: progress, transition: transition) } } - + public init(context: AccountContext, location: ChatListControllerLocation, controlsHistoryPreload: Bool, hideNetworkActivityStatus: Bool = false, previewing: Bool = false, enableDebugActions: Bool) { self.context = context self.controlsHistoryPreload = controlsHistoryPreload self.hideNetworkActivityStatus = hideNetworkActivityStatus - + self.location = location self.previewing = previewing - + self.presentationData = (context.sharedContext.currentPresentationData.with { $0 }) self.presentationDataValue.set(.single(self.presentationData)) - + self.animationCache = context.animationCache self.animationRenderer = context.animationRenderer - + var groupCallPanelSource: EnginePeer.Id? var chatListNotices = false switch self.location { @@ -271,7 +271,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController case .savedMessagesChats: break } - + self.globalControlPanelsContext = GlobalControlPanelsContext( context: context, mediaPlayback: true, @@ -279,16 +279,16 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController groupCalls: groupCallPanelSource, chatListNotices: chatListNotices ) - + super.init(context: context, navigationBarPresentationData: nil) - + self.accessoryPanelContainer = ASDisplayNode() - + self.tabBarItemContextActionType = .always self.automaticallyControlPresentationContextLayout = false - + self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style - + let title: String switch self.location { case let .chatList(groupId): @@ -304,7 +304,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController case .savedMessagesChats: title = "" } - + let primaryContext = ChatListLocationContext( context: context, location: self.location, @@ -323,41 +323,41 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController ) self.primaryContext = primaryContext self.primaryInfoReady.set(primaryContext.ready.get()) - + if !previewing { switch self.location { case let .chatList(groupId): if groupId == .root { self.tabBarItem.title = self.presentationData.strings.DialogList_Title - + let icon: UIImage? if useSpecialTabBarIcons() { icon = UIImage(bundleImageName: "Chat List/Tabs/Holiday/IconChats") } else { icon = UIImage(bundleImageName: "Chat List/Tabs/IconChats") } - + self.tabBarItem.image = icon self.tabBarItem.selectedImage = icon if !self.presentationData.reduceMotion { self.tabBarItem.animationName = "TabChats" self.tabBarItem.animationOffset = CGPoint(x: 0.0, y: UIScreenPixel) } - + self.primaryContext?.leftButton = AnyComponentWithIdentity(id: "edit", component: AnyComponent(NavigationButtonComponent( content: .text(title: self.presentationData.strings.Common_Edit, isBold: false), pressed: { [weak self] _ in self?.editPressed() } ))) - + self.primaryContext?.rightButton = AnyComponentWithIdentity(id: "compose", component: AnyComponent(NavigationButtonComponent( content: .icon(imageName: "Chat List/ComposeIcon"), pressed: { [weak self] _ in self?.composePressed() } ))) - + //let backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.DialogList_Title, style: .plain, target: nil, action: nil) //backBarButtonItem.accessibilityLabel = self.presentationData.strings.Common_Back //self.navigationItem.backBarButtonItem = backBarButtonItem @@ -375,7 +375,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController case .savedMessagesChats: break } - + let backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) backBarButtonItem.accessibilityLabel = self.presentationData.strings.Common_Back self.navigationItem.backBarButtonItem = backBarButtonItem @@ -386,7 +386,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController break } } - + self.scrollToTop = { [weak self] in if let strongSelf = self { strongSelf.chatListDisplayNode.willScrollToTop() @@ -397,7 +397,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let strongSelf = self else { return } - + if strongSelf.chatListDisplayNode.searchDisplayController != nil { strongSelf.deactivateSearch(animated: true) } else { @@ -407,7 +407,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController strongSelf.chatListDisplayNode.effectiveContainerNode.currentItemNode.scrollToPosition(.top(adjustForTempInset: false)) case let .known(offset): let isFirstFilter = strongSelf.chatListDisplayNode.effectiveContainerNode.currentItemNode.chatListFilter == strongSelf.chatListDisplayNode.mainContainerNode.availableFilters.first?.filter - + if offset <= ChatListNavigationBar.searchScrollHeight + 1.0 && strongSelf.chatListDisplayNode.inlineStackContainerNode != nil { strongSelf.setInlineChatList(location: nil) } else if offset <= ChatListNavigationBar.searchScrollHeight + 1.0 && !isFirstFilter { @@ -424,7 +424,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if let componentView = strongSelf.chatListHeaderView(), let storyPeerListView = componentView.storyPeerListView() { storyPeerListView.scrollToTop() } - + strongSelf.chatListDisplayNode.willScrollToTop() if let inlineStackContainerNode = strongSelf.chatListDisplayNode.inlineStackContainerNode { inlineStackContainerNode.currentItemNode.scrollToPosition(.top(adjustForTempInset: false)) @@ -435,7 +435,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + self.badgeDisposable = (combineLatest(renderedTotalUnreadCount(accountManager: context.sharedContext.accountManager, engine: context.engine), self.presentationDataValue.get()) |> deliverOnMainQueue).startStrict(next: { [weak self] count, presentationData in if let strongSelf = self { if count.0 == 0 { @@ -445,29 +445,29 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } }).strict() - + self.presentationDataDisposable = (context.sharedContext.presentationData |> deliverOnMainQueue).startStrict(next: { [weak self] presentationData in if let strongSelf = self { let previousTheme = strongSelf.presentationData.theme let previousStrings = strongSelf.presentationData.strings - + strongSelf.presentationData = presentationData strongSelf.presentationDataValue.set(.single(presentationData)) - + if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings { strongSelf.updateThemeAndStrings() } } }).strict() - + if !previewing { enum State: Equatable { case empty(hasDownloads: Bool) case downloading(progress: Double) case hasUnseen } - + let entriesWithFetchStatuses = Signal<[(entry: FetchManagerEntrySummary, progress: Double)], NoError> { subscriber in let queue = Queue() final class StateHolder { @@ -476,26 +476,26 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController var isRemoved: Bool = false var statusDisposable: Disposable? var status: EngineMediaResource.FetchStatus? - + init(entry: FetchManagerEntrySummary) { self.entry = entry } - + deinit { self.statusDisposable?.dispose() } } - + let queue: Queue - + var entryContexts: [FetchManagerLocationEntryId: EntryContext] = [:] - + let state = Promise<[(entry: FetchManagerEntrySummary, progress: Double)]>() - + init(queue: Queue) { self.queue = queue } - + func update(engine: TelegramEngine, entries: [FetchManagerEntrySummary]) { if entries.isEmpty { self.entryContexts.removeAll() @@ -508,9 +508,9 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController context = EntryContext(entry: entry) self.entryContexts[entry.id] = context } - + context.entry = entry - + if context.isRemoved { context.isRemoved = false context.status = nil @@ -518,12 +518,12 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController context.statusDisposable = nil } } - + for (_, context) in self.entryContexts { if !entries.contains(where: { $0.id == context.entry.id }) { context.isRemoved = true } - + if context.statusDisposable == nil { context.statusDisposable = (engine.resources.status(resource: EngineMediaResource(context.entry.resourceReference.resource)) |> deliverOn(self.queue)).startStrict(next: { [weak self, weak context] status in @@ -538,10 +538,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + self.notifyUpdatedIfReady() } - + func notifyUpdatedIfReady() { var result: [(entry: FetchManagerEntrySummary, progress: Double)] = [] loop: for (_, context) in self.entryContexts { @@ -581,19 +581,19 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController subscriber.putNext(state) })) } - + return ActionDisposable { entriesDisposable.dispose() holderStateDisposable.dispose() } } - + let displayRecentDownloads = context.account.postbox.tailChatListView(groupId: .root, filterPredicate: nil, count: 11, summaryComponents: ChatListEntrySummaryComponents(components: [:])) |> map { view -> Bool in return view.0.entries.count >= 10 } |> distinctUntilChanged - + let stateSignal: Signal = (combineLatest(queue: .mainQueue(), entriesWithFetchStatuses, recentDownloadItems(postbox: context.account.postbox), displayRecentDownloads) |> map { entries, recentDownloadItems, displayRecentDownloads -> State in if !entries.isEmpty && displayRecentDownloads { @@ -628,7 +628,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } |> distinctUntilChanged |> deliverOnMainQueue) - + self.activeDownloadsDisposable = stateSignal.startStrict(next: { [weak self] state in guard let strongSelf = self else { return @@ -639,7 +639,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController switch state { case let .downloading(progress): strongSelf.hasDownloads = true - + animation = LottieAnimationComponent.AnimationItem( name: "anim_search_downloading", mode: .animating(loop: true) @@ -650,12 +650,12 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController "Arrow2.Union.Fill 1": strongSelf.presentationData.theme.list.itemAccentColor, ] progressValue = progress - + strongSelf.clearUnseenDownloadsTimer?.invalidate() strongSelf.clearUnseenDownloadsTimer = nil case .hasUnseen: strongSelf.hasDownloads = true - + animation = LottieAnimationComponent.AnimationItem( name: "anim_search_downloaded", mode: .animating(loop: false) @@ -671,7 +671,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController "Arrow2.Union.Fill 1": strongSelf.presentationData.theme.rootController.navigationSearchBar.inputFillColor.blitOver(strongSelf.presentationData.theme.rootController.navigationBar.opaqueBackgroundColor, alpha: 1.0), ] progressValue = 1.0 - + if strongSelf.clearUnseenDownloadsTimer == nil { let timeout: Double #if DEBUG @@ -690,15 +690,15 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } case let .empty(hasDownloadsValue): strongSelf.hasDownloads = hasDownloadsValue - + animation = nil colors = [:] progressValue = nil - + strongSelf.clearUnseenDownloadsTimer?.invalidate() strongSelf.clearUnseenDownloadsTimer = nil } - + if let animation = animation, let progressValue = progressValue { let contentComponent = AnyComponent(ZStack([ AnyComponentWithIdentity(id: 0, component: AnyComponent(LottieAnimationComponent( @@ -713,7 +713,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController value: progressValue ))) ])) - + if let navigationBarView = strongSelf.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { navigationBarView.searchContentNode?.placeholderNode.setAccessoryComponent(component: AnyComponent(Button( content: contentComponent, @@ -732,26 +732,26 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } }) } - + if enableDebugActions { self.tabBarItemDebugTapAction = { preconditionFailure("debug tap") } } - + if case .chatList(.root) = self.location { self.chatListDisplayNode.mainContainerNode.currentItemFilterUpdated = { [weak self] filter, fraction, transition, force in guard let strongSelf = self else { return } - + if let navigationBarView = strongSelf.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View, let headerPanelsView = navigationBarView.headerPanels as? HeaderPanelContainerComponent.View, let tabsView = headerPanelsView.tabs as? HorizontalTabsComponent.View { tabsView.updateTabSwitchFraction(fraction: fraction, isDragging: strongSelf.chatListDisplayNode.mainContainerNode.isSwitchingCurrentItemFilterByDragging, transition: ComponentTransition(transition)) } } self.reloadFilters() } - + self.storiesPostingAvailabilityDisposable = (self.context.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key: PreferencesKeys.appConfiguration)) |> map { view -> AppConfiguration in let appConfiguration: AppConfiguration = view?.get(AppConfiguration.self) ?? AppConfiguration.defaultValue @@ -769,11 +769,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.storyPostingAvailabilityValue.set(postingAvailability) } }) - + self.updateNavigationMetadata() self.updateTabBarSearchState(ViewController.TabBarSearchState(isActive: false), transition: .immediate) - + self.globalControlPanelsContextStateDisposable = (self.globalControlPanelsContext.state |> deliverOnMainQueue).startStrict(next: { [weak self] state in guard let self else { @@ -787,7 +787,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + deinit { self.openMessageFromSearchDisposable.dispose() self.badgeDisposable?.dispose() @@ -817,12 +817,12 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } self.globalControlPanelsContextStateDisposable?.dispose() } - + private func updateNavigationMetadata() { guard let currentContext = self.secondaryContext ?? self.primaryContext else { return } - + switch currentContext.location { case .chatList: self.navigationBar?.userInfo = nil @@ -847,14 +847,14 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + func findTitleView() -> ChatListTitleView? { guard let componentView = self.chatListHeaderView() else { return nil } return componentView.findTitleView() } - + private var previousEmojiSetupTimestamp: Double? func openStatusSetup(sourceView: UIView) { let currentTimestamp = CACurrentMediaTime() @@ -862,7 +862,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return } self.previousEmojiSetupTimestamp = currentTimestamp - + self.emojiStatusSelectionController?.dismiss() var selectedItems = Set() var topStatusTitle = self.presentationData.strings.PeerStatusSetup_NoTimerTitle @@ -870,7 +870,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if let emojiStatus = self.chatListHeaderView()?.emojiStatus() { selectedItems.insert(MediaId(namespace: Namespaces.Media.CloudFile, id: emojiStatus.fileId)) currentSelection = emojiStatus.fileId - + if let timestamp = emojiStatus.expirationDate { topStatusTitle = peerStatusExpirationString(statusTimestamp: timestamp, relativeTo: Int32(Date().timeIntervalSince1970), strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat) } @@ -904,23 +904,23 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.emojiStatusSelectionController = controller self.present(controller, in: .window(.root)) } - + func allowAutomaticOrder() { if !self.shouldFixStorySubscriptionOrder { return } - + self.shouldFixStorySubscriptionOrder = false self.fixedStorySubscriptionOrder = self.rawStorySubscriptions?.items.map(\.peer.id) ?? [] if self.orderedStorySubscriptions != self.rawStorySubscriptions { self.orderedStorySubscriptions = self.rawStorySubscriptions - + // important not to cause a loop DispatchQueue.main.async { [weak self] in guard let self else { return } - + self.chatListDisplayNode.requestNavigationBarLayout(transition: ComponentTransition.immediate.withUserData(ChatListNavigationBar.AnimationHint( disableStoriesAnimations: false, crossfadeStoryPeers: true @@ -928,14 +928,14 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + private func updateThemeAndStrings() { if case .chatList(.root) = self.location { self.tabBarItem.title = self.presentationData.strings.DialogList_Title let backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.DialogList_Title, style: .plain, target: nil, action: nil) backBarButtonItem.accessibilityLabel = self.presentationData.strings.Common_Back self.navigationItem.backBarButtonItem = backBarButtonItem - + if !self.presentationData.reduceMotion { self.tabBarItem.animationName = "TabChats" } else { @@ -946,17 +946,17 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController backBarButtonItem.accessibilityLabel = self.presentationData.strings.Common_Back self.navigationItem.backBarButtonItem = backBarButtonItem } - + self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style self.navigationBar?.updatePresentationData(NavigationBarPresentationData(presentationData: self.presentationData), transition: .immediate) - + if self.isNodeLoaded { self.chatListDisplayNode.updatePresentationData(self.presentationData) } - + self.requestLayout(transition: .immediate) } - + func tabContextGesture(id: Int32?, sourceNode: ContextExtractedContentContainingNode?, sourceView: ContextExtractedContentContainingView?, gesture: ContextGesture?, keepInPlace: Bool, isDisabled: Bool) { let context = self.context let filterPeersAreMuted: Signal<(areMuted: Bool, peerIds: [EnginePeer.Id])?, NoError> = self.context.engine.peers.currentChatListFilters() @@ -968,7 +968,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard case let .filter(_, _, _, data) = filter else { return .single(nil) } - + let filterPredicate: ChatListFilterPredicate = chatListFilterPredicate(filter: data, accountPeerId: context.account.peerId) return context.engine.peers.getChatListPeers(filterPredicate: filterPredicate) |> mapToSignal { peers -> Signal<(areMuted: Bool, peerIds: [EnginePeer.Id])?, NoError> in @@ -1007,7 +1007,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + let _ = combineLatest( queue: Queue.mainQueue(), self.context.engine.peers.currentChatListFilters(), @@ -1062,7 +1062,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } }) }))) - + if let _ = filters.first(where: { $0.id == id }) { items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.ChatList_AddChatsToFolder, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Add"), color: theme.contextMenu.primaryColor) @@ -1071,7 +1071,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + if isDisabled { let context = self.context var replaceImpl: ((ViewController) -> Void)? @@ -1102,10 +1102,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if filter.id == id, case let .filter(_, _, _, data) = filter { let (accountPeer, limits, premiumLimits) = result let isPremium = accountPeer?.isPremium ?? false - + let limit = limits.maxFolderChatsCount let premiumLimit = premiumLimits.maxFolderChatsCount - + if data.includePeers.peers.count >= premiumLimit { let controller = PremiumLimitScreen(context: self.context, subject: .chatsPerFolder, count: Int32(data.includePeers.peers.count), action: { return true @@ -1127,7 +1127,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController f(.dismissWithoutContent) return } - + let _ = (self.context.engine.peers.currentChatListFilters() |> deliverOnMainQueue).startStandalone(next: { [weak self] filters in guard let self else { @@ -1152,7 +1152,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } }) }))) - + if let filterEntries = self.tabContainerData?.0 { for filter in filterEntries { if case let .filter(filterId, _, unread) = filter, filterId == id { @@ -1168,7 +1168,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) }))) } - + for filter in filters { if filter.id == filterId, case let .filter(_, title, _, data) = filter { if let filterPeersAreMuted, filterPeersAreMuted.peerIds.count <= 200 { @@ -1177,17 +1177,17 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }, action: { [weak self] c, f in c?.dismiss(completion: { }) - + guard let self else { return } - + let _ = (self.context.engine.peers.updateMultiplePeerMuteSettings(peerIds: filterPeersAreMuted.peerIds, muted: !filterPeersAreMuted.areMuted) |> deliverOnMainQueue).startStandalone(completed: { [weak self] in guard let self else { return } - + let iconColor: UIColor = .white let overlayController: UndoOverlayController if !filterPeersAreMuted.areMuted { @@ -1199,7 +1199,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController ChatTextInputAttributes.bold: true ]), at: folderNameRange.location) } - + overlayController = UndoOverlayController(presentationData: self.presentationData, content: .universalWithEntities(context: self.context, animation: "anim_profilemute", scale: 0.075, colors: [ "Middle.Group 1.Fill 1": iconColor, "Top.Group 1.Fill 1": iconColor, @@ -1216,7 +1216,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController ChatTextInputAttributes.bold: true ]), at: folderNameRange.location) } - + overlayController = UndoOverlayController(presentationData: self.presentationData, content: .universalWithEntities(context: self.context, animation: "anim_profileunmute", scale: 0.075, colors: [ "Middle.Group 1.Fill 1": iconColor, "Top.Group 1.Fill 1": iconColor, @@ -1229,7 +1229,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) }))) } - + if !data.includePeers.peers.isEmpty && data.categories.isEmpty && !data.excludeRead && !data.excludeMuted && !data.excludeArchived && data.excludePeers.isEmpty { items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.ChatList_ContextMenuShare, textColor: .primary, badge: nil, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor) @@ -1242,16 +1242,16 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) }))) } - + break } } - + break } } } - + items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.ChatList_RemoveFolder, textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { [weak self] c, f in @@ -1275,7 +1275,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) }))) } - + if filters.count > 1 { items.append(.separator) items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.ChatList_ReorderTabs, icon: { theme in @@ -1285,10 +1285,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + self.chatListDisplayNode.isReorderingFilters = true self.isReorderingTabsValue.set(true) - + (self.parent as? TabBarController)?.updateIsTabBarEnabled(false, transition: .animated(duration: 0.2, curve: .easeInOut)) if let layout = self.validLayout { self.updateLayout(layout: layout, transition: .animated(duration: 0.2, curve: .easeInOut)) @@ -1296,7 +1296,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) }))) } - + if let sourceNode { let controller = makeContextController(presentationData: self.presentationData, source: .extracted(ChatListHeaderBarContextExtractedContentSource(controller: self, sourceNode: sourceNode, sourceView: sourceView, keepInPlace: keepInPlace)), items: .single(ContextController.Items(content: .list(items))), recognizer: nil, gesture: gesture) self.context.sharedContext.mainWindow?.presentInGlobalOverlay(controller) @@ -1306,26 +1306,26 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } }) } - + override public func loadDisplayNode() { self.displayNode = ChatListControllerNode(context: self.context, location: self.location, previewing: self.previewing, controlsHistoryPreload: self.controlsHistoryPreload, presentationData: self.presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, controller: self) - + self.chatListDisplayNode.navigationBar = self.navigationBar - + self.chatListDisplayNode.requestDeactivateSearch = { [weak self] in self?.deactivateSearch(animated: true) } - + self.chatListDisplayNode.mainContainerNode.activateSearch = { [weak self] in self?.activateSearch() } - + self.chatListDisplayNode.mainContainerNode.presentAlert = { [weak self] text in if let strongSelf = self { self?.present(textAlertController(context: strongSelf.context, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) } } - + self.chatListDisplayNode.mainContainerNode.present = { [weak self] c in if let strongSelf = self { if c is UndoOverlayController { @@ -1336,27 +1336,27 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + self.chatListDisplayNode.mainContainerNode.push = { [weak self] c in if let strongSelf = self { strongSelf.push(c) } } - + self.chatListDisplayNode.mainContainerNode.toggleArchivedFolderHiddenByDefault = { [weak self] in guard let strongSelf = self else { return } strongSelf.toggleArchivedFolderHiddenByDefault() } - + self.chatListDisplayNode.mainContainerNode.hidePsa = { [weak self] peerId in guard let strongSelf = self else { return } strongSelf.hidePsa(peerId) } - + self.chatListDisplayNode.mainContainerNode.deletePeerChat = { [weak self] peerId, joined in guard let strongSelf = self else { return @@ -1387,22 +1387,22 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } strongSelf.setPeerThreadHidden(peerId: peerId, threadId: threadId, isHidden: isHidden) } - + self.chatListDisplayNode.mainContainerNode.peerSelected = { [weak self] peer, threadId, animated, activateInput, promoInfo in Task { @MainActor [weak self] in guard let self else { return } - + let subject: ChatControllerSubject? = nil - + var forumSourcePeer: Signal = .single(nil) if case let .savedMessagesChats(peerId) = self.location, peerId != self.context.account.peerId { forumSourcePeer = self.context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: peerId) ) } - + let _ = (combineLatest(queue: .mainQueue(), self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.CachedData(id: peer.id)), forumSourcePeer @@ -1414,21 +1414,21 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let navigationController = self.navigationController as? NavigationController else { return } - + var peer = peer var threadId = threadId if let forumSourcePeer { threadId = peer.id.toInt64() peer = forumSourcePeer } - + var scrollToEndIfExists = false if let layout = self.validLayout, case .regular = layout.metrics.widthClass { scrollToEndIfExists = true } - + var openAsInlineForum = true - + if case let .channel(channel) = peer, channel.flags.contains(.isMonoforum) { openAsInlineForum = false } else if case let .channel(channel) = peer, channel.flags.contains(.displayForumAsTabs) { @@ -1438,7 +1438,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController openAsInlineForum = false } } - + if openAsInlineForum, case let .channel(channel) = peer, channel.isForum, threadId == nil { self.chatListDisplayNode.clearHighlightAnimated(true) if self.chatListDisplayNode.inlineStackContainerNode?.location == .forum(peerId: channel.id) { @@ -1448,7 +1448,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return } - + if case let .channel(channel) = peer, channel.isForumOrMonoForum, let threadId { self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams( navigationController: navigationController, @@ -1471,7 +1471,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController subject: subject, keepStack: .always )) - + self.chatListDisplayNode.clearHighlightAnimated(true) } else { var navigationAnimationOptions: NavigationAnimationOptions = [] @@ -1482,10 +1482,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController navigationAnimationOptions = .removeOnMasterDetails } } - + let chatLocation: NavigateToChatControllerParams.Location chatLocation = .peer(peer) - + self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams( navigationController: navigationController, context: self.context, @@ -1501,7 +1501,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return } self.chatListDisplayNode.mainContainerNode.currentItemNode.clearHighlightAnimated(true) - + if let promoInfo = promoInfo { switch promoInfo { case .proxy: @@ -1529,7 +1529,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } else if let string = self.presentationData.strings.secondaryComponent?.dict[key] { text = string } - + controller.displayPromoAnnouncement(text: text) let _ = ApplicationSpecificNotice.setPsaAcknowledgment(accountManager: self.context.sharedContext.accountManager, peerId: peer.id).startStandalone() } @@ -1541,12 +1541,12 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) } } - + self.chatListDisplayNode.mainContainerNode.groupSelected = { [weak self] groupId in guard let self else { return } - + let _ = self.context.engine.privacy.updateGlobalPrivacySettings().startStandalone() let _ = (combineLatest( ApplicationSpecificNotice.displayChatListArchiveTooltip(accountManager: self.context.sharedContext.accountManager), @@ -1559,26 +1559,26 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + self.chatListDisplayNode.mainContainerNode.currentItemNode.clearHighlightAnimated(true) - + if let navigationController = self.navigationController as? NavigationController { let chatListController = ChatListControllerImpl(context: self.context, location: .chatList(groupId: groupId), controlsHistoryPreload: false, enableDebugActions: false) chatListController.navigationPresentation = .master navigationController.pushViewController(chatListController) } - + if !didDisplayTip, chatListHead.items.count < 10 { #if DEBUG #else let _ = ApplicationSpecificNotice.setDisplayChatListArchiveTooltip(accountManager: self.context.sharedContext.accountManager).startStandalone() #endif - + self.push(ArchiveInfoScreen(context: self.context, settings: settings)) } }) } - + self.chatListDisplayNode.mainContainerNode.updatePeerGrouping = { [weak self] peerId, group in guard let strongSelf = self else { return @@ -1595,21 +1595,21 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) } } - + self.chatListDisplayNode.mainContainerNode.openBirthdaySetup = { [weak self] in guard let self else { return } self.openBirthdaySetup() } - + self.chatListDisplayNode.mainContainerNode.openStarsTopup = { [weak self] amount in guard let self else { return } self.openStarsTopup(amount: amount) } - + self.chatListDisplayNode.mainContainerNode.openWebApp = { [weak self] user in guard let self else { return @@ -1630,7 +1630,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController verifyAgeCompletion: nil ) } - + self.chatListDisplayNode.mainContainerNode.openAccountFreezeInfo = { [weak self] in guard let self else { return @@ -1638,7 +1638,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController let controller = self.context.sharedContext.makeAccountFreezeInfoScreen(context: self.context) self.push(controller) } - + self.chatListDisplayNode.mainContainerNode.openPhotoSetup = { [weak self] in guard let self else { return @@ -1648,7 +1648,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return nil } - + let toastScreen = AvatarUploadToastScreen( context: self.context, image: image, @@ -1677,7 +1677,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } ) - + if let navigationController = self.navigationController as? NavigationController { var viewControllers = navigationController.viewControllers if let index = viewControllers.firstIndex(where: { $0 is TabBarController }) { @@ -1689,13 +1689,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } else { self.push(toastScreen) } - + return toastScreen.targetAvatarView }) } } - - + + self.chatListDisplayNode.mainContainerNode.openPremiumManagement = { [weak self] in guard let self else { return @@ -1708,7 +1708,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: url, forceExternal: !url.hasPrefix("tg://") && !url.contains("?start="), presentationData: context.sharedContext.currentPresentationData.with({$0}), navigationController: self.navigationController as? NavigationController, dismissInput: {}) } - + self.chatListDisplayNode.requestOpenMessageFromSearch = { [weak self] peer, threadId, messageId, deactivateOnAction in if let strongSelf = self { strongSelf.openMessageFromSearchDisposable.set((strongSelf.context.engine.peers.ensurePeerIsLocallyAvailable(peer: peer) @@ -1738,7 +1738,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController })) } } - + self.chatListDisplayNode.requestOpenPeerFromSearch = { [weak self] peer, threadId, dismissSearch in if let strongSelf = self { let storedPeer = strongSelf.context.engine.peers.ensurePeerIsLocallyAvailable(peer: peer) |> map { _ -> Void in return Void() } @@ -1769,23 +1769,23 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController })) } } - + self.chatListDisplayNode.dismissSearch = { [weak self] in if let self { self.deactivateSearch(animated: true) } } - + self.chatListDisplayNode.requestOpenRecentPeerOptions = { [weak self] peer in if let strongSelf = self { strongSelf.view.window?.endEditing(true) let actionSheet = ActionSheetController(presentationData: strongSelf.presentationData) - + actionSheet.setItemGroups([ ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Delete, color: .destructive, action: { [weak actionSheet] in actionSheet?.dismissAnimated() - + if let strongSelf = self { let _ = strongSelf.context.engine.peers.removeRecentPeer(peerId: peer.id).startStandalone() } @@ -1800,7 +1800,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController strongSelf.present(actionSheet, in: .window(.root)) } } - + self.chatListDisplayNode.requestAddContact = { [weak self] phoneNumber in if let strongSelf = self { strongSelf.view.endEditing(true) @@ -1813,7 +1813,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) } } - + self.chatListDisplayNode.dismissSelfIfCompletedPresentation = { [weak self] in guard let strongSelf = self, let navigationController = strongSelf.navigationController as? NavigationController else { return @@ -1823,7 +1823,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } navigationController.filterController(strongSelf, animated: true) } - + self.chatListDisplayNode.emptyListAction = { [weak self] _ in guard let strongSelf = self, let navigationController = strongSelf.navigationController as? NavigationController else { return @@ -1835,10 +1835,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController let context = strongSelf.context let controller = ForumCreateTopicScreen(context: context, peerId: peerId, mode: .create) controller.navigationPresentation = .modal - + controller.completion = { [weak controller] title, fileId, iconColor, _ in controller?.isInProgress = true - + let _ = (context.engine.peers.createForumChannelTopic(id: peerId, title: title, iconColor: iconColor, iconFileId: fileId) |> deliverOnMainQueue).startStandalone(next: { topicId in let _ = context.sharedContext.navigateToForumThread(context: context, peerId: peerId, threadId: topicId, messageId: nil, navigationController: navigationController, activateInput: .text, scrollToEndIfExists: false, keepStack: .never, animated: true).startStandalone() @@ -1852,24 +1852,24 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + self.chatListDisplayNode.cancelEditing = { [weak self] in guard let strongSelf = self else { return } let _ = strongSelf.reorderingDonePressed() } - + self.chatListDisplayNode.toolbarActionSelected = { [weak self] action in self?.toolbarActionSelected(action: action) } - + self.chatListDisplayNode.mainContainerNode.activateChatPreview = { [weak self] item, threadId, node, gesture, location in guard let strongSelf = self else { gesture?.cancel() return } - + var joined = false if case let .peer(peerData) = item.content, let message = peerData.messages.first { for media in message.media { @@ -1878,7 +1878,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + switch item.content { case .loading: break @@ -1891,7 +1891,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController let peer = peerData.peer let threadInfo = peerData.threadInfo let promoInfo = peerData.promoInfo - + switch item.index { case .chatList: if case let .channel(channel) = peer.peer, (channel.isForum || (channel.isMonoForum && threadId != nil)) { @@ -1902,7 +1902,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController )), subject: nil, botStart: nil, mode: .standard(.previewing), params: nil) chatController.canReadHistory.set(false) source = .controller(ContextControllerContentSourceImpl(controller: chatController, sourceNode: node, navigationController: strongSelf.navigationController as? NavigationController)) - + let contextController = makeContextController(context: strongSelf.context, presentationData: strongSelf.presentationData, source: source, items: chatForumTopicMenuItems(context: strongSelf.context, peerId: peer.peerId, threadId: threadId, isPinned: nil, isClosed: nil, chatListController: strongSelf, joined: joined, canSelect: false) |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) strongSelf.presentInGlobalOverlay(contextController) } else { @@ -1919,7 +1919,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } else { source = .controller(ContextControllerContentSourceImpl(controller: peerInfoController, sourceNode: node, navigationController: strongSelf.navigationController as? NavigationController)) } - + let contextController = makeContextController(context: strongSelf.context, presentationData: strongSelf.presentationData, source: source, items: chatContextMenuItems(context: strongSelf.context, peerId: peer.id, promoInfo: promoInfo, source: .chatList(filter: strongSelf.chatListDisplayNode.mainContainerNode.currentItemNode.chatListFilter), chatListController: strongSelf, joined: joined) |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) strongSelf.presentInGlobalOverlay(contextController) } @@ -1937,10 +1937,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } source = .controller(ContextControllerContentSourceImpl(controller: chatController, sourceNode: node, navigationController: strongSelf.navigationController as? NavigationController)) } - + let contextController = makeContextController(context: strongSelf.context, presentationData: strongSelf.presentationData, source: source, items: chatContextMenuItems(context: strongSelf.context, peerId: peer.peerId, promoInfo: promoInfo, source: .chatList(filter: strongSelf.chatListDisplayNode.mainContainerNode.currentItemNode.chatListFilter), chatListController: strongSelf, joined: joined) |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) strongSelf.presentInGlobalOverlay(contextController) - + dismissPreviewingImpl = { [weak self, weak contextController] animateIn in if let self, let contextController { if animateIn { @@ -1971,13 +1971,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController )), subject: nil, botStart: nil, mode: .standard(.previewing), params: nil) chatController.canReadHistory.set(false) source = .controller(ContextControllerContentSourceImpl(controller: chatController, sourceNode: node, navigationController: strongSelf.navigationController as? NavigationController)) - + let contextController = makeContextController(context: strongSelf.context, presentationData: strongSelf.presentationData, source: source, items: chatForumTopicMenuItems(context: strongSelf.context, peerId: peer.peerId, threadId: threadId, isPinned: isPinned, isClosed: threadInfo?.isClosed, chatListController: strongSelf, joined: joined, canSelect: true) |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) strongSelf.presentInGlobalOverlay(contextController) } } } - + self.chatListDisplayNode.mainContainerNode.openStories = { [weak self] subject, itemNode in guard let self else { return @@ -1985,11 +1985,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let itemNode = itemNode as? ChatListItemNode else { return } - + if let storyPeerListView = self.chatListHeaderView()?.storyPeerListView() { storyPeerListView.cancelLoadingItem() } - + switch subject { case .archive: StoryContainerScreen.openArchivedStories(context: self.context, parentController: self, avatarNode: itemNode.avatarNode, sharedProgressDisposable: self.sharedOpenStoryProgressDisposable) @@ -1997,13 +1997,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController StoryContainerScreen.openPeerStories(context: self.context, peerId: peerId, parentController: self, avatarNode: itemNode.avatarNode, sharedProgressDisposable: self.sharedOpenStoryProgressDisposable) } } - + self.chatListDisplayNode.peerContextAction = { [weak self] peer, source, node, gesture, location in guard let strongSelf = self else { gesture?.cancel() return } - + if case let .channel(channel) = peer, channel.isForumOrMonoForum { let chatListController = ChatListControllerImpl(context: strongSelf.context, location: .forum(peerId: channel.id), controlsHistoryPreload: false, hideNetworkActivityStatus: true, previewing: true, enableDebugActions: false) chatListController.navigationPresentation = .master @@ -2022,12 +2022,12 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController chatController.canReadHistory.set(false) contextContentSource = .controller(ContextControllerContentSourceImpl(controller: chatController, sourceNode: node, navigationController: strongSelf.navigationController as? NavigationController)) } - + let contextController = makeContextController(context: strongSelf.context, presentationData: strongSelf.presentationData, source: contextContentSource, items: chatContextMenuItems(context: strongSelf.context, peerId: peer.id, promoInfo: nil, source: .search(source), chatListController: strongSelf, joined: false) |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) strongSelf.presentInGlobalOverlay(contextController) } } - + if case .chatList(.root) = self.location { self.ready.set(combineLatest([self.mainReady.get(), self.storiesReady.get()]) |> map { values -> Bool in @@ -2040,22 +2040,34 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.primaryInfoReady.get(), self.storiesReady.get() ] - + if case .chatList(.archive) = self.location { //signals.append(self.mainReady.get()) } else { self.storiesReady.set(.single(true)) } - + self.ready.set(combineLatest(signals) |> map { values -> Bool in return !values.contains(where: { !$0 }) } |> filter { $0 }) } - + self.displayNodeDidLoad() - + + // WinterGram: two-finger swipe on the chat list to quickly cycle between accounts. + if case .chatList(.root) = self.location { + let leftSwipe = UISwipeGestureRecognizer(target: self, action: #selector(self.winterGramQuickSwitchGesture(_:))) + leftSwipe.direction = .left + leftSwipe.numberOfTouchesRequired = 2 + self.displayNode.view.addGestureRecognizer(leftSwipe) + let rightSwipe = UISwipeGestureRecognizer(target: self, action: #selector(self.winterGramQuickSwitchGesture(_:))) + rightSwipe.direction = .right + rightSwipe.numberOfTouchesRequired = 2 + self.displayNode.view.addGestureRecognizer(rightSwipe) + } + if case .chatList = self.location { let automaticDownloadNetworkType = context.account.networkType |> map { type -> MediaAutoDownloadNetworkType in @@ -2067,7 +2079,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } |> distinctUntilChanged - + let preferHighQualityStories: Signal = combineLatest( context.sharedContext.automaticMediaDownloadSettings |> map { settings in @@ -2083,7 +2095,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return setting && isPremium } |> distinctUntilChanged - + self.preloadStorySubscriptionsDisposable = (combineLatest(queue: .mainQueue(), self.context.engine.messages.preloadStorySubscriptions(isHidden: self.location == .chatList(groupId: .archive), preferHighQuality: preferHighQualityStories), self.context.sharedContext.automaticMediaDownloadSettings, @@ -2093,17 +2105,17 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + var autodownloadEnabled = true if !shouldDownloadMediaAutomatically(settings: automaticMediaDownloadSettings, peerType: .contact, networkType: automaticDownloadNetworkType, authorPeerId: nil, contactsPeerIds: [], media: nil, isStory: true) { autodownloadEnabled = false } - + var resources = resources if !autodownloadEnabled { resources.removeAll() } - + var validIds: [MediaId] = [] for (_, info) in resources.sorted(by: { $0.value.priority < $1.value.priority }) { if let mediaId = info.media.id { @@ -2113,7 +2125,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + var removeIds: [MediaId] = [] for (id, disposable) in self.preloadStoryResourceDisposables { if !validIds.contains(id) { @@ -2125,7 +2137,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.preloadStoryResourceDisposables.removeValue(forKey: id) } }) - + if self.previewing { self.storiesReady.set(.single(true)) } else { @@ -2134,7 +2146,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + self.rawStorySubscriptions = rawStorySubscriptions var items: [EngineStorySubscriptions.Item] = [] if self.shouldFixStorySubscriptionOrder { @@ -2155,24 +2167,24 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController hasMoreToken: rawStorySubscriptions.hasMoreToken ) self.fixedStorySubscriptionOrder = items.map(\.peer.id) - + let transition: ContainedViewLayoutTransition if self.didAppear { transition = .animated(duration: 0.4, curve: .spring) } else { transition = .immediate } - + self.chatListDisplayNode.temporaryContentOffsetChangeTransition = transition self.requestLayout(transition: transition) self.chatListDisplayNode.temporaryContentOffsetChangeTransition = nil - + if !shouldDisplayStoriesInChatListHeader(storySubscriptions: rawStorySubscriptions, isHidden: self.location == .chatList(groupId: .archive)) { self.chatListDisplayNode.scrollToTopIfStoriesAreExpanded() } - + self.storiesReady.set(.single(true)) - + Queue.mainQueue().after(1.0, { [weak self] in guard let self else { return @@ -2187,16 +2199,16 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } self.updateStoryUploadProgress(progress) }) - + if case .chatList(.root) = self.location { self.storyArchiveSubscriptionsDisposable = (self.context.engine.messages.storySubscriptions(isHidden: true) |> deliverOnMainQueue).startStrict(next: { [weak self] rawStoryArchiveSubscriptions in guard let self else { return } - + self.rawStoryArchiveSubscriptions = rawStoryArchiveSubscriptions - + let archiveStoryState: ChatListNodeState.StoryState? if rawStoryArchiveSubscriptions.items.isEmpty { archiveStoryState = nil @@ -2218,31 +2230,31 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController hasUnseenCloseFriends: hasUnseenCloseFriends ) } - + self.chatListDisplayNode.mainContainerNode.currentItemNode.updateState { chatListState in var chatListState = chatListState - + chatListState.archiveStoryState = archiveStoryState - + return chatListState } - + self.storiesReady.set(.single(true)) - + Queue.mainQueue().after(1.0, { [weak self] in guard let self else { return } self.maybeDisplayStoryTooltip() }) - + self.hasPendingStoriesPromise.set(rawStoryArchiveSubscriptions.accountItem?.hasPending ?? false) }) } } } } - + private weak var storyTooltip: TooltipScreen? fileprivate func maybeDisplayStoryTooltip() { let content = self.updateHeaderContent() @@ -2258,7 +2270,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if self.displayedStoriesTooltip { return } - + if case .chatList(groupId: .root) = self.location, let orderedStorySubscriptions = self.orderedStorySubscriptions, !orderedStorySubscriptions.items.isEmpty { let _ = (ApplicationSpecificNotice.displayChatListStoriesTooltip(accountManager: self.context.sharedContext.accountManager) |> deliverOnMainQueue).startStandalone(next: { [weak self] didDisplay in @@ -2268,13 +2280,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if didDisplay { return } - + if let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View, !navigationBarView.storiesUnlocked, !self.displayedStoriesTooltip { if let storyPeerListView = self.chatListHeaderView()?.storyPeerListView(), let (anchorView, anchorRect) = storyPeerListView.anchorForTooltip() { self.displayedStoriesTooltip = true - + let absoluteFrame = anchorView.convert(anchorRect, to: self.view) - + let itemList = orderedStorySubscriptions.items.prefix(3).map(\.peer.compactDisplayTitle) var itemListString: String = itemList.joined(separator: ", ") if #available(iOS 13.0, *) { @@ -2284,9 +2296,9 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController itemListString = value } } - + let text: String = self.presentationData.strings.ChatList_StoryFeedTooltipUsers(itemListString).string - + let tooltipScreen = TooltipScreen( account: self.context.account, sharedContext: self.context.sharedContext, @@ -2299,7 +2311,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController ) self.present(tooltipScreen, in: .current) self.storyTooltip = tooltipScreen - + #if !DEBUG let _ = ApplicationSpecificNotice.setDisplayChatListStoriesTooltip(accountManager: self.context.sharedContext.accountManager).startStandalone() #endif @@ -2308,20 +2320,20 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) } } - + public override func displayNodeDidLoad() { super.displayNodeDidLoad() - + Queue.mainQueue().after(1.0) { self.context.prefetchManager?.prepareNextGreetingSticker() } } - + public static var sharedPreviousPowerSavingEnabled: Bool? - + override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - + if self.powerSavingMonitoringDisposable == nil { self.powerSavingMonitoringDisposable = (self.context.sharedContext.automaticMediaDownloadSettings |> mapToSignal { settings -> Signal in @@ -2332,20 +2344,20 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return } var previousValueValue: Bool? - + previousValueValue = ChatListControllerImpl.sharedPreviousPowerSavingEnabled ChatListControllerImpl.sharedPreviousPowerSavingEnabled = isPowerSavingEnabled - + /*#if DEBUG previousValueValue = false #endif*/ - + if isPowerSavingEnabled != previousValueValue && previousValueValue != nil && isPowerSavingEnabled { let batteryLevel = UIDevice.current.batteryLevel if batteryLevel > 0.0 && self.view.window != nil { let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let batteryPercentage = Int(batteryLevel * 100.0) - + self.dismissAllUndoControllers() self.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "lowbattery_30", scale: 1.0, colors: [:], title: presentationData.strings.PowerSaving_AlertEnabledTitle, text: presentationData.strings.PowerSaving_AlertEnabledText("\(batteryPercentage)").string, customUndoText: presentationData.strings.PowerSaving_AlertEnabledAction, timeout: 5.0), elevatedLayout: false, action: { [weak self] action in if case .undo = action, let self { @@ -2361,11 +2373,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } }) } - + self.didAppear = true - + self.chatListDisplayNode.mainContainerNode.updateEnableAdjacentFilterLoading(true) - + self.chatListDisplayNode.mainContainerNode.didBeginSelectingChats = { [weak self] in guard let strongSelf = self else { return @@ -2385,7 +2397,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + self.chatListDisplayNode.mainContainerNode.displayFilterLimit = { [weak self] in guard let strongSelf = self else { return @@ -2402,11 +2414,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } strongSelf.push(controller) } - + guard case .chatList(.root) = self.location else { if !self.didSuggestLocalization { self.didSuggestLocalization = true - + let _ = (self.chatListDisplayNode.mainContainerNode.ready |> filter { $0 } |> take(1) @@ -2417,10 +2429,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.onDidAppear?() }) } - + return } - + #if true && DEBUG DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.0, execute: { [weak self] in guard let strongSelf = self else { @@ -2440,7 +2452,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController let hasPasscode = passcodeView.data.isLockable if hasPasscode { let _ = ApplicationSpecificNotice.setPasscodeLockTips(accountManager: strongSelf.context.sharedContext.accountManager).startStandalone() - + let tooltipController = TooltipController(content: .text(strongSelf.presentationData.strings.DialogList_PasscodeLockHelp), baseFontSize: strongSelf.presentationData.listsFontSize.baseDisplaySize, dismissByTapOutside: true) strongSelf.present(tooltipController, in: .window(.root), with: TooltipControllerPresentationArguments(sourceViewAndRect: { [weak self] in if let self, let componentView = self.chatListHeaderView(), let storyPeerListView = componentView.storyPeerListView(), let lockViewFrame = storyPeerListView.lockViewFrame() { @@ -2456,14 +2468,14 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } })) } - + if !self.didSuggestLocalization { self.didSuggestLocalization = true - + let context = self.context - + let suggestedLocalization = self.context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.SuggestedLocalization()) - + let signal = combineLatest( self.context.sharedContext.accountManager.transaction { transaction -> String in let languageCode: String @@ -2491,7 +2503,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return (value.0, suggestedLocalization) }) }) - + self.suggestLocalizationDisposable.set((signal |> deliverOnMainQueue).startStrict(next: { [weak self] suggestedLocalization in guard let strongSelf = self, let (currentLanguageCode, suggestedLocalization) = suggestedLocalization else { return @@ -2506,21 +2518,21 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController _ = strongSelf.context.engine.localization.markSuggestedLocalizationAsSeenInteractively(languageCode: suggestedLocalization.languageCode).startStandalone() } })) - + self.suggestAutoarchiveDisposable.set((self.context.engine.notices.getServerProvidedSuggestions() |> deliverOnMainQueue).startStrict(next: { [weak self] values in guard let strongSelf = self else { return } - + let context = strongSelf.context if values.contains(.setupLoginEmail) || values.contains(.setupLoginEmailBlocking) { if strongSelf.didSuggestLoginEmailSetup { return } - + strongSelf.didSuggestLoginEmailSetup = true - + let _ = (context.engine.notices.getServerProvidedSuggestions(reload: true) |> deliverOnMainQueue).start(next: { [weak self] currentValues in guard let strongSelf = self, currentValues.contains(.setupLoginEmail) || currentValues.contains(.setupLoginEmailBlocking) else { @@ -2545,29 +2557,29 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) return } - + if values.contains(.setupPasskey) { if strongSelf.didSuggestLoginPasskeySetup { return } strongSelf.didSuggestLoginPasskeySetup = true - + let _ = (context.engine.notices.getServerProvidedSuggestions(reload: true) |> deliverOnMainQueue).start(next: { [weak strongSelf] currentValues in guard let strongSelf, currentValues.contains(.setupPasskey) else { return } - + Task { @MainActor [weak strongSelf] in guard let strongSelf else { return } - + let passkeysData = await strongSelf.context.engine.auth.passkeysData().get() if !passkeysData.isEmpty { return } - + if let navigationController = strongSelf.navigationController as? NavigationController { let controller = strongSelf.context.sharedContext.makePasskeySetupController(context: strongSelf.context, displaySkip: true, navigationController: navigationController, completion: { let _ = context.engine.notices.dismissServerProvidedSuggestion(suggestion: ServerProvidedSuggestion.setupPasskey.id).startStandalone() @@ -2578,10 +2590,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } }) - + return } - + if strongSelf.didSuggestAutoarchive { return } @@ -2605,7 +2617,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) ], actionLayout: .vertical, parseMarkdown: true), in: .window(.root)) })) - + Queue.mainQueue().after(1.0, { let _ = ( self.context.engine.data.get( @@ -2624,11 +2636,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let strongSelf = self else { return } - + guard let value = value else { return } - + let controller = TwoFactorAuthSplashScreen(sharedContext: context.sharedContext, engine: .authorized(strongSelf.context.engine), mode: .intro(.init( title: strongSelf.presentationData.strings.ForcedPasswordSetup_Intro_Title, text: strongSelf.presentationData.strings.ForcedPasswordSetup_Intro_Text, @@ -2640,7 +2652,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let strongSelf = self, let controller = controller else { return true } - + controller.present(textAlertController(context: strongSelf.context, title: strongSelf.presentationData.strings.ForcedPasswordSetup_Intro_DismissTitle, text: strongSelf.presentationData.strings.ForcedPasswordSetup_Intro_DismissText(value), actions: [ TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.ForcedPasswordSetup_Intro_DismissActionCancel, action: { }), @@ -2651,15 +2663,15 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController controller?.dismiss() }) ], parseMarkdown: true), in: .window(.root)) - + return false } strongSelf.push(controller) - + let _ = value }) }) - + Queue.mainQueue().after(2.0, { [weak self] in guard let self else { return @@ -2676,7 +2688,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if let rightButtonView = componentView.rightButtonViews["compose"] { let absoluteFrame = rightButtonView.convert(rightButtonView.bounds, to: self.view) let text: String = self.presentationData.strings.ChatList_EmptyListTooltip - + let tooltipController = TooltipController(content: .text(text), baseFontSize: self.presentationData.listsFontSize.baseDisplaySize, timeout: 30.0, dismissByTapOutside: true, dismissImmediatelyOnLayoutUpdate: true, padding: 6.0, innerPadding: UIEdgeInsets(top: 2.0, left: 3.0, bottom: 2.0, right: 3.0)) self.present(tooltipController, in: .current, with: TooltipControllerPresentationArguments(sourceNodeAndRect: { [weak self] in guard let self else { @@ -2688,15 +2700,15 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } }) - + self.onDidAppear?() } - + self.chatListDisplayNode.mainContainerNode.addedVisibleChatsWithPeerIds = { [weak self] peerIds in guard let strongSelf = self else { return } - + strongSelf.forEachController({ controller in if let controller = controller as? UndoOverlayController { switch controller.content { @@ -2711,7 +2723,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return true }) } - + if !self.processedFeaturedFilters { let initializedFeatured = self.context.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key: PreferencesKeys.chatListFiltersFeaturedState)) |> mapToSignal { view -> Signal in @@ -2722,7 +2734,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } |> take(1) - + let initializedFilters = self.context.engine.peers.updatedChatListFiltersInfo() |> mapToSignal { (filters, isInitialized) -> Signal in if isInitialized { @@ -2732,7 +2744,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } |> take(1) - + self.featuredFiltersDisposable.set(( combineLatest(initializedFeatured, initializedFilters) |> take(1) @@ -2742,7 +2754,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let strongSelf = self else { return } - + strongSelf.processedFeaturedFilters = true if hasFeatured { if let _ = strongSelf.validLayout, let _ = strongSelf.parent as? TabBarController { @@ -2754,7 +2766,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if count >= 2 { return } - + let absoluteFrame = sourceFrame let text: String if hasFilters { @@ -2764,9 +2776,9 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } else { text = strongSelf.presentationData.strings.ChatList_TabIconFoldersTooltipEmptyFolders } - + let location = CGRect(origin: CGPoint(x: absoluteFrame.midX, y: absoluteFrame.minY - 8.0), size: CGSize()) - + parentController.present(TooltipScreen(account: strongSelf.context.account, sharedContext: strongSelf.context.sharedContext, text: .plain(text: text), icon: .animation(name: "ChatListFoldersTooltip", delay: 0.6, tintColor: nil), location: .point(location, .bottom), shouldDismissOnTouch: { point, _ in guard let strongSelf = self, let parentController = strongSelf.parent as? TabBarController else { return .dismiss(consume: false) @@ -2782,7 +2794,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController })) } } - + func dismissAllUndoControllers() { self.forEachController({ controller in if let controller = controller as? UndoOverlayController { @@ -2790,40 +2802,40 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return true }) - + if let emojiStatusSelectionController = self.emojiStatusSelectionController { self.emojiStatusSelectionController = nil emojiStatusSelectionController.dismiss() } } - + override public func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - + self.chatListDisplayNode.mainContainerNode.updateEnableAdjacentFilterLoading(false) - + self.dismissAllUndoControllers() - + self.featuredFiltersDisposable.set(nil) } - + override public func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - + if self.dismissSearchOnDisappear { self.dismissSearchOnDisappear = false self.deactivateSearch(animated: false) } - + self.chatListDisplayNode.clearHighlightAnimated(true) - + self.sharedOpenStoryProgressDisposable.set(nil) - + if let storyPeerListView = self.chatListHeaderView()?.storyPeerListView() { storyPeerListView.cancelLoadingItem() } } - + func updateHeaderContent() -> (primaryContent: ChatListHeaderComponent.Content?, secondaryContent: ChatListHeaderComponent.Content?) { var primaryContent: ChatListHeaderComponent.Content? if let primaryContext = self.primaryContext { @@ -2867,14 +2879,14 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } ) } - + return (primaryContent, secondaryContent) } - + override public func updateNavigationBarLayout(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.updateNavigationBarLayout(layout, transition: transition) } - + func chatListHeaderView() -> ChatListHeaderComponent.View? { if let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { if let componentView = navigationBarView.headerContent.view as? ChatListHeaderComponent.View { @@ -2883,7 +2895,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return nil } - + private weak var storyCameraTooltip: TooltipScreen? fileprivate func openStoryCamera(fromList: Bool, gesturePullOffset: CGFloat? = nil) { guard !self.context.isFrozen else { @@ -2891,17 +2903,17 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.push(controller) return } - + var reachedCountLimit = false var premiumNeeded = false var hasActiveCall = false var hasActiveGroupCall = false var hasLiveStream = false - + if let componentView = self.chatListHeaderView(), let storyPeerListView = componentView.storyPeerListView(), storyPeerListView.isLiveStreaming { hasLiveStream = true } - + let storiesCountLimit = self.context.userLimits.maxExpiringStoriesCount var storiesCount = 0 if let rawStorySubscriptions = self.rawStorySubscriptions, let accountItem = rawStorySubscriptions.accountItem { @@ -2910,7 +2922,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController reachedCountLimit = true } } - + switch self.storyPostingAvailability { case .premium: if !self.isPremium { @@ -2921,7 +2933,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController default: break } - + if let callManager = self.context.sharedContext.callManager { if callManager.hasActiveGroupCall { hasActiveGroupCall = true @@ -2929,7 +2941,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController hasActiveCall = true } } - + if !hasLiveStream && reachedCountLimit { let context = self.context var replaceImpl: ((ViewController) -> Void)? @@ -2946,7 +2958,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return } - + if !hasLiveStream && (premiumNeeded || hasActiveCall || hasActiveGroupCall) { if let storyCameraTooltip = self.storyCameraTooltip { self.storyCameraTooltip = nil @@ -2970,7 +2982,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if let sourceFrame { let context = self.context let location = CGRect(origin: CGPoint(x: sourceFrame.midX, y: sourceFrame.maxY), size: CGSize()) - + let text: String if premiumNeeded { text = self.presentationData.strings.StoryFeed_TooltipPremiumPostingLimited @@ -2984,7 +2996,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } else { text = "" } - + let tooltipController = TooltipScreen( context: context, account: context.account, @@ -3009,7 +3021,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return } - + var cameraTransitionIn: StoryCameraTransitionIn? if let componentView = self.chatListHeaderView() { if fromList { @@ -3032,13 +3044,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + if let rootController = self.context.sharedContext.mainWindow?.viewController as? TelegramRootControllerInterface { let coordinator = rootController.openStoryCamera(mode: .photo, customTarget: nil, resumeLiveStream: hasLiveStream, transitionIn: cameraTransitionIn, transitionedIn: {}, transitionOut: self.storyCameraTransitionOut()) coordinator?.animateIn() } } - + func displayContinueLiveStream() { self.present(textAlertController(context: self.context, title: self.presentationData.strings.ChatList_AlertResumeLiveStreamTitle, text: self.presentationData.strings.ChatList_AlertResumeLiveStreamText, actions: [ TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { @@ -3051,7 +3063,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) ]), in: .window(.root)) } - + public func storyCameraTransitionOut() -> (Stories.PendingTarget?, Bool) -> StoryCameraTransitionOut? { return { [weak self] target, isArchived in guard let self, let target else { @@ -3067,7 +3079,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController case .botPreview: peerId = nil } - + if let peerId, let (transitionView, _) = componentView.storyPeerListView()?.transitionViewForItem(peerId: peerId) { return StoryCameraTransitionOut( destinationView: transitionView, @@ -3079,20 +3091,40 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return nil } } - + + // WinterGram: cycle to the next/previous account on a two-finger swipe. + @objc private func winterGramQuickSwitchGesture(_ recognizer: UISwipeGestureRecognizer) { + guard recognizer.state == .ended else { + return + } + let forward = recognizer.direction == .left + let _ = (self.context.sharedContext.activeAccountsWithInfo + |> take(1) + |> deliverOnMainQueue).startStandalone(next: { [weak self] _, accounts in + guard let self else { + return + } + guard accounts.count > 1, let currentIndex = accounts.firstIndex(where: { $0.account.id == self.context.account.id }) else { + return + } + let nextIndex = forward ? (currentIndex + 1) % accounts.count : (currentIndex - 1 + accounts.count) % accounts.count + self.context.sharedContext.switchToAccount(id: accounts[nextIndex].account.id, fromSettingsController: nil, withChatListController: nil) + }) + } + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) - + let wasInVoiceOver = self.validLayout?.inVoiceOver ?? false - + self.validLayout = layout - + self.updateLayout(layout: layout, transition: transition) - + if layout.inVoiceOver != wasInVoiceOver { self.chatListDisplayNode.scrollToTop() } - + if case .chatList = self.location, let componentView = self.chatListHeaderView() { componentView.storyComposeAction = { [weak self] offset in guard let self else { @@ -3100,17 +3132,17 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } self.openStoryCamera(fromList: true, gesturePullOffset: offset) } - + componentView.storyPeerAction = { [weak self] peer in guard let self else { return } - + guard let peer else { self.chatListDisplayNode.scrollToStories(animated: true) return } - + if peer.id == self.context.account.peerId { if let rawStorySubscriptions = self.rawStorySubscriptions { var openCamera = false @@ -3119,22 +3151,22 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } else { openCamera = true } - + if openCamera { self.openStoryCamera(fromList: true) return } } } - + self.openStories(peerId: peer.id) } - + componentView.storyContextPeerAction = { [weak self] sourceNode, gesture, peer in guard let self else { return } - + let _ = (self.context.engine.data.get( TelegramEngine.EngineData.Item.Peer.NotificationSettings(id: peer.id), TelegramEngine.EngineData.Item.NotificationSettings.Global(), @@ -3144,13 +3176,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + if peer.isService { return } - + var items: [ContextMenuItem] = [] - + if peer.id == self.context.account.peerId { items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.StoryFeed_ContextAddStory, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Add"), color: theme.contextMenu.primaryColor) @@ -3159,11 +3191,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + self.openStoryCamera(fromList: true) }) }))) - + items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.StoryFeed_ContextSavedStories, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Stories"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in @@ -3171,11 +3203,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + self.push(PeerInfoStoryGridScreen(context: self.context, peerId: self.context.account.peerId, scope: .saved)) }) }))) - + items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.StoryFeed_ContextArchivedStories, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Archive"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in @@ -3183,7 +3215,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + self.push(PeerInfoStoryGridScreen(context: self.context, peerId: self.context.account.peerId, scope: .archive)) }) }))) @@ -3196,12 +3228,12 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + self.openStoryCamera(fromList: true) }) }))) } - + let openTitle: String let openIcon: String switch channel.info { @@ -3219,7 +3251,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + let _ = (self.context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: peer.id) ) @@ -3230,12 +3262,12 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let navigationController = self.navigationController as? NavigationController else { return } - + self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(peer))) }) }) }))) - + let hideText: String if self.location == .chatList(groupId: .archive) { hideText = self.presentationData.strings.StoryFeed_ContextUnarchive @@ -3247,7 +3279,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return generateTintedImage(image: UIImage(bundleImageName: iconName), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in f(.dismissWithoutContent) - + guard let self else { return } @@ -3259,7 +3291,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.context.engine.peers.updatePeerStoriesHidden(id: peer.id, isHidden: true) undoValue = false } - + if self.location == .chatList(groupId: .archive) { self.present(UndoOverlayController(presentationData: self.presentationData, content: .archivedChat(peerId: peer.id.toInt64(), title: "", text: self.presentationData.strings.StoryFeed_TooltipUnarchive(peer.compactDisplayTitle).string, undo: true), elevatedLayout: false, position: .bottom, animateInAsReplacement: false, action: { [weak self] action in if case .undo = action { @@ -3288,11 +3320,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self, let navigationController = self.navigationController as? NavigationController else { return } - + self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(peer))) }) }))) - + items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.StoryFeed_ContextOpenProfile, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/User"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in @@ -3300,7 +3332,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + let _ = (self.context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: peer.id) ) @@ -3315,18 +3347,18 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) }) }))) - + let isMuted = resolvedAreStoriesMuted(globalSettings: globalSettings._asGlobalNotificationSettings(), peer: peer, peerSettings: notificationSettings._asNotificationSettings(), topSearchPeers: topSearchPeers) items.append(.action(ContextMenuActionItem(text: isMuted ? self.presentationData.strings.StoryFeed_ContextNotifyOn : self.presentationData.strings.StoryFeed_ContextNotifyOff, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: isMuted ? "Chat/Context Menu/Unmute" : "Chat/Context Menu/Muted"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in f(.default) - + guard let self else { return } let _ = self.context.engine.peers.togglePeerStoriesMuted(peerId: peer.id).startStandalone() - + let iconColor = UIColor.white let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } if isMuted { @@ -3359,12 +3391,12 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController ), in: .current) } }))) - + items.append(.action(ContextMenuActionItem(text: presentationData.strings.StoryFeed_ViewAnonymously, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: self.context.isPremium ? "Chat/Context Menu/Eye" : "Chat/Context Menu/EyeLocked"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, a in a(.default) - + guard let self else { return } @@ -3383,7 +3415,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) } }))) - + let hideText: String if self.location == .chatList(groupId: .archive) { hideText = self.presentationData.strings.StoryFeed_ContextUnarchive @@ -3395,7 +3427,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return generateTintedImage(image: UIImage(bundleImageName: iconName), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in f(.dismissWithoutContent) - + guard let self else { return } @@ -3407,7 +3439,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.context.engine.peers.updatePeerStoriesHidden(id: peer.id, isHidden: true) undoValue = false } - + if self.location == .chatList(groupId: .archive) { self.present(UndoOverlayController(presentationData: self.presentationData, content: .archivedChat(peerId: peer.id.toInt64(), title: "", text: self.presentationData.strings.StoryFeed_TooltipUnarchive(peer.compactDisplayTitle).string, undo: true), elevatedLayout: false, position: .bottom, animateInAsReplacement: false, action: { [weak self] action in if case .undo = action { @@ -3429,14 +3461,14 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } }))) } - + let controller = makeContextController(presentationData: self.presentationData, source: .extracted(ChatListHeaderBarContextExtractedContentSource(controller: self, sourceNode: sourceNode, sourceView: nil, keepInPlace: false)), items: .single(ContextController.Items(content: .list(items))), recognizer: nil, gesture: gesture) self.context.sharedContext.mainWindow?.presentInGlobalOverlay(controller) }) } } } - + public func transitionViewForOwnStoryItem() -> UIView? { if let componentView = self.chatListHeaderView() { if let (transitionView, _) = componentView.storyPeerListView()?.transitionViewForItem(peerId: self.context.account.peerId) { @@ -3445,30 +3477,30 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return nil } - + private(set) var storyUploadProgress: [PeerId: Float] = [:] private func updateStoryUploadProgress(_ progress: [PeerId: Float]) { self.storyUploadProgress = progress.mapValues { max(0.027, min(0.99, $0)) } - + if let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { navigationBarView.updateStoryUploadProgress(storyUploadProgress: self.storyUploadProgress) } } - + public func scrollToStories(peerId: EnginePeer.Id? = nil) { self.chatListDisplayNode.scrollToStories(animated: false) - + if let peerId, let componentView = self.chatListHeaderView(), let storyPeerListView = componentView.storyPeerListView() { storyPeerListView.ensureItemVisible(peerId: peerId) } } - + public func scrollToStoriesAnimated() { self.chatListDisplayNode.scrollToStories(animated: true) } - + private func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { var tabContainerOffset: CGFloat = 0.0 if !self.displayNavigationBar { @@ -3477,24 +3509,24 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } let navigationBarHeight: CGFloat = 0.0 - + self.chatListDisplayNode.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, visualNavigationHeight: navigationBarHeight, cleanNavigationBarHeight: navigationBarHeight, storiesInset: 0.0, transition: transition) } - + override public func navigationStackConfigurationUpdated(next: [ViewController]) { super.navigationStackConfigurationUpdated(next: next) } - + public func activateEdit() { self.editPressed() } - + public func openEmojiStatusSetup() { if let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { navigationBarView.openEmojiStatusSetup() } } - + @objc func editPressed() { if self.secondaryContext == nil { if case .chatList(.root) = self.chatListDisplayNode.effectiveContainerNode.location { @@ -3515,10 +3547,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController (self.navigationController as? NavigationController)?.updateMasterDetailsBlackout(.master, transition: .animated(duration: 0.5, curve: .spring)) } } - + //TODO:update search enabled //self.searchContentNode?.setIsEnabled(false, animated: true) - + self.chatListDisplayNode.didBeginSelectingChatsWhileEditing = false self.chatListDisplayNode.effectiveContainerNode.updateState { state in var state = state @@ -3531,15 +3563,15 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.updateLayout(layout: layout, transition: .animated(duration: 0.2, curve: .easeInOut)) } } - + @objc fileprivate func donePressed() { let skipLayoutUpdate = self.reorderingDonePressed() - + (self.navigationController as? NavigationController)?.updateMasterDetailsBlackout(nil, transition: .animated(duration: 0.4, curve: .spring)) - + //TODO:update search enabled //self.searchContentNode?.setIsEnabled(true, animated: true) - + self.chatListDisplayNode.didBeginSelectingChatsWhileEditing = false self.chatListDisplayNode.effectiveContainerNode.updateState { state in var state = state @@ -3550,14 +3582,14 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return state } self.chatListDisplayNode.isEditing = false - + if !skipLayoutUpdate { if let layout = self.validLayout { self.updateLayout(layout: layout, transition: .animated(duration: 0.2, curve: .easeInOut)) } } } - + private var skipTabContainerUpdate = false fileprivate func reorderingDonePressed() -> Bool { guard let defaultFilters = self.tabContainerData else { @@ -3573,7 +3605,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } let _ = defaultFilterIds - + var reorderedFilterIdsValue: [Int32]? if let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View, let headerPanelsView = navigationBarView.headerPanels as? HeaderPanelContainerComponent.View, let tabsView = headerPanelsView.tabs as? HorizontalTabsComponent.View, let reorderedItemIds = tabsView.reorderedItemIds { reorderedFilterIdsValue = reorderedItemIds.compactMap { item -> Int32? in @@ -3586,7 +3618,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return value } } - + if let reorderedFilterIdsValue, let tabContainerData = self.tabContainerData { var entries: [ChatListFilterTabEntry] = [] for id in reorderedFilterIdsValue { @@ -3602,7 +3634,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } self.tabContainerData?.0 = entries } - + let completion = { [weak self] in guard let strongSelf = self else { return @@ -3611,10 +3643,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController strongSelf.chatListDisplayNode.isReorderingFilters = false strongSelf.isReorderingTabsValue.set(false) (strongSelf.parent as? TabBarController)?.updateIsTabBarEnabled(true, transition: .animated(duration: 0.2, curve: .easeInOut)) - + //TODO:update search enabled //strongSelf.searchContentNode?.setIsEnabled(true, animated: true) - + if let layout = strongSelf.validLayout { strongSelf.updateLayout(layout: layout, transition: .animated(duration: 0.2, curve: .easeInOut)) } @@ -3649,13 +3681,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return true } - + public func resetForumStackIfOpen() { if self.secondaryContext != nil { self.setInlineChatList(location: nil, animated: false) } } - + public func setInlineChatList(location: ChatListControllerLocation?, animated: Bool = true) { if let location { let inlineNode = self.chatListDisplayNode.makeInlineChatList(location: location) @@ -3668,7 +3700,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController isReorderingTabs: .single(false), storyPostingAvailable: .single(false) ) - + self.pendingSecondaryContext = pendingSecondaryContext let _ = (pendingSecondaryContext.ready.get() |> filter { $0 } @@ -3677,11 +3709,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self, let pendingSecondaryContext = pendingSecondaryContext, self.pendingSecondaryContext === pendingSecondaryContext else { return } - + if self.chatListDisplayNode.effectiveContainerNode.currentItemNode.currentState.editing { self.donePressed() } - + self.secondaryContext = pendingSecondaryContext self.setToolbar(pendingSecondaryContext.toolbar, transition: animated ? .animated(duration: 0.5, curve: .spring) : .immediate) self.chatListDisplayNode.setInlineChatList(inlineStackContainerNode: inlineNode) @@ -3691,29 +3723,29 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if self.chatListDisplayNode.effectiveContainerNode.currentItemNode.currentState.editing { self.donePressed() } - + self.secondaryContext = nil self.setToolbar(self.primaryContext?.toolbar, transition: animated ? .animated(duration: 0.5, curve: .spring) : .immediate) self.chatListDisplayNode.setInlineChatList(inlineStackContainerNode: nil) self.updateNavigationMetadata() } } - + private func navigationBackPressed() { self.dismiss() } - + public static func openMoreMenu(context: AccountContext, peerId: EnginePeer.Id, sourceController: ViewController, isViewingAsTopics: Bool, sourceView: UIView, gesture: ContextGesture?) { let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) |> deliverOnMainQueue).startStandalone(next: { peer in guard case let .channel(channel) = peer else { return } - + let strings = context.sharedContext.currentPresentationData.with { $0 }.strings - + var items: [ContextMenuItem] = [] - + items.append(.action(ContextMenuActionItem(text: strings.Chat_ContextViewAsTopics, icon: { theme in if !isViewingAsTopics { return UIImage() @@ -3721,11 +3753,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) }, action: { [weak sourceController] _, a in a(.default) - + guard let sourceController = sourceController, let navigationController = sourceController.navigationController as? NavigationController else { return } - + if let targetController = navigationController.viewControllers.first(where: { controller in var checkController = controller if let tabBarController = checkController as? TabBarController { @@ -3747,7 +3779,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController let chatController = context.sharedContext.makeChatListController(context: context, location: .forum(peerId: peerId), controlsHistoryPreload: false, hideNetworkActivityStatus: false, previewing: false, enableDebugActions: false) navigationController.replaceController(sourceController, with: chatController, animated: false) } - + let _ = context.engine.peers.updateForumViewAsMessages(peerId: peerId, value: false).startStandalone() }))) items.append(.action(ContextMenuActionItem(text: strings.Chat_ContextViewAsMessages, icon: { theme in @@ -3761,9 +3793,9 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let sourceController = sourceController, let navigationController = sourceController.navigationController as? NavigationController else { return } - + let chatController = context.sharedContext.makeChatController(context: context, chatLocation: .peer(id: peerId), subject: nil, botStart: nil, mode: .standard(.default), params: nil) - + if let sourceController = sourceController as? ChatListControllerImpl, case .forum(peerId) = sourceController.location { navigationController.replaceController(sourceController, with: chatController, animated: false) } else { @@ -3780,16 +3812,16 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) navigationController.pushViewController(chatController, animated: false) } - + let _ = context.engine.peers.updateForumViewAsMessages(peerId: peerId, value: true).startStandalone() }))) items.append(.separator) - + items.append(.action(ContextMenuActionItem(text: strings.GroupInfo_Title, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Groups"), color: theme.contextMenu.primaryColor) }, action: { [weak sourceController] _, f in f(.default) - + let _ = (context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: peerId) ) @@ -3800,13 +3832,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController (sourceController.navigationController as? NavigationController)?.pushViewController(controller) }) }))) - + if channel.hasPermission(.inviteMembers) { items.append(.action(ContextMenuActionItem(text: strings.GroupInfo_AddParticipant, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddUser"), color: theme.contextMenu.primaryColor) }, action: { [weak sourceController] _, f in f(.default) - + let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) |> deliverOnMainQueue).startStandalone(next: { peer in guard let sourceController = sourceController, let peer = peer else { @@ -3818,7 +3850,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) }))) } - + var needsSeparatorForCreateTopic = true if let sourceController = sourceController as? ChatController { items.append(.separator) @@ -3826,7 +3858,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Search"), color: theme.contextMenu.primaryColor) }, action: { [weak sourceController] action in action.dismissWithResult(.default) - + sourceController?.beginMessageSearch("") }))) needsSeparatorForCreateTopic = false @@ -3835,19 +3867,19 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if needsSeparatorForCreateTopic { items.append(.separator) } - + items.append(.action(ContextMenuActionItem(text: strings.Chat_CreateTopic, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor) }, action: { action in action.dismissWithResult(.default) - + let controller = ForumCreateTopicScreen(context: context, peerId: peerId, mode: .create) controller.navigationPresentation = .modal - + controller.completion = { [weak controller] title, fileId, iconColor, _ in controller?.isInProgress = true controller?.view.endEditing(true) - + let _ = (context.engine.peers.createForumChannelTopic(id: peerId, title: title, iconColor: iconColor, iconFileId: fileId) |> deliverOnMainQueue).startStandalone(next: { topicId in if let navigationController = (sourceController.navigationController as? NavigationController) { @@ -3866,10 +3898,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController sourceController.presentInGlobalOverlay(contextController) }) } - + func openArchiveMoreMenu(sourceView: UIView, gesture: ContextGesture?) { let _ = self.context.engine.privacy.updateGlobalPrivacySettings().startStandalone() - + let _ = ( self.context.engine.messages.chatList(group: .archive, count: 10) |> take(1) |> deliverOnMainQueue).startStandalone(next: { [weak self] archiveChatList in @@ -3877,26 +3909,26 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return } let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } - + var items: [ContextMenuItem] = [] - + items.append(.action(ContextMenuActionItem(text: presentationData.strings.ChatList_Archive_ContextSettings, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Customize"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, a in a(.default) - + guard let self else { return } self.push(self.context.sharedContext.makeArchiveSettingsController(context: self.context)) }))) - + if !archiveChatList.items.isEmpty { items.append(.action(ContextMenuActionItem(text: presentationData.strings.ChatList_Archive_ContextInfo, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/Question"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, a in a(.default) - + guard let self else { return } @@ -3914,7 +3946,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, a in a(.default) - + guard let self else { return } @@ -3926,7 +3958,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.presentInGlobalOverlay(contextController) }) } - + private var initializedFilters = false private func reloadFilters(firstUpdate: (() -> Void)? = nil) { let filterItems = chatListFilterItems(context: self.context) @@ -3940,13 +3972,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let strongSelf = self else { return } - + let isPremium = peerView.peers[peerView.peerId]?.isPremium strongSelf.isPremium = isPremium ?? false - + let (_, items) = countAndFilterItems var filterItems: [ChatListFilterTabEntry] = [] - + for (filter, unreadCount, hasUnmutedUnread) in items { switch filter { case .allChats: @@ -3959,13 +3991,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController filterItems.append(.filter(id: id, text: title, unread: ChatListFilterTabEntryUnreadCount(value: unreadCount, hasUnmuted: hasUnmutedUnread))) } } - + var resolvedItems = filterItems if case .chatList(.root) = strongSelf.location { } else { resolvedItems = [] } - + let firstItem = countAndFilterItems.1.first?.0 ?? .allChats let firstItemEntryId: ChatListFilterTabEntryId switch firstItem { @@ -3974,7 +4006,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController case let .filter(id, _, _, _): firstItemEntryId = .filter(id) } - + var selectedEntryId = !strongSelf.initializedFilters ? firstItemEntryId : strongSelf.chatListDisplayNode.mainContainerNode.currentItemFilter var resetCurrentEntry = false if !resolvedItems.contains(where: { $0.id == selectedEntryId }) { @@ -4018,7 +4050,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController availableFilters.insert(.all, at: 0) } strongSelf.chatListDisplayNode.mainContainerNode.updateAvailableFilters(availableFilters, limit: filtersLimit) - + if isPremium == nil && items.isEmpty { strongSelf.mainReady.set(strongSelf.chatListDisplayNode.mainContainerNode.currentItemNode.ready) } else if !strongSelf.initializedFilters { @@ -4033,27 +4065,27 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } strongSelf.initializedFilters = true } - + let animated = strongSelf.didSetupTabs strongSelf.didSetupTabs = true - + if let layout = strongSelf.validLayout { let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.2, curve: .easeInOut) : .immediate strongSelf.containerLayoutUpdated(layout, transition: transition) (strongSelf.parent as? TabBarController)?.updateLayout(transition: transition) } - + if !notifiedFirstUpdate { notifiedFirstUpdate = true firstUpdate?() } - + if resetCurrentEntry { strongSelf.selectTab(id: selectedEntryId, switchToChatsIfNeeded: false) } })) } - + func selectTab(id: ChatListFilterTabEntryId, switchToChatsIfNeeded: Bool = true) { if self.parent == nil, switchToChatsIfNeeded { if let navigationController = self.context.sharedContext.mainWindow?.viewController as? NavigationController { @@ -4067,7 +4099,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + let _ = (self.context.engine.peers.currentChatListFilters() |> deliverOnMainQueue).startStandalone(next: { [weak self] filters in guard let strongSelf = self else { @@ -4103,7 +4135,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } }) } - + private func readAllInFilter(id: Int32) { for filter in self.chatListDisplayNode.mainContainerNode.availableFilters { if case let .filter(filter) = filter, case let .filter(filterId, _, _, data) = filter, filterId == id { @@ -4113,13 +4145,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController for additionalGroupId in filterPredicate.includeAdditionalPeerGroupIds { markItems.append((EngineChatList.Group(additionalGroupId), filterPredicate)) } - + let _ = self.context.engine.messages.markAllChatsAsReadInteractively(items: markItems).startStandalone() break } } } - + private func shareFolder(filterId: Int32, data: ChatListFilterData, title: ChatFolderTitle) { let presentationData = self.presentationData let progressSignal = Signal { [weak self] subscriber in @@ -4134,7 +4166,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController |> runOn(Queue.mainQueue()) |> delay(0.8, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() - + let signal: Signal<[ExportedChatFolderLink]?, NoError> = self.context.engine.peers.getExportedChatFolderLinks(id: filterId) |> afterDisposed { Queue.mainQueue().async { @@ -4146,7 +4178,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + if links == nil || links?.count == 0 { openCreateChatListFolderLink(context: self.context, folderId: filterId, checkIfExists: false, title: title, peerIds: data.includePeers.peers, pushController: { [weak self] c in self?.push(c) @@ -4174,7 +4206,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } }) } - + public func navigateToFolder(folderId: Int32, completion: @escaping () -> Void) { let _ = (self.chatListDisplayNode.mainContainerNode.availableFiltersSignal |> filter { filters in @@ -4195,7 +4227,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + if self.chatListDisplayNode.inlineStackContainerNode != nil { self.setInlineChatList(location: nil) } @@ -4208,7 +4240,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } }) } - + public func openStoriesFromNotification(peerId: EnginePeer.Id, storyId: Int32) { let presentationData = self.presentationData let progressSignal = Signal { [weak self] subscriber in @@ -4223,7 +4255,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController |> runOn(Queue.mainQueue()) |> delay(0.8, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() - + let signal: Signal = self.context.engine.messages.peerStoriesAreReady( id: peerId, minId: storyId @@ -4238,7 +4270,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController progressDisposable.dispose() } } - + self.sharedOpenStoryProgressDisposable.set((signal |> deliverOnMainQueue).startStrict(completed: { [weak self] in guard let self else { return @@ -4267,24 +4299,24 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController ) })) } - + public func openStories(peerId: EnginePeer.Id) { self.openStories(peerId: peerId, completion: { _ in }) } - + public func openStories(peerId: EnginePeer.Id, completion: @escaping (StoryContainerScreen) -> Void = { _ in }) { if let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { if navigationBarView.storiesUnlocked { self.shouldFixStorySubscriptionOrder = true } } - + if peerId != self.context.account.peerId { if let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { if navigationBarView.storiesUnlocked { if let componentView = self.chatListHeaderView(), let storyPeerListView = componentView.storyPeerListView() { let _ = storyPeerListView - + var initialOrder: [EnginePeer.Id] = [] if let orderedStorySubscriptions = self.orderedStorySubscriptions { if let accountItem = orderedStorySubscriptions.accountItem { @@ -4296,7 +4328,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController initialOrder.append(item.peer.id) } } - + StoryContainerScreen.openPeerStoriesCustom( context: self.context, peerId: peerId, @@ -4319,12 +4351,12 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController sourceCornerRadius: transitionView.bounds.height * 0.5, sourceIsAvatar: true ) - + Queue.mainQueue().after(0.3, { [weak self] in guard let self else { return } - + self.chatListDisplayNode.mainContainerNode.currentItemNode.scroller.panGestureRecognizer.state = .cancelled }) } @@ -4337,11 +4369,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return nil } - + if let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { if navigationBarView.storiesUnlocked { self.scrollToStories() - + if let componentView = self.chatListHeaderView() { if let (transitionView, transitionContentView) = componentView.storyPeerListView()?.transitionViewForItem(peerId: peerId) { return StoryContainerScreen.TransitionOut( @@ -4356,7 +4388,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + return nil }, setFocusedItem: { [weak self] focusedItem in @@ -4378,13 +4410,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }, completion: completion ) - + return } } } } - + let storyContent = StoryContentContextImpl(context: self.context, isHidden: self.location == .chatList(groupId: .archive), focusedPeerId: peerId, singlePeer: false, fixedOrder: self.fixedStorySubscriptionOrder) let _ = (storyContent.state |> take(1) @@ -4392,12 +4424,12 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + if peerId == self.context.account.peerId, storyContentState.slice == nil { self.openStoryCamera(fromList: true) return } - + var transitionIn: StoryContainerScreen.TransitionIn? if let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { if navigationBarView.storiesUnlocked { @@ -4413,7 +4445,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + let storyContainerScreen = StoryContainerScreen( context: self.context, content: storyContent, @@ -4422,7 +4454,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return nil } - + if let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { if navigationBarView.storiesUnlocked { if let componentView = self.chatListHeaderView() { @@ -4439,7 +4471,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + return nil } ) @@ -4449,29 +4481,29 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.push(storyContainerScreen) }) } - + func askForFilterRemoval(id: Int32) { let apply: () -> Void = { [weak self] in guard let strongSelf = self else { return } - + let commit: () -> Void = { guard let strongSelf = self else { return } - + if strongSelf.chatListDisplayNode.mainContainerNode.currentItemNode.chatListFilter?.id == id { if strongSelf.chatListDisplayNode.mainContainerNode.currentItemNode.currentState.editing { strongSelf.donePressed() } } - + let _ = (strongSelf.context.engine.peers.updateChatListFiltersInteractively { filters in return filters.filter({ $0.id != id }) }).startStandalone() } - + if strongSelf.chatListDisplayNode.mainContainerNode.currentItemNode.chatListFilter?.id == id { strongSelf.chatListDisplayNode.mainContainerNode.switchToFilter(id: .all, completion: { commit() @@ -4480,7 +4512,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController commit() } } - + let _ = (self.context.engine.peers.currentChatListFilters() |> take(1) |> deliverOnMainQueue).startStandalone(next: { [weak self] filters in @@ -4490,7 +4522,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let filter = filters.first(where: { $0.id == id }), case let .filter(_, title, _, data) = filter else { return } - + if data.isShared { let _ = (combineLatest( self.context.engine.data.get( @@ -4504,28 +4536,28 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + let presentationData = self.presentationData - + let peers = peerData.0 - + var memberCounts: [EnginePeer.Id: Int] = [:] for (id, count) in peerData.1 { if let count { memberCounts[id] = count } } - + var hasLinks = false if let links, !links.isEmpty { hasLinks = true } - + let confirmDeleteFolder: () -> Void = { [weak self] in guard let self else { return } - + let filteredPeers = peers.compactMap { $0 }.filter { peer in if case .channel = peer { return true @@ -4559,7 +4591,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.push(previewScreen) } } - + if hasLinks { self.present(textAlertController(context: self.context, title: presentationData.strings.ChatList_AlertDeleteFolderTitle, text: presentationData.strings.ChatList_AlertDeleteFolderText, actions: [ TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: { @@ -4584,32 +4616,32 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } }) } - + public private(set) var isSearchActive: Bool = false - + public func activateSearch(filter: ChatListSearchFilter, query: String? = nil) { self.activateSearchInternal(isFromTabBar: false, filter: filter, query: query) } - + public func activateSearchInternal(isFromTabBar: Bool, filter: ChatListSearchFilter, query: String? = nil) { var searchContentNode: NavigationBarSearchContentNode? if !isFromTabBar, let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { searchContentNode = navigationBarView.searchContentNode } - + self.activateSearch(filter: filter, query: query, skipScrolling: false, searchContentNode: searchContentNode) } - + public func activateSearch(query: String? = nil) { var isForum = false if case .forum = self.location { isForum = true } - + let filter: ChatListSearchFilter = isForum ? .topics : .chats self.activateSearch(filter: filter, query: query) } - + private var previousSearchToggleTimestamp: Double? func activateSearch(filter: ChatListSearchFilter = .chats, query: String? = nil, skipScrolling: Bool = false, searchContentNode: NavigationBarSearchContentNode?) { Task { @MainActor [weak self] in @@ -4622,37 +4654,37 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return } self.previousSearchToggleTimestamp = currentTimestamp - + if let storyTooltip = self.storyTooltip { storyTooltip.dismiss() } - + var filter = filter if case .forum = self.chatListDisplayNode.effectiveContainerNode.location { filter = .topics } - - if self.chatListDisplayNode.searchDisplayController == nil { + + if self.chatListDisplayNode.searchDisplayController == nil { let (_, _) = await combineLatest(self.chatListDisplayNode.mainContainerNode.currentItemNode.contentsReady |> take(1), self.context.account.postbox.tailChatListView(groupId: .root, count: 16, summaryComponents: ChatListEntrySummaryComponents(components: [:])) |> take(1)).get() do { let displaySearchFilters = true - + if let filterContainerNodeAndActivate = await self.chatListDisplayNode.activateSearch(placeholderNode: searchContentNode?.placeholderNode, displaySearchFilters: displaySearchFilters, hasDownloads: self.hasDownloads, initialFilter: filter, navigationController: self.navigationController as? NavigationController, searchBarIsExternal: searchContentNode == nil) { let activate = filterContainerNodeAndActivate - + activate(filter != .downloads) - + if let searchContentNode = self.chatListDisplayNode.searchDisplayController?.contentNode as? ChatListSearchContainerNode { searchContentNode.search(filter: filter, query: query) } } - + let transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring) self.setDisplayNavigationBar(false, transition: transition) if searchContentNode == nil { self.updateTabBarSearchState(ViewController.TabBarSearchState(isActive: true), transition: transition) - + if let searchBarNode = self.currentTabBarSearchNode?() as? SearchBarNode { self.chatListDisplayNode.searchDisplayController?.setSearchBar(searchBarNode) searchBarNode.activate() @@ -4678,7 +4710,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + public func deactivateSearch(animated: Bool) { guard !self.displayNavigationBar else { return @@ -4688,14 +4720,14 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return } self.previousSearchToggleTimestamp = currentTimestamp - + var completion: (() -> Void)? - + var searchContentNode: NavigationBarSearchContentNode? if let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { searchContentNode = navigationBarView.searchContentNode } - + if let searchContentNode { let previousFrame = searchContentNode.placeholderNode.frame if case .chatList(.root) = self.location { @@ -4706,19 +4738,19 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } else { completion = self.chatListDisplayNode.deactivateSearch(placeholderNode: nil, animated: animated) } - + self.chatListDisplayNode.tempAllowAvatarExpansion = true self.requestLayout(transition: .animated(duration: 0.5, curve: .spring)) self.chatListDisplayNode.tempAllowAvatarExpansion = false - + let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.4, curve: .spring) : .immediate self.setDisplayNavigationBar(true, transition: transition) - + completion?() - + self.updateTabBarSearchState(ViewController.TabBarSearchState(isActive: false), transition: transition) (self.parent as? TabBarController)?.updateIsTabBarHidden(false, transition: transition) - + self.isSearchActive = false if let navigationController = self.navigationController as? NavigationController { for controller in navigationController.globalOverlayControllers { @@ -4729,18 +4761,18 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + public func activateCompose() { self.composePressed() } - + @objc fileprivate func composePressed() { guard !self.context.isFrozen else { let controller = self.context.sharedContext.makeAccountFreezeInfoScreen(context: self.context) self.push(controller) return } - + guard let navigationController = self.navigationController as? NavigationController else { return } @@ -4750,16 +4782,16 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController hasComposeController = true } } - + if !hasComposeController { let controller = self.context.sharedContext.makeComposeController(context: self.context) navigationController.pushViewController(controller) } } - + public override var keyShortcuts: [KeyShortcut] { let strings = self.presentationData.strings - + let toggleSearch: () -> Void = { [weak self] in if let strongSelf = self { if strongSelf.displayNavigationBar { @@ -4769,7 +4801,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + let inputShortcuts: [KeyShortcut] = [ KeyShortcut(title: strings.KeyCommand_JumpToPreviousChat, input: UIKeyCommand.inputUpArrow, modifiers: [.alternate], action: { [weak self] in if let strongSelf = self { @@ -4804,7 +4836,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController KeyShortcut(title: strings.KeyCommand_Find, input: "\t", modifiers: [], action: toggleSearch), KeyShortcut(input: UIKeyCommand.inputEscape, modifiers: [], action: toggleSearch) ] - + let openTab: (Int) -> Void = { [weak self] index in if let strongSelf = self { let filters = strongSelf.chatListDisplayNode.mainContainerNode.availableFilters @@ -4819,7 +4851,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + let openChat: (Int) -> Void = { [weak self] index in if let strongSelf = self { if index == 0 { @@ -4829,7 +4861,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + let folderShortcuts: [KeyShortcut] = (0 ... 9).map { index in return KeyShortcut(input: "\(index)", modifiers: [.command], action: { if index == 0 { @@ -4839,16 +4871,16 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } }) } - + let chatShortcuts: [KeyShortcut] = (0 ... 9).map { index in return KeyShortcut(input: "\(index)", modifiers: [.command, .alternate], action: { openChat(index) }) } - + return inputShortcuts + folderShortcuts + chatShortcuts } - + override public func toolbarActionSelected(action: ToolbarActionOption) { let peerIds = self.chatListDisplayNode.effectiveContainerNode.currentItemNode.currentState.selectedPeerIds let threadIds = self.chatListDisplayNode.effectiveContainerNode.currentItemNode.currentState.selectedThreadIds @@ -4896,11 +4928,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController var items: [ActionSheetItem] = [] items.append(ActionSheetButtonItem(title: self.presentationData.strings.ChatList_DeleteThreadsConfirmation(Int32(threadIds.count)), color: .destructive, action: { [weak self, weak actionSheet] in actionSheet?.dismissAnimated() - + guard let strongSelf = self else { return } - + strongSelf.chatListDisplayNode.effectiveContainerNode.currentItemNode.setCurrentRemovingItemId(ChatListNodeState.ItemId(peerId: peerId, threadId: threadIds.first)) strongSelf.chatListDisplayNode.effectiveContainerNode.updateState(onlyCurrent: false, { state in var state = state @@ -4909,9 +4941,9 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return state }) - + let text = strongSelf.presentationData.strings.ChatList_DeletedThreads(Int32(threadIds.count)) - + strongSelf.present(UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: strongSelf.context, title: NSAttributedString(string: text), text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { value in guard let strongSelf = self else { return false @@ -4930,7 +4962,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController |> runOn(Queue.mainQueue()) |> delay(0.8, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() - + let signal: Signal = strongSelf.context.engine.peers.removeForumChannelThreads(id: peerId, threadIds: Array(threadIds)) |> afterDisposed { Queue.mainQueue().async { @@ -4939,7 +4971,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } let _ = (signal |> deliverOnMainQueue).start() - + strongSelf.chatListDisplayNode.effectiveContainerNode.updateState(onlyCurrent: false, { state in var state = state for threadId in threadIds { @@ -4947,7 +4979,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return state }) - + return true } else if value == .undo { strongSelf.chatListDisplayNode.effectiveContainerNode.currentItemNode.setCurrentRemovingItemId(ChatListNodeState.ItemId(peerId: peerId, threadId: threadIds.first)) @@ -4963,10 +4995,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return false }), in: .current) - + strongSelf.donePressed() })) - + actionSheet.setItemGroups([ ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ @@ -4984,7 +5016,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + var havePrivateChats = false var haveNonPrivateChats = false for peer in peers { @@ -4997,17 +5029,17 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + let actionSheet = ActionSheetController(presentationData: self.presentationData) var items: [ActionSheetItem] = [] if havePrivateChats { items.append(ActionSheetButtonItem(title: haveNonPrivateChats ? self.presentationData.strings.ChatList_DeleteForAllWhenPossible : self.presentationData.strings.ChatList_DeleteForAll, color: .destructive, action: { [weak self, weak actionSheet] in actionSheet?.dismissAnimated() - + guard let strongSelf = self else { return } - + strongSelf.chatListDisplayNode.effectiveContainerNode.updateState(onlyCurrent: false, { state in var state = state for peerId in peerIds { @@ -5015,9 +5047,9 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return state }) - + let text = strongSelf.presentationData.strings.ChatList_DeletedChats(Int32(peerIds.count)) - + strongSelf.present(UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: strongSelf.context, title: NSAttributedString(string: text), text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { value in guard let strongSelf = self else { return false @@ -5036,7 +5068,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController |> runOn(Queue.mainQueue()) |> delay(0.8, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() - + let signal: Signal = strongSelf.context.engine.peers.removePeerChats(peerIds: Array(peerIds), deleteGloballyIfPossible: true) |> afterDisposed { Queue.mainQueue().async { @@ -5045,7 +5077,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } let _ = (signal |> deliverOnMainQueue).start() - + strongSelf.chatListDisplayNode.effectiveContainerNode.updateState(onlyCurrent: false, { state in var state = state for peerId in peerIds { @@ -5053,7 +5085,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return state }) - + return true } else if value == .undo { strongSelf.chatListDisplayNode.effectiveContainerNode.currentItemNode.setCurrentRemovingItemId(ChatListNodeState.ItemId(peerId: peerIds.first!, threadId: nil)) @@ -5069,17 +5101,17 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return false }), in: .current) - + strongSelf.donePressed() })) } items.append(ActionSheetButtonItem(title: havePrivateChats ? self.presentationData.strings.ChatList_DeleteForMe : self.presentationData.strings.ChatList_DeleteConfirmation(Int32(peerIds.count)), color: .destructive, action: { [weak self, weak actionSheet] in actionSheet?.dismissAnimated() - + guard let strongSelf = self else { return } - + strongSelf.chatListDisplayNode.effectiveContainerNode.updateState(onlyCurrent: false, { state in var state = state for peerId in peerIds { @@ -5087,9 +5119,9 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return state }) - + let text = strongSelf.presentationData.strings.ChatList_DeletedChats(Int32(peerIds.count)) - + strongSelf.present(UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: strongSelf.context, title: NSAttributedString(string: text), text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { value in guard let strongSelf = self else { return false @@ -5108,7 +5140,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController |> runOn(Queue.mainQueue()) |> delay(0.8, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() - + let signal: Signal = strongSelf.context.engine.peers.removePeerChats(peerIds: Array(peerIds)) |> afterDisposed { Queue.mainQueue().async { @@ -5117,7 +5149,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } let _ = (signal |> deliverOnMainQueue).start() - + strongSelf.chatListDisplayNode.effectiveContainerNode.updateState(onlyCurrent: false, { state in var state = state for peerId in peerIds { @@ -5125,7 +5157,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return state }) - + return true } else if value == .undo { strongSelf.chatListDisplayNode.effectiveContainerNode.currentItemNode.setCurrentRemovingItemId(ChatListNodeState.ItemId(peerId: peerIds.first!, threadId: nil)) @@ -5141,10 +5173,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return false }), in: .current) - + strongSelf.donePressed() })) - + actionSheet.setItemGroups([ ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ @@ -5191,14 +5223,14 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController |> runOn(Queue.mainQueue()) |> delay(0.8, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() - + let signal: Signal = self.context.peerChannelMemberCategoriesContextsManager.join(engine: self.context.engine, peerId: peerId, hash: nil) |> afterDisposed { Queue.mainQueue().async { progressDisposable.dispose() } } - + var didJoin = false self.joinForumDisposable.set((signal |> deliverOnMainQueue).startStrict(next: { [weak self] result in @@ -5220,9 +5252,9 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let strongSelf = self, let peer = peer else { return } - + let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - + let text: String switch error { case .inviteRequestSent: @@ -5284,7 +5316,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + func toggleArchivedFolderHiddenByDefault() { var updatedValue = false let _ = (updateChatArchiveSettings(engine: self.context.engine, { settings in @@ -5311,7 +5343,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return true }) - + if updatedValue { strongSelf.present(UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .hidArchive(title: strongSelf.presentationData.strings.ChatList_UndoArchiveHiddenTitle, text: strongSelf.presentationData.strings.ChatList_UndoArchiveHiddenText, undo: false), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] value in guard let strongSelf = self else { @@ -5323,7 +5355,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController settings.isHiddenByDefault = false return settings }).startStandalone() - + return true } return false @@ -5334,7 +5366,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } }) } - + func hidePsa(_ id: PeerId) { self.chatListDisplayNode.mainContainerNode.updateState { state in var state = state @@ -5342,10 +5374,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController state.peerIdWithRevealedOptions = nil return state } - + let _ = hideAccountPromoInfoChat(account: self.context.account, peerId: id).startStandalone() } - + func deletePeerChat(peerId: PeerId, joined: Bool) { let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.RenderedPeer(id: peerId)) |> deliverOnMainQueue).startStandalone(next: { [weak self] peer in @@ -5353,7 +5385,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return } strongSelf.view.window?.endEditing(true) - + var canRemoveGlobally = false let limitsConfiguration = strongSelf.context.currentLimitsConfiguration.with { $0 } if peer.peerId.namespace == Namespaces.Peer.CloudUser && peer.peerId != strongSelf.context.account.peerId { @@ -5363,7 +5395,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } else if peer.peerId.namespace == Namespaces.Peer.SecretChat { canRemoveGlobally = true } - + if case let .user(user) = chatPeer, user.botInfo == nil, canRemoveGlobally { strongSelf.maybeAskForPeerChatRemoval(peer: peer, joined: joined, completion: { _ in }, removed: {}) } else { @@ -5372,7 +5404,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController var canClear = true var canStop = false var canRemoveGlobally = false - + var deleteTitle = strongSelf.presentationData.strings.Common_Delete if case let .channel(channel) = chatPeer { if channel.isMonoForum { @@ -5403,7 +5435,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController canClear = true deleteTitle = strongSelf.presentationData.strings.ChatList_DeleteChat } - + let limitsConfiguration = strongSelf.context.currentLimitsConfiguration.with { $0 } if case .user = chatPeer, chatPeer.id != strongSelf.context.account.peerId { if limitsConfiguration.maxMessageRevokeIntervalInPrivateChats == LimitsConfiguration.timeIntervalForever { @@ -5412,7 +5444,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } else if case .secretChat = chatPeer { canRemoveGlobally = true } - + var isGroupOrChannel = false switch mainPeer { case .legacyGroup, .channel: @@ -5420,18 +5452,18 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController default: break } - + if canRemoveGlobally && isGroupOrChannel { items.append(DeleteChatPeerActionSheetItem(context: strongSelf.context, peer: mainPeer, chatPeer: chatPeer, action: .deleteAndLeave, strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder)) - + items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.ChatList_DeleteForCurrentUser, color: .destructive, action: { [weak self, weak actionSheet] in actionSheet?.dismissAnimated() - + let proceed = { self?.schedulePeerChatRemoval(peer: peer, type: .forLocalPeer, deleteGloballyIfPossible: false, completion: { }) } - + let shouldCheckFutureCreator: Bool if case let .channel(channel) = peer.peer, channel.flags.contains(.isCreator) { shouldCheckFutureCreator = true @@ -5440,7 +5472,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } else { shouldCheckFutureCreator = false } - + if let self, shouldCheckFutureCreator { let _ = (self.context.engine.peers.getFutureCreatorAfterLeave(peerId: peer.peerId) |> deliverOnMainQueue).start(next: { [weak self] nextCreator in @@ -5461,27 +5493,27 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController proceed() } })) - + let deleteForAllText: String if case let .channel(channel) = mainPeer, case .broadcast = channel.info { deleteForAllText = strongSelf.presentationData.strings.ChatList_DeleteForAllSubscribers } else { deleteForAllText = strongSelf.presentationData.strings.ChatList_DeleteForAllMembers } - + items.append(ActionSheetButtonItem(title: deleteForAllText, color: .destructive, action: { [weak actionSheet] in actionSheet?.dismissAnimated() guard let strongSelf = self else { return } - + let deleteForAllConfirmation: String if case let .channel(channel) = mainPeer, case .broadcast = channel.info { deleteForAllConfirmation = strongSelf.presentationData.strings.ChannelInfo_DeleteChannelConfirmation } else { deleteForAllConfirmation = strongSelf.presentationData.strings.ChannelInfo_DeleteGroupConfirmation } - + strongSelf.present(textAlertController(context: strongSelf.context, title: strongSelf.presentationData.strings.ChatList_DeleteForEveryoneConfirmationTitle, text: deleteForAllConfirmation, actions: [ TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_Cancel, action: { }), @@ -5493,11 +5525,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController })) } else { items.append(DeleteChatPeerActionSheetItem(context: strongSelf.context, peer: mainPeer, chatPeer: chatPeer, action: .delete, strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder)) - + if canStop { items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.DialogList_DeleteBotConversationConfirmation, color: .destructive, action: { [weak actionSheet] in actionSheet?.dismissAnimated() - + if let strongSelf = self { strongSelf.maybeAskForPeerChatRemoval(peer: peer, completion: { _ in }, removed: { @@ -5509,7 +5541,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } })) } - + if canClear { let beginClear: (InteractiveHistoryClearingType) -> Void = { type in guard let strongSelf = self else { @@ -5526,7 +5558,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return true }) - + strongSelf.present(UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: strongSelf.context, title: NSAttributedString(string: strongSelf.presentationData.strings.Undo_ChatCleared), text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { value in guard let strongSelf = self else { return false @@ -5554,23 +5586,23 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return false }), in: .current) } - + items.append(ActionSheetButtonItem(title: canStop ? strongSelf.presentationData.strings.DialogList_DeleteBotClearHistory : strongSelf.presentationData.strings.DialogList_ClearHistoryConfirmation, color: .accent, action: { [weak actionSheet] in actionSheet?.dismissAnimated() - + guard let strongSelf = self else { return } - + if case .secretChat = chatPeer { beginClear(.forEveryone) } else { if canRemoveGlobally { let actionSheet = ActionSheetController(presentationData: strongSelf.presentationData) var items: [ActionSheetItem] = [] - + items.append(DeleteChatPeerActionSheetItem(context: strongSelf.context, peer: mainPeer, chatPeer: chatPeer, action: .clearHistory(canClearCache: false), strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder)) - + if joined || mainPeer.isDeleted { items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Delete, color: .destructive, action: { [weak actionSheet] in beginClear(.forEveryone) @@ -5586,7 +5618,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController actionSheet?.dismissAnimated() })) } - + actionSheet.setItemGroups([ ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ @@ -5608,7 +5640,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } })) } - + if case .secretChat = chatPeer { items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.ChatList_DeleteForEveryone(mainPeer.compactDisplayTitle).string, color: .destructive, action: { [weak actionSheet] in actionSheet?.dismissAnimated() @@ -5624,7 +5656,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let strongSelf = self else { return } - + var isGroupOrChannel = false switch mainPeer { case .legacyGroup, .channel: @@ -5632,39 +5664,39 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController default: break } - + if canRemoveGlobally && isGroupOrChannel { let actionSheet = ActionSheetController(presentationData: strongSelf.presentationData) var items: [ActionSheetItem] = [] - + items.append(DeleteChatPeerActionSheetItem(context: strongSelf.context, peer: mainPeer, chatPeer: chatPeer, action: .deleteAndLeave, strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder)) - + items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.ChatList_DeleteForCurrentUser, color: .destructive, action: { [weak actionSheet] in actionSheet?.dismissAnimated() self?.schedulePeerChatRemoval(peer: peer, type: .forLocalPeer, deleteGloballyIfPossible: false, completion: { }) })) - + let deleteForAllText: String if case let .channel(channel) = mainPeer, case .broadcast = channel.info { deleteForAllText = strongSelf.presentationData.strings.ChatList_DeleteForAllSubscribers } else { deleteForAllText = strongSelf.presentationData.strings.ChatList_DeleteForAllMembers } - + items.append(ActionSheetButtonItem(title: deleteForAllText, color: .destructive, action: { [weak actionSheet] in actionSheet?.dismissAnimated() guard let strongSelf = self else { return } - + let deleteForAllConfirmation: String if case let .channel(channel) = mainPeer, case .broadcast = channel.info { deleteForAllConfirmation = strongSelf.presentationData.strings.ChatList_DeleteForAllSubscribersConfirmationText } else { deleteForAllConfirmation = strongSelf.presentationData.strings.ChatList_DeleteForAllMembersConfirmationText } - + strongSelf.present(textAlertController(context: strongSelf.context, title: strongSelf.presentationData.strings.ChatList_DeleteForEveryoneConfirmationTitle, text: deleteForAllConfirmation, actions: [ TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_Cancel, action: { }), @@ -5674,7 +5706,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) ], parseMarkdown: true), in: .window(.root)) })) - + actionSheet.setItemGroups([ ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ @@ -5690,7 +5722,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController })) } } - + actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in @@ -5702,17 +5734,17 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } }) } - + func deletePeerThread(peerId: EnginePeer.Id, threadId: Int64) { let actionSheet = ActionSheetController(presentationData: self.presentationData) var items: [ActionSheetItem] = [] - + items.append(ActionSheetTextItem(title: self.presentationData.strings.ChatList_DeleteTopicConfirmationText, parseMarkdown: true)) items.append(ActionSheetButtonItem(title: self.presentationData.strings.ChatList_DeleteTopicConfirmationAction, color: .destructive, action: { [weak self, weak actionSheet] in actionSheet?.dismissAnimated() self?.commitDeletePeerThread(peerId: peerId, threadId: threadId, completion: {}) })) - + actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in @@ -5722,7 +5754,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController ]) self.present(actionSheet, in: .window(.root)) } - + func selectPeerThread(peerId: EnginePeer.Id, threadId: Int64) { self.chatListDisplayNode.effectiveContainerNode.updateState({ state in var state = state @@ -5731,7 +5763,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) self.chatListDisplayNode.effectiveContainerNode.didBeginSelectingChats?() } - + private func commitDeletePeerThread(peerId: EnginePeer.Id, threadId: Int64, completion: @escaping () -> Void) { self.forEachController({ controller in if let controller = controller as? UndoOverlayController { @@ -5739,23 +5771,23 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return true }) - + self.chatListDisplayNode.effectiveContainerNode.currentItemNode.setCurrentRemovingItemId(ChatListNodeState.ItemId(peerId: peerId, threadId: threadId)) self.chatListDisplayNode.effectiveContainerNode.updateState({ state in var state = state state.pendingRemovalItemIds.insert(ChatListNodeState.ItemId(peerId: peerId, threadId: threadId)) return state }) - + let statusText = self.presentationData.strings.Undo_DeletedTopic - + self.present(UndoOverlayController(presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: self.context, title: NSAttributedString(string: statusText), text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] value in guard let self else { return false } if value == .commit { self.chatListDisplayNode.effectiveContainerNode.currentItemNode.setCurrentRemovingItemId(ChatListNodeState.ItemId(peerId: peerId, threadId: threadId)) - + let _ = self.context.engine.peers.removeForumChannelThread(id: peerId, threadId: threadId).startStandalone(completed: { [weak self] in guard let self else { return @@ -5767,13 +5799,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) self.chatListDisplayNode.effectiveContainerNode.currentItemNode.setCurrentRemovingItemId(nil) }) - + self.chatListDisplayNode.effectiveContainerNode.updateState({ state in var state = state state.selectedThreadIds.remove(threadId) return state }) - + completion() return true } else if value == .undo { @@ -5789,15 +5821,15 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return false }), in: .current) } - + private func setPeerThreadStopped(peerId: EnginePeer.Id, threadId: Int64, isStopped: Bool) { self.actionDisposables.add(self.context.engine.peers.setForumChannelTopicClosed(id: peerId, threadId: threadId, isClosed: isStopped).startStrict()) } - + private func setPeerThreadPinned(peerId: EnginePeer.Id, threadId: Int64, isPinned: Bool) { self.actionDisposables.add(self.context.engine.peers.toggleForumChannelTopicPinned(id: peerId, threadId: threadId).startStrict()) } - + private func setPeerThreadHidden(peerId: EnginePeer.Id, threadId: Int64, isHidden: Bool) { self.actionDisposables.add((self.context.engine.peers.setForumChannelTopicHidden(id: peerId, threadId: threadId, isHidden: isHidden) |> deliverOnMainQueue).startStrict(completed: { [weak self] in @@ -5807,7 +5839,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController state.hiddenItemShouldBeTemporaryRevealed = false return state } - + if isHidden { strongSelf.present(UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .hidArchive(title: strongSelf.presentationData.strings.ChatList_GeneralHidden, text: strongSelf.presentationData.strings.ChatList_GeneralHiddenInfo, undo: false), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] value in guard let strongSelf = self else { @@ -5826,7 +5858,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } })) } - + public func maybeAskForPeerChatRemoval(peer: EngineRenderedPeer, joined: Bool = false, deleteGloballyIfPossible: Bool = false, completion: @escaping (Bool) -> Void, removed: @escaping () -> Void) { guard let chatPeer = peer.peers[peer.peerId], let mainPeer = peer.chatMainPeer else { completion(false) @@ -5845,7 +5877,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if case .secretChat = chatPeer { canRemoveGlobally = true } - + if deleteGloballyIfPossible && self.canDeletePeerGloballyAsCreator(mainPeer) { self.schedulePeerChatRemoval(peer: peer, type: .forEveryone, deleteGloballyIfPossible: true, completion: { removed() @@ -5853,7 +5885,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController completion(true) return } - + if canRemoveGlobally { var actions: [AlertScreen.Action] = [] if joined || mainPeer.isDeleted { @@ -5870,7 +5902,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) completion(true) })) - + actions.append(.init(title: self.presentationData.strings.ChatList_DeleteForEveryone(mainPeer.compactDisplayTitle).string, type: .destructive, action: { [weak self] in guard let strongSelf = self else { return @@ -5889,7 +5921,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController })) } actions.append(.init(title: self.presentationData.strings.Common_Cancel)) - + let title: String = self.presentationData.strings.ChatList_DeleteChat var text: String if mainPeer.id == self.context.account.peerId { @@ -5903,7 +5935,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } else { text = self.presentationData.strings.ChatList_DeleteChatConfirmation("**\(chatPeer.displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder))**").string } - + let alertScreen = AlertScreen( context: self.context, configuration: AlertScreen.Configuration(actionAlignment: .vertical), @@ -5957,7 +5989,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController removed() }) } - + let shouldCheckFutureCreator: Bool if case let .channel(channel) = peer.peer, channel.flags.contains(.isCreator) { shouldCheckFutureCreator = true @@ -5966,7 +5998,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } else { shouldCheckFutureCreator = false } - + if shouldCheckFutureCreator { let _ = (self.context.engine.peers.getFutureCreatorAfterLeave(peerId: peer.peerId) |> deliverOnMainQueue).start(next: { [weak self] nextCreator in @@ -5990,7 +6022,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } - + private func canDeletePeerGloballyAsCreator(_ peer: EnginePeer) -> Bool { if case let .channel(channel) = peer { return !channel.isMonoForum && channel.flags.contains(.isCreator) && channel.addressName == nil @@ -6000,19 +6032,19 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return false } } - + func archiveChats(peerIds: [PeerId]) { guard !peerIds.isEmpty else { return } let engine = self.context.engine - + let hasArchived = engine.messages.chatList(group: .archive, count: 10) |> take(1) |> map { list -> Bool in return !list.items.isEmpty } - + self.chatListDisplayNode.mainContainerNode.currentItemNode.setCurrentRemovingItemId(ChatListNodeState.ItemId(peerId: peerIds[0], threadId: nil)) let _ = (combineLatest( ApplicationSpecificNotice.incrementArchiveChatTips(accountManager: self.context.sharedContext.accountManager, count: 1), @@ -6025,11 +6057,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return } strongSelf.chatListDisplayNode.mainContainerNode.currentItemNode.setCurrentRemovingItemId(nil) - + for peerId in peerIds { deleteSendMessageIntents(peerId: peerId) } - + let action: (UndoOverlayAction) -> Bool = { value in guard let strongSelf = self else { return false @@ -6048,14 +6080,14 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return false } } - + strongSelf.forEachController({ controller in if let controller = controller as? UndoOverlayController { controller.dismissWithCommitActionAndReplacementAnimation() } return true }) - + var title = peerIds.count == 1 ? strongSelf.presentationData.strings.ChatList_UndoArchiveTitle : strongSelf.presentationData.strings.ChatList_UndoArchiveMultipleTitle let text: String let undo: Bool @@ -6069,22 +6101,22 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } let controller = UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .archivedChat(peerId: peerIds[0].toInt64(), title: title, text: text, undo: undo), elevatedLayout: false, animateInAsReplacement: true, action: action) strongSelf.present(controller, in: .current) - + strongSelf.chatListDisplayNode.playArchiveAnimation() }) }) } - + private func schedulePeerChatRemoval(peer: EngineRenderedPeer, type: InteractiveMessagesDeletionType, deleteGloballyIfPossible: Bool, completion: @escaping () -> Void) { guard let chatPeer = peer.peers[peer.peerId] else { return } - + var deleteGloballyIfPossible = deleteGloballyIfPossible if case .forEveryone = type { deleteGloballyIfPossible = true } - + let peerId = peer.peerId self.chatListDisplayNode.effectiveContainerNode.currentItemNode.setCurrentRemovingItemId(ChatListNodeState.ItemId(peerId: peerId, threadId: nil)) self.chatListDisplayNode.effectiveContainerNode.updateState({ state in @@ -6125,14 +6157,14 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController statusText = self.presentationData.strings.Undo_ChatDeleted } } - + self.forEachController({ controller in if let controller = controller as? UndoOverlayController { controller.dismissWithCommitActionAndReplacementAnimation() } return true }) - + self.present(UndoOverlayController(presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: self.context, title: NSAttributedString(string: statusText), text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] value in guard let strongSelf = self else { return false @@ -6152,16 +6184,16 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return state }) strongSelf.chatListDisplayNode.effectiveContainerNode.currentItemNode.setCurrentRemovingItemId(nil) - + deleteSendMessageIntents(peerId: peerId) }) - + strongSelf.chatListDisplayNode.effectiveContainerNode.updateState({ state in var state = state state.selectedPeerIds.remove(peerId) return state }) - + completion() return true } else if value == .undo { @@ -6177,7 +6209,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return false }), in: .current) } - + override public func setToolbar(_ toolbar: Toolbar?, transition: ContainedViewLayoutTransition) { if case .chatList(.root) = self.chatListDisplayNode.mainContainerNode.location { super.setToolbar(toolbar, transition: transition) @@ -6186,7 +6218,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.requestLayout(transition: transition) } } - + public var lockViewFrame: CGRect? { if let componentView = self.chatListHeaderView(), let storyPeerListView = componentView.storyPeerListView(), let lockViewFrame = storyPeerListView.lockViewFrame() { return storyPeerListView.convert(lockViewFrame, to: self.view) @@ -6194,7 +6226,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return nil } } - + private func openFilterSettings() { self.chatListDisplayNode.mainContainerNode.updateEnableAdjacentFilterLoading(false) if let navigationController = self.context.sharedContext.mainWindow?.viewController as? NavigationController { @@ -6204,11 +6236,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController navigationController.pushViewController(controller) } } - + override public func tabBarDisabledAction() { self.donePressed() } - + override public func tabBarItemContextAction(sourceView: ContextExtractedContentContainingView, gesture: ContextGesture) { let _ = (combineLatest(queue: .mainQueue(), self.context.engine.peers.currentChatListFilters(), @@ -6224,13 +6256,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let strongSelf = self else { return } - + let (accountPeer, limits, _) = result let isPremium = accountPeer?.isPremium ?? false - + let _ = strongSelf.context.engine.peers.markChatListFeaturedFiltersAsSeen().startStandalone() let (_, filterItems) = filterItemsAndTotalCount - + var items: [ContextMenuItem] = [] items.append(.action(ContextMenuActionItem(text: presetList.isEmpty ? strongSelf.presentationData.strings.ChatList_AddFolder : strongSelf.presentationData.strings.ChatList_EditFolders, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: presetList.isEmpty ? "Chat/Context Menu/Add" : "Chat/Context Menu/ItemList"), color: theme.contextMenu.primaryColor) @@ -6242,7 +6274,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController strongSelf.openFilterSettings() }) }))) - + if strongSelf.chatListDisplayNode.effectiveContainerNode.currentItemNode.chatListFilter != nil { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.ChatList_FolderAllChats, icon: { theme in return nil @@ -6254,7 +6286,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController strongSelf.selectTab(id: .all) }))) } - + if !presetList.isEmpty { if presetList.count > 1 { items.append(.separator) @@ -6267,7 +6299,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if !isPremium && filterCount >= limits.maxFoldersCount { isDisabled = true } - + for item in filterItems { if item.0.id == id && item.1 != 0 { badge = ContextMenuActionBadge(value: "\(item.1)", color: item.2 ? .accent : .inactive) @@ -6321,11 +6353,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController strongSelf.selectTab(id: .filter(id)) } }))) - + filterCount += 1 } } - + let controller = makeContextController(context: strongSelf.context, presentationData: strongSelf.presentationData, source: .reference(ChatListTabBarContextReferenceContentSource(controller: strongSelf, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), recognizer: nil, gesture: gesture) strongSelf.context.sharedContext.mainWindow?.presentInGlobalOverlay(controller) }) @@ -6338,7 +6370,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController override public func tabBarDeactivateSearch() { self.deactivateSearch(animated: true) } - + private var playedSignUpCompletedAnimation = false public func playSignUpCompletedAnimation() { guard !self.playedSignUpCompletedAnimation else { @@ -6349,11 +6381,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.view.addSubview(ConfettiView(frame: self.view.bounds)) } } - + func openBirthdaySetup() { let context = self.context let _ = context.engine.notices.dismissServerProvidedSuggestion(suggestion: ServerProvidedSuggestion.setupBirthday.id).startStandalone() - + let settingsPromise: Promise if let rootController = self.context.sharedContext.mainWindow?.viewController as? TelegramRootControllerInterface, let current = rootController.getPrivacySettings() { settingsPromise = current @@ -6361,7 +6393,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController settingsPromise = Promise() settingsPromise.set(.single(nil) |> then(context.engine.privacy.requestAccountPrivacySettings() |> map(Optional.init))) } - + let controller = context.sharedContext.makeBirthdayPickerScreen( context: context, settings: settingsPromise, @@ -6377,9 +6409,9 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - + let _ = context.engine.accountData.updateBirthday(birthday: value).startStandalone() - + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } self.present(UndoOverlayController(presentationData: presentationData, content: .actionSucceeded(title: nil, text: self.presentationData.strings.Birthday_Added, cancel: nil, destructive: false), elevatedLayout: false, action: { _ in return true @@ -6388,7 +6420,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController ) self.push(controller) } - + func openStarsTopup(amount: Int64?) { guard let starsContext = self.context.starsContext else { return @@ -6396,14 +6428,14 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController let controller = self.context.sharedContext.makeStarsPurchaseScreen(context: self.context, starsContext: starsContext, options: [], purpose: amount.flatMap({ .topUp(requiredStars: $0, purpose: "subs") }) ?? .generic, targetPeerId: nil, customTheme: nil, completion: { _ in }) self.push(controller) } - + func openAdInfo(node: ASDisplayNode, adPeer: AdPeer) { let controller = self let referenceView = node.view let context = self.context let presentationData = context.sharedContext.currentPresentationData.with { $0 } - + var actions: [ContextMenuItem] = [] if adPeer.sponsorInfo != nil || adPeer.additionalInfo != nil { actions.append(.action(ContextMenuActionItem(text: presentationData.strings.Chat_ContextMenu_AdSponsorInfo, textColor: .primary, icon: { theme in @@ -6411,22 +6443,22 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }, iconSource: nil, action: { [weak self] c, _ in let _ = self var subItems: [ContextMenuItem] = [] - + subItems.append(.action(ContextMenuActionItem(text: presentationData.strings.Common_Back, textColor: .primary, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) }, iconSource: nil, iconPosition: .left, action: { c, _ in c?.popItems() }))) - + subItems.append(.separator) - + if let sponsorInfo = adPeer.sponsorInfo { subItems.append(.action(ContextMenuActionItem(text: sponsorInfo, textColor: .primary, textLayout: .multiline, textFont: .custom(font: Font.regular(floor(presentationData.listsFontSize.baseDisplaySize * 0.8)), height: nil, verticalOffset: nil), badge: nil, icon: { theme in return nil }, iconSource: nil, action: { [weak self] c, _ in c?.dismiss(completion: { UIPasteboard.general.string = sponsorInfo - + if let self { self.present(UndoOverlayController(presentationData: presentationData, content: .copy(text: presentationData.strings.Chat_ContextMenu_AdSponsorInfoCopied), elevatedLayout: false, action: { _ in return true @@ -6441,7 +6473,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }, iconSource: nil, action: { [weak self] c, _ in c?.dismiss(completion: { UIPasteboard.general.string = additionalInfo - + if let self { self.present(UndoOverlayController(presentationData: presentationData, content: .copy(text: presentationData.strings.Chat_ContextMenu_AdSponsorInfoCopied), elevatedLayout: false, action: { _ in return true @@ -6450,11 +6482,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) }))) } - + c?.pushItems(items: .single(ContextController.Items(content: .list(subItems)))) }))) } - + actions.append(.action(ContextMenuActionItem(text: presentationData.strings.Chat_ContextMenu_AboutAd, textColor: .primary, textLayout: .twoLinesMax, textFont: .custom(font: Font.regular(presentationData.listsFontSize.baseDisplaySize - 1.0), height: nil, verticalOffset: nil), badge: nil, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.actionSheet.primaryTextColor) }, iconSource: nil, action: { [weak self] _, f in @@ -6463,16 +6495,16 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.push(AdsInfoScreen(context: self.context, mode: .search)) } }))) - + actions.append(.action(ContextMenuActionItem(text: presentationData.strings.Chat_ContextMenu_ReportAd, textColor: .primary, textLayout: .twoLinesMax, textFont: .custom(font: Font.regular(presentationData.listsFontSize.baseDisplaySize - 1.0), height: nil, verticalOffset: nil), badge: nil, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Restrict"), color: theme.actionSheet.primaryTextColor) }, iconSource: nil, action: { [weak self] _, f in f(.default) - + guard let navigationController = self?.navigationController as? NavigationController else { return } - + let _ = (context.engine.messages.reportAdMessage(opaqueId: adPeer.opaqueId, option: nil) |> deliverOnMainQueue).start(next: { [weak navigationController] result in if case let .options(title, options) = result { @@ -6491,9 +6523,9 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } }) }))) - + actions.append(.separator) - + actions.append(.action(ContextMenuActionItem(text: presentationData.strings.Chat_ContextMenu_RemoveAd, textColor: .primary, textLayout: .twoLinesMax, textFont: .custom(font: Font.regular(presentationData.listsFontSize.baseDisplaySize - 1.0), height: nil, verticalOffset: nil), badge: nil, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.primaryTextColor) }, iconSource: nil, action: { [weak self] c, _ in @@ -6508,9 +6540,9 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.present(UndoOverlayController(presentationData: self.presentationData, content: .actionSucceeded(title: nil, text: self.presentationData.strings.ReportAd_Hidden, cancel: nil, destructive: false), elevatedLayout: false, action: { _ in return true }), in: .current) - + let _ = self.context.engine.accountData.updateAdMessagesEnabled(enabled: false).start() - + if let searchContentNode = self.chatListDisplayNode.searchDisplayController?.contentNode as? ChatListSearchContainerNode { searchContentNode.removeAds() } @@ -6527,11 +6559,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } }) }))) - + let contextController = makeContextController(presentationData: presentationData, source: .reference(AdsInfoContextReferenceContentSource(controller: controller, sourceView: referenceView, insets: .zero, contentInsets: .zero)), items: .single(ContextController.Items(content: .list(actions))), gesture: nil) controller.presentInGlobalOverlay(contextController) } - + private var storyCameraTransitionInCoordinator: StoryCameraTransitionInCoordinator? var hasStoryCameraTransition: Bool { return self.storyCameraTransitionInCoordinator != nil @@ -6540,7 +6572,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let rootController = self.context.sharedContext.mainWindow?.viewController as? TelegramRootControllerInterface else { return } - + let coordinator: StoryCameraTransitionInCoordinator? if let current = self.storyCameraTransitionInCoordinator { coordinator = current @@ -6559,7 +6591,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController case .botPreview: peerId = nil } - + if let peerId, let (transitionView, _) = componentView.storyPeerListView()?.transitionViewForItem(peerId: peerId) { return StoryCameraTransitionOut( destinationView: transitionView, @@ -6574,14 +6606,14 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } coordinator?.updateTransitionProgress(transitionFraction) } - + func storyCameraPanGestureEnded(transitionFraction: CGFloat, velocity: CGFloat) { if let coordinator = self.storyCameraTransitionInCoordinator { coordinator.completeWithTransitionProgressAndVelocity(transitionFraction, velocity) self.storyCameraTransitionInCoordinator = nil } } - + var isStoryPostingAvailable: Bool { guard !self.context.isFrozen else { return false @@ -6600,15 +6632,15 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController private final class ChatListTabBarContextReferenceContentSource: ContextReferenceContentSource { let keepInPlace: Bool = true let actionsHorizontalAlignment: ContextActionsHorizontalAlignment = .center - + private let controller: ChatListController private let sourceView: ContextExtractedContentContainingView - + init(controller: ChatListController, sourceView: ContextExtractedContentContainingView) { self.controller = controller self.sourceView = sourceView } - + func transitionInfo() -> ContextControllerReferenceViewInfo? { return ContextControllerReferenceViewInfo( referenceView: self.sourceView.contentView, @@ -6621,15 +6653,15 @@ private final class ChatListTabBarContextReferenceContentSource: ContextReferenc private final class ChatListHeaderBarContextReferenceContentSource: ContextReferenceContentSource { let keepInPlace: Bool = true let actionsHorizontalAlignment: ContextActionsHorizontalAlignment = .center - + private let controller: ChatListController private let sourceView: ContextExtractedContentContainingView - + init(controller: ChatListController, sourceView: ContextExtractedContentContainingView) { self.controller = controller self.sourceView = sourceView } - + func transitionInfo() -> ContextControllerReferenceViewInfo? { return ContextControllerReferenceViewInfo( referenceView: self.sourceView.contentView, @@ -6643,18 +6675,18 @@ private final class ChatListHeaderBarContextExtractedContentSource: ContextExtra let keepInPlace: Bool let ignoreContentTouches: Bool = true let blurBackground: Bool = true - + private let controller: ChatListController private let sourceNode: ContextExtractedContentContainingNode? private let sourceView: ContextExtractedContentContainingView? - + init(controller: ChatListController, sourceNode: ContextExtractedContentContainingNode?, sourceView: ContextExtractedContentContainingView?, keepInPlace: Bool) { self.controller = controller self.sourceNode = sourceNode self.sourceView = sourceView self.keepInPlace = keepInPlace } - + func takeView() -> ContextControllerTakeViewInfo? { if let sourceNode = self.sourceNode { return ContextControllerTakeViewInfo(containingItem: .node(sourceNode), contentAreaInScreenSpace: UIScreen.main.bounds) @@ -6662,21 +6694,21 @@ private final class ChatListHeaderBarContextExtractedContentSource: ContextExtra return ContextControllerTakeViewInfo(containingItem: .view(self.sourceView!), contentAreaInScreenSpace: UIScreen.main.bounds) } } - + func putBack() -> ContextControllerPutBackViewInfo? { return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds) } } -private final class ChatListContextLocationContentSource: ContextLocationContentSource { +private final class ChatListContextLocationContentSource: ContextLocationContentSource { private let controller: ViewController private let location: CGPoint - + init(controller: ViewController, location: CGPoint) { self.controller = controller self.location = location } - + func transitionInfo() -> ContextControllerLocationViewInfo? { return ContextControllerLocationViewInfo(location: self.location, contentAreaInScreenSpace: UIScreen.main.bounds) } @@ -6700,21 +6732,21 @@ private final class ChatListLocationContext { let context: AccountContext let location: ChatListControllerLocation weak var parentController: ChatListControllerImpl? - + private var proxyUnavailableTooltipController: TooltipController? private var didShowProxyUnavailableTooltipController = false - + private var titleDisposable: Disposable? - + private(set) var title: String = "" private(set) var chatTitleComponent: ChatTitleComponent? private(set) var chatListTitle: NetworkStatusTitle? - + var leftButton: AnyComponentWithIdentity? var rightButton: AnyComponentWithIdentity? var proxyButton: AnyComponentWithIdentity? var storyButton: AnyComponentWithIdentity? - + var rightButtons: [AnyComponentWithIdentity] { var result: [AnyComponentWithIdentity] = [] if let rightButton = self.rightButton { @@ -6728,16 +6760,16 @@ private final class ChatListLocationContext { } return result } - + private(set) var toolbar: Toolbar? - + private let previousEditingAndNetworkStateValue = Atomic<(Bool, AccountNetworkState)?>(value: nil) - + private var didSetReady: Bool = false let ready = Promise() - + private var stateDisposable: Disposable? - + init( context: AccountContext, location: ChatListControllerLocation, @@ -6750,7 +6782,7 @@ private final class ChatListLocationContext { self.context = context self.location = location self.parentController = parentController - + let hasProxy = context.sharedContext.accountManager.sharedData(keys: [SharedDataKeys.proxySettings]) |> map { sharedData -> (Bool, Bool) in if let settings = sharedData.entries[SharedDataKeys.proxySettings]?.get(ProxySettings.self) { @@ -6762,13 +6794,13 @@ private final class ChatListLocationContext { |> distinctUntilChanged(isEqual: { lhs, rhs in return lhs == rhs }) - + let passcode = context.sharedContext.accountManager.accessChallengeData() |> map { view -> (Bool, Bool) in let data = view.data return (data.isLockable, false) } - + let peerStatus: Signal switch self.location { case .chatList(.root): @@ -6789,7 +6821,7 @@ private final class ChatListLocationContext { default: peerStatus = .single(nil) } - + let networkState: Signal #if DEBUG && false networkState = .single(AccountNetworkState.connecting(proxy: nil)) |> then(.single(AccountNetworkState.updating(proxy: nil)) |> delay(2.0, queue: .mainQueue())) |> then(.single(AccountNetworkState.online(proxy: nil)) |> delay(2.0, queue: .mainQueue())) |> then(.complete() |> delay(2.0, queue: .mainQueue())) |> restart @@ -6820,13 +6852,13 @@ private final class ChatListLocationContext { |> deliverOnMainQueue).start(next: { value in subscriber.putNext(value) }) - + return ActionDisposable { disposable.dispose() } } #endif - + switch location { case .chatList: if !hideNetworkActivityStatus { @@ -6843,7 +6875,7 @@ private final class ChatListLocationContext { guard let self else { return } - + self.updateChatList( networkState: networkState, proxy: proxy, @@ -6859,12 +6891,12 @@ private final class ChatListLocationContext { self.didSetReady = true self.ready.set(.single(true)) } - case let .forum(peerId): + case let .forum(peerId): let peerView = Promise() peerView.set(context.account.viewTracker.peerView(peerId)) - + var onlineMemberCount: Signal<(total: Int32?, recent: Int32?), NoError> = .single((nil, nil)) - + let recentOnlineSignal: Signal<(total: Int32?, recent: Int32?), NoError> = peerView.get() |> map { view -> Bool? in if let cachedData = view.cachedData as? CachedChannelData, let peer = peerViewMainPeer(view) as? TelegramChannel { @@ -6898,7 +6930,7 @@ private final class ChatListLocationContext { } } onlineMemberCount = recentOnlineSignal - + self.titleDisposable = (combineLatest(queue: Queue.mainQueue(), peerView.get(), onlineMemberCount, @@ -6921,7 +6953,7 @@ private final class ChatListLocationContext { self.didSetReady = true self.ready.set(.single(true)) } - + let context = self.context let location = self.location let peerIdsAndOptions: Signal<(ChatListSelectionOptions, Set, Set)?, NoError> = containerNode.currentItemState @@ -6959,12 +6991,12 @@ private final class ChatListLocationContext { case .savedMessagesChats: return .single(nil) } - + } else { return .single(nil) } } - + let peerView: Signal if case let .forum(peerId) = location { peerView = context.account.viewTracker.peerView(peerId) @@ -6972,7 +7004,7 @@ private final class ChatListLocationContext { } else { peerView = .single(nil) } - + let previousToolbarValue = Atomic(value: nil) self.stateDisposable = combineLatest(queue: .mainQueue(), parentController.updatedPresentationData.1, @@ -7046,7 +7078,7 @@ private final class ChatListLocationContext { case .group: actionTitle = presentationData.strings.Group_JoinGroup } - + } toolbar = Toolbar(leftAction: nil, rightAction: nil, middleAction: ToolbarAction(title: actionTitle, isEnabled: true)) } @@ -7065,12 +7097,12 @@ private final class ChatListLocationContext { } }) } - + deinit { self.titleDisposable?.dispose() self.stateDisposable?.dispose() } - + private func updateChatList( networkState: AccountNetworkState, proxy: (Bool, Bool), @@ -7095,9 +7127,9 @@ private final class ChatListLocationContext { defaultTitle = "" } let previousEditingAndNetworkState = self.previousEditingAndNetworkStateValue.swap((stateAndFilterId.state.editing, networkState)) - + var titleContent: NetworkStatusTitle - + if stateAndFilterId.state.editing { if case .chatList(.root) = self.location { self.rightButton = nil @@ -7105,7 +7137,7 @@ private final class ChatListLocationContext { self.proxyButton = nil } let title = !stateAndFilterId.state.selectedPeerIds.isEmpty ? presentationData.strings.ChatList_SelectedChats(Int32(stateAndFilterId.state.selectedPeerIds.count)) : defaultTitle - + var animated = false if let (previousEditing, previousNetworkState) = previousEditingAndNetworkState { if previousEditing != stateAndFilterId.state.editing, previousNetworkState == networkState, case .online = networkState { @@ -7126,9 +7158,9 @@ private final class ChatListLocationContext { let _ = self?.parentController?.reorderingDonePressed() } ))) - + let (_, connectsViaProxy) = proxy - + switch networkState { case .waitingForNetwork: titleContent = NetworkStatusTitle(text: presentationData.strings.State_WaitingForNetwork, activity: true, hasProxy: false, connectsViaProxy: connectsViaProxy, isPasscodeSet: false, isManuallyLocked: false, peerStatus: peerStatus) @@ -7148,7 +7180,7 @@ private final class ChatListLocationContext { var isRoot = false if case .chatList(.root) = self.location { isRoot = true - + if isReorderingTabs { self.rightButton = AnyComponentWithIdentity(id: "done", component: AnyComponent(NavigationButtonComponent( content: .text(title: presentationData.strings.Common_Done, isBold: true), @@ -7164,7 +7196,7 @@ private final class ChatListLocationContext { } ))) } - + if isReorderingTabs { self.leftButton = AnyComponentWithIdentity(id: "done", component: AnyComponent(NavigationButtonComponent( content: .text(title: presentationData.strings.Common_Done, isBold: true), @@ -7189,7 +7221,7 @@ private final class ChatListLocationContext { ))) } } - + if storyPostingAvailable { self.storyButton = AnyComponentWithIdentity(id: "story", component: AnyComponent(NavigationButtonComponent( content: .icon(imageName: "Chat List/AddStoryIcon"), @@ -7197,7 +7229,7 @@ private final class ChatListLocationContext { guard let self, let parentController = self.parentController else { return } - + if let componentView = parentController.chatListHeaderView(), let storyPeerListView = componentView.storyPeerListView(), storyPeerListView.isLiveStreaming { parentController.displayContinueLiveStream() } else { @@ -7225,7 +7257,7 @@ private final class ChatListLocationContext { } ))) } - + let (hasProxy, connectsViaProxy) = proxy let (isPasscodeSet, isManuallyLocked) = passcode var checkProxy = false @@ -7243,7 +7275,7 @@ private final class ChatListLocationContext { case .online: titleContent = NetworkStatusTitle(text: defaultTitle, activity: false, hasProxy: isRoot && hasProxy, connectsViaProxy: connectsViaProxy, isPasscodeSet: isRoot && isPasscodeSet, isManuallyLocked: isRoot && isManuallyLocked, peerStatus: peerStatus) } - + if titleContent.hasProxy { let proxyStatus: ChatTitleProxyStatus if titleContent.connectsViaProxy { @@ -7251,7 +7283,7 @@ private final class ChatListLocationContext { } else { proxyStatus = .available } - + self.proxyButton = AnyComponentWithIdentity(id: "proxy", component: AnyComponent(NavigationButtonComponent( content: .proxy(status: proxyStatus), pressed: { [weak self] _ in @@ -7261,15 +7293,15 @@ private final class ChatListLocationContext { (parentController.navigationController as? NavigationController)?.pushViewController(self.context.sharedContext.makeProxySettingsController(context: self.context)) } ))) - + titleContent.hasProxy = false titleContent.connectsViaProxy = false } else { self.proxyButton = nil } - + self.chatListTitle = titleContent - + if case .chatList(.root) = self.location, checkProxy { if self.proxyUnavailableTooltipController == nil, !self.didShowProxyUnavailableTooltipController, let parentController = self.parentController, parentController.isNodeLoaded, parentController.displayNode.view.window != nil, parentController.navigationController?.topViewController == nil { self.didShowProxyUnavailableTooltipController = true @@ -7295,14 +7327,14 @@ private final class ChatListLocationContext { } } } - + if !self.didSetReady { self.didSetReady = true self.ready.set(.single(true)) } - + self.parentController?.requestLayout(transition: .animated(duration: 0.45, curve: .spring)) - + Queue.mainQueue().after(1.0, { [weak self] in guard let self else { return @@ -7310,7 +7342,7 @@ private final class ChatListLocationContext { self.parentController?.maybeDisplayStoryTooltip() }) } - + private func updateForum( peerId: EnginePeer.Id, peerView: PeerView, @@ -7369,7 +7401,7 @@ private final class ChatListLocationContext { } ) } - + if stateAndFilterId.state.editing { self.rightButton = AnyComponentWithIdentity(id: "done", component: AnyComponent(NavigationButtonComponent( content: .text(title: presentationData.strings.Common_Done, isBold: true), @@ -7396,12 +7428,12 @@ private final class ChatListLocationContext { } ))) } - + if !self.didSetReady { self.didSetReady = true self.ready.set(.single(true)) } - + if let channel = peerView.peers[peerView.peerId] as? TelegramChannel, !channel.isForumOrMonoForum { if let parentController = self.parentController, let navigationController = parentController.navigationController as? NavigationController { let chatController = self.context.sharedContext.makeChatController(context: self.context, chatLocation: .peer(id: peerId), subject: nil, botStart: nil, mode: .standard(.default), params: nil) @@ -7411,7 +7443,7 @@ private final class ChatListLocationContext { self.parentController?.requestLayout(transition: .animated(duration: 0.45, curve: .spring)) } } - + private func performMoreAction(sourceView: UIView) { guard let parentController = self.parentController else { return @@ -7434,14 +7466,14 @@ private final class AdsInfoContextReferenceContentSource: ContextReferenceConten let sourceView: UIView let insets: UIEdgeInsets let contentInsets: UIEdgeInsets - + init(controller: ViewController, sourceView: UIView, insets: UIEdgeInsets, contentInsets: UIEdgeInsets = UIEdgeInsets()) { self.controller = controller self.sourceView = sourceView self.insets = insets self.contentInsets = contentInsets } - + func transitionInfo() -> ContextControllerReferenceViewInfo? { return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds.inset(by: self.insets), insets: self.contentInsets) } @@ -7461,10 +7493,10 @@ public func resolveChatListNavigationTarget(navigationController: NavigationCont return ChatListNavigationTarget(chatListController: chatListController, popToController: controller) } } - + if let controller = navigationController.viewControllers.first as? TabBarController, let chatListController = controller.currentController as? ChatListControllerImpl { return ChatListNavigationTarget(chatListController: chatListController, popToController: nil) } - + return nil } diff --git a/submodules/ChatListUI/Sources/ChatListControllerNode.swift b/submodules/ChatListUI/Sources/ChatListControllerNode.swift index 61cbefe701..9b3e31bc6b 100644 --- a/submodules/ChatListUI/Sources/ChatListControllerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListControllerNode.swift @@ -33,7 +33,7 @@ import GlassControls public enum ChatListContainerNodeFilter: Equatable { case all case filter(ChatListFilter) - + public var id: ChatListFilterTabEntryId { switch self { case .all: @@ -42,7 +42,7 @@ public enum ChatListContainerNodeFilter: Equatable { return .filter(filter.id) } } - + public var filter: ChatListFilter? { switch self { case .all: @@ -65,16 +65,16 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele private let filterEmptyAction: (ChatListFilter?) -> Void private let secondaryEmptyAction: () -> Void private let openArchiveSettings: () -> Void - + fileprivate var onStoriesLockedUpdated: ((Bool) -> Void)? - + fileprivate var onFilterSwitch: (() -> Void)? - + private var presentationData: PresentationData - + private let animationCache: AnimationCache private let animationRenderer: MultiAnimationRenderer - + private var itemNodes: [ChatListFilterTabEntryId: ChatListContainerItemNode] = [:] private var pendingItemNode: (ChatListFilterTabEntryId, ChatListContainerItemNode, Disposable)? private(set) var availableFilters: [ChatListContainerNodeFilter] = [.all] { @@ -86,10 +86,10 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele var availableFiltersSignal: Signal<[ChatListContainerNodeFilter], NoError> { return self.availableFiltersPromise.get() } - + private var filtersLimit: Int32? = nil private var selectedId: ChatListFilterTabEntryId - + var hintUpdatedStoryExpansion: Bool = false var ignoreStoryUnlockedScrolling: Bool = false var tempTopInset: CGFloat = 0.0 { @@ -102,51 +102,51 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele } } } - + var initialScrollingOffset: CGFloat? - + public private(set) var transitionFraction: CGFloat = 0.0 private var transitionFractionOffset: CGFloat = 0.0 private var disableItemNodeOperationsWhileAnimating: Bool = false private var validLayout: (layout: ContainerViewLayout, navigationBarHeight: CGFloat, visualNavigationHeight: CGFloat, originalNavigationHeight: CGFloat, cleanNavigationBarHeight: CGFloat, insets: UIEdgeInsets, isReorderingFilters: Bool, isEditing: Bool, inlineNavigationLocation: ChatListControllerLocation?, inlineNavigationTransitionFraction: CGFloat, storiesInset: CGFloat)? - + private var scrollingOffset: (navigationHeight: CGFloat, offset: CGFloat)? - + private var enableAdjacentFilterLoading: Bool = false - + private var panRecognizer: InteractiveTransitionGestureRecognizer? - + let leftSeparatorLayer: SimpleLayer - + private let _ready = Promise() public var ready: Signal { return _ready.get() } - + private let _validLayoutReady = Promise() var validLayoutReady: Signal { return _validLayoutReady.get() } - + private var currentItemNodeValue: ChatListContainerItemNode? public var currentItemNode: ChatListNode { return self.currentItemNodeValue!.listNode } - + private let currentItemStateValue = Promise<(state: ChatListNodeState, filterId: Int32?)>() var currentItemState: Signal<(state: ChatListNodeState, filterId: Int32?), NoError> { return self.currentItemStateValue.get() } - + public var currentItemFilterUpdated: ((ChatListFilterTabEntryId, CGFloat, ContainedViewLayoutTransition, Bool) -> Void)? public private(set) var isSwitchingCurrentItemFilterByDragging: Bool = false public var currentItemFilter: ChatListFilterTabEntryId { return self.currentItemNode.chatListFilter.flatMap { .filter($0.id) } ?? .all } - + private var didSetupContentOffset = false private var isSettingUpContentOffset = false - + private func applyItemNodeAsCurrent(id: ChatListFilterTabEntryId, itemNode: ChatListContainerItemNode) { if let previousItemNode = self.currentItemNodeValue { previousItemNode.listNode.activateSearch = nil @@ -175,12 +175,12 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele previousItemNode.listNode.addedVisibleChatsWithPeerIds = nil previousItemNode.listNode.didBeginSelectingChats = nil previousItemNode.listNode.canExpandHiddenItems = nil - + previousItemNode.accessibilityElementsHidden = true } self.currentItemNodeValue = itemNode itemNode.accessibilityElementsHidden = false - + itemNode.listNode.activateSearch = { [weak self] in self?.activateSearch?() } @@ -233,22 +233,22 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele if self.isSettingUpContentOffset { return } - + if !self.didSetupContentOffset, let initialScrollingOffset = self.initialScrollingOffset { self.initialScrollingOffset = nil self.didSetupContentOffset = true self.isSettingUpContentOffset = true - + let _ = itemNode.listNode.scrollToOffsetFromTop(initialScrollingOffset, animated: false) - + let offset = itemNode.listNode.visibleContentOffset() self.contentOffset = offset self.contentOffsetChanged?(offset, self.currentItemNode) - + self.isSettingUpContentOffset = false return } - + if !self.isInlineMode, itemNode.listNode.isTracking && !self.currentItemNode.startedScrollingAtUpperBound && self.tempTopInset == 0.0 { if case let .known(value) = offset { if value < -1.0 { @@ -259,10 +259,10 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele } } } - + self.contentOffset = offset self.contentOffsetChanged?(offset, self.currentItemNode) - + if !self.isInlineMode, self.currentItemNode.startedScrollingAtUpperBound && self.tempTopInset != 0.0 { if case let .known(value) = offset { if value > 4.0 { @@ -281,17 +281,17 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele guard let self else { return } - + self.didBeginInteractiveDragging?(listView) - + if self.isInlineMode { return } - + guard let validLayout = self.validLayout else { return } - + let tempTopInset: CGFloat if validLayout.inlineNavigationLocation != nil { tempTopInset = 0.0 @@ -327,7 +327,7 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele guard let self else { return false } - + return self.contentScrollingEnded?(listView) ?? false } itemNode.listNode.pinnedHeaderDisplayFractionUpdated = { [weak self] transition in @@ -372,7 +372,7 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele itemNode.listNode.openAccountFreezeInfo = { [weak self] in self?.openAccountFreezeInfo?() } - + self.currentItemStateValue.set(itemNode.listNode.state |> map { state in let filterId: Int32? switch id { @@ -383,7 +383,7 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele } return (state, filterId) }) - + let enablePreload = context.sharedContext.accountManager.sharedData(keys: Set([ApplicationSpecificSharedDataKeys.automaticMediaDownloadSettings])) |> map { sharedData -> Bool in var automaticMediaDownloadSettings: MediaAutoDownloadSettings @@ -395,7 +395,7 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele return automaticMediaDownloadSettings.energyUsageSettings.autodownloadInBackground } |> distinctUntilChanged - + if self.controlsHistoryPreload, case .chatList(groupId: .root) = self.location { self.context.account.viewTracker.chatListPreloadItems.set(combineLatest(queue: .mainQueue(), context.sharedContext.enablePreloads.get(), @@ -411,7 +411,7 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele }) } } - + public var activateSearch: (() -> Void)? var presentAlert: ((String) -> Void)? var present: ((ViewController) -> Void)? @@ -446,7 +446,7 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele var didBeginSelectingChats: (() -> Void)? var canExpandHiddenItems: (() -> Bool)? public var displayFilterLimit: (() -> Void)? - + public var pinnedHeaderDisplayFraction: CGFloat { guard let currentItemNodeValue = self.currentItemNodeValue else { return 0.0 @@ -464,7 +464,7 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele } } } - + if let nextItemNode { let absTransitionFraction = abs(self.transitionFraction) return (1.0 - absTransitionFraction) * currentItemNodeValue.listNode.pinnedScrollFraction + absTransitionFraction * nextItemNode.listNode.pinnedScrollFraction @@ -474,7 +474,7 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele } return currentItemNodeValue.listNode.pinnedScrollFraction } - + public init( context: AccountContext, controller: ChatListControllerImpl?, @@ -502,21 +502,21 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele self.secondaryEmptyAction = secondaryEmptyAction self.openArchiveSettings = openArchiveSettings self.controlsHistoryPreload = controlsHistoryPreload - + self.presentationData = presentationData self.animationCache = animationCache self.animationRenderer = animationRenderer - + self.selectedId = .all - + self.leftSeparatorLayer = SimpleLayer() self.leftSeparatorLayer.isHidden = true self.leftSeparatorLayer.backgroundColor = presentationData.theme.rootController.navigationBar.separatorColor.cgColor - + super.init() - + self.backgroundColor = presentationData.theme.chatList.backgroundColor - + let itemNode = ChatListContainerItemNode(context: self.context, controller: self.controller, location: self.location, filter: nil, chatListMode: chatListMode, previewing: self.previewing, isInlineMode: self.isInlineMode, controlsHistoryPreload: self.controlsHistoryPreload, presentationData: presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, becameEmpty: { [weak self] filter in self?.filterBecameEmpty(filter) }, emptyAction: { [weak self] filter in @@ -528,11 +528,11 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele }, autoSetReady: true, isMainTab: nil) self.itemNodes[.all] = itemNode self.addSubnode(itemNode) - + self._ready.set(itemNode.listNode.ready) - + self.applyItemNodeAsCurrent(id: .all, itemNode: itemNode) - + let panRecognizer = InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.panGesture(_:)), allowedDirections: { [weak self] _ in guard let self, self.availableFilters.count > 1 || (self.controller?.isStoryPostingAvailable == true && !(self.context.sharedContext.callManager?.hasActiveCall ?? false)) else { return [] @@ -562,18 +562,18 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele panRecognizer.cancelsTouchesInView = true self.panRecognizer = panRecognizer self.view.addGestureRecognizer(panRecognizer) - + self.view.layer.addSublayer(self.leftSeparatorLayer) } - + deinit { self.pendingItemNode?.2.dispose() } - + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return false } - + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { if let _ = otherGestureRecognizer as? InteractiveTransitionGestureRecognizer { return false @@ -583,29 +583,29 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele } return false } - + @objc private func panGesture(_ recognizer: UIPanGestureRecognizer) { let filtersLimit = self.filtersLimit.flatMap({ $0 + 1 }) ?? Int32(self.availableFilters.count) let maxFilterIndex = min(Int(filtersLimit), self.availableFilters.count) - 1 - + switch recognizer.state { case .began: self.onFilterSwitch?() - + self.transitionFractionOffset = 0.0 if let (layout, navigationBarHeight, visualNavigationHeight, originalNavigationHeight, cleanNavigationBarHeight, insets, isReorderingFilters, isEditing, inlineNavigationLocation, inlineNavigationTransitionFraction, storiesInset) = self.validLayout, let itemNode = self.itemNodes[self.selectedId] { for (id, itemNode) in self.itemNodes { if id != selectedId { itemNode.emptyNode?.restartAnimation() - + if let controller = self.controller, let chatListDisplayNode = controller.displayNode as? ChatListControllerNode, let navigationBarComponentView = chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View, let clippedScrollOffset = navigationBarComponentView.clippedScrollOffset { let scrollOffset = clippedScrollOffset - + let _ = itemNode.listNode.scrollToOffsetFromTop(scrollOffset, animated: false) } } } - + if let presentationLayer = itemNode.layer.presentation() { self.transitionFraction = presentationLayer.frame.minX / layout.size.width self.transitionFractionOffset = self.transitionFraction @@ -624,31 +624,31 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele if let (layout, navigationBarHeight, visualNavigationHeight, originalNavigationHeight: originalNavigationHeight, cleanNavigationBarHeight, insets, isReorderingFilters, isEditing, inlineNavigationLocation, inlineNavigationTransitionFraction, storiesInset) = self.validLayout, let selectedIndex = self.availableFilters.firstIndex(where: { $0.id == self.selectedId }) { let translation = recognizer.translation(in: self.view) var transitionFraction = translation.x / layout.size.width - + var transition: ContainedViewLayoutTransition = .immediate - + func rubberBandingOffset(offset: CGFloat, bandingStart: CGFloat) -> CGFloat { let bandedOffset = offset - bandingStart let range: CGFloat = 600.0 let coefficient: CGFloat = 0.4 return bandingStart + (1.0 - (1.0 / ((bandedOffset * coefficient / range) + 1.0))) * range } - + var hasLiveStream = false if let componentView = self.controller?.chatListHeaderView(), let storyPeerListView = componentView.storyPeerListView(), storyPeerListView.isLiveStreaming { hasLiveStream = true } - + if case .compact = layout.metrics.widthClass, self.controller?.isStoryPostingAvailable == true && !(self.context.sharedContext.callManager?.hasActiveCall ?? false) { if hasLiveStream { if translation.x >= 30.0 { self.panRecognizer?.cancel() - + self.controller?.displayContinueLiveStream() } return } - + let cameraIsAlreadyOpened = self.controller?.hasStoryCameraTransition ?? false if selectedIndex <= 0 && translation.x > 0.0 { transitionFraction = 0.0 @@ -656,7 +656,7 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele } else if translation.x <= 0.0 && cameraIsAlreadyOpened { self.controller?.storyCameraPanGestureChanged(transitionFraction: 0.0) } - + if cameraIsAlreadyOpened { transitionFraction = 0.0 return @@ -667,17 +667,17 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele transitionFraction = rubberBandingOffset(offset: overscroll, bandingStart: 0.0) / layout.size.width } } - + if selectedIndex >= maxFilterIndex && translation.x < 0.0 { let overscroll = -translation.x transitionFraction = -rubberBandingOffset(offset: overscroll, bandingStart: 0.0) / layout.size.width - + if let filtersLimit = self.filtersLimit, selectedIndex >= filtersLimit - 1 { transitionFraction = 0.0 self.transitionFractionOffset = 0.0 recognizer.isEnabled = false recognizer.isEnabled = true - + transition = .animated(duration: 0.45, curve: .spring) self.displayFilterLimit?() } @@ -720,13 +720,13 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele directionIsToRight = translation.x > layout.size.width / 2.0 } } - + let hasStoryCameraTransition = self.controller?.hasStoryCameraTransition ?? false if hasStoryCameraTransition { self.controller?.storyCameraPanGestureEnded(transitionFraction: translation.x / layout.size.width, velocity: velocity.x) } var applyNodeAsCurrent: ChatListFilterTabEntryId? - + if let directionIsToRight = directionIsToRight { var updatedIndex = selectedIndex if directionIsToRight { @@ -751,7 +751,7 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele self.update(layout: layout, navigationBarHeight: navigationBarHeight, visualNavigationHeight: visualNavigationHeight, originalNavigationHeight: originalNavigationHeight, cleanNavigationBarHeight: cleanNavigationBarHeight, insets: insets, isReorderingFilters: isReorderingFilters, isEditing: isEditing, inlineNavigationLocation: inlineNavigationLocation, inlineNavigationTransitionFraction: inlineNavigationTransitionFraction, storiesInset: storiesInset, transition: .immediate) } } - + if let switchToId = applyNodeAsCurrent, let itemNode = self.itemNodes[switchToId] { self.applyItemNodeAsCurrent(id: switchToId, itemNode: itemNode) } @@ -763,14 +763,14 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele break } } - + func fixContentOffset(offset: CGFloat) { self.currentItemNode.fixContentOffset(offset: offset) } - + public func updatePresentationData(_ presentationData: PresentationData) { self.presentationData = presentationData - + if let validLayout = self.validLayout { if let _ = validLayout.inlineNavigationLocation { self.backgroundColor = self.presentationData.theme.chatList.backgroundColor.mixedWith(self.presentationData.theme.chatList.pinnedItemBackgroundColor, alpha: validLayout.inlineNavigationTransitionFraction) @@ -778,14 +778,14 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele self.backgroundColor = self.presentationData.theme.chatList.backgroundColor } } - + self.leftSeparatorLayer.backgroundColor = self.presentationData.theme.rootController.navigationBar.separatorColor.cgColor - + for (_, itemNode) in self.itemNodes { itemNode.updatePresentationData(presentationData) } } - + func playArchiveAnimation() { if let itemNode = self.itemNodes[self.selectedId] { itemNode.listNode.forEachVisibleItemNode { node in @@ -795,19 +795,19 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele } } } - + public func scrollToTop(animated: Bool, adjustForTempInset: Bool) { if let itemNode = self.itemNodes[self.selectedId] { itemNode.listNode.scrollToPosition(.top(adjustForTempInset: adjustForTempInset), animated: animated) } } - + func updateSelectedChatLocation(data: ChatLocation?, progress: CGFloat, transition: ContainedViewLayoutTransition) { for (_, itemNode) in self.itemNodes { itemNode.listNode.updateSelectedChatLocation(data, progress: progress, transition: transition) } } - + func updateState(onlyCurrent: Bool = true, _ f: (ChatListNodeState) -> ChatListNodeState) { self.currentItemNode.updateState(f) let updatedState = self.currentItemNode.currentState @@ -826,7 +826,7 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele } } } - + public func updateAvailableFilters(_ availableFilters: [ChatListContainerNodeFilter], limit: Int32?) { if self.availableFilters != availableFilters { let apply: () -> Void = { [weak self] in @@ -848,17 +848,17 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele } } } - + public func updateEnableAdjacentFilterLoading(_ value: Bool) { if value != self.enableAdjacentFilterLoading { self.enableAdjacentFilterLoading = value - + if self.enableAdjacentFilterLoading, let (layout, navigationBarHeight, visualNavigationHeight, originalNavigationHeight, cleanNavigationBarHeight, insets, isReorderingFilters, isEditing, inlineNavigationLocation, inlineNavigationTransitionFraction, storiesInset) = self.validLayout { self.update(layout: layout, navigationBarHeight: navigationBarHeight, visualNavigationHeight: visualNavigationHeight, originalNavigationHeight: originalNavigationHeight, cleanNavigationBarHeight: cleanNavigationBarHeight, insets: insets, isReorderingFilters: isReorderingFilters, isEditing: isEditing, inlineNavigationLocation: inlineNavigationLocation, inlineNavigationTransitionFraction: inlineNavigationTransitionFraction, storiesInset: storiesInset, transition: .immediate) } } } - + public func switchToFilter(id: ChatListFilterTabEntryId, animated: Bool = true, completion: (() -> Void)? = nil) { self.onFilterSwitch?() if id != self.selectedId, let index = self.availableFilters.firstIndex(where: { $0.id == id }) { @@ -866,13 +866,13 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele guard let (layout, navigationBarHeight, visualNavigationHeight, originalNavigationHeight, cleanNavigationBarHeight, insets, isReorderingFilters, isEditing, inlineNavigationLocation, inlineNavigationTransitionFraction, storiesInset) = self.validLayout else { return } - + if let controller = self.controller, let chatListDisplayNode = controller.displayNode as? ChatListControllerNode, let navigationBarComponentView = chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View, let clippedScrollOffset = navigationBarComponentView.clippedScrollOffset { let scrollOffset = clippedScrollOffset - + let _ = itemNode.listNode.scrollToOffsetFromTop(scrollOffset, animated: false) } - + self.selectedId = id self.applyItemNodeAsCurrent(id: id, itemNode: itemNode) let transition: ContainedViewLayoutTransition = .animated(duration: 0.35, curve: .spring) @@ -894,54 +894,54 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele self.pendingItemNode?.2.dispose() let disposable = MetaDisposable() self.pendingItemNode = (id, itemNode, disposable) - + if !animated { self.selectedId = id self.applyItemNodeAsCurrent(id: id, itemNode: itemNode) self.currentItemFilterUpdated?(self.currentItemFilter, self.transitionFraction, .immediate, false) } - + disposable.set((itemNode.listNode.ready |> take(1) |> deliverOnMainQueue).startStrict(next: { [weak self, weak itemNode] _ in guard let strongSelf = self, let itemNode = itemNode, itemNode === strongSelf.pendingItemNode?.1 else { return } - + strongSelf.pendingItemNode?.2.dispose() strongSelf.pendingItemNode = nil itemNode.listNode.tempTopInset = strongSelf.tempTopInset - + if let controller = strongSelf.controller, let chatListDisplayNode = controller.displayNode as? ChatListControllerNode, let navigationBarComponentView = chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View, let clippedScrollOffset = navigationBarComponentView.clippedScrollOffset { let scrollOffset = clippedScrollOffset - + let _ = itemNode.listNode.scrollToOffsetFromTop(scrollOffset, animated: false) } - + guard let (layout, navigationBarHeight, visualNavigationHeight, originalNavigationHeight, cleanNavigationBarHeight, insets, isReorderingFilters, isEditing, inlineNavigationLocation, inlineNavigationTransitionFraction, storiesInset) = strongSelf.validLayout else { strongSelf.itemNodes[id] = itemNode strongSelf.addSubnode(itemNode) - + strongSelf.selectedId = id strongSelf.applyItemNodeAsCurrent(id: id, itemNode: itemNode) strongSelf.currentItemFilterUpdated?(strongSelf.currentItemFilter, strongSelf.transitionFraction, .immediate, false) strongSelf.pinnedHeaderDisplayFractionUpdated?(.immediate) - + completion?() return } - + let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.35, curve: .spring) : .immediate if let previousIndex = strongSelf.availableFilters.firstIndex(where: { $0.id == strongSelf.selectedId }), let index = strongSelf.availableFilters.firstIndex(where: { $0.id == id }) { let previousId = strongSelf.selectedId let offsetDirection: CGFloat = index < previousIndex ? 1.0 : -1.0 let offset = offsetDirection * layout.size.width - + var validNodeIds: [ChatListFilterTabEntryId] = [] for i in max(0, index - 1) ... min(strongSelf.availableFilters.count - 1, index + 1) { validNodeIds.append(strongSelf.availableFilters[i].id) } - + var removeIds: [ChatListFilterTabEntryId] = [] for (id, _) in strongSelf.itemNodes { if !validNodeIds.contains(id) { @@ -959,38 +959,38 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele } } } - + strongSelf.itemNodes[id] = itemNode strongSelf.addSubnode(itemNode) - + let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: layout.size) itemNode.frame = itemFrame - + transition.animatePositionAdditive(node: itemNode, offset: CGPoint(x: -offset, y: 0.0)) - + itemNode.updateLayout(size: layout.size, insets: insets, visualNavigationHeight: visualNavigationHeight, originalNavigationHeight: originalNavigationHeight, inlineNavigationLocation: inlineNavigationLocation, inlineNavigationTransitionFraction: inlineNavigationTransitionFraction, storiesInset: storiesInset, transition: .immediate) if let scrollingOffset = strongSelf.scrollingOffset { itemNode.updateScrollingOffset(navigationHeight: scrollingOffset.navigationHeight, offset: scrollingOffset.offset, transition: .immediate) } - + strongSelf.selectedId = id if let currentItemNode = strongSelf.currentItemNodeValue { itemNode.listNode.adjustScrollOffsetForNavigation(isNavigationHidden: currentItemNode.listNode.isNavigationHidden) } strongSelf.applyItemNodeAsCurrent(id: id, itemNode: itemNode) - + strongSelf.update(layout: layout, navigationBarHeight: navigationBarHeight, visualNavigationHeight: visualNavigationHeight, originalNavigationHeight: originalNavigationHeight, cleanNavigationBarHeight: cleanNavigationBarHeight, insets: insets, isReorderingFilters: isReorderingFilters, isEditing: isEditing, inlineNavigationLocation: inlineNavigationLocation, inlineNavigationTransitionFraction: inlineNavigationTransitionFraction, storiesInset: storiesInset, transition: .immediate) - + strongSelf.currentItemFilterUpdated?(strongSelf.currentItemFilter, strongSelf.transitionFraction, transition, false) strongSelf.pinnedHeaderDisplayFractionUpdated?(transition) } - + completion?() })) - + if let (layout, _, visualNavigationHeight, originalNavigationHeight, _, insets, _, _, inlineNavigationLocation, inlineNavigationTransitionFraction, storiesInset) = self.validLayout { itemNode.updateLayout(size: layout.size, insets: insets, visualNavigationHeight: visualNavigationHeight, originalNavigationHeight: originalNavigationHeight, inlineNavigationLocation: inlineNavigationLocation, inlineNavigationTransitionFraction: inlineNavigationTransitionFraction, storiesInset: storiesInset, transition: .immediate) - + if let scrollingOffset = self.scrollingOffset { itemNode.updateScrollingOffset(navigationHeight: scrollingOffset.navigationHeight, offset: scrollingOffset.offset, transition: .immediate) } @@ -999,38 +999,38 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele } } } - + func updateScrollingOffset(navigationHeight: CGFloat, offset: CGFloat, transition: ContainedViewLayoutTransition) { self.scrollingOffset = (navigationHeight, offset) for (_, itemNode) in self.itemNodes { itemNode.updateScrollingOffset(navigationHeight: navigationHeight, offset: offset, transition: transition) } } - + public func update(layout: ContainerViewLayout, navigationBarHeight: CGFloat, visualNavigationHeight: CGFloat, originalNavigationHeight: CGFloat, cleanNavigationBarHeight: CGFloat, insets: UIEdgeInsets, isReorderingFilters: Bool, isEditing: Bool, inlineNavigationLocation: ChatListControllerLocation?, inlineNavigationTransitionFraction: CGFloat, storiesInset: CGFloat, transition: ContainedViewLayoutTransition) { self.validLayout = (layout, navigationBarHeight, visualNavigationHeight, originalNavigationHeight, cleanNavigationBarHeight, insets, isReorderingFilters, isEditing, inlineNavigationLocation, inlineNavigationTransitionFraction, storiesInset) - + self._validLayoutReady.set(.single(true)) - + transition.updateAlpha(node: self, alpha: isReorderingFilters ? 0.5 : 1.0) self.isUserInteractionEnabled = !isReorderingFilters - + if let _ = inlineNavigationLocation { transition.updateBackgroundColor(node: self, color: self.presentationData.theme.chatList.backgroundColor.mixedWith(self.presentationData.theme.chatList.pinnedItemBackgroundColor, alpha: inlineNavigationTransitionFraction)) } else { transition.updateBackgroundColor(node: self, color: self.presentationData.theme.chatList.backgroundColor) } - + self.panRecognizer?.isEnabled = !isEditing - + transition.updateFrame(layer: self.leftSeparatorLayer, frame: CGRect(origin: CGPoint(x: -UIScreenPixel, y: 0.0), size: CGSize(width: UIScreenPixel, height: layout.size.height))) - + if let selectedIndex = self.availableFilters.firstIndex(where: { $0.id == self.selectedId }) { var validNodeIds: [ChatListFilterTabEntryId] = [] for i in max(0, selectedIndex - 1) ... min(self.availableFilters.count - 1, selectedIndex + 1) { let id = self.availableFilters[i].id validNodeIds.append(id) - + if self.itemNodes[id] == nil && self.enableAdjacentFilterLoading && !self.disableItemNodeOperationsWhileAnimating { let itemNode = ChatListContainerItemNode(context: self.context, controller: self.controller, location: self.location, filter: self.availableFilters[i].filter, chatListMode: self.chatListMode, previewing: self.previewing, isInlineMode: self.isInlineMode, controlsHistoryPreload: self.controlsHistoryPreload, presentationData: self.presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, becameEmpty: { [weak self] filter in self?.filterBecameEmpty(filter) @@ -1045,7 +1045,7 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele self.itemNodes[id] = itemNode } } - + var removeIds: [ChatListFilterTabEntryId] = [] var animateSlidingIds: [ChatListFilterTabEntryId] = [] var slidingOffset: CGFloat? @@ -1057,34 +1057,34 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele continue } let indexDistance = CGFloat(index - selectedIndex) + self.transitionFraction - + let wasAdded = itemNode.supernode == nil var nodeTransition = transition if wasAdded { self.addSubnode(itemNode) nodeTransition = .immediate } - + let itemFrame = CGRect(origin: CGPoint(x: indexDistance * layout.size.width, y: 0.0), size: layout.size) if !wasAdded && slidingOffset == nil { slidingOffset = itemNode.frame.minX - itemFrame.minX } nodeTransition.updateFrame(node: itemNode, frame: itemFrame, completion: { _ in }) - + var itemInlineNavigationTransitionFraction = inlineNavigationTransitionFraction if indexDistance != 0 { if itemInlineNavigationTransitionFraction != 0.0 || itemInlineNavigationTransitionFraction != 1.0 { itemInlineNavigationTransitionFraction = itemNode.validLayout?.inlineNavigationTransitionFraction ?? 0.0 } } - + itemNode.listNode.isMainTab.set(self.availableFilters.firstIndex(where: { $0.id == id }) == 0) itemNode.updateLayout(size: layout.size, insets: insets, visualNavigationHeight: visualNavigationHeight, originalNavigationHeight: originalNavigationHeight, inlineNavigationLocation: inlineNavigationLocation, inlineNavigationTransitionFraction: itemInlineNavigationTransitionFraction, storiesInset: storiesInset, transition: nodeTransition) if let scrollingOffset = self.scrollingOffset { itemNode.updateScrollingOffset(navigationHeight: scrollingOffset.navigationHeight, offset: scrollingOffset.offset, transition: nodeTransition) } - + if wasAdded, case .animated = transition { animateSlidingIds.append(id) } @@ -1114,9 +1114,9 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { private var presentationData: PresentationData private let animationCache: AnimationCache private let animationRenderer: MultiAnimationRenderer - + let mainContainerNode: ChatListContainerNode - + var effectiveContainerNode: ChatListContainerNode { if let inlineStackContainerNode = self.inlineStackContainerNode { return inlineStackContainerNode @@ -1124,44 +1124,44 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { return self.mainContainerNode } } - + private(set) var inlineStackContainerTransitionFraction: CGFloat = 0.0 private(set) var inlineStackContainerNode: ChatListContainerNode? private var inlineContentPanRecognizer: InteractiveTransitionGestureRecognizer? var temporaryContentOffsetChangeTransition: ContainedViewLayoutTransition? - + private var tapRecognizer: UITapGestureRecognizer? var navigationBar: NavigationBar? let navigationBarView = ComponentView() weak var controller: ChatListControllerImpl? - + private var toolbar: ComponentView? var toolbarData: Toolbar? var toolbarActionSelected: ((ToolbarActionOption) -> Void)? - + private var isSearchDisplayControllerActive: ChatListNavigationBar.ActiveSearch? private var skipSearchDisplayControllerLayout: Bool = false private(set) var searchDisplayController: SearchDisplayController? private var disappearingSearchDisplayController: SearchDisplayController? - + var isReorderingFilters: Bool = false var didBeginSelectingChatsWhileEditing: Bool = false var isEditing: Bool = false - + var tempAllowAvatarExpansion: Bool = false private var tempDisableStoriesAnimations: Bool = false private var tempNavigationScrollingTransition: ContainedViewLayoutTransition? - + private var allowOverscrollStoryExpansion: Bool = false private var currentOverscrollStoryExpansionTimestamp: Double? - + private var allowOverscrollItemExpansion: Bool = false private var currentOverscrollItemExpansionTimestamp: Double? - + private var containerLayout: (layout: ContainerViewLayout, navigationBarHeight: CGFloat, visualNavigationHeight: CGFloat, cleanNavigationBarHeight: CGFloat, storiesInset: CGFloat)? - + var contentScrollingEnded: ((ListView) -> Bool)? - + var requestDeactivateSearch: (() -> Void)? var requestOpenPeerFromSearch: ((EnginePeer, Int64?, Bool) -> Void)? var requestOpenRecentPeerOptions: ((EnginePeer) -> Void)? @@ -1173,16 +1173,16 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { var emptyListAction: ((EnginePeer.Id?) -> Void)? var cancelEditing: (() -> Void)? var dismissSearch: (() -> Void)? - + let debugListView = ListViewImpl() - + init(context: AccountContext, location: ChatListControllerLocation, previewing: Bool, controlsHistoryPreload: Bool, presentationData: PresentationData, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, controller: ChatListControllerImpl) { self.context = context self.location = location self.presentationData = presentationData self.animationCache = animationCache self.animationRenderer = animationRenderer - + var filterBecameEmpty: ((ChatListFilter?) -> Void)? var filterEmptyAction: ((ChatListFilter?) -> Void)? var secondaryEmptyAction: (() -> Void)? @@ -1196,19 +1196,19 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { }, openArchiveSettings: { openArchiveSettings?() }) - + self.controller = controller - + super.init() - + self.setViewBlock({ return UITracingLayerView() }) - + self.backgroundColor = presentationData.theme.chatList.backgroundColor - + self.addSubnode(self.mainContainerNode) - + self.mainContainerNode.contentOffsetChanged = { [weak self] offset, listView in self?.contentOffsetChanged(offset: offset, listView: listView, isPrimary: true) } @@ -1227,9 +1227,9 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { self.mainContainerNode.shouldStopScrolling = { [weak self] listView, velocity in return self?.shouldStopScrolling(listView: listView, velocity: velocity, isPrimary: true) ?? false } - + self.addSubnode(self.debugListView) - + filterBecameEmpty = { [weak self] _ in guard let strongSelf = self else { return @@ -1244,29 +1244,29 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } strongSelf.emptyListAction?(nil) } - + secondaryEmptyAction = { [weak self] in guard let strongSelf = self, case let .forum(peerId) = strongSelf.location, let controller = strongSelf.controller else { return } - + let chatController = strongSelf.context.sharedContext.makeChatController(context: strongSelf.context, chatLocation: .peer(id: peerId), subject: nil, botStart: nil, mode: .standard(.default), params: nil) (controller.navigationController as? NavigationController)?.replaceController(controller, with: chatController, animated: false) } - + openArchiveSettings = { [weak self] in guard let self, let controller = self.controller else { return } controller.push(self.context.sharedContext.makeArchiveSettingsController(context: self.context)) } - + self.mainContainerNode.onFilterSwitch = { [weak self] in if let strongSelf = self { strongSelf.controller?.dismissAllUndoControllers() } } - + self.mainContainerNode.onStoriesLockedUpdated = { [weak self] isLocked in guard let self else { return @@ -1278,12 +1278,12 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { self.controller?.requestLayout(transition: .immediate) } } - + self.mainContainerNode.canExpandHiddenItems = { [weak self] in guard let self, let controller = self.controller else { return false } - + if let storySubscriptions = controller.orderedStorySubscriptions, shouldDisplayStoriesInChatListHeader(storySubscriptions: storySubscriptions, isHidden: controller.location == .chatList(groupId: .archive)) { if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { if navigationBarComponentView.storiesUnlocked { @@ -1295,7 +1295,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { return true } } - + let inlineContentPanRecognizer = InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.inlineContentPanGesture(_:)), allowedDirections: { [weak self] _ in guard let strongSelf = self, strongSelf.inlineStackContainerNode != nil else { return [] @@ -1309,26 +1309,26 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { self.inlineContentPanRecognizer = inlineContentPanRecognizer self.view.addGestureRecognizer(inlineContentPanRecognizer) } - + override func didLoad() { super.didLoad() - + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) self.tapRecognizer = tapRecognizer self.view.addGestureRecognizer(tapRecognizer) tapRecognizer.isEnabled = false } - + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.cancelEditing?() } } - + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return false } - + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { if let _ = otherGestureRecognizer as? InteractiveTransitionGestureRecognizer { return false @@ -1338,7 +1338,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } return false } - + @objc private func inlineContentPanGesture(_ recognizer: UIPanGestureRecognizer) { switch recognizer.state { case .began: @@ -1375,7 +1375,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { directionIsToRight = translation.x > inlineStackContainerNode.bounds.width / 2.0 } } - + if let directionIsToRight = directionIsToRight, directionIsToRight { self.controller?.setInlineChatList(location: nil) } else { @@ -1387,20 +1387,20 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { break } } - + func updatePresentationData(_ presentationData: PresentationData) { self.presentationData = presentationData - + self.backgroundColor = self.presentationData.theme.chatList.backgroundColor - + self.mainContainerNode.updatePresentationData(presentationData) self.inlineStackContainerNode?.updatePresentationData(presentationData) self.searchDisplayController?.updatePresentationData(presentationData) } - + private func updateNavigationBar(layout: ContainerViewLayout, deferScrollApplication: Bool, transition: ComponentTransition) -> (navigationHeight: CGFloat, storiesInset: CGFloat) { let headerContent = self.controller?.updateHeaderContent() - + var panels: [HeaderPanelContainerComponent.Panel] = [] if let chatListNotice = self.controller?.globalControlPanelsContextState?.chatListNotice { panels.append(HeaderPanelContainerComponent.Panel( @@ -1468,7 +1468,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } if let mediaPlayback = self.controller?.globalControlPanelsContextState?.mediaPlayback { if let playlistLocation = mediaPlayback.playlistLocation as? PeerMessagesPlaylistLocation, case let .custom(_, _, _, _, hidePanel) = playlistLocation, hidePanel { - + } else { panels.append(HeaderPanelContainerComponent.Panel( key: "media", @@ -1500,7 +1500,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { ))) ) } - + var navigationHeaderPanels: AnyComponent? if self.controller?.tabContainerData != nil || !panels.isEmpty { var tabs: AnyComponent? @@ -1530,9 +1530,9 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { case let .filter(id): selectedTab = AnyHashable(id) } - + let isEditing = self.isReorderingFilters || (self.mainContainerNode.currentItemNode.currentState.editing && !self.didBeginSelectingChatsWhileEditing) - + tabs = AnyComponent(HorizontalTabsComponent( context: self.context, theme: self.presentationData.theme, @@ -1556,7 +1556,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { ) } } - + return HorizontalTabsComponent.Tab( id: id, content: .title(title), @@ -1565,18 +1565,18 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { guard let self, let tabContainerData = self.controller?.tabContainerData else { return } - + let isPremium = self.context.isPremium - + let mappedId: ChatListFilterTabEntryId = entry.id - + var isDisabled = false if let filtersLimit = tabContainerData.2 { if let folderIndex = folderFilterIndex(mappedId, tabContainerData.0) { isDisabled = !isPremium && folderIndex >= filtersLimit } } - + if isDisabled { let filtersCount = tabContainerData.0.count(where: { item in if case .all = item { @@ -1604,9 +1604,9 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { guard let self, let tabContainerData = self.controller?.tabContainerData else { return } - + let isPremium = self.context.isPremium - + let mappedId: Int32? switch entry { case .all: @@ -1614,14 +1614,14 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { case let .filter(idValue, _, _): mappedId = idValue } - + var isDisabled = false if let filtersLimit = tabContainerData.2 { if let folderIndex = folderFilterIndex(entry.id, tabContainerData.0) { isDisabled = !isPremium && folderIndex >= filtersLimit } } - + self.controller?.tabContextGesture(id: mappedId, sourceNode: nil, sourceView: sourceView, gesture: gesture, keepInPlace: false, isDisabled: isDisabled) }, deleteAction: (!isEditing || isMainTab) ? nil : { [weak self] in @@ -1639,14 +1639,14 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { liftWhileSwitching: layout.deviceMetrics.type != .tablet )) } - + navigationHeaderPanels = AnyComponent(HeaderPanelContainerComponent( theme: self.presentationData.theme, tabs: tabs, panels: panels )) } - + var effectiveStorySubscriptions: EngineStorySubscriptions? if let controller = self.controller, case .forum = controller.location { effectiveStorySubscriptions = nil @@ -1657,7 +1657,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { effectiveStorySubscriptions = EngineStorySubscriptions(accountItem: nil, items: [], hasMoreToken: nil) } } - + let navigationBarSize = self.navigationBarView.update( transition: transition, component: AnyComponent(ChatListNavigationBar( @@ -1683,14 +1683,14 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { guard let self, let controller = self.controller else { return } - + var isForum = false if case .forum = controller.location { isForum = true } - + let filter: ChatListSearchFilter = isForum ? .topics : .chats - + controller.activateSearch( filter: filter, query: nil, @@ -1718,18 +1718,18 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { if deferScrollApplication { navigationBarComponentView.deferScrollApplication = true } - + if navigationBarComponentView.superview == nil { self.view.addSubview(navigationBarComponentView) } transition.setFrame(view: navigationBarComponentView, frame: CGRect(origin: CGPoint(), size: navigationBarSize)) - + return (navigationBarSize.height, 0.0) } else { return (0.0, 0.0) } } - + private func updateNavigationScrolling(navigationHeight: CGFloat, transition: ContainedViewLayoutTransition) { var mainOffset: CGFloat if let contentOffset = self.mainContainerNode.contentOffset, case let .known(value) = contentOffset { @@ -1737,14 +1737,14 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } else { mainOffset = navigationHeight } - + self.mainContainerNode.updateScrollingOffset(navigationHeight: navigationHeight, offset: mainOffset, transition: transition) - + mainOffset = min(mainOffset, ChatListNavigationBar.searchScrollHeight) if abs(mainOffset) < 0.1 { mainOffset = 0.0 } - + let resultingOffset: CGFloat if let inlineStackContainerNode = self.inlineStackContainerNode { var inlineOffset: CGFloat @@ -1757,17 +1757,17 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { if abs(inlineOffset) < 0.1 { inlineOffset = 0.0 } - + resultingOffset = mainOffset * (1.0 - self.inlineStackContainerTransitionFraction) + inlineOffset * self.inlineStackContainerTransitionFraction } else { resultingOffset = mainOffset } - + var offset = resultingOffset if self.isSearchDisplayControllerActive != nil { offset = 0.0 } - + var allowAvatarsExpansion: Bool = true if !self.mainContainerNode.currentItemNode.startedScrollingAtUpperBound && !self.tempAllowAvatarExpansion { if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { @@ -1776,14 +1776,14 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } } } - + if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { navigationBarComponentView.applyScroll(offset: offset, allowAvatarsExpansion: allowAvatarsExpansion, forceUpdate: false, transition: ComponentTransition(transition).withUserData(ChatListNavigationBar.AnimationHint( disableStoriesAnimations: self.tempDisableStoriesAnimations, crossfadeStoryPeers: false ))) } - + let mainDelta: CGFloat if let _ = self.inlineStackContainerNode { mainDelta = resultingOffset - max(0.0, mainOffset) @@ -1792,22 +1792,22 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } transition.updateSublayerTransformOffset(layer: self.mainContainerNode.layer, offset: CGPoint(x: 0.0, y: -mainDelta)) } - + func requestNavigationBarLayout(transition: ComponentTransition) { guard let (layout, _, _, _, _) = self.containerLayout else { return } let _ = self.updateNavigationBar(layout: layout, deferScrollApplication: false, transition: transition) } - + func scrollToStories(animated: Bool) { if self.inlineStackContainerNode != nil { return } - + if let controller = self.controller, let storySubscriptions = controller.orderedStorySubscriptions, shouldDisplayStoriesInChatListHeader(storySubscriptions: storySubscriptions, isHidden: controller.location == .chatList(groupId: .archive)) { let _ = storySubscriptions - + self.tempAllowAvatarExpansion = true self.tempDisableStoriesAnimations = !animated self.tempNavigationScrollingTransition = animated ? .animated(duration: 0.3, curve: .custom(0.33, 0.52, 0.25, 0.99)) : .immediate @@ -1817,28 +1817,28 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { tempNavigationScrollingTransition = nil } } - + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, visualNavigationHeight: CGFloat, cleanNavigationBarHeight: CGFloat, storiesInset: CGFloat, transition: ContainedViewLayoutTransition) { var navigationBarHeight = navigationBarHeight var visualNavigationHeight = visualNavigationHeight var cleanNavigationBarHeight = cleanNavigationBarHeight var storiesInset = storiesInset - + let navigationBarLayout = self.updateNavigationBar(layout: layout, deferScrollApplication: true, transition: ComponentTransition(transition)) self.mainContainerNode.initialScrollingOffset = ChatListNavigationBar.searchScrollHeight + navigationBarLayout.storiesInset - + navigationBarHeight = navigationBarLayout.navigationHeight visualNavigationHeight = navigationBarLayout.navigationHeight cleanNavigationBarHeight = navigationBarLayout.navigationHeight storiesInset = navigationBarLayout.storiesInset - + self.containerLayout = (layout, navigationBarHeight, visualNavigationHeight, cleanNavigationBarHeight, storiesInset) - + var insets = layout.insets(options: [.input]) insets.top += navigationBarHeight insets.left += layout.safeInsets.left insets.right += layout.safeInsets.right - + if let toolbarData = self.toolbarData { var panelsBottomInset: CGFloat = layout.insets(options: []).bottom if layout.metrics.widthClass == .regular, let inputHeight = layout.inputHeight, inputHeight != 0.0 { @@ -1849,11 +1849,11 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } else { panelsBottomInset = max(panelsBottomInset, 8.0) } - + let sideInset: CGFloat = 20.0 let toolbarHeight = 44.0 let toolbarFrame = CGRect(origin: CGPoint(x: sideInset, y: layout.size.height - panelsBottomInset - toolbarHeight), size: CGSize(width: layout.size.width - sideInset * 2.0, height: toolbarHeight)) - + let toolbar: ComponentView var toolbarTransition = ComponentTransition(transition) if let current = self.toolbar { @@ -1863,7 +1863,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { self.toolbar = toolbar toolbarTransition = .immediate } - + let _ = toolbar.update( transition: toolbarTransition, component: AnyComponent(GlassControlPanelComponent( @@ -1918,7 +1918,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { environment: {}, containerSize: toolbarFrame.size ) - + if let toolbarView = toolbar.view { if toolbarView.superview == nil { self.view.addSubview(toolbarView) @@ -1935,11 +1935,11 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { }) } } - + var childrenLayout = layout childrenLayout.intrinsicInsets = UIEdgeInsets(top: visualNavigationHeight, left: childrenLayout.intrinsicInsets.left, bottom: childrenLayout.intrinsicInsets.bottom, right: childrenLayout.intrinsicInsets.right) self.controller?.presentationContext.containerLayoutUpdated(childrenLayout, transition: transition) - + transition.updateFrame(node: self.mainContainerNode, frame: CGRect(origin: CGPoint(), size: layout.size)) var mainNavigationBarHeight = navigationBarHeight var cleanMainNavigationBarHeight = cleanNavigationBarHeight @@ -1950,7 +1950,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { mainInsets.top = visualNavigationHeight } self.mainContainerNode.update(layout: layout, navigationBarHeight: mainNavigationBarHeight, visualNavigationHeight: visualNavigationHeight, originalNavigationHeight: navigationBarHeight, cleanNavigationBarHeight: cleanMainNavigationBarHeight, insets: mainInsets, isReorderingFilters: self.isReorderingFilters, isEditing: self.isEditing, inlineNavigationLocation: self.inlineStackContainerNode?.location, inlineNavigationTransitionFraction: self.inlineStackContainerTransitionFraction, storiesInset: storiesInset, transition: transition) - + if let inlineStackContainerNode = self.inlineStackContainerNode { var inlineStackContainerNodeTransition = transition var animateIn = false @@ -1959,7 +1959,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { inlineStackContainerNodeTransition = .immediate animateIn = true } - + let inlineSideInset: CGFloat = layout.safeInsets.left + 72.0 var inlineStackFrame = CGRect(origin: CGPoint(x: inlineSideInset, y: 0.0), size: CGSize(width: layout.size.width - inlineSideInset, height: layout.size.height)) inlineStackFrame.origin.x += (1.0 - self.inlineStackContainerTransitionFraction) * inlineStackFrame.width @@ -1969,21 +1969,21 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { inlineLayout.safeInsets.left = 0.0 inlineLayout.intrinsicInsets.left = 0.0 inlineLayout.additionalInsets.left = 0.0 - + var inlineInsets = insets inlineInsets.left = 0.0 - + let inlineNavigationHeight: CGFloat = navigationBarLayout.navigationHeight - navigationBarLayout.storiesInset - + inlineStackContainerNode.update(layout: inlineLayout, navigationBarHeight: inlineNavigationHeight, visualNavigationHeight: inlineNavigationHeight, originalNavigationHeight: inlineNavigationHeight, cleanNavigationBarHeight: inlineNavigationHeight, insets: inlineInsets, isReorderingFilters: self.isReorderingFilters, isEditing: self.isEditing, inlineNavigationLocation: nil, inlineNavigationTransitionFraction: 0.0, storiesInset: storiesInset, transition: inlineStackContainerNodeTransition) - + if animateIn { transition.animatePosition(node: inlineStackContainerNode, from: CGPoint(x: inlineStackContainerNode.position.x + inlineStackContainerNode.bounds.width + UIScreenPixel, y: inlineStackContainerNode.position.y)) } } - + self.tapRecognizer?.isEnabled = self.isReorderingFilters - + if let searchDisplayController = self.searchDisplayController { if !self.skipSearchDisplayControllerLayout { searchDisplayController.containerLayoutUpdated(layout, navigationBarHeight: cleanNavigationBarHeight, transition: transition) @@ -1992,28 +1992,28 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { if let disappearingSearchDisplayController = self.disappearingSearchDisplayController { disappearingSearchDisplayController.containerLayoutUpdated(layout, navigationBarHeight: cleanNavigationBarHeight, transition: transition) } - + self.updateNavigationScrolling(navigationHeight: navigationBarLayout.navigationHeight, transition: transition) - + if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { navigationBarComponentView.deferScrollApplication = false navigationBarComponentView.applyCurrentScroll(transition: ComponentTransition(transition)) } } - + @MainActor func activateSearch(placeholderNode: SearchBarPlaceholderNode?, displaySearchFilters: Bool, hasDownloads: Bool, initialFilter: ChatListSearchFilter, navigationController: NavigationController?, searchBarIsExternal: Bool) async -> ((Bool) -> Void)? { guard let (containerLayout, _, _, cleanNavigationBarHeight, _) = self.containerLayout, self.searchDisplayController == nil else { return nil } - + let effectiveLocation = self.inlineStackContainerNode?.location ?? self.location - + let filter: ChatListNodePeersFilter = [] if case .forum = effectiveLocation { //filter.insert(.excludeRecent) } - + var folder: (Int32, String)? if let folders = self.controller?.tabContainerData?.0 { switch self.effectiveContainerNode.currentItemFilter { @@ -2027,7 +2027,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } } } - + let contentNode = ChatListSearchContainerNode(context: self.context, animationCache: self.animationCache, animationRenderer: self.animationRenderer, filter: filter, requestPeerType: nil, location: effectiveLocation, folder: folder, displaySearchFilters: displaySearchFilters, hasDownloads: hasDownloads, initialFilter: initialFilter, openPeer: { [weak self] peer, _, threadId, dismissSearch in self?.requestOpenPeerFromSearch?(peer, threadId, dismissSearch) }, openDisabledPeer: { _, _, _ in @@ -2057,10 +2057,10 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { contentNode.openAdInfo = { [weak self] node, adPeer in self?.controller?.openAdInfo(node: node, adPeer: adPeer) } - + let searchTips = await ApplicationSpecificNotice.getGlobalPostsSearch(accountManager: self.context.sharedContext.accountManager).get() contentNode.displayGlobalPostsNewBadge = searchTips < 3 - + self.searchDisplayController = SearchDisplayController(presentationData: self.presentationData, mode: .list, contentNode: contentNode, cancel: { [weak self] in if let requestDeactivateSearch = self?.requestDeactivateSearch { requestDeactivateSearch() @@ -2068,20 +2068,20 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { }, fieldStyle: placeholderNode?.fieldStyle ?? .modern, searchBarIsExternal: searchBarIsExternal) self.mainContainerNode.accessibilityElementsHidden = true self.inlineStackContainerNode?.accessibilityElementsHidden = true - + return ({ [weak self] focus in guard let strongSelf = self else { return } - + strongSelf.isSearchDisplayControllerActive = ChatListNavigationBar.ActiveSearch(isExternal: placeholderNode == nil) - + strongSelf.searchDisplayController?.containerLayoutUpdated(containerLayout, navigationBarHeight: cleanNavigationBarHeight, transition: .immediate) strongSelf.searchDisplayController?.activate(insertSubnode: { [weak self] subnode, isSearchBar in guard let self else { return } - + if isSearchBar { if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { navigationBarComponentView.searchContentNode?.addSubnode(subnode) @@ -2090,11 +2090,11 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { self.insertSubnode(subnode, aboveSubnode: self.debugListView) } }, placeholder: placeholderNode, focus: focus) - + strongSelf.controller?.requestLayout(transition: .animated(duration: 0.5, curve: .spring)) }) } - + func deactivateSearch(placeholderNode: SearchBarPlaceholderNode?, animated: Bool) -> (() -> Void)? { if let searchDisplayController = self.searchDisplayController { self.isSearchDisplayControllerActive = nil @@ -2102,7 +2102,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { self.disappearingSearchDisplayController = searchDisplayController self.mainContainerNode.accessibilityElementsHidden = false self.inlineStackContainerNode?.accessibilityElementsHidden = false - + return { [weak self, weak placeholderNode] in guard let self, let (layout, _, _, cleanNavigationBarHeight, _) = self.containerLayout else { return @@ -2116,35 +2116,35 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { self.disappearingSearchDisplayController = nil } }) - + searchDisplayController.containerLayoutUpdated(layout, navigationBarHeight: cleanNavigationBarHeight, transition: .animated(duration: 0.4, curve: .spring)) - + self.controller?.requestLayout(transition: .animated(duration: 0.4, curve: .spring)) } } else { return nil } } - + func clearHighlightAnimated(_ animated: Bool) { self.mainContainerNode.currentItemNode.clearHighlightAnimated(true) self.inlineStackContainerNode?.currentItemNode.clearHighlightAnimated(true) } - + private var contentOffsetSyncLockedIn: Bool = false - + func willScrollToTop() { if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { navigationBarComponentView.applyScroll(offset: 0.0, allowAvatarsExpansion: false, transition: ComponentTransition(animation: .curve(duration: 0.3, curve: .slide))) } } - + private func contentOffsetChanged(offset: ListViewVisibleContentOffset, listView: ListView, isPrimary: Bool) { guard let containerLayout = self.containerLayout else { return } self.updateNavigationScrolling(navigationHeight: containerLayout.navigationBarHeight, transition: self.tempNavigationScrollingTransition ?? .immediate) - + if listView.isDragging { var overscrollSelectedId: EnginePeer.Id? var overscrollHiddenChatItemsAllowed = false @@ -2152,13 +2152,13 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { overscrollSelectedId = storyPeerListView.overscrollSelectedId overscrollHiddenChatItemsAllowed = storyPeerListView.overscrollHiddenChatItemsAllowed } - + if let chatListNode = listView as? ChatListNode { if chatListNode.hasItemsToBeRevealed() { overscrollSelectedId = nil } } - + if let controller = self.controller { if let peerId = overscrollSelectedId { if self.allowOverscrollStoryExpansion && self.inlineStackContainerNode == nil && isPrimary { @@ -2167,21 +2167,21 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } else { self.currentOverscrollStoryExpansionTimestamp = timestamp } - + if let currentOverscrollStoryExpansionTimestamp = self.currentOverscrollStoryExpansionTimestamp, currentOverscrollStoryExpansionTimestamp <= timestamp - 0.0 { self.allowOverscrollStoryExpansion = false self.currentOverscrollStoryExpansionTimestamp = nil self.allowOverscrollItemExpansion = false self.currentOverscrollItemExpansionTimestamp = nil HapticFeedback().tap() - + controller.openStories(peerId: peerId) } } } else { if !overscrollHiddenChatItemsAllowed { var manuallyAllow = false - + if isPrimary { if let storySubscriptions = controller.orderedStorySubscriptions, shouldDisplayStoriesInChatListHeader(storySubscriptions: storySubscriptions, isHidden: controller.location == .chatList(groupId: .archive)) { } else { @@ -2190,12 +2190,12 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } else { manuallyAllow = true } - + if manuallyAllow, case let .known(value) = offset, value + listView.tempTopInset <= -40.0 { overscrollHiddenChatItemsAllowed = true } } - + if overscrollHiddenChatItemsAllowed { if self.allowOverscrollItemExpansion { let timestamp = CACurrentMediaTime() @@ -2203,10 +2203,10 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } else { self.currentOverscrollItemExpansionTimestamp = timestamp } - + if let currentOverscrollItemExpansionTimestamp = self.currentOverscrollItemExpansionTimestamp, currentOverscrollItemExpansionTimestamp <= timestamp - 0.0 { self.allowOverscrollItemExpansion = false - + if isPrimary { self.mainContainerNode.currentItemNode.revealScrollHiddenItem() } else { @@ -2219,21 +2219,21 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } } } - + private func shouldStopScrolling(listView: ListView, velocity: CGFloat, isPrimary: Bool) -> Bool { if abs(velocity) > 0.8 { return false } - + if !isPrimary || self.inlineStackContainerNode == nil { } else { return false } - + guard let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View else { return false } - + if let clippedScrollOffset = navigationBarComponentView.clippedScrollOffset { let searchScrollOffset = clippedScrollOffset if searchScrollOffset > 0.0 && searchScrollOffset < ChatListNavigationBar.searchScrollHeight { @@ -2242,10 +2242,10 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { return true } } - + return false } - + private func didBeginInteractiveDragging(listView: ListView, isPrimary: Bool) { if isPrimary { if let chatListNode = listView as? ChatListNode, !chatListNode.hasItemsToBeRevealed() { @@ -2256,7 +2256,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } self.allowOverscrollItemExpansion = true } - + private func endedInteractiveDragging(listView: ListView, isPrimary: Bool) { if isPrimary { self.allowOverscrollStoryExpansion = false @@ -2265,17 +2265,17 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { self.allowOverscrollItemExpansion = false self.currentOverscrollItemExpansionTimestamp = nil } - + private func contentScrollingEnded(listView: ListView, isPrimary: Bool) -> Bool { if !isPrimary || self.inlineStackContainerNode == nil { } else { return false } - + guard let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View else { return false } - + if let clippedScrollOffset = navigationBarComponentView.clippedScrollOffset { let searchScrollOffset = clippedScrollOffset if searchScrollOffset > 0.0 && searchScrollOffset < ChatListNavigationBar.searchScrollHeight { @@ -2294,10 +2294,10 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { return true } } - + return false } - + private func pinnedHeaderDisplayFractionUpdated(transition: ContainedViewLayoutTransition) { var pinnedFraction: CGFloat = 0.0 if let inlineStackContainerNode = self.inlineStackContainerNode, self.inlineStackContainerTransitionFraction != 0.0 { @@ -2305,22 +2305,22 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } else { pinnedFraction = self.mainContainerNode.pinnedHeaderDisplayFraction } - + if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { navigationBarComponentView.updateEdgeEffectForPinnedFraction(pinnedFraction: pinnedFraction, transition: ComponentTransition(transition)) } } - + func makeInlineChatList(location: ChatListControllerLocation) -> ChatListContainerNode { var forumPeerId: EnginePeer.Id? if case let .forum(peerId) = location { forumPeerId = peerId } - + let inlineStackContainerNode = ChatListContainerNode(context: self.context, controller: self.controller, location: location, previewing: false, controlsHistoryPreload: false, isInlineMode: true, presentationData: self.presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, filterBecameEmpty: { _ in }, filterEmptyAction: { [weak self] _ in self?.emptyListAction?(forumPeerId) }, secondaryEmptyAction: {}, openArchiveSettings: {}) return inlineStackContainerNode } - + func setInlineChatList(inlineStackContainerNode: ChatListContainerNode?) { if let inlineStackContainerNode = inlineStackContainerNode { if self.inlineStackContainerNode !== inlineStackContainerNode { @@ -2329,9 +2329,9 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { let _ = self.mainContainerNode.currentItemNode.scrollToOffsetFromTop(self.mainContainerNode.currentItemNode.tempTopInset, animated: true) } } - + inlineStackContainerNode.leftSeparatorLayer.isHidden = false - + inlineStackContainerNode.presentAlert = self.mainContainerNode.presentAlert inlineStackContainerNode.present = self.mainContainerNode.present inlineStackContainerNode.push = self.mainContainerNode.push @@ -2343,7 +2343,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { inlineStackContainerNode.peerSelected = self.mainContainerNode.peerSelected inlineStackContainerNode.groupSelected = self.mainContainerNode.groupSelected inlineStackContainerNode.updatePeerGrouping = self.mainContainerNode.updatePeerGrouping - + inlineStackContainerNode.contentOffsetChanged = { [weak self] offset, listView in self?.contentOffsetChanged(offset: offset, listView: listView, isPrimary: false) } @@ -2362,39 +2362,39 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { inlineStackContainerNode.pinnedHeaderDisplayFractionUpdated = { [weak self] transition in self?.pinnedHeaderDisplayFractionUpdated(transition: transition) } - + inlineStackContainerNode.activateChatPreview = self.mainContainerNode.activateChatPreview inlineStackContainerNode.openStories = self.mainContainerNode.openStories inlineStackContainerNode.addedVisibleChatsWithPeerIds = self.mainContainerNode.addedVisibleChatsWithPeerIds inlineStackContainerNode.didBeginSelectingChats = self.mainContainerNode.didBeginSelectingChats inlineStackContainerNode.displayFilterLimit = nil - + let previousInlineStackContainerNode = self.inlineStackContainerNode - + if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View, let clippedScrollOffset = navigationBarComponentView.clippedScrollOffset { let scrollOffset = max(0.0, clippedScrollOffset) inlineStackContainerNode.initialScrollingOffset = scrollOffset } - + self.inlineStackContainerNode = inlineStackContainerNode self.inlineStackContainerTransitionFraction = 1.0 - + if let _ = self.containerLayout { let transition: ContainedViewLayoutTransition = .animated(duration: 0.5, curve: .spring) - + if let contentOffset = self.mainContainerNode.contentOffset, case let .known(offset) = contentOffset, offset < 0.0 { if let containerLayout = self.containerLayout { self.updateNavigationScrolling(navigationHeight: containerLayout.navigationBarHeight, transition: transition) self.mainContainerNode.scrollToTop(animated: true, adjustForTempInset: false) } } - + if let previousInlineStackContainerNode { transition.updatePosition(node: previousInlineStackContainerNode, position: CGPoint(x: previousInlineStackContainerNode.position.x + previousInlineStackContainerNode.bounds.width + UIScreenPixel, y: previousInlineStackContainerNode.position.y), completion: { [weak previousInlineStackContainerNode] _ in previousInlineStackContainerNode?.removeFromSupernode() }) } - + self.controller?.requestLayout(transition: transition) } else { previousInlineStackContainerNode?.removeFromSupernode() @@ -2404,14 +2404,14 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { if let inlineStackContainerNode = self.inlineStackContainerNode { self.inlineStackContainerNode = nil self.inlineStackContainerTransitionFraction = 0.0 - + if let _ = self.containerLayout { let transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring) - + transition.updatePosition(node: inlineStackContainerNode, position: CGPoint(x: inlineStackContainerNode.position.x + inlineStackContainerNode.bounds.width + UIScreenPixel, y: inlineStackContainerNode.position.y), completion: { [weak inlineStackContainerNode] _ in inlineStackContainerNode?.removeFromSupernode() }) - + self.temporaryContentOffsetChangeTransition = transition self.tempNavigationScrollingTransition = transition self.controller?.requestLayout(transition: transition) @@ -2423,11 +2423,11 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } } } - + func playArchiveAnimation() { self.mainContainerNode.playArchiveAnimation() } - + func scrollToTop() { if let searchDisplayController = self.searchDisplayController { searchDisplayController.contentNode.scrollToTop() @@ -2437,7 +2437,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { self.mainContainerNode.scrollToTop(animated: true, adjustForTempInset: false) } } - + func scrollToTopIfStoriesAreExpanded() { if let contentOffset = self.mainContainerNode.contentOffset, case let .known(offset) = contentOffset, offset < 0.0 { self.mainContainerNode.scrollToTop(animated: true, adjustForTempInset: false) @@ -2447,6 +2447,9 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } func shouldDisplayStoriesInChatListHeader(storySubscriptions: EngineStorySubscriptions, isHidden: Bool) -> Bool { + if currentWinterGramSettings.disableStories { + return false + } if !storySubscriptions.items.isEmpty { return true } diff --git a/submodules/ChatListUI/Sources/Node/ChatListItem.swift b/submodules/ChatListUI/Sources/Node/ChatListItem.swift index 67257066fe..46f94ab43a 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItem.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItem.swift @@ -5,6 +5,7 @@ import Display import SwiftSignalKit import TelegramCore import TelegramPresentationData +import TelegramUIPreferences import ItemListUI import PresentationDataUtils import AvatarNode @@ -37,7 +38,7 @@ public enum ChatListItemContent { public let isClosed: Bool public let isHidden: Bool public let threadPeer: EnginePeer? - + public init(id: Int64, info: EngineMessageHistoryThread.Info, isOwnedByMe: Bool, isClosed: Bool, isHidden: Bool, threadPeer: EnginePeer?) { self.id = id self.info = info @@ -46,7 +47,7 @@ public enum ChatListItemContent { self.isHidden = isHidden self.threadPeer = threadPeer } - + public static func ==(lhs: ThreadInfo, rhs: ThreadInfo) -> Bool { if lhs.id != rhs.id { return false @@ -69,7 +70,7 @@ public enum ChatListItemContent { return true } } - + public final class DraftState: Equatable { let text: String let entities: [MessageTextEntity] @@ -89,11 +90,11 @@ public enum ChatListItemContent { return true } } - + public struct StoryState: Equatable { public var stats: EngineChatList.StoryStats public var hasUnseenCloseFriends: Bool - + public init( stats: EngineChatList.StoryStats, hasUnseenCloseFriends: Bool @@ -102,19 +103,19 @@ public enum ChatListItemContent { self.hasUnseenCloseFriends = hasUnseenCloseFriends } } - + public struct Tag: Equatable { public var id: Int32 public var title: ChatFolderTitle public var colorId: Int32 - + public init(id: Int32, title: ChatFolderTitle, colorId: Int32) { self.id = id self.title = title self.colorId = colorId } } - + public struct CustomMessageListData: Equatable { public var commandPrefix: String? public var searchQuery: String? @@ -123,7 +124,7 @@ public enum ChatListItemContent { public var hideDate: Bool public var hidePeerStatus: Bool public var isInTransparentContainer: Bool - + public init(commandPrefix: String?, searchQuery: String?, messageCount: Int?, hideSeparator: Bool, hideDate: Bool, hidePeerStatus: Bool, isInTransparentContainer: Bool = false) { self.commandPrefix = commandPrefix self.searchQuery = searchQuery @@ -134,7 +135,7 @@ public enum ChatListItemContent { self.isInTransparentContainer = isInTransparentContainer } } - + public struct PeerData { public var messages: [EngineMessage] public var peer: EngineRenderedPeer @@ -160,7 +161,7 @@ public enum ChatListItemContent { public var displayAsTopicList: Bool public var tags: [Tag] public var customMessageListData: CustomMessageListData? - + public init( messages: [EngineMessage], peer: EngineRenderedPeer, @@ -213,7 +214,7 @@ public enum ChatListItemContent { self.customMessageListData = customMessageListData } } - + public struct GroupReferenceData { public var groupId: EngineChatList.Group public var peers: [EngineChatList.GroupItem.Item] @@ -222,7 +223,7 @@ public enum ChatListItemContent { public var hiddenByDefault: Bool public var appearsPinned: Bool public var storyState: StoryState? - + public init( groupId: EngineChatList.Group, peers: [EngineChatList.GroupItem.Item], @@ -245,7 +246,7 @@ public enum ChatListItemContent { case loading case peer(PeerData) case groupReference(GroupReferenceData) - + public var chatLocation: ChatLocation? { switch self { case .loading: @@ -267,7 +268,7 @@ private final class ChatListItemTagListComponent: Component { let tags: [ChatListItemContent.Tag] let theme: PresentationTheme let sizeFactor: CGFloat - + init( context: AccountContext, tags: [ChatListItemContent.Tag], @@ -279,7 +280,7 @@ private final class ChatListItemTagListComponent: Component { self.theme = theme self.sizeFactor = sizeFactor } - + static func ==(lhs: ChatListItemTagListComponent, rhs: ChatListItemTagListComponent) -> Bool { if lhs.context !== rhs.context { return false @@ -295,28 +296,28 @@ private final class ChatListItemTagListComponent: Component { } return true } - + private final class ItemView: UIView { let backgroundView: UIImageView let title = ComponentView() - + private var currentTitle: ChatFolderTitle? - + override init(frame: CGRect) { self.backgroundView = UIImageView(image: tagBackgroundImage) - + super.init(frame: frame) - + self.addSubview(self.backgroundView) } - + required init?(coder: NSCoder) { preconditionFailure() } - + func update(context: AccountContext, title: ChatFolderTitle, backgroundColor: UIColor, foregroundColor: UIColor, sizeFactor: CGFloat) -> CGSize { self.currentTitle = title - + let titleValue = ChatFolderTitle(text: title.text.isEmpty ? " " : title.text, entities: title.entities, enableAnimations: title.enableAnimations) let titleSize = self.title.update( transition: .immediate, @@ -332,15 +333,15 @@ private final class ChatListItemTagListComponent: Component { environment: {}, containerSize: CGSize(width: 100.0, height: 100.0) ) - + let backgroundSideInset: CGFloat = floorToScreenPixels(4.0 * sizeFactor) let backgroundVerticalInset: CGFloat = floorToScreenPixels(2.0 * sizeFactor) let backgroundSize = CGSize(width: titleSize.width + backgroundSideInset * 2.0, height: titleSize.height + backgroundVerticalInset * 2.0) - + let backgroundFrame = CGRect(origin: CGPoint(), size: backgroundSize) self.backgroundView.frame = backgroundFrame self.backgroundView.tintColor = backgroundColor - + let titleFrame = titleSize.centered(in: backgroundFrame) if let titleView = self.title.view { if titleView.superview == nil { @@ -348,10 +349,10 @@ private final class ChatListItemTagListComponent: Component { } titleView.frame = titleFrame } - + return backgroundSize } - + func updateVisibility(_ isVisible: Bool) { guard let currentTitle = self.currentTitle else { return @@ -361,10 +362,10 @@ private final class ChatListItemTagListComponent: Component { } } } - + final class View: UIView { private var itemViews: [Int32: ItemView] = [:] - + var isVisible: Bool = false { didSet { if self.isVisible != oldValue { @@ -374,15 +375,15 @@ private final class ChatListItemTagListComponent: Component { } } } - + override init(frame: CGRect) { super.init(frame: frame) } - + required init?(coder: NSCoder) { preconditionFailure() } - + func update(component: ChatListItemTagListComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { var validIds: [Int32] = [] let spacing: CGFloat = floorToScreenPixels(5.0 * component.sizeFactor) @@ -391,12 +392,12 @@ private final class ChatListItemTagListComponent: Component { if nextX != 0.0 { nextX += spacing } - + let itemId: Int32 let itemTitle: ChatFolderTitle let itemBackgroundColor: UIColor let itemForegroundColor: UIColor - + if validIds.count >= 3 { itemId = Int32.max itemTitle = ChatFolderTitle(text: "+\(component.tags.count - validIds.count)", entities: [], enableAnimations: true) @@ -404,15 +405,15 @@ private final class ChatListItemTagListComponent: Component { itemBackgroundColor = itemForegroundColor.withMultipliedAlpha(0.1) } else { itemId = tag.id - + let tagColor = PeerNameColor(rawValue: tag.colorId) let resolvedColor = component.context.peerNameColors.getChatFolderTag(tagColor, dark: component.theme.overallDarkAppearance) - + itemTitle = ChatFolderTitle(text: tag.title.text.uppercased(), entities: tag.title.entities, enableAnimations: tag.title.enableAnimations) itemBackgroundColor = resolvedColor.main.withMultipliedAlpha(0.1) itemForegroundColor = resolvedColor.main } - + let itemView: ItemView if let current = self.itemViews[itemId] { itemView = current @@ -421,15 +422,15 @@ private final class ChatListItemTagListComponent: Component { self.itemViews[itemId] = itemView self.addSubview(itemView) } - + let itemSize = itemView.update(context: component.context, title: itemTitle, backgroundColor: itemBackgroundColor, foregroundColor: itemForegroundColor, sizeFactor: component.sizeFactor) let itemFrame = CGRect(origin: CGPoint(x: nextX, y: 0.0), size: itemSize) itemView.frame = itemFrame itemView.updateVisibility(self.isVisible) - + validIds.append(itemId) nextX += itemSize.width - + if validIds.count >= 4 { break } @@ -444,15 +445,15 @@ private final class ChatListItemTagListComponent: Component { for id in removedIds { self.itemViews.removeValue(forKey: id) } - + return availableSize } } - + func makeView() -> View { return View(frame: CGRect()) } - + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } @@ -462,20 +463,20 @@ public class ChatListItem: ListViewItem { public enum EnabledContextActions { public struct Actions: OptionSet { public var rawValue: Int32 - + public init(rawValue: Int32) { self.rawValue = rawValue } - + public static let toggleUnread = Actions(rawValue: 1 << 0) public static let delete = Actions(rawValue: 1 << 1) public static let togglePinned = Actions(rawValue: 1 << 2) } - + case custom(Actions) case auto } - + let presentationData: ChatListPresentationData let context: AccountContext let chatListLocation: ChatListControllerLocation @@ -488,15 +489,15 @@ public class ChatListItem: ListViewItem { let enabledContextActions: EnabledContextActions? let hiddenOffset: Bool let interaction: ChatListNodeInteraction - + public let selectable: Bool = true - + public var approximateHeight: CGFloat { return self.hiddenOffset ? 0.0 : 44.0 } - + let header: ListViewItemHeader? - + public var isPinned: Bool { switch self.index { case let .chatList(index): @@ -509,7 +510,7 @@ public class ChatListItem: ListViewItem { } } } - + public init(presentationData: ChatListPresentationData, context: AccountContext, chatListLocation: ChatListControllerLocation, filterData: ChatListItemFilterData?, index: EngineChatList.Item.Index, content: ChatListItemContent, editing: Bool, hasActiveRevealControls: Bool, selected: Bool, header: ListViewItemHeader?, enabledContextActions: EnabledContextActions?, hiddenOffset: Bool, interaction: ChatListNodeInteraction) { self.presentationData = presentationData self.chatListLocation = chatListLocation @@ -525,18 +526,18 @@ public class ChatListItem: ListViewItem { self.hiddenOffset = hiddenOffset self.interaction = interaction } - + public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { async { let node = ChatListItemNode() let (first, last, firstWithHeader, nextIsPinned, nextHasActiveRevealControls) = ChatListItem.mergeType(item: self, previousItem: previousItem, nextItem: nextItem) node.insets = ChatListItemNode.insets(first: first, last: last, firstWithHeader: firstWithHeader) - + let (nodeLayout, apply) = node.asyncLayout()(self, params, first, last, firstWithHeader, nextIsPinned, nextHasActiveRevealControls) - + node.insets = nodeLayout.insets node.contentSize = nodeLayout.contentSize - + Queue.mainQueue().async { completion(node, { return (nil, { _ in @@ -548,7 +549,7 @@ public class ChatListItem: ListViewItem { } } } - + public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { Queue.mainQueue().async { assert(node() is ChatListItemNode) @@ -561,7 +562,7 @@ public class ChatListItem: ListViewItem { if case .None = animation { animated = false } - + let (nodeLayout, apply) = layout(self, params, first, last, firstWithHeader, nextIsPinned, nextHasActiveRevealControls) Queue.mainQueue().async { completion(nodeLayout, { _ in @@ -572,7 +573,7 @@ public class ChatListItem: ListViewItem { } } } - + public func selected(listView: ListView) { switch self.content { case .loading: @@ -599,7 +600,7 @@ public class ChatListItem: ListViewItem { self.interaction.groupSelected(groupReferenceData.groupId) } } - + static func mergeType(item: ChatListItem, previousItem: ListViewItem?, nextItem: ListViewItem?) -> (first: Bool, last: Bool, firstWithHeader: Bool, nextIsPinned: Bool, nextHasActiveRevealControls: Bool) { var first = false var last = false @@ -677,7 +678,7 @@ private func canArchivePeer(id: EnginePeer.Id, accountPeerId: EnginePeer.Id) -> public struct ChatListItemFilterData: Equatable { public var excludesArchived: Bool - + public init(excludesArchived: Bool) { self.excludesArchived = excludesArchived } @@ -823,7 +824,7 @@ private func leftRevealOptions(strings: PresentationStrings, theme: Presentation if case let .channel(channel) = peer, channel.isForumOrMonoForum { canMarkUnread = false } - + if canMarkUnread { options.append(ItemListRevealOption(key: RevealOptionKey.toggleMarkedUnread.rawValue, title: strings.DialogList_Unread, icon: unreadIcon, color: theme.list.itemDisclosureActions.accent.fillColor, iconColor: theme.list.itemDisclosureActions.accent.foregroundColor, textColor: theme.chatList.dateTextColor)) } @@ -850,10 +851,10 @@ private func leftRevealOptions(strings: PresentationStrings, theme: Presentation private final class ChatListItemAccessibilityCustomAction: UIAccessibilityCustomAction { let key: Int32 - + init(name: String, target: Any?, selector: Selector, key: Int32) { self.key = key - + super.init(name: name, target: target, selector: selector) } } @@ -864,13 +865,13 @@ private final class CachedChatListSearchResult { let text: String let searchQuery: String let resultRanges: [Range] - + init(text: String, searchQuery: String, resultRanges: [Range]) { self.text = text self.searchQuery = searchQuery self.resultRanges = resultRanges } - + func matches(text: String, searchQuery: String) -> Bool { if self.text != text { return false @@ -885,12 +886,12 @@ private final class CachedChatListSearchResult { private final class CachedCustomTextEntities { let text: String let textEntities: [MessageTextEntity] - + init(text: String, textEntities: [MessageTextEntity]) { self.text = text self.textEntities = textEntities } - + func matches(text: String) -> Bool { if self.text != text { return false @@ -905,39 +906,39 @@ private final class ChatListMediaPreviewNode: ASDisplayNode { private let context: AccountContext let message: EngineMessage let media: EngineMedia - + private let imageNode: TransformImageNode private let playIcon: ASImageNode - + private var requestedImage: Bool = false private var disposable: Disposable? - + init(context: AccountContext, message: EngineMessage, media: EngineMedia) { self.context = context self.message = message self.media = media - + self.imageNode = TransformImageNode() self.playIcon = ASImageNode() self.playIcon.image = playIconImage - + super.init() - + self.addSubnode(self.imageNode) self.addSubnode(self.playIcon) } - + deinit { self.disposable?.dispose() } - + func updateLayout(size: CGSize, synchronousLoads: Bool) { if let image = self.playIcon.image { self.playIcon.frame = CGRect(origin: CGPoint(x: floor((size.width - image.size.width) / 2.0), y: floor((size.height - image.size.height) / 2.0)), size: image.size) } - + let hasSpoiler = self.message.attributes.contains(where: { $0 is MediaSpoilerMessageAttribute }) - + var isRound = false var dimensions = CGSize(width: 100.0, height: 100.0) if case let .image(image) = self.media { @@ -982,7 +983,7 @@ private final class ChatListMediaPreviewNode: ASDisplayNode { } } } - + let radius: CGFloat if isRound { radius = size.width / 2.0 @@ -991,7 +992,7 @@ private final class ChatListMediaPreviewNode: ASDisplayNode { } else { radius = 2.0 } - + let makeLayout = self.imageNode.asyncLayout() self.imageNode.frame = CGRect(origin: CGPoint(), size: size) let apply = makeLayout(TransformImageArguments(corners: ImageCorners(radius: radius), imageSize: dimensions.aspectFilled(size), boundingSize: size, intrinsicInsets: UIEdgeInsets())) @@ -1008,7 +1009,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { let titleTopicIconView: ComponentHostView? var titleTopicAvatarNode: AvatarNode? var titleTopicIconComponent: EmojiStatusComponent? - + var visibilityStatus: Bool = false { didSet { if self.visibilityStatus != oldValue { @@ -1023,15 +1024,15 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + private init(topicTitleNode: TextNode, titleTopicIconView: ComponentHostView?, titleTopicAvatarNode: AvatarNode?, titleTopicIconComponent: EmojiStatusComponent?) { self.topicTitleNode = topicTitleNode self.titleTopicIconView = titleTopicIconView self.titleTopicAvatarNode = titleTopicAvatarNode self.titleTopicIconComponent = titleTopicIconComponent - + super.init() - + self.addSubnode(self.topicTitleNode) if let titleTopicAvatarNode = self.titleTopicAvatarNode { self.view.addSubview(titleTopicAvatarNode.view) @@ -1040,20 +1041,20 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { self.view.addSubview(titleTopicIconView) } } - + static func asyncLayout(_ currentNode: TopicItemNode?) -> (_ constrainedWidth: CGFloat, _ context: AccountContext, _ theme: PresentationTheme, _ threadId: Int64, _ threadPeer: EnginePeer?, _ title: NSAttributedString, _ iconId: Int64?, _ iconColor: Int32?) -> (CGSize, () -> TopicItemNode) { let makeTopicTitleLayout = TextNode.asyncLayout(currentNode?.topicTitleNode) - + return { constrainedWidth, context, theme, threadId, threadPeer, title, iconId, iconColor in let remainingWidth = max(1.0, constrainedWidth - (((iconId == nil && iconColor == nil && threadPeer == nil) ? 1.0 : 18.0) + 2.0)) - + let topicTitleArguments = TextNodeLayoutArguments(attributedString: title, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: remainingWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 1.0, bottom: 2.0, right: 1.0)) - + let topicTitleLayout = makeTopicTitleLayout(topicTitleArguments) - + return (CGSize(width: ((iconId == nil && iconColor == nil && threadPeer == nil) ? 1.0 : 18.0) + 2.0 + topicTitleLayout.0.size.width, height: topicTitleLayout.0.size.height), { let topicTitleNode = topicTitleLayout.1() - + let titleTopicIconContent: EmojiStatusComponent.Content? if threadId == 1 { titleTopicIconContent = .image(image: PresentationResourcesChatList.generalTopicSmallIcon(theme), tintColor: nil) @@ -1064,10 +1065,10 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { titleTopicIconContent = nil } - + var titleTopicIconComponent: EmojiStatusComponent? var titleTopicIconView: ComponentHostView? - + if let titleTopicIconContent { titleTopicIconComponent = EmojiStatusComponent( context: context, @@ -1077,14 +1078,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { isVisibleForAnimations: (currentNode?.visibilityStatus ?? false) && context.sharedContext.energyUsageSettings.loopEmoji, action: nil ) - + if let current = currentNode?.titleTopicIconView { titleTopicIconView = current } else { titleTopicIconView = ComponentHostView() } } - + var titleTopicAvatarNode: AvatarNode? if let _ = threadPeer { if let current = currentNode?.titleTopicAvatarNode { @@ -1093,11 +1094,11 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { titleTopicAvatarNode = AvatarNode(font: avatarPlaceholderFont(size: 8.0)) } } - + let targetNode = currentNode ?? TopicItemNode(topicTitleNode: topicTitleNode, titleTopicIconView: titleTopicIconView, titleTopicAvatarNode: titleTopicAvatarNode, titleTopicIconComponent: titleTopicIconComponent) - + targetNode.titleTopicIconComponent = titleTopicIconComponent - + if let titleTopicIconView, let titleTopicIconComponent { let iconSize = titleTopicIconView.update( transition: .immediate, @@ -1106,11 +1107,11 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { containerSize: CGSize(width: 18.0, height: 18.0) ) titleTopicIconView.frame = CGRect(origin: CGPoint(x: 0.0, y: 2.0), size: iconSize) - + topicTitleNode.frame = CGRect(origin: CGPoint(x: 18.0 + 2.0, y: 0.0), size: topicTitleLayout.0.size) } else if let titleTopicAvatarNode, let threadPeer { let iconSize = CGSize(width: 18.0, height: 18.0) - + titleTopicAvatarNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 2.0), size: iconSize) titleTopicAvatarNode.updateSize(size: iconSize) if threadPeer.smallProfileImage != nil { @@ -1118,24 +1119,24 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { titleTopicAvatarNode.setPeer(context: context, theme: theme, peer: threadPeer, overrideImage: nil, emptyColor: theme.list.mediaPlaceholderColor, clipStyle: .round, synchronousLoad: false, displayDimensions: iconSize) } - + topicTitleNode.frame = CGRect(origin: CGPoint(x: 18.0 + 2.0, y: 0.0), size: topicTitleLayout.0.size) } else { topicTitleNode.frame = CGRect(origin: CGPoint(x: 1.0, y: 0.0), size: topicTitleLayout.0.size) } - + return targetNode }) } } } - + public final class AuthorNode: ASDisplayNode { public let authorNode: TextNode var titleTopicArrowNode: ASImageNode? var topicNodes: [Int64: TopicItemNode] = [:] var topicNodeOrder: [Int64] = [] - + public var visibilityStatus: Bool = false { didSet { if self.visibilityStatus != oldValue { @@ -1145,16 +1146,16 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + override public init() { self.authorNode = TextNode() self.authorNode.displaysAsynchronously = true - + super.init() - + self.addSubnode(self.authorNode) } - + func setFirstTopicHighlighted(_ isHighlighted: Bool) { guard let id = self.topicNodeOrder.first, let itemNode = self.topicNodes[id] else { return @@ -1167,7 +1168,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { itemNode.layer.animateAlpha(from: 0.65, to: 1.0, duration: 0.2) } } - + func assignParentNode(parentNode: ASDisplayNode?) { for (id, topicNode) in self.topicNodes { if id == self.topicNodeOrder.first, let parentNode { @@ -1181,24 +1182,24 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + func asyncLayout() -> (_ context: AccountContext, _ constrainedWidth: CGFloat, _ theme: PresentationTheme, _ authorTitle: NSAttributedString?, _ topics: [(id: Int64, threadPeer: EnginePeer?, title: NSAttributedString, iconId: Int64?, iconColor: Int32?)]) -> (CGSize, () -> CGRect?) { let makeAuthorLayout = TextNode.asyncLayout(self.authorNode) var makeExistingTopicLayouts: [Int64: (_ constrainedWidth: CGFloat, _ context: AccountContext, _ theme: PresentationTheme, _ threadId: Int64, _ threadPeer: EnginePeer?, _ title: NSAttributedString, _ iconId: Int64?, _ iconColor: Int32?) -> (CGSize, () -> TopicItemNode)] = [:] for (topicId, topicNode) in self.topicNodes { makeExistingTopicLayouts[topicId] = TopicItemNode.asyncLayout(topicNode) } - + return { [weak self] context, constrainedWidth, theme, authorTitle, topics in var maxTitleWidth = constrainedWidth if !topics.isEmpty { maxTitleWidth = floor(constrainedWidth * 0.7) } - + let authorTitleLayout = makeAuthorLayout(TextNodeLayoutArguments(attributedString: authorTitle, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: maxTitleWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 1.0, bottom: 2.0, right: 1.0))) - + var remainingWidth = constrainedWidth - authorTitleLayout.0.size.width - + var arrowIconImage: UIImage? if !topics.isEmpty { if authorTitle != nil { @@ -1208,20 +1209,20 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + var topicsSizeAndApply: [(Int64, CGSize, () -> TopicItemNode)] = [] for topic in topics { if remainingWidth <= ((topic.iconId == nil && topic.iconColor == nil) ? 8.0 : 22.0) + 2.0 + 10.0 { break } - + let makeTopicLayout = makeExistingTopicLayouts[topic.id] ?? TopicItemNode.asyncLayout(nil) let (topicSize, topicApply) = makeTopicLayout(remainingWidth, context, theme, topic.id, topic.threadPeer, topic.title, topic.iconId, topic.iconColor) topicsSizeAndApply.append((topic.id, topicSize, topicApply)) - + remainingWidth -= topicSize.width + 4.0 } - + var size = authorTitleLayout.0.size if !topicsSizeAndApply.isEmpty { for item in topicsSizeAndApply { @@ -1229,21 +1230,21 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { size.width += 10.0 + item.1.width } } - + return (size, { guard let self else { return nil } - + let _ = authorTitleLayout.1() let authorFrame = CGRect(origin: CGPoint(), size: authorTitleLayout.0.size) self.authorNode.frame = authorFrame - + var nextX = authorFrame.maxX - 1.0 if authorTitle == nil { nextX = 0.0 } - + if let arrowIconImage = arrowIconImage { let titleTopicArrowNode: ASImageNode if let current = self.titleTopicArrowNode { @@ -1263,7 +1264,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { titleTopicArrowNode.removeFromSupernode() } } - + var topTopicRect: CGRect? var topicNodeOrder: [Int64] = [] for item in topicsSizeAndApply { @@ -1291,33 +1292,33 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { self.topicNodes.removeValue(forKey: id) } self.topicNodeOrder = topicNodeOrder - + return topTopicRect }) } } } - + private struct ContentImageSpec { var message: EngineMessage var media: EngineMedia var size: CGSize - + init(message: EngineMessage, media: EngineMedia, size: CGSize) { self.message = message self.media = media self.size = size } } - + public private(set) var item: ChatListItem? - + private let backgroundNode: ASDisplayNode private let highlightedBackgroundNode: ASDisplayNode - + let contextContainer: ContextControllerSourceNode let mainContentContainerNode: ASDisplayNode - + public let avatarContainerNode: ASDisplayNode public let avatarNode: AvatarNode var avatarIconView: ComponentHostView? @@ -1325,9 +1326,9 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { var avatarVideoNode: AvatarVideoNode? var avatarTapRecognizer: UITapGestureRecognizer? private var avatarMediaNode: ChatListMediaPreviewNode? - + private var inlineNavigationMarkLayer: SimpleLayer? - + public let titleNode: TextNode private var titleBadge: (backgroundView: UIImageView, textNode: TextNode)? public let authorNode: AuthorNode @@ -1363,42 +1364,44 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { var credibilityIconComponent: EmojiStatusComponent? var statusIconView: ComponentHostView? var statusIconComponent: EmojiStatusComponent? + var winterGramIconView: ComponentHostView? + var winterGramIconComponent: EmojiStatusComponent? let mutedIconNode: ASImageNode var itemTagList: ComponentView? var actionButtonTitleNode: TextNode? var actionButtonBackgroundView: UIImageView? var actionButtonNode: HighlightableButtonNode? - + private var placeholderNode: ShimmerEffectNode? private var absoluteLocation: (CGRect, CGSize)? - + private var hierarchyTrackingLayer: HierarchyTrackingLayer? private var cachedDataDisposable = MetaDisposable() - + private var currentTextLeftCutout: CGFloat = 0.0 private var currentMediaPreviewSpecs: [ContentImageSpec] = [] private var mediaPreviewNodes: [EngineMedia.Id: ChatListMediaPreviewNode] = [:] - + var selectableControlNode: ItemListSelectableControlNode? var reorderControlNode: ItemListEditableReorderControlNode? - + private var peerPresenceManager: PeerPresenceStatusManager? - + private var cachedChatListText: (String, String)? private var cachedChatListSearchResult: CachedChatListSearchResult? private var cachedChatListQuoteSearchResult: CachedChatListSearchResult? private var cachedCustomTextEntities: CachedCustomTextEntities? - + var layoutParams: (ChatListItem, first: Bool, last: Bool, firstWithHeader: Bool, nextIsPinned: Bool, nextHasActiveRevealControls: Bool, ListViewItemLayoutParams, countersSize: CGFloat)? - + private var isHighlighted: Bool = false private var nextHasActiveRevealControls: Bool = false private var skipFadeout: Bool = false private var customAnimationInProgress: Bool = false - + private var onlineIsVoiceChat: Bool = false private var currentOnline: Bool? - + override public var canBeSelected: Bool { if self.selectableControlNode != nil || self.item?.editing == true { return false @@ -1406,7 +1409,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { return super.canBeSelected } } - + override public var defaultAccessibilityLabel: String? { get { return self.accessibilityLabel @@ -1425,7 +1428,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } set(value) { } } - + override public var accessibilityLabel: String? { get { guard let item = self.item else { @@ -1459,7 +1462,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } set(value) { } } - + override public var accessibilityValue: String? { get { guard let item = self.item else { @@ -1529,7 +1532,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } set(value) { } } - + override public var visibility: ListViewItemNodeVisibility { didSet { let wasVisible = self.visibilityStatus @@ -1545,7 +1548,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + private var visibilityStatus: Bool = false { didSet { if self.visibilityStatus != oldValue { @@ -1553,9 +1556,9 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { self.avatarVideoNode?.resetPlayback() } self.updateVideoVisibility() - + self.textNode.visibilityRect = self.visibilityStatus ? CGRect.infinite : nil - + if let verifiedIconView = self.verifiedIconView, let verifiedIconComponent = self.verifiedIconComponent { let _ = verifiedIconView.update( transition: .immediate, @@ -1581,14 +1584,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { ) } self.authorNode.visibilityStatus = self.visibilityStatus - + if let itemTagListView = self.itemTagList?.view as? ChatListItemTagListComponent.View { itemTagListView.isVisible = self.visibilityStatus } } } } - + private var trackingIsInHierarchy: Bool = false { didSet { if self.trackingIsInHierarchy != oldValue { @@ -1601,82 +1604,82 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + required init() { self.backgroundNode = ASDisplayNode() self.backgroundNode.isLayerBacked = true self.backgroundNode.displaysAsynchronously = false - + self.avatarContainerNode = ASDisplayNode() self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 26.0)) - + self.highlightedBackgroundNode = ASDisplayNode() self.highlightedBackgroundNode.isLayerBacked = true - + self.contextContainer = ContextControllerSourceNode() - + self.mainContentContainerNode = ASDisplayNode() self.mainContentContainerNode.clipsToBounds = true - + self.measureNode = TextNode() - + self.titleNode = TextNode() self.titleNode.isUserInteractionEnabled = false self.titleNode.displaysAsynchronously = true - + self.authorNode = AuthorNode() self.authorNode.isUserInteractionEnabled = false - + self.textNode = TextNodeWithEntities() self.textNode.textNode.isUserInteractionEnabled = false self.textNode.textNode.displaysAsynchronously = true self.textNode.textNode.anchorPoint = CGPoint() - + self.inputActivitiesNode = ChatListInputActivitiesNode() self.inputActivitiesNode.isUserInteractionEnabled = false self.inputActivitiesNode.alpha = 0.0 - + self.dateNode = TextNode() self.dateNode.isUserInteractionEnabled = false self.dateNode.displaysAsynchronously = true - + self.statusNode = ChatListStatusNode() self.badgeNode = ChatListBadgeNode() self.mentionBadgeNode = ChatListBadgeNode() self.onlineNode = PeerOnlineMarkerNode() - + self.forwardedIconNode = ASImageNode() self.forwardedIconNode.isLayerBacked = true self.forwardedIconNode.displaysAsynchronously = false self.forwardedIconNode.displayWithoutProcessing = true - + self.pinnedIconNode = ASImageNode() self.pinnedIconNode.isLayerBacked = true self.pinnedIconNode.displaysAsynchronously = false self.pinnedIconNode.displayWithoutProcessing = true - + self.mutedIconNode = ASImageNode() self.mutedIconNode.isLayerBacked = true self.mutedIconNode.displaysAsynchronously = false self.mutedIconNode.displayWithoutProcessing = true - + self.separatorNode = ASDisplayNode() self.separatorNode.isLayerBacked = true - + super.init(layerBacked: false, rotated: false, seeThrough: false) - + self.isAccessibilityElement = true - + self.addSubnode(self.backgroundNode) self.addSubnode(self.separatorNode) - + self.addSubnode(self.contextContainer) self.contextContainer.addSubnode(self.mainContentContainerNode) - + self.avatarContainerNode.addSubnode(self.avatarNode) self.contextContainer.addSubnode(self.avatarContainerNode) self.avatarNode.addSubnode(self.onlineNode) - + self.mainContentContainerNode.addSubnode(self.titleNode) self.mainContentContainerNode.addSubnode(self.authorNode) self.mainContentContainerNode.addSubnode(self.textNode.textNode) @@ -1686,19 +1689,19 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { self.mainContentContainerNode.addSubnode(self.badgeNode) self.mainContentContainerNode.addSubnode(self.mentionBadgeNode) self.mainContentContainerNode.addSubnode(self.mutedIconNode) - + self.peerPresenceManager = PeerPresenceStatusManager(update: { [weak self] in if let strongSelf = self, let layoutParams = strongSelf.layoutParams { let (_, apply) = strongSelf.asyncLayout()(layoutParams.0, layoutParams.6, layoutParams.1, layoutParams.2, layoutParams.3, layoutParams.4, layoutParams.5) let _ = apply(false, false) } }) - + self.contextContainer.shouldBegin = { [weak self] location in guard let strongSelf = self, let item = strongSelf.item else { return false } - + strongSelf.contextContainer.additionalActivationProgressLayer = nil if let inlineNavigationLocation = item.interaction.inlineNavigationLocation { if case let .peer(peerId) = inlineNavigationLocation.location { @@ -1713,10 +1716,10 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { strongSelf.contextContainer.targetNodeForActivationProgress = nil } - + return true } - + self.contextContainer.activated = { [weak self] gesture, location in guard let strongSelf = self, let item = strongSelf.item else { return @@ -1729,7 +1732,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } item.interaction.activateChatPreview?(item, threadId, strongSelf.contextContainer, gesture, nil) } - + self.onDidLoad { [weak self] _ in guard let self else { return @@ -1739,29 +1742,29 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { self.avatarNode.view.addGestureRecognizer(avatarTapRecognizer) } } - + deinit { self.cachedDataDisposable.dispose() } - + override public func secondaryAction(at point: CGPoint) { guard let item = self.item else { return } item.interaction.activateChatPreview?(item, nil, self.contextContainer, nil, point) } - + func setupItem(item: ChatListItem, synchronousLoads: Bool) { let previousItem = self.item self.item = item - + var storyState: ChatListItemContent.StoryState? if case let .peer(peerData) = item.content { storyState = peerData.storyState } else if case let .groupReference(groupReference) = item.content { storyState = groupReference.storyState } - + var peer: EnginePeer? var displayAsMessage = false var enablePreview = true @@ -1790,11 +1793,11 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } self.avatarNode.setPeer(context: item.context, theme: item.presentationData.theme, peer: peer, overrideImage: .archivedChatsIcon(hiddenByDefault: groupReferenceData.hiddenByDefault), emptyColor: item.presentationData.theme.list.mediaPlaceholderColor, synchronousLoad: synchronousLoads) } - + if item.interaction.activateChatPreview == nil { enablePreview = false } - + self.avatarNode.setStoryStats(storyStats: storyState.flatMap { storyState in return AvatarNode.StoryStats( totalCount: storyState.stats.totalCount, @@ -1808,14 +1811,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { inactiveLineWidth: 1.33 ), transition: .immediate) self.avatarNode.isUserInteractionEnabled = storyState != nil - + if let stats = storyState?.stats, stats.hasLiveItems { if self.avatarLiveBadge == nil { let avatarLiveBadge: (outline: UIImageView, foreground: UIImageView) = (UIImageView(), UIImageView()) self.avatarLiveBadge = avatarLiveBadge self.avatarNode.view.addSubview(avatarLiveBadge.outline) self.avatarNode.view.addSubview(avatarLiveBadge.foreground) - + let liveString = NSAttributedString(string: item.presentationData.strings.Story_LiveBadge, font: Font.semibold(10.0), textColor: .white) let liveStringBounds = liveString.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil) let liveBadgeSize = CGSize(width: ceil(liveStringBounds.width) + 4.0 * 2.0, height: ceil(liveStringBounds.height) + 2.0 * 2.0) @@ -1824,10 +1827,10 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { defer { UIGraphicsPopContext() } - + context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(UIColor(rgb: 0xFF2D55).cgColor) - + func roundedRectCgPath(roundRect rect: CGRect, topLeftRadius: CGFloat, topRightRadius: CGFloat, bottomLeftRadius: CGFloat, bottomRightRadius: CGFloat) -> CGPath { let path = CGMutablePath() @@ -1871,17 +1874,17 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } path.closeSubpath() - + return path } - + let radius = size.height * 0.5 context.addPath(roundedRectCgPath(roundRect: CGRect(origin: CGPoint(), size: size), topLeftRadius: radius, topRightRadius: radius, bottomLeftRadius: radius, bottomRightRadius: radius)) context.fillPath() - + liveString.draw(at: CGPoint(x: floorToScreenPixels((size.width - liveStringBounds.width) * 0.5), y: floorToScreenPixels((size.height - liveStringBounds.height) * 0.5))) }) - + if let image = avatarLiveBadge.foreground.image { avatarLiveBadge.outline.image = generateStretchableFilledCircleImage(diameter: image.size.height + 2.0 * 2.0, color: .white)?.withRenderingMode(.alwaysTemplate) } @@ -1893,7 +1896,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { avatarLiveBadge.foreground.removeFromSuperview() } } - + if let peer = peer { var overrideImage: AvatarNodeImageOverride? if case let .peer(peerData) = item.content, peerData.customMessageListData != nil { @@ -1922,13 +1925,13 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { isForumAvatar = true } } - + var avatarDiameter = min(60.0, floor(item.presentationData.fontSize.baseDisplaySize * 60.0 / 17.0)) - + if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData, customMessageListData.commandPrefix != nil { avatarDiameter = 40.0 } - + if avatarDiameter != 60.0 { let avatarFontSize = floor(avatarDiameter * 26.0 / 60.0) if self.avatarNode.font.pointSize != avatarFontSize { @@ -1943,14 +1946,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { avatarClipStyle = .round } - + if peer.smallProfileImage != nil && overrideImage == nil { self.avatarNode.setPeerV2(context: item.context, theme: item.presentationData.theme, peer: peer, overrideImage: overrideImage, emptyColor: item.presentationData.theme.list.mediaPlaceholderColor, clipStyle: avatarClipStyle, synchronousLoad: synchronousLoads, displayDimensions: CGSize(width: avatarDiameter, height: avatarDiameter)) } else { self.avatarNode.setPeer(context: item.context, theme: item.presentationData.theme, peer: peer, overrideImage: overrideImage, emptyColor: item.presentationData.theme.list.mediaPlaceholderColor, clipStyle: avatarClipStyle, synchronousLoad: synchronousLoads, displayDimensions: CGSize(width: 60.0, height: 60.0)) } - - if peer.isPremium && peer.id != item.context.account.peerId { + + if peer.isPremium && !currentWinterGramSettings.hidePremiumStatuses && peer.id != item.context.account.peerId { let context = item.context self.cachedDataDisposable.set((context.account.postbox.peerView(id: peer.id) |> deliverOnMainQueue).startStrict(next: { [weak self] peerView in @@ -1961,7 +1964,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { var personalPhoto: TelegramMediaImage? var profilePhoto: TelegramMediaImage? var isKnown = false - + if let cachedPeerData = cachedPeerData { if case let .known(maybePersonalPhoto) = cachedPeerData.personalPhoto { personalPhoto = maybePersonalPhoto @@ -1976,7 +1979,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { isKnown = true } } - + if isKnown { let photo = personalPhoto ?? profilePhoto if let photo = photo, item.context.sharedContext.energyUsageSettings.loopEmoji, (!photo.videoRepresentations.isEmpty || photo.emojiMarkup != nil) { @@ -1989,7 +1992,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.avatarVideoNode = videoNode } videoNode.update(peer: peer, photo: photo, size: CGSize(width: 60.0, height: 60.0)) - + if strongSelf.hierarchyTrackingLayer == nil { let hierarchyTrackingLayer = HierarchyTrackingLayer() hierarchyTrackingLayer.didEnterHierarchy = { [weak self] in @@ -1998,7 +2001,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } strongSelf.trackingIsInHierarchy = true } - + hierarchyTrackingLayer.didExitHierarchy = { [weak self] in guard let strongSelf = self else { return @@ -2015,7 +2018,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } strongSelf.hierarchyTrackingLayer?.removeFromSuperlayer() strongSelf.hierarchyTrackingLayer = nil - } + } strongSelf.updateVideoVisibility() } else { if let photo = peer.largeProfileImage, photo.hasVideo { @@ -2025,18 +2028,18 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { })) } else { self.cachedDataDisposable.set(nil) - + self.avatarVideoNode?.removeFromSupernode() self.avatarVideoNode = nil - + self.hierarchyTrackingLayer?.removeFromSuperlayer() self.hierarchyTrackingLayer = nil } } - + self.contextContainer.isGestureEnabled = enablePreview && !item.editing } - + override public func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { let layout = self.asyncLayout() let (first, last, firstWithHeader, nextIsPinned, nextHasActiveRevealControls) = ChatListItem.mergeType(item: item as! ChatListItem, previousItem: previousItem, nextItem: nextItem) @@ -2045,19 +2048,19 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { self.contentSize = nodeLayout.contentSize self.insets = nodeLayout.insets } - + class func insets(first: Bool, last: Bool, firstWithHeader: Bool) -> UIEdgeInsets { return UIEdgeInsets(top: firstWithHeader ? 29.0 : 0.0, left: 0.0, bottom: 0.0, right: 0.0) } - + override public func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) { super.setHighlighted(highlighted, at: point, animated: animated) - + self.isHighlighted = highlighted - + self.updateIsHighlighted(transition: (animated && !highlighted) ? .animated(duration: 0.3, curve: .easeInOut) : .immediate) } - + var reallyHighlighted: Bool { var reallyHighlighted = self.isHighlighted || self.isRevealOptionsActive if let item = self.item { @@ -2074,12 +2077,12 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } return reallyHighlighted } - + func updateIsHighlighted(transition: ContainedViewLayoutTransition) { let highlightProgress: CGFloat = self.item?.interaction.highlightedChatLocation?.progress ?? 1.0 transition.updateCornerRadius(node: self.highlightedBackgroundNode, cornerRadius: self.isRevealOptionsActive ? 26.0 : 0.0) self.updateSeparatorAlpha(transition: transition) - + if self.reallyHighlighted { if self.highlightedBackgroundNode.supernode == nil { self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode) @@ -2087,11 +2090,11 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } self.highlightedBackgroundNode.layer.removeAllAnimations() transition.updateAlpha(layer: self.highlightedBackgroundNode.layer, alpha: highlightProgress) - + if let compoundHighlightingNode = self.compoundHighlightingNode { transition.updateAlpha(layer: compoundHighlightingNode.layer, alpha: 0.0) } - + if let item = self.item, case .chatList = item.index { self.onlineNode.setImage(PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .highlighted, voiceChat: self.onlineIsVoiceChat), color: nil, transition: transition) self.starView?.setOutlineColor(item.presentationData.theme.chatList.itemHighlightedBackgroundColor, transition: transition) @@ -2106,11 +2109,11 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } }) } - + if let compoundHighlightingNode = self.compoundHighlightingNode { transition.updateAlpha(layer: compoundHighlightingNode.layer, alpha: self.authorNode.alpha) } - + if let item = self.item { let onlineIcon: UIImage? let effectiveBackgroundColor: UIColor @@ -2125,7 +2128,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { self.starView?.setOutlineColor(effectiveBackgroundColor, transition: transition) } } - + if let item = self.item { if let avatarLiveBadge = self.avatarLiveBadge { let effectiveBackgroundColor: UIColor @@ -2134,7 +2137,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { effectiveBackgroundColor = item.presentationData.theme.chatList.itemBackgroundColor } - + let highlightAlpha = self.highlightedBackgroundNode.supernode == nil ? 0.0 : self.highlightedBackgroundNode.alpha let outlineColor = item.presentationData.theme.chatList.itemHighlightedBackgroundColor.mixedWith(effectiveBackgroundColor, alpha: 1.0 - highlightAlpha) transition.updateTintColor(view: avatarLiveBadge.outline, color: outlineColor) @@ -2150,7 +2153,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { transition.updateAlpha(node: self.separatorNode, alpha: revealSeparatorAlpha) } } - + override public func tapped() { guard let item = self.item, item.editing else { return @@ -2166,7 +2169,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + func asyncLayout() -> (_ item: ChatListItem, _ params: ListViewItemLayoutParams, _ first: Bool, _ last: Bool, _ firstWithHeader: Bool, _ nextIsPinned: Bool, _ nextHasActiveRevealControls: Bool) -> (ListViewItemNodeLayout, (Bool, Bool) -> Void) { let dateLayout = TextNode.asyncLayout(self.dateNode) let textLayout = TextNodeWithEntities.asyncLayout(self.textNode) @@ -2182,13 +2185,13 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { let selectableControlLayout = ItemListSelectableControlNode.asyncLayout(self.selectableControlNode) let reorderControlLayout = ItemListEditableReorderControlNode.asyncLayout(self.reorderControlNode) let makeActionButtonTitleNodeLayout = TextNode.asyncLayout(self.actionButtonTitleNode) - + let currentItem = self.layoutParams?.0 let currentChatListText = self.cachedChatListText let currentChatListSearchResult = self.cachedChatListSearchResult let currentChatListQuoteSearchResult = self.cachedChatListQuoteSearchResult let currentCustomTextEntities = self.cachedCustomTextEntities - + return { item, params, first, last, firstWithHeader, nextIsPinned, nextHasActiveRevealControls in let titleFont = Font.semibold(floor(item.presentationData.fontSize.itemListBaseFontSize * 16.0 / 17.0)) let textFont = Font.regular(floor(item.presentationData.fontSize.itemListBaseFontSize * 15.0 / 17.0)) @@ -2196,7 +2199,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { let dateFont = Font.regular(floor(item.presentationData.fontSize.itemListBaseFontSize * 14.0 / 17.0)) let badgeFont = Font.with(size: floor(item.presentationData.fontSize.itemListBaseFontSize * 12.0 / 17.0), design: .regular, weight: .semibold, traits: [.monospacedNumbers]) let avatarBadgeFont = Font.with(size: floor(item.presentationData.fontSize.itemListBaseFontSize * 16.0 / 17.0), design: .regular, weight: .regular, traits: [.monospacedNumbers]) - + let account = item.context.account var messages: [EngineMessage] enum ContentPeer { @@ -2223,9 +2226,9 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { var topForumTopicItems: [EngineChatList.ForumTopicData] = [] var autoremoveTimeout: Int32? var itemTags: [ChatListItemContent.Tag] = [] - + var groupHiddenByDefault = false - + switch item.content { case .loading: messages = [] @@ -2261,11 +2264,11 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { let displayAsMessageValue = peerData.displayAsMessage let forumTopicDataValue = peerData.forumTopicData let topForumTopicItemsValue = peerData.topForumTopicItems - + itemTags = peerData.tags - + autoremoveTimeout = peerData.autoremoveTimeout - + messages = messagesValue contentPeer = .chat(peerValue) combinedReadState = combinedReadStateValue @@ -2290,7 +2293,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { hasUnseenPollVotes = hasUnseenPollVotesValue forumTopicData = forumTopicDataValue topForumTopicItems = topForumTopicItemsValue - + if item.interaction.searchTextHighightState != nil, threadInfo == nil, topForumTopicItems.isEmpty, let message = messagesValue.first, let threadId = message.threadId, let associatedThreadInfo = message.associatedThreadInfo { var threadPeer: EnginePeer? if case let .channel(channel) = peerValue.peer, channel.isMonoForum { @@ -2298,7 +2301,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } topForumTopicItems = [EngineChatList.ForumTopicData(id: threadId, title: associatedThreadInfo.title, iconFileId: associatedThreadInfo.icon, iconColor: associatedThreadInfo.iconColor, maxOutgoingReadMessageId: message.id, isUnread: false, threadPeer: threadPeer)] } - + switch peerValue.peer { case .user, .secretChat: if let peerPresence = peerPresence { @@ -2315,7 +2318,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { default: inputActivities = inputActivitiesValue } - + isPeerGroup = false promoInfo = promoInfoValue displayAsMessage = displayAsMessageValue @@ -2325,7 +2328,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { let messageValue = groupReferenceData.message let unreadCountValue = groupReferenceData.unreadCount let hiddenByDefault = groupReferenceData.hiddenByDefault - + if let _ = messageValue, !peers.isEmpty { contentPeer = .chat(peers[0].peer) } else { @@ -2352,7 +2355,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { displayAsMessage = false hasFailedMessages = false } - + if let messageValue = messages.last { for media in messageValue.media { if let media = media as? TelegramMediaAction, case .historyCleared = media.action { @@ -2360,7 +2363,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + let useChatListLayout: Bool if case .chatList = item.chatListLocation { useChatListLayout = true @@ -2371,15 +2374,15 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { useChatListLayout = false } - + let theme = item.presentationData.theme.chatList - + var updatedTheme: PresentationTheme? - + if currentItem?.presentationData.theme !== item.presentationData.theme { updatedTheme = item.presentationData.theme } - + var authorAttributedString: NSAttributedString? var authorIsCurrentChat: Bool = false var textAttributedString: NSAttributedString? @@ -2390,7 +2393,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { var badgeContent = ChatListBadgeContent.none var mentionBadgeContent = ChatListBadgeContent.none var statusState = ChatListStatusNodeState.none - + var currentBadgeBackgroundImage: UIImage? var currentAvatarBadgeBackgroundImage: UIImage? var currentMentionBadgeImage: UIImage? @@ -2400,13 +2403,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { var currentVerifiedIconContent: EmojiStatusComponent.Content? var currentStatusIconContent: EmojiStatusComponent.Content? var currentStatusIconParticleColor: UIColor? + var currentWinterGramOfficial = false var currentSecretIconImage: UIImage? var currentMessageTypeIcon: UIImage? var currentMessageTypeIconOffset: CGPoint = .zero - + var selectableControlSizeAndApply: (CGFloat, (CGSize, Bool) -> ItemListSelectableControlNode)? var reorderControlSizeAndApply: (CGFloat, (CGFloat, Bool, ContainedViewLayoutTransition) -> ItemListEditableReorderControlNode)? - + let editingOffset: CGFloat var reorderInset: CGFloat = 0.0 if item.editing { @@ -2416,15 +2420,15 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { selectionControlStyle = .compact } - + let sizeAndApply = selectableControlLayout(item.presentationData.theme.list.itemCheckColors.strokeColor, item.presentationData.theme.list.itemCheckColors.fillColor, item.presentationData.theme.list.itemCheckColors.foregroundColor, item.selected, selectionControlStyle, nil) if promoInfo == nil && !isPeerGroup { selectableControlSizeAndApply = sizeAndApply } editingOffset = sizeAndApply.0 - + var canReorder = false - + if case let .chatList(index) = item.index, index.pinningIndex != nil, promoInfo == nil, !isPeerGroup { canReorder = true } else if case let .forum(pinnedIndex, _, _, _, _) = item.index, case .index = pinnedIndex { @@ -2435,11 +2439,11 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData, customMessageListData.commandPrefix != nil { canReorder = true } - + if canReorder { let sizeAndApply = reorderControlLayout(item.presentationData.theme) reorderControlSizeAndApply = sizeAndApply @@ -2448,13 +2452,13 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { editingOffset = 0.0 } - + let enableChatListPhotos = true - + // if changed, adjust setupItem accordingly var avatarDiameter = min(60.0, floor(item.presentationData.fontSize.baseDisplaySize * 60.0 / 17.0)) let avatarLeftInset: CGFloat - + if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData, customMessageListData.commandPrefix != nil { avatarDiameter = 40.0 avatarLeftInset = 17.0 + avatarDiameter @@ -2467,32 +2471,32 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { avatarLeftInset = 24.0 + avatarDiameter } } - + let badgeDiameter = floor(item.presentationData.fontSize.baseDisplaySize * 20.0 / 17.0) let avatarBadgeDiameter: CGFloat = floor(floor(item.presentationData.fontSize.itemListBaseFontSize * 22.0 / 17.0)) let avatarTimerBadgeDiameter: CGFloat = floor(floor(item.presentationData.fontSize.itemListBaseFontSize * 24.0 / 17.0)) - + let currentAvatarBadgeCleanBackgroundImage: UIImage? = PresentationResourcesChatList.badgeBackgroundBorder(item.presentationData.theme, diameter: avatarBadgeDiameter + 4.0) - + let leftInset: CGFloat = params.leftInset + avatarLeftInset - + enum ContentData { case chat(itemPeer: EngineRenderedPeer, threadInfo: ChatListItemContent.ThreadInfo?, peer: EnginePeer?, hideAuthor: Bool, messageText: String, messageEntities: [MessageTextEntity], spoilers: [NSRange]?, customEmojiRanges: [(NSRange, ChatTextInputTextCustomEmojiAttribute)]?) case group(peers: [EngineChatList.GroupItem.Item]) } - + let contentData: ContentData - + var hideAuthor = false switch contentPeer { case let .chat(itemPeer): var (peer, initialHideAuthor, messageText, messageEntities, spoilers, customEmojiRanges) = chatListItemStrings(strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, dateTimeFormat: item.presentationData.dateTimeFormat, contentSettings: item.context.currentContentSettings.with { $0 }, messages: messages, chatPeer: itemPeer, accountPeerId: item.context.account.peerId, enableMediaEmoji: !enableChatListPhotos, isPeerGroup: isPeerGroup) - + if case let .psa(_, maybePsaText) = promoInfo, let psaText = maybePsaText { initialHideAuthor = true messageText = psaText } - + switch itemPeer.peer { case .user: if let attribute = messages.first?._asMessage().reactionsAttribute { @@ -2513,17 +2517,17 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { default: break } - + contentData = .chat(itemPeer: itemPeer, threadInfo: threadInfo, peer: peer, hideAuthor: hideAuthor, messageText: messageText, messageEntities: messageEntities, spoilers: spoilers, customEmojiRanges: customEmojiRanges) hideAuthor = initialHideAuthor case let .group(groupPeers): contentData = .group(peers: groupPeers) hideAuthor = true } - + var attributedText: NSAttributedString var hasDraft = false - + var inlineAuthorPrefix: String? var useInlineAuthorPrefix = false if case .groupReference = item.content { @@ -2532,7 +2536,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { if !itemTags.isEmpty { forumTopicData = nil topForumTopicItems = [] - + if case let .chat(itemPeer, _, _, _, _, _, _, _) = contentData { if let messagePeer = itemPeer.chatMainPeer { switch messagePeer { @@ -2548,7 +2552,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + if useInlineAuthorPrefix { if case let .user(author) = messages.last?.author { if author.id == item.context.account.peerId { @@ -2558,18 +2562,18 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + var chatListText: (String, String)? var chatListSearchResult: CachedChatListSearchResult? var chatListQuoteSearchResult: CachedChatListSearchResult? var customTextEntities: CachedCustomTextEntities? - + let contentImageSide: CGFloat = max(10.0, min(20.0, floor(item.presentationData.fontSize.baseDisplaySize * 18.0 / 17.0))) let contentImageSize = CGSize(width: contentImageSide, height: contentImageSide) let contentImageSpacing: CGFloat = 2.0 let forwardedIconSpacing: CGFloat = 6.0 let contentImageTrailingSpace: CGFloat = 5.0 - + var contentImageSpecs: [ContentImageSpec] = [] var avatarContentImageSpec: ContentImageSpec? var forumThread: (id: Int64, title: String, iconId: Int64?, iconColor: Int32, threadPeer: EnginePeer?, isUnread: Bool)? @@ -2595,7 +2599,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } var messageTypeIcon: MessageTypeIcon? var ignoreForwardedIcon = false - + switch contentData { case let .chat(itemPeer, _, _, _, text, entities, spoilers, customEmojiRanges): var isUser = false @@ -2635,7 +2639,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + if case .chatList = item.chatListLocation, itemPeer.peerId == item.context.account.peerId, let message = messages.first { var effectiveAuthor: EngineRawPeer? = message.author?._asPeer() if let forwardInfo = message.forwardInfo { @@ -2651,14 +2655,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { effectiveAuthor = TelegramUser(id: EnginePeer.Id(namespace: Namespaces.Peer.Empty, id: EnginePeer.Id.Id._internalFromInt64Value(Int64(authorSignature.persistentHashValue % 32))), accessHash: nil, firstName: authorSignature, lastName: nil, username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil) } } - + if let effectiveAuthor, effectiveAuthor.id != itemPeer.chatMainPeer?.id { authorIsCurrentChat = false peerText = EnginePeer(effectiveAuthor).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) ignoreForwardedIcon = true } } - + if let _ = peerText, case let .channel(channel) = itemPeer.chatMainPeer, channel.isForumOrMonoForum, threadInfo == nil { if let forumTopicData { forumThread = (forumTopicData.id, forumTopicData.title, forumTopicData.iconFileId, forumTopicData.iconColor, forumTopicData.threadPeer, forumTopicData.isUnread) @@ -2669,7 +2673,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { if let forumTopicData, forumThread == nil, case let .user(user) = itemPeer.chatMainPeer, let botInfo = user.botInfo, botInfo.flags.contains(.hasForum) { forumThread = (forumTopicData.id, forumTopicData.title, forumTopicData.iconFileId, forumTopicData.iconColor, forumTopicData.threadPeer, forumTopicData.isUnread) } - + let messageText: String if let currentChatListText = currentChatListText, currentChatListText.0 == text { messageText = currentChatListText.1 @@ -2684,11 +2688,11 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } chatListText = (text, messageText) } - + if inlineAuthorPrefix == nil, let mediaDraftContentType { hasDraft = true authorAttributedString = NSAttributedString(string: item.presentationData.strings.DialogList_Draft, font: textFont, textColor: theme.messageDraftTextColor) - + switch mediaDraftContentType { case .audio: attributedText = NSAttributedString(string: item.presentationData.strings.Message_Audio, font: textFont, textColor: theme.messageTextColor) @@ -2698,7 +2702,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else if inlineAuthorPrefix == nil, let draftState = draftState { hasDraft = true let draftText = stringWithAppliedEntities(draftState.text, entities: draftState.entities, baseColor: theme.messageTextColor, linkColor: theme.messageTextColor, baseFont: textFont, linkFont: textFont, boldFont: textFont, italicFont: textFont, boldItalicFont: textFont, fixedFont: textFont, blockQuoteFont: textFont, message: nil) - + if !itemTags.isEmpty { let tempAttributedText = foldLineBreaks(draftText) let attributedTextWithDraft = NSMutableAttributedString() @@ -2707,16 +2711,16 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { attributedText = attributedTextWithDraft } else { authorAttributedString = NSAttributedString(string: item.presentationData.strings.DialogList_Draft, font: textFont, textColor: theme.messageDraftTextColor) - + attributedText = foldLineBreaks(draftText) } } else if let message = messages.last { var composedString: NSMutableAttributedString - + if let peerText = peerText { authorAttributedString = NSAttributedString(string: peerText, font: textFont, textColor: theme.authorNameColor) } - + var entities = entities.filter { entity in switch entity.type { case .Spoiler, .CustomEmoji, .FormattedDate: @@ -2730,7 +2734,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { if let _ = message.media.first(where: { $0 is TelegramMediaPoll }) { entities = [] } - + if message.id.peerId.isTelegramNotifications || message.id.peerId.isVerificationCodes { let regex: NSRegularExpression? if message.id.peerId.isTelegramNotifications { @@ -2748,11 +2752,11 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { customTextEntities = CachedCustomTextEntities(text: messageText, textEntities: entities) } } - + if let customTextEntities, !customTextEntities.textEntities.isEmpty { entities.append(contentsOf: customTextEntities.textEntities) } - + let messageString: NSAttributedString if !messageText.isEmpty && entities.count > 0 { let appliedString = stringWithAppliedEntities(messageText, entities: entities, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat, baseColor: theme.messageTextColor, linkColor: theme.messageTextColor, baseFont: textFont, linkFont: textFont, boldFont: textFont, italicFont: italicTextFont, boldItalicFont: textFont, fixedFont: textFont, blockQuoteFont: textFont, underlineLinks: false, message: message._asMessage()) @@ -2792,7 +2796,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { composedString = NSMutableAttributedString(attributedString: messageString) } - + var composedReplyString: NSMutableAttributedString? if let searchQuery = item.interaction.searchTextHighightState { var quoteText: String? @@ -2811,14 +2815,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { let quoteString = foldLineBreaks(stringWithAppliedEntities(quoteText, entities: [], baseColor: theme.messageTextColor, linkColor: theme.messageTextColor, baseFont: textFont, linkFont: textFont, boldFont: textFont, italicFont: italicTextFont, boldItalicFont: textFont, fixedFont: textFont, blockQuoteFont: textFont, underlineLinks: false, message: nil)) composedReplyString = NSMutableAttributedString(attributedString: quoteString) } - + if let cached = currentChatListSearchResult, cached.matches(text: composedString.string, searchQuery: searchQuery) { chatListSearchResult = cached } else { let (ranges, text) = findSubstringRanges(in: composedString.string, query: searchQuery) chatListSearchResult = CachedChatListSearchResult(text: text, searchQuery: searchQuery, resultRanges: ranges) } - + if let composedReplyString { if let cached = currentChatListQuoteSearchResult, cached.matches(text: composedReplyString.string, searchQuery: searchQuery) { chatListQuoteSearchResult = cached @@ -2833,7 +2837,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { chatListSearchResult = nil chatListQuoteSearchResult = nil } - + if let chatListSearchResult = chatListSearchResult, let firstRange = chatListSearchResult.resultRanges.first { for range in chatListSearchResult.resultRanges { let stringRange = NSRange(range, in: chatListSearchResult.text) @@ -2847,7 +2851,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { composedString.addAttribute(.foregroundColor, value: theme.messageHighlightedTextColor, range: stringRange) } } - + let firstRangeOrigin = chatListSearchResult.text.distance(from: chatListSearchResult.text.startIndex, to: firstRange.lowerBound) if firstRangeOrigin > 24 && !chatListSearchResult.searchQuery.hasPrefix("#") { var leftOrigin: Int = 0 @@ -2873,7 +2877,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { composedReplyString.addAttribute(.foregroundColor, value: theme.messageHighlightedTextColor, range: stringRange) } } - + let firstRangeOrigin = chatListQuoteSearchResult.text.distance(from: chatListQuoteSearchResult.text.startIndex, to: firstRange.lowerBound) if firstRangeOrigin > 24 { var leftOrigin: Int = 0 @@ -2886,12 +2890,12 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { composedReplyString = composedReplyString.attributedSubstring(from: NSMakeRange(leftOrigin, composedReplyString.length - leftOrigin)).mutableCopy() as! NSMutableAttributedString composedReplyString.insert(NSAttributedString(string: "\u{2026}", attributes: [NSAttributedString.Key.font: textFont, NSAttributedString.Key.foregroundColor: theme.messageTextColor]), at: 0) } - + composedString = composedReplyString } - + attributedText = composedString - + if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData, let commandPrefix = customMessageListData.commandPrefix { let mutableAttributedText = NSMutableAttributedString(attributedString: attributedText) let boldTextFont = Font.semibold(floor(item.presentationData.fontSize.itemListBaseFontSize * 15.0 / 17.0)) @@ -2904,7 +2908,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } attributedText = mutableAttributedText } - + if !ignoreForwardedIcon { if case .savedMessagesChats = item.chatListLocation { } else if let forwardInfo = message.forwardInfo, !forwardInfo.flags.contains(.isImported) && !message.id.peerId.isVerificationCodes { @@ -2943,7 +2947,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + var displayMediaPreviews = true if message._asMessage().containsSecretMedia { displayMediaPreviews = false @@ -2953,17 +2957,17 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { if displayMediaPreviews { let contentImageFillSize = CGSize(width: 8.0, height: contentImageSize.height) _ = contentImageFillSize - + var contentImageIsDisplayedAsAvatar = false if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData, customMessageListData.commandPrefix != nil { contentImageIsDisplayedAsAvatar = true } - + for message in messages { if contentImageSpecs.count >= 3 { break } - + inner: for media in message.media { if let paidContent = media as? TelegramMediaPaidContent { let fitSize = contentImageSize @@ -3047,7 +3051,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + if contentImageIsDisplayedAsAvatar { avatarContentImageSpec = contentImageSpecs.first contentImageSpecs.removeAll() @@ -3055,14 +3059,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } else { attributedText = NSAttributedString(string: messageText, font: textFont, textColor: theme.messageTextColor) - + var peerText: String? if case .groupReference = item.content { if let messagePeer = itemPeer.chatMainPeer { peerText = messagePeer.displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) } } - + if let peerText = peerText { authorAttributedString = NSAttributedString(string: peerText, font: textFont, textColor: theme.authorNameColor) } @@ -3089,7 +3093,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } attributedText = textString } - + switch messageTypeIcon { case let .call(type, direction): switch type { @@ -3135,7 +3139,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { break } let messageTypeIconScale = min(1.0, item.presentationData.fontSize.itemListBaseFontSize / 17.0) - + if let currentMessageTypeIcon { textLeftCutout += currentMessageTypeIcon.size.width * messageTypeIconScale if !contentImageSpecs.isEmpty { @@ -3144,7 +3148,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { textLeftCutout += contentImageTrailingSpace - 1.0 } } - + for i in 0 ..< contentImageSpecs.count { if i != 0 { textLeftCutout += contentImageSpacing @@ -3154,7 +3158,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { textLeftCutout += contentImageTrailingSpace } } - + switch contentData { case let .chat(itemPeer, threadInfo, _, _, _, _, _, _): if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData { @@ -3169,7 +3173,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { textColor = theme.titleColor } titleAttributedString = NSAttributedString(string: displayTitle, font: titleFont, textColor: textColor) - + if case let .channel(channel) = itemPeer.peer, channel.flags.contains(.isMonoforum) { titleBadgeText = item.presentationData.strings.ChatList_MonoforumLabel } @@ -3206,9 +3210,9 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { case .group: titleAttributedString = NSAttributedString(string: item.presentationData.strings.ChatList_ArchivedChatsTitle, font: titleFont, textColor: theme.titleColor) } - + textAttributedString = attributedText - + let dateText: String var topIndex: EngineMessage.Index? switch item.content { @@ -3227,14 +3231,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { var t = Int(topIndex.timestamp) var timeinfo = tm() localtime_r(&t, &timeinfo) - + let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) - + dateText = stringForRelativeTimestamp(strings: item.presentationData.strings, relativeTimestamp: topIndex.timestamp, relativeTo: timestamp, dateTimeFormat: item.presentationData.dateTimeFormat) } else { dateText = "" } - + if isPeerGroup { dateAttributedString = NSAttributedString(string: "", font: dateFont, textColor: theme.dateTextColor) } else if let promoInfo = promoInfo { @@ -3254,7 +3258,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { dateAttributedString = NSAttributedString(string: dateText, font: dateFont, textColor: theme.dateTextColor) } - + if !isPeerGroup, let message = messages.last, message.author?.id == account.peerId && !hasDraft { if message.flags.isSending && !message._asMessage().isSentOrAcknowledged { statusState = .clock(PresentationResourcesChatList.clockFrameImage(item.presentationData.theme), PresentationResourcesChatList.clockMinImage(item.presentationData.theme)) @@ -3278,7 +3282,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + if unreadCount.unread { let badgeTextColor: UIColor if unreadCount.muted { @@ -3310,7 +3314,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { badgeContent = .blank } - + if let mutedCount = unreadCount.mutedCount, mutedCount > 0 { let mutedUnreadCountText = compactNumericCountString(Int(mutedCount), decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator) currentMentionBadgeImage = PresentationResourcesChatList.badgeBackgroundInactive(item.presentationData.theme, diameter: badgeDiameter) @@ -3344,28 +3348,28 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { currentPinnedIconImage = PresentationResourcesChatList.badgeBackgroundPinned(item.presentationData.theme, diameter: badgeDiameter) } } - + let isMuted = isRemovedFromTotalUnreadCount if isMuted { currentMutedIconImage = PresentationResourcesChatList.mutedIcon(item.presentationData.theme) } - + var statusWidth: CGFloat if case .none = statusState { statusWidth = 0.0 } else { statusWidth = 24.0 } - + var dateIconImage: UIImage? if let threadInfo, threadInfo.isClosed { dateIconImage = PresentationResourcesChatList.statusLockIcon(item.presentationData.theme) } - + if let dateIconImage { statusWidth += dateIconImage.size.width + 4.0 } - + var titleIconsWidth: CGFloat = 0.0 if let currentMutedIconImage = currentMutedIconImage { if titleIconsWidth.isZero { @@ -3373,7 +3377,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } titleIconsWidth += currentMutedIconImage.size.width } - + var isSubscription = false var isSecret = false if !isPeerGroup { @@ -3384,13 +3388,13 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { if isSecret { currentSecretIconImage = PresentationResourcesChatList.secretIcon(item.presentationData.theme) } - + let premiumConfiguration = PremiumConfiguration.with(appConfiguration: item.context.currentAppConfiguration.with { $0 }) var isAccountPeer = false if case let .chatList(index) = item.index, index.messageIndex.id.peerId == item.context.account.peerId { isAccountPeer = true } - + if !isPeerGroup && !isAccountPeer && threadInfo == nil { if displayAsMessage { switch item.content { @@ -3401,7 +3405,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { iconPeer = peerData.messages.last?.author } - + if let peer = iconPeer { if case let .peer(peerData) = item.content, peerData.customMessageListData != nil { currentCredibilityIconContent = nil @@ -3416,16 +3420,17 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { if let color = emojiStatus.color { currentStatusIconParticleColor = UIColor(rgb: UInt32(bitPattern: color)) } - } else if peer.isPremium && !premiumConfiguration.isPremiumDisabled { + } else if peer.isPremium && !currentWinterGramSettings.hidePremiumStatuses && !premiumConfiguration.isPremiumDisabled { currentCredibilityIconContent = .premium(color: item.presentationData.theme.list.itemAccentColor) } - + if peer.isVerified { currentCredibilityIconContent = .verified(fillColor: item.presentationData.theme.list.itemCheckColors.fillColor, foregroundColor: item.presentationData.theme.list.itemCheckColors.foregroundColor, sizeType: .compact) } if let verificationIconFileId = peer.verificationIconFileId { currentVerifiedIconContent = .animation(content: .customEmoji(fileId: verificationIconFileId), size: CGSize(width: 32.0, height: 32.0), placeholderColor: item.presentationData.theme.list.mediaPlaceholderColor, themeColor: item.presentationData.theme.list.itemAccentColor, loopMode: .count(0)) } + currentWinterGramOfficial = isWinterGramOfficialPeer(peer) } default: break @@ -3447,28 +3452,29 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { if let color = emojiStatus.color { currentStatusIconParticleColor = UIColor(rgb: UInt32(bitPattern: color)) } - } else if peer.isPremium && !premiumConfiguration.isPremiumDisabled { + } else if peer.isPremium && !currentWinterGramSettings.hidePremiumStatuses && !premiumConfiguration.isPremiumDisabled { currentCredibilityIconContent = .premium(color: item.presentationData.theme.list.itemAccentColor) } - + if peer.isVerified { currentCredibilityIconContent = .verified(fillColor: item.presentationData.theme.list.itemCheckColors.fillColor, foregroundColor: item.presentationData.theme.list.itemCheckColors.foregroundColor, sizeType: .compact) } if let verificationIconFileId = peer.verificationIconFileId { currentVerifiedIconContent = .animation(content: .customEmoji(fileId: verificationIconFileId), size: CGSize(width: 32.0, height: 32.0), placeholderColor: item.presentationData.theme.list.mediaPlaceholderColor, themeColor: item.presentationData.theme.list.itemAccentColor, loopMode: .count(0)) } + currentWinterGramOfficial = isWinterGramOfficialPeer(peer) } } if let currentSecretIconImage = currentSecretIconImage { titleIconsWidth += currentSecretIconImage.size.width + 2.0 } - + var titleLeftOffset: CGFloat = 0.0 if let currentVerifiedIconContent { if titleLeftOffset.isZero, case .animation = currentVerifiedIconContent { titleLeftOffset += 19.0 } - + if titleIconsWidth.isZero { titleIconsWidth += 4.0 } else { @@ -3483,7 +3489,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { titleIconsWidth += 8.0 } } - + if let currentCredibilityIconContent { if titleIconsWidth.isZero { titleIconsWidth += 4.0 @@ -3499,7 +3505,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { titleIconsWidth += 8.0 } } - + if let currentStatusIconContent { if titleIconsWidth.isZero { titleIconsWidth += 4.0 @@ -3515,22 +3521,31 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { titleIconsWidth += 8.0 } } - + + if currentWinterGramOfficial { + if titleIconsWidth.isZero { + titleIconsWidth += 4.0 + } else { + titleIconsWidth += 2.0 + } + titleIconsWidth += 18.0 + } + let layoutOffset: CGFloat = 0.0 - + let rawContentWidth = params.width - leftInset - params.rightInset - 18.0 - editingOffset - + let (dateLayout, dateApply) = dateLayout(TextNodeLayoutArguments(attributedString: dateAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: rawContentWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) - + let (badgeLayout, badgeApply) = badgeLayout(CGSize(width: rawContentWidth, height: CGFloat.greatestFiniteMagnitude), badgeDiameter, badgeFont, currentBadgeBackgroundImage, badgeContent) - + let (mentionBadgeLayout, mentionBadgeApply) = mentionBadgeLayout(CGSize(width: rawContentWidth, height: CGFloat.greatestFiniteMagnitude), badgeDiameter, badgeFont, currentMentionBadgeImage, mentionBadgeContent) - + var actionButtonTitleNodeLayoutAndApply: (TextNodeLayout, () -> TextNode)? if !item.editing, case .none = badgeContent, case .none = mentionBadgeContent, case let .chat(itemPeer) = contentPeer, case let .user(user) = itemPeer.chatMainPeer, let botInfo = user.botInfo, botInfo.flags.contains(.hasWebApp) { actionButtonTitleNodeLayoutAndApply = makeActionButtonTitleNodeLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.presentationData.strings.ChatList_InlineButtonOpenApp, font: Font.semibold(floor(item.presentationData.fontSize.itemListBaseFontSize * 15.0 / 17.0)), textColor: theme.unreadBadgeActiveTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: rawContentWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) } - + var badgeSize: CGFloat = 0.0 if !badgeLayout.width.isZero { badgeSize += badgeLayout.width + 5.0 @@ -3560,15 +3575,15 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { badgeSize += actionButtonTitleNodeLayout.size.width + 12.0 * 2.0 } badgeSize = max(badgeSize, reorderInset) - + if !itemTags.isEmpty { authorAttributedString = nil } - + var effectiveAuthorTitle = (hideAuthor && !hasDraft) ? nil : authorAttributedString - + let isSearching = item.interaction.searchTextHighightState != nil - + var isFirstForumThreadSelectable = false var forumThreads: [(id: Int64, threadPeer: EnginePeer?, title: NSAttributedString, iconId: Int64?, iconColor: Int32?)] = [] if case .savedMessagesChats = item.chatListLocation { @@ -3583,15 +3598,15 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { forumThreads.append((id: topicItem.id, threadPeer: topicItem.threadPeer, title: NSAttributedString(string: topicItem.threadPeer?.compactDisplayTitle ?? " ", font: textFont, textColor: topicItem.isUnread || isSearching ? theme.authorNameColor : theme.messageTextColor), iconId: nil, iconColor: nil)) } } - + if let effectiveAuthorTitle, let textAttributedStringValue = textAttributedString { let mutableTextAttributedString = NSMutableAttributedString() mutableTextAttributedString.append(NSAttributedString(string: effectiveAuthorTitle.string + ": ", font: textFont, textColor: theme.authorNameColor)) mutableTextAttributedString.append(textAttributedStringValue) - + textAttributedString = mutableTextAttributedString } - + effectiveAuthorTitle = nil } } else if forumThread != nil || !topForumTopicItems.isEmpty { @@ -3601,36 +3616,36 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { isFirstForumThreadSelectable = forumThread.isUnread } - + forumThreads.append((id: forumThread.id, threadPeer: forumThread.threadPeer, title: NSAttributedString(string: forumThread.title, font: textFont, textColor: forumThread.isUnread || isSearching ? theme.authorNameColor : theme.messageTextColor), iconId: forumThread.iconId, iconColor: forumThread.iconColor)) } for topicItem in topForumTopicItems { if case let .peer(peer) = item.content, peer.peer.peerId.id._internalGetInt64Value() == topicItem.id { - + } else if forumThread?.id != topicItem.id { forumThreads.append((id: topicItem.id, threadPeer: topicItem.threadPeer, title: NSAttributedString(string: topicItem.title, font: textFont, textColor: topicItem.isUnread || isSearching ? theme.authorNameColor : theme.messageTextColor), iconId: topicItem.iconFileId, iconColor: topicItem.iconColor)) } } - + if let effectiveAuthorTitle, let textAttributedStringValue = textAttributedString { let mutableTextAttributedString = NSMutableAttributedString() mutableTextAttributedString.append(NSAttributedString(string: effectiveAuthorTitle.string + ": ", font: textFont, textColor: theme.authorNameColor)) mutableTextAttributedString.append(textAttributedStringValue) - + textAttributedString = mutableTextAttributedString } - + effectiveAuthorTitle = nil } - + if authorIsCurrentChat { effectiveAuthorTitle = nil } - + let (authorLayout, authorApply) = authorLayout(item.context, rawContentWidth - badgeSize, item.presentationData.theme, effectiveAuthorTitle, forumThreads) - + var textBottomRightCutout: CGFloat = 0.0 - + let trailingTextBadgeInsets = UIEdgeInsets(top: 2.0 - UIScreenPixel, left: 5.0, bottom: 2.0 - UIScreenPixel, right: 5.0) var trailingTextBadgeLayoutAndApply: (TextNodeLayout, () -> TextNode)? if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData, customMessageListData.commandPrefix != nil, let messageCount = customMessageListData.messageCount, messageCount > 1 { @@ -3641,20 +3656,20 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { trailingTextBadgeLayoutAndApply = (layout, apply) textBottomRightCutout += layout.size.width + 4.0 + trailingTextBadgeInsets.left + trailingTextBadgeInsets.right } - + var textCutout: TextNodeCutout? if !textLeftCutout.isZero || !textBottomRightCutout.isZero { textCutout = TextNodeCutout(topLeft: textLeftCutout.isZero ? nil : CGSize(width: textLeftCutout, height: 10.0), topRight: nil, bottomRight: textBottomRightCutout.isZero ? nil : CGSize(width: textBottomRightCutout, height: 10.0)) } - + var textMaxWidth = rawContentWidth - badgeSize - + var textArrowImage: UIImage? if isFirstForumThreadSelectable { textArrowImage = PresentationResourcesItemList.disclosureArrowImage(item.presentationData.theme) textMaxWidth -= 18.0 } - + let textLineSpacing: CGFloat = min(0.2, item.presentationData.fontSize.itemListBaseFontSize * 0.2 / 17.0) let (textLayout, textApply) = textLayout(TextNodeLayoutArguments( attributedString: textAttributedString, @@ -3667,7 +3682,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { cutout: textCutout, insets: UIEdgeInsets(top: 2.0, left: 1.0, bottom: 2.0, right: 1.0) )) - + let maxTitleLines: Int switch item.index { case .forum: @@ -3675,31 +3690,31 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { case .chatList: maxTitleLines = 1 } - + var titleLeftCutout: CGFloat = 0.0 if item.interaction.isInlineMode { titleLeftCutout = 22.0 } - + if let titleAttributedStringValue = titleAttributedString, titleAttributedStringValue.length == 0 { titleAttributedString = NSAttributedString(string: " ", font: titleFont, textColor: theme.titleColor) } - + var titleRectWidth = rawContentWidth - dateLayout.size.width - 10.0 - statusWidth - titleIconsWidth var titleCutout: TextNodeCutout? if !titleLeftCutout.isZero { titleCutout = TextNodeCutout(topLeft: CGSize(width: titleLeftCutout, height: 10.0), topRight: nil, bottomRight: nil) } - + var titleBadgeLayoutAndApply: (TextNodeLayout, () -> TextNode)? if let titleBadgeText { let titleBadgeLayoutAndApplyValue = titleBadgeLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: titleBadgeText, font: Font.semibold(11.0), textColor: theme.titleColor.withMultipliedAlpha(0.4)), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: titleRectWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) titleBadgeLayoutAndApply = titleBadgeLayoutAndApplyValue titleRectWidth = max(10.0, titleRectWidth - titleBadgeLayoutAndApplyValue.0.size.width - 8.0) } - + let (titleLayout, titleApply) = titleLayout(TextNodeLayoutArguments(attributedString: titleAttributedString, backgroundColor: nil, maximumNumberOfLines: maxTitleLines, truncationType: .end, constrainedSize: CGSize(width: titleRectWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: titleCutout, insets: UIEdgeInsets())) - + var inputActivitiesSize: CGSize? var inputActivitiesApply: (() -> Void)? var chatPeerId: EnginePeer.Id? @@ -3717,11 +3732,11 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { inputActivitiesSize = size inputActivitiesApply = apply } - + var online = false var animateOnline = false var onlineIsVoiceChat = false - + var isPinned = false if case let .chatList(index) = item.index { isPinned = index.pinningIndex != nil @@ -3741,7 +3756,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { let renderedPeer = peerData.peer let presence = peerData.presence let displayAsMessage = peerData.displayAsMessage - + if !displayAsMessage { if case let .user(peer) = renderedPeer.chatMainPeer, let presence = presence, !isServicePeer(peer) && !peer.flags.contains(.isSupport) && peer.id != item.context.account.peerId { let updatedPresence = EnginePeer.Presence(status: presence.status, lastActivity: 0) @@ -3765,7 +3780,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { animateOnline = true } } - + if let enabledContextActions = item.enabledContextActions { switch enabledContextActions { case .auto: @@ -3823,7 +3838,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { case let .custom(actions): peerRevealOptions = [] peerLeftRevealOptions = [] - + if actions.contains(.toggleUnread) { if unreadCount.unread { peerLeftRevealOptions.append(ItemListRevealOption(key: RevealOptionKey.toggleMarkedUnread.rawValue, title: item.presentationData.strings.DialogList_Read, icon: readIcon, color: item.presentationData.theme.list.itemDisclosureActions.inactive.fillColor, iconColor: item.presentationData.theme.list.itemDisclosureActions.neutral1.foregroundColor, textColor: item.presentationData.theme.chatList.dateTextColor)) @@ -3843,20 +3858,20 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { peerRevealOptions = groupReferenceRevealOptions(strings: item.presentationData.strings, theme: item.presentationData.theme, isEditing: item.editing, hiddenByDefault: groupHiddenByDefault) peerLeftRevealOptions = [] } - + if item.interaction.inlineNavigationLocation != nil { peerRevealOptions = [] peerLeftRevealOptions = [] } - + let (onlineLayout, onlineApply) = onlineLayout(online, onlineIsVoiceChat) var animateContent = false if let currentItem = currentItem, currentItem.content.chatLocation == item.content.chatLocation { animateContent = true } - + let (measureLayout, measureApply) = makeMeasureLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: " ", font: titleFont, textColor: .black), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: titleRectWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) - + let titleSpacing: CGFloat = -1.0 let authorSpacing: CGFloat = -3.0 var itemHeight: CGFloat = 8.0 * 2.0 + 1.0 @@ -3870,16 +3885,16 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { itemHeight += titleSpacing itemHeight += authorSpacing } - + let rawContentRect = CGRect(origin: CGPoint(x: 2.0, y: layoutOffset + floor(item.presentationData.fontSize.itemListBaseFontSize * 8.0 / 17.0)), size: CGSize(width: rawContentWidth, height: itemHeight - 12.0 - 9.0)) - + let insets = ChatListItemNode.insets(first: first, last: last, firstWithHeader: firstWithHeader) var heightOffset: CGFloat = 0.0 if item.hiddenOffset { heightOffset = -itemHeight } let layout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: max(0.0, itemHeight + heightOffset)), insets: insets) - + var customActions: [ChatListItemAccessibilityCustomAction] = [] for option in peerLeftRevealOptions { customActions.append(ChatListItemAccessibilityCustomAction(name: option.title, target: nil, selector: #selector(ChatListItemNode.performLocalAccessibilityCustomAction(_:)), key: option.key)) @@ -3887,7 +3902,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { for option in peerRevealOptions { customActions.append(ChatListItemAccessibilityCustomAction(name: option.title, target: nil, selector: #selector(ChatListItemNode.performLocalAccessibilityCustomAction(_:)), key: option.key)) } - + return (layout, { [weak self] synchronousLoads, animated in if let strongSelf = self { strongSelf.layoutParams = (item, first, last, firstWithHeader, nextIsPinned, nextHasActiveRevealControls, params, countersSize) @@ -3898,50 +3913,50 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.cachedChatListQuoteSearchResult = chatListQuoteSearchResult strongSelf.cachedCustomTextEntities = customTextEntities strongSelf.onlineIsVoiceChat = onlineIsVoiceChat - + var animateOnline = animateOnline if let currentOnline = strongSelf.currentOnline, currentOnline == online { animateOnline = false } strongSelf.currentOnline = online - + if item.hiddenOffset { strongSelf.layer.zPosition = -1.0 } - + if case .groupReference = item.content { strongSelf.layer.sublayerTransform = CATransform3DMakeTranslation(0.0, layout.contentSize.height - itemHeight, 0.0) } - + if let _ = updatedTheme { strongSelf.separatorNode.backgroundColor = item.presentationData.theme.chatList.itemSeparatorColor } - + let revealOffset = 0.0 - + let transition: ContainedViewLayoutTransition if animated { transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) } else { transition = .immediate } - + transition.updateAlpha(node: strongSelf, alpha: item.hiddenOffset ? 0.0 : 1.0) ComponentTransition(transition).setBlur(layer: strongSelf.layer, radius: item.hiddenOffset ? 8.0 : 0.0) - + let contextContainerFrame = CGRect(origin: CGPoint(), size: CGSize(width: layout.contentSize.width, height: itemHeight)) // strongSelf.contextContainer.position = contextContainerFrame.center transition.updatePosition(node: strongSelf.contextContainer, position: contextContainerFrame.center) transition.updateBounds(node: strongSelf.contextContainer, bounds: contextContainerFrame.offsetBy(dx: -strongSelf.revealOffset, dy: 0.0)) - + var mainContentFrame: CGRect var mainContentBoundsOffset: CGFloat var mainContentAlpha: CGFloat = 1.0 - + if useChatListLayout { mainContentFrame = CGRect(origin: CGPoint(x: leftInset - 2.0, y: 0.0), size: CGSize(width: layout.contentSize.width, height: layout.contentSize.height)) mainContentBoundsOffset = mainContentFrame.origin.x - + if let inlineNavigationLocation = item.interaction.inlineNavigationLocation { mainContentAlpha = 1.0 - inlineNavigationLocation.progress mainContentBoundsOffset += (mainContentFrame.width - mainContentFrame.minX) * inlineNavigationLocation.progress @@ -3950,12 +3965,12 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { mainContentFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: layout.contentSize.width, height: layout.contentSize.height)) mainContentBoundsOffset = 0.0 } - + transition.updatePosition(node: strongSelf.mainContentContainerNode, position: mainContentFrame.center) - + transition.updateBounds(node: strongSelf.mainContentContainerNode, bounds: CGRect(origin: CGPoint(x: mainContentBoundsOffset, y: 0.0), size: mainContentFrame.size)) transition.updateAlpha(node: strongSelf.mainContentContainerNode, alpha: mainContentAlpha) - + var crossfadeContent = false if let selectableControlSizeAndApply = selectableControlSizeAndApply { let selectableControlSize = CGSize(width: selectableControlSizeAndApply.0, height: layout.contentSize.height) @@ -3983,7 +3998,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { selectableControlNode?.removeFromSupernode() }) } - + var animateBadges = animateContent if let reorderControlSizeAndApply = reorderControlSizeAndApply { let reorderControlFrame = CGRect(origin: CGPoint(x: params.width + revealOffset - params.rightInset - reorderControlSizeAndApply.0, y: layoutOffset), size: CGSize(width: reorderControlSizeAndApply.0, height: layout.contentSize.height)) @@ -3994,7 +4009,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { reorderControlNode.frame = reorderControlFrame reorderControlNode.alpha = 0.0 transition.updateAlpha(node: reorderControlNode, alpha: 1.0) - + transition.updateAlpha(node: strongSelf.dateNode, alpha: 0.0) if let dateStatusIconNode = strongSelf.dateStatusIconNode { transition.updateAlpha(node: dateStatusIconNode, alpha: 0.0) @@ -4022,32 +4037,32 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { transition.updateAlpha(node: strongSelf.pinnedIconNode, alpha: 1.0) transition.updateAlpha(node: strongSelf.statusNode, alpha: 1.0) } - + let contentRect = rawContentRect.offsetBy(dx: editingOffset + leftInset + revealOffset, dy: 0.0) - + let avatarFrame = CGRect(origin: CGPoint(x: leftInset - avatarLeftInset + editingOffset + 10.0 + 6.0 + revealOffset, y: floor((itemHeight - avatarDiameter) / 2.0)), size: CGSize(width: avatarDiameter, height: avatarDiameter)) var avatarScaleOffset: CGFloat = 0.0 var avatarScale: CGFloat = 1.0 if let inlineNavigationLocation = item.interaction.inlineNavigationLocation { let targetAvatarScale: CGFloat = floor(item.presentationData.fontSize.itemListBaseFontSize * 54.0 / 17.0) / avatarFrame.width avatarScale = targetAvatarScale * inlineNavigationLocation.progress + 1.0 * (1.0 - inlineNavigationLocation.progress) - + let targetAvatarScaleOffset: CGFloat = -(avatarFrame.width - avatarFrame.width * avatarScale) * 0.5 avatarScaleOffset = targetAvatarScaleOffset * inlineNavigationLocation.progress } - + transition.updateFrame(node: strongSelf.avatarContainerNode, frame: avatarFrame) transition.updatePosition(node: strongSelf.avatarNode, position: avatarFrame.offsetBy(dx: -avatarFrame.minX, dy: -avatarFrame.minY).center.offsetBy(dx: avatarScaleOffset, dy: 0.0)) transition.updateBounds(node: strongSelf.avatarNode, bounds: CGRect(origin: CGPoint(), size: avatarFrame.size)) transition.updateTransformScale(node: strongSelf.avatarNode, scale: avatarScale) strongSelf.avatarNode.updateSize(size: avatarFrame.size) strongSelf.updateVideoVisibility() - + var itemPeerId: EnginePeer.Id? if case let .chatList(index) = item.index { itemPeerId = index.messageIndex.id.peerId } - + if let itemPeerId = itemPeerId, let inlineNavigationLocation = item.interaction.inlineNavigationLocation, inlineNavigationLocation.location.peerId == itemPeerId { let inlineNavigationMarkLayer: SimpleLayer var animateIn = false @@ -4076,10 +4091,10 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { transition.updatePosition(layer: inlineNavigationMarkLayer, position: CGPoint(x: -inlineNavigationMarkLayer.bounds.width * 0.5, y: avatarFrame.midY)) } } - + if let inlineNavigationLocation = item.interaction.inlineNavigationLocation, badgeContent != .none { var animateIn = false - + let avatarBadgeBackground: ASImageNode if let current = strongSelf.avatarBadgeBackground { avatarBadgeBackground = current @@ -4088,9 +4103,9 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.avatarBadgeBackground = avatarBadgeBackground strongSelf.avatarNode.addSubnode(avatarBadgeBackground) } - + avatarBadgeBackground.image = currentAvatarBadgeCleanBackgroundImage - + let avatarBadgeNode: ChatListBadgeNode if let current = strongSelf.avatarBadgeNode { avatarBadgeNode = current @@ -4101,18 +4116,18 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.avatarBadgeNode = avatarBadgeNode strongSelf.avatarNode.addSubnode(avatarBadgeNode) } - + let makeAvatarBadgeLayout = avatarBadgeNode.asyncLayout() let (avatarBadgeLayout, avatarBadgeApply) = makeAvatarBadgeLayout(CGSize(width: rawContentWidth, height: CGFloat.greatestFiniteMagnitude), avatarBadgeDiameter, avatarBadgeFont, currentAvatarBadgeBackgroundImage, badgeContent) let _ = avatarBadgeApply(animateBadges, false) let avatarBadgeFrame = CGRect(origin: CGPoint(x: avatarFrame.width - avatarBadgeLayout.width, y: avatarFrame.height - avatarBadgeLayout.height), size: avatarBadgeLayout) avatarBadgeNode.position = avatarBadgeFrame.center avatarBadgeNode.bounds = CGRect(origin: CGPoint(), size: avatarBadgeFrame.size) - + let avatarBadgeBackgroundFrame = avatarBadgeFrame.insetBy(dx: -2.0, dy: -2.0) avatarBadgeBackground.position = avatarBadgeBackgroundFrame.center avatarBadgeBackground.bounds = CGRect(origin: CGPoint(), size: avatarBadgeBackgroundFrame.size) - + if animateIn { ContainedViewLayoutTransition.immediate.updateSublayerTransformScale(node: avatarBadgeNode, scale: 0.00001) ContainedViewLayoutTransition.immediate.updateTransformScale(layer: avatarBadgeBackground.layer, scale: 0.00001) @@ -4131,7 +4146,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { }) } } - + if let threadInfo = threadInfo, !displayAsMessage { let avatarIconView: ComponentHostView if let current = strongSelf.avatarIconView { @@ -4141,7 +4156,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.avatarIconView = avatarIconView strongSelf.mainContentContainerNode.view.addSubview(avatarIconView) } - + let avatarIconContent: EmojiStatusComponent.Content if threadInfo.id == 1 { avatarIconContent = .image(image: PresentationResourcesChatList.generalTopicIcon(item.presentationData.theme), tintColor: nil) @@ -4150,7 +4165,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { avatarIconContent = .topic(title: String(threadInfo.info.title.prefix(1)), color: threadInfo.info.iconColor, size: CGSize(width: 32.0, height: 32.0)) } - + let avatarIconComponent = EmojiStatusComponent( context: item.context, animationCache: item.interaction.animationCache, @@ -4160,14 +4175,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { action: nil ) strongSelf.avatarIconComponent = avatarIconComponent - + let iconSize = avatarIconView.update( transition: .immediate, component: AnyComponent(avatarIconComponent), environment: {}, containerSize: item.interaction.isInlineMode ? CGSize(width: 18.0, height: 18.0) : CGSize(width: 32.0, height: 32.0) ) - + let avatarIconFrame: CGRect if item.interaction.isInlineMode { avatarIconFrame = CGRect(origin: CGPoint(x: contentRect.origin.x, y: contentRect.origin.y + 1.0), size: iconSize) @@ -4179,13 +4194,13 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.avatarIconView = nil avatarIconView.removeFromSuperview() } - + if !useChatListLayout { strongSelf.avatarContainerNode.isHidden = true } else { strongSelf.avatarContainerNode.isHidden = false } - + let onlineFrame: CGRect if onlineIsVoiceChat { onlineFrame = CGRect(origin: CGPoint(x: avatarFrame.width - onlineLayout.width + 1.0 - UIScreenPixel, y: avatarFrame.height - onlineLayout.height + 1.0 - UIScreenPixel), size: onlineLayout) @@ -4193,29 +4208,29 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { onlineFrame = CGRect(origin: CGPoint(x: avatarFrame.width - onlineLayout.width - 2.0, y: avatarFrame.height - onlineLayout.height - 2.0), size: onlineLayout) } transition.updateFrame(node: strongSelf.onlineNode, frame: onlineFrame) - + if let avatarLiveBadge = strongSelf.avatarLiveBadge, let iconImage = avatarLiveBadge.foreground.image, let outlineImage = avatarLiveBadge.outline.image { let outlineInset = (outlineImage.size.height - iconImage.size.height) * 0.5 let liveBadgeFrame = CGRect(origin: CGPoint(x: floor((avatarFrame.width - iconImage.size.width) * 0.5), y: avatarFrame.height + 5.0 - iconImage.size.height), size: iconImage.size) transition.updateFrame(view: avatarLiveBadge.foreground, frame: liveBadgeFrame) transition.updateFrame(view: avatarLiveBadge.outline, frame: liveBadgeFrame.insetBy(dx: -outlineInset, dy: -outlineInset)) - + let effectiveBackgroundColor: UIColor if item.isPinned { effectiveBackgroundColor = item.presentationData.theme.chatList.pinnedItemBackgroundColor } else { effectiveBackgroundColor = item.presentationData.theme.chatList.itemBackgroundColor } - + let highlightAlpha = strongSelf.highlightedBackgroundNode.supernode == nil ? 0.0 : strongSelf.highlightedBackgroundNode.alpha let outlineColor = item.presentationData.theme.chatList.itemHighlightedBackgroundColor.mixedWith(effectiveBackgroundColor, alpha: 1.0 - highlightAlpha) transition.updateTintColor(view: avatarLiveBadge.outline, color: outlineColor) } - + let onlineInlineNavigationFraction: CGFloat = item.interaction.inlineNavigationLocation?.progress ?? 0.0 transition.updateAlpha(node: strongSelf.onlineNode, alpha: 1.0 - onlineInlineNavigationFraction) transition.updateSublayerTransformScale(node: strongSelf.onlineNode, scale: (1.0 - onlineInlineNavigationFraction) * 1.0 + onlineInlineNavigationFraction * 0.00001) - + let onlineIcon: UIImage? let effectiveBackgroundColor: UIColor if strongSelf.reallyHighlighted { @@ -4229,7 +4244,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { effectiveBackgroundColor = item.presentationData.theme.chatList.itemBackgroundColor } strongSelf.onlineNode.setImage(onlineIcon, color: item.presentationData.theme.list.itemCheckColors.foregroundColor, transition: .immediate) - + if isSubscription, autoremoveTimeout == nil { let starView: StarView if let current = strongSelf.starView { @@ -4240,7 +4255,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.contextContainer.view.addSubview(starView) } starView.outlineColor = effectiveBackgroundColor - + let starSize = CGSize(width: 20.0, height: 20.0) let starFrame = CGRect(origin: CGPoint(x: avatarFrame.maxX - starSize.width + 1.0, y: avatarFrame.maxY - starSize.height + 1.0), size: starSize) transition.updateFrame(view: starView, frame: starFrame) @@ -4248,14 +4263,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.starView = nil starView.removeFromSuperview() } - + let autoremoveTimeoutFraction: CGFloat if online { autoremoveTimeoutFraction = 0.0 } else { autoremoveTimeoutFraction = 1.0 - onlineInlineNavigationFraction } - + if let autoremoveTimeout = autoremoveTimeout { let avatarTimerBadge: AvatarBadgeView var avatarTimerTransition = transition @@ -4276,17 +4291,17 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { avatarTimerTransition.updatePosition(layer: avatarTimerBadge.layer, position: avatarBadgeFrame.center) avatarTimerTransition.updateBounds(layer: avatarTimerBadge.layer, bounds: CGRect(origin: CGPoint(), size: avatarBadgeFrame.size)) avatarTimerTransition.updateTransformScale(layer: avatarTimerBadge.layer, scale: autoremoveTimeoutFraction * 1.0 + (1.0 - autoremoveTimeoutFraction) * 0.00001) - + strongSelf.avatarNode.badgeView = avatarTimerBadge } else if let avatarTimerBadge = strongSelf.avatarTimerBadge { strongSelf.avatarTimerBadge = nil strongSelf.avatarNode.badgeView = nil avatarTimerBadge.removeFromSuperview() } - + let _ = measureApply() let _ = dateApply() - + var currentTextSnapshotView: UIView? if transition.isAnimated, let currentItem, currentItem.editing != item.editing, strongSelf.textNode.textNode.cachedLayout?.linesRects() != textLayout.linesRects() { if let textSnapshotView = strongSelf.textNode.textNode.view.snapshotContentTree() { @@ -4299,7 +4314,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.18) } } - + let _ = textApply(TextNodeWithEntities.Arguments( context: item.context, cache: item.interaction.animationCache, @@ -4307,22 +4322,22 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { placeholderColor: item.presentationData.theme.list.mediaPlaceholderColor, attemptSynchronous: synchronousLoads )) - + var topForumTopicRect = authorApply() if !isFirstForumThreadSelectable { topForumTopicRect = nil } - + let _ = titleApply() let _ = badgeApply(animateBadges, !isMuted) let _ = mentionBadgeApply(animateBadges, true) let _ = onlineApply(animateContent && animateOnline) - + var dateFrame = CGRect(origin: CGPoint(x: contentRect.maxX - dateLayout.size.width, y: contentRect.origin.y + 2.0), size: dateLayout.size) - + if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData, customMessageListData.messageCount != nil, customMessageListData.commandPrefix == nil { dateFrame.origin.x -= 10.0 - + let dateDisclosureIconView: UIImageView if let current = strongSelf.dateDisclosureIconView { dateDisclosureIconView = current @@ -4342,13 +4357,13 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.dateDisclosureIconView = nil dateDisclosureIconView.removeFromSuperview() } - + transition.updateFrame(node: strongSelf.dateNode, frame: dateFrame) - + var statusOffset: CGFloat = 0.0 if let dateIconImage { statusOffset += 2.0 + dateIconImage.size.width + 4.0 - + let dateStatusIconNode: ASImageNode if let current = strongSelf.dateStatusIconNode { dateStatusIconNode = current @@ -4358,70 +4373,70 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.mainContentContainerNode.addSubnode(dateStatusIconNode) } dateStatusIconNode.image = dateIconImage - + var dateStatusX: CGFloat = contentRect.origin.x dateStatusX += contentRect.size.width dateStatusX += -dateLayout.size.width - 4.0 - dateIconImage.size.width - + var dateStatusY: CGFloat = contentRect.origin.y + 2.0 + UIScreenPixel dateStatusY += -UIScreenPixel + floor((dateLayout.size.height - dateIconImage.size.height) / 2.0) - + transition.updateFrame(node: dateStatusIconNode, frame: CGRect(origin: CGPoint(x: dateStatusX, y: dateStatusY), size: dateIconImage.size)) } else if let dateStatusIconNode = strongSelf.dateStatusIconNode { strongSelf.dateStatusIconNode = nil dateStatusIconNode.removeFromSupernode() } - + let statusSize = CGSize(width: 24.0, height: 24.0) - + var statusX: CGFloat = contentRect.origin.x statusX += contentRect.size.width statusX += -dateLayout.size.width - statusSize.width - statusOffset - + strongSelf.statusNode.frame = CGRect(origin: CGPoint(x: statusX, y: contentRect.origin.y + 2.0 - UIScreenPixel + floor((dateLayout.size.height - statusSize.height) / 2.0)), size: statusSize) strongSelf.statusNode.fontSize = item.presentationData.fontSize.itemListBaseFontSize let _ = strongSelf.statusNode.transitionToState(statusState, animated: animateContent) - + let rightAccessoryVerticalOffset: CGFloat = floorToScreenPixels(-4.0 * min(1.0, item.presentationData.fontSize.itemListBaseFontSize / 17.0)) var nextBadgeX: CGFloat = contentRect.maxX if let _ = currentBadgeBackgroundImage { let badgeFrame = CGRect(x: nextBadgeX - badgeLayout.width, y: contentRect.maxY - badgeLayout.height + rightAccessoryVerticalOffset, width: badgeLayout.width, height: badgeLayout.height) - + transition.updateFrame(node: strongSelf.badgeNode, frame: badgeFrame) nextBadgeX -= badgeLayout.width + 6.0 } - + if currentMentionBadgeImage != nil || currentBadgeBackgroundImage != nil { let badgeFrame = CGRect(x: nextBadgeX - mentionBadgeLayout.width, y: contentRect.maxY - mentionBadgeLayout.height + rightAccessoryVerticalOffset, width: mentionBadgeLayout.width, height: mentionBadgeLayout.height) - + transition.updateFrame(node: strongSelf.mentionBadgeNode, frame: badgeFrame) nextBadgeX -= mentionBadgeLayout.width + 6.0 } - + if let currentPinnedIconImage = currentPinnedIconImage { strongSelf.pinnedIconNode.image = currentPinnedIconImage strongSelf.pinnedIconNode.isHidden = false - + let pinnedIconSize = currentPinnedIconImage.size let pinnedIconFrame = CGRect(x: nextBadgeX - pinnedIconSize.width, y: contentRect.maxY - pinnedIconSize.height + rightAccessoryVerticalOffset, width: pinnedIconSize.width, height: pinnedIconSize.height) - + strongSelf.pinnedIconNode.frame = pinnedIconFrame nextBadgeX -= pinnedIconSize.width + 6.0 } else { strongSelf.pinnedIconNode.image = nil strongSelf.pinnedIconNode.isHidden = true } - + if let (actionButtonTitleNodeLayout, apply) = actionButtonTitleNodeLayoutAndApply { let actionButtonSideInset = floor(item.presentationData.fontSize.itemListBaseFontSize * 12.0 / 17.0) let actionButtonTopInset = floor(item.presentationData.fontSize.itemListBaseFontSize * 5.0 / 17.0) let actionButtonBottomInset = floor(item.presentationData.fontSize.itemListBaseFontSize * 4.0 / 17.0) - + let actionButtonSize = CGSize(width: actionButtonTitleNodeLayout.size.width + actionButtonSideInset * 2.0, height: actionButtonTitleNodeLayout.size.height + actionButtonTopInset + actionButtonBottomInset) var actionButtonFrame = CGRect(x: nextBadgeX - actionButtonSize.width, y: contentRect.minY + floor((contentRect.height - actionButtonSize.height) * 0.5), width: actionButtonSize.width, height: actionButtonSize.height) actionButtonFrame.origin.y = max(actionButtonFrame.origin.y, dateFrame.maxY + floor(item.presentationData.fontSize.itemListBaseFontSize * 4.0 / 17.0)) actionButtonFrame.origin.y += 4.0 - + let actionButtonNode: HighlightableButtonNode var animateActionButtonIn = false if let current = strongSelf.actionButtonNode { @@ -4433,7 +4448,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.mainContentContainerNode.addSubnode(actionButtonNode) actionButtonNode.addTarget(strongSelf, action: #selector(strongSelf.actionButtonPressed), forControlEvents: .touchUpInside) } - + let actionButtonBackgroundView: UIImageView if let current = strongSelf.actionButtonBackgroundView { actionButtonBackgroundView = current @@ -4442,20 +4457,20 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.actionButtonBackgroundView = actionButtonBackgroundView actionButtonNode.view.addSubview(actionButtonBackgroundView) } - + if actionButtonBackgroundView.image?.size.height != actionButtonSize.height { actionButtonBackgroundView.image = generateStretchableFilledCircleImage(diameter: actionButtonSize.height, color: .white)?.withRenderingMode(.alwaysTemplate) } - + actionButtonBackgroundView.tintColor = theme.unreadBadgeActiveBackgroundColor - + let actionButtonTitleNode = apply() if strongSelf.actionButtonTitleNode !== actionButtonTitleNode { strongSelf.actionButtonTitleNode?.removeFromSupernode() strongSelf.actionButtonTitleNode = actionButtonTitleNode actionButtonNode.addSubnode(actionButtonTitleNode) } - + actionButtonNode.isUserInteractionEnabled = true actionButtonNode.frame = actionButtonFrame actionButtonBackgroundView.frame = CGRect(origin: CGPoint(), size: actionButtonFrame.size) @@ -4464,7 +4479,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { actionButtonNode.alpha = 0.0 } transition.updateAlpha(node: actionButtonNode, alpha: 1.0) - + nextBadgeX -= actionButtonSize.width + 6.0 } else { if let actionButtonNode = strongSelf.actionButtonNode { @@ -4492,7 +4507,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + var titleOffset: CGFloat = titleLeftOffset if let currentSecretIconImage = currentSecretIconImage { let iconNode: ASImageNode @@ -4513,15 +4528,15 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.secretIconNode = nil secretIconNode.removeFromSupernode() } - + let contentDelta = CGPoint(x: contentRect.origin.x - (strongSelf.titleNode.frame.minX - titleOffset), y: contentRect.origin.y - (strongSelf.titleNode.frame.minY - UIScreenPixel)) let titleFrame = CGRect(origin: CGPoint(x: contentRect.origin.x + titleOffset, y: contentRect.origin.y + UIScreenPixel), size: titleLayout.size) strongSelf.titleNode.frame = titleFrame - + let authorNodeFrame = CGRect(origin: CGPoint(x: contentRect.origin.x - 1.0, y: contentRect.minY + titleLayout.size.height - 2.0), size: authorLayout) strongSelf.authorNode.frame = authorNodeFrame let textNodeFrame = CGRect(origin: CGPoint(x: contentRect.origin.x - 1.0, y: contentRect.minY + titleLayout.size.height - 2.0 + (authorLayout.height.isZero ? 0.0 : (authorLayout.height - 3.0))), size: textLayout.size) - + if let topForumTopicRect, !isSearching { let compoundHighlightingNode: LinkHighlightingNode if let current = strongSelf.compoundHighlightingNode { @@ -4533,7 +4548,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.compoundHighlightingNode = compoundHighlightingNode strongSelf.mainContentContainerNode.insertSubnode(compoundHighlightingNode, at: 0) } - + let compoundTextButtonNode: HighlightTrackingButtonNode if let current = strongSelf.compoundTextButtonNode { compoundTextButtonNode = current @@ -4554,7 +4569,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { compoundHighlightingNode.alpha = 1.0 compoundHighlightingNode.layer.animateAlpha(from: 0.65, to: 1.0, duration: 0.2) - + let prevAlpha = strongSelf.textNode.textNode.alpha strongSelf.textNode.textNode.alpha = strongSelf.authorNode.alpha strongSelf.textNode.textNode.layer.animateAlpha(from: prevAlpha, to: strongSelf.authorNode.alpha, duration: 0.2) @@ -4562,7 +4577,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + var topRect = topForumTopicRect topRect.origin.x -= 1.0 topRect.size.width += 2.0 @@ -4570,28 +4585,28 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { textRect.origin.x = topRect.minX textRect.size.height -= 1.0 textRect.size.width += 16.0 - + compoundHighlightingNode.frame = CGRect(origin: CGPoint(x: authorNodeFrame.minX, y: authorNodeFrame.minY), size: CGSize(width: textNodeFrame.maxX - authorNodeFrame.minX, height: textNodeFrame.maxY - authorNodeFrame.minY)) - + let midY = floor((topForumTopicRect.minY + textRect.maxY) / 2.0) + 1.0 - + let finalTopRect = CGRect(origin: topRect.origin, size: CGSize(width: topRect.width, height: midY - topRect.minY)) var finalBottomRect = CGRect(origin: CGPoint(x: textRect.minX, y: midY), size: CGSize(width: textRect.width, height: textRect.maxY - midY)) if finalBottomRect.maxX < finalTopRect.maxX && abs(finalBottomRect.maxX - finalTopRect.maxX) < 5.0 { finalBottomRect.size.width = finalTopRect.maxX - finalBottomRect.minX } - + compoundHighlightingNode.inset = 0.0 compoundHighlightingNode.outerRadius = floor(finalBottomRect.height * 0.5) compoundHighlightingNode.innerRadius = 4.0 - + compoundHighlightingNode.updateRects([ finalTopRect, finalBottomRect ], color: theme.pinnedItemBackgroundColor.mixedWith(theme.unreadBadgeInactiveBackgroundColor, alpha: 0.1)) - + transition.updateFrame(node: compoundTextButtonNode, frame: compoundHighlightingNode.frame) - + if let textArrowImage = textArrowImage { let textArrowNode: ASImageNode if let current = strongSelf.textArrowNode { @@ -4623,7 +4638,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { textArrowNode.removeFromSupernode() } } - + if let compoundTextButtonNode = strongSelf.compoundTextButtonNode { if strongSelf.textNode.textNode.supernode !== compoundTextButtonNode { compoundTextButtonNode.addSubnode(strongSelf.textNode.textNode) @@ -4632,7 +4647,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } strongSelf.textNode.textNode.frame = textNodeFrame.offsetBy(dx: -compoundTextButtonNode.frame.minX, dy: -compoundTextButtonNode.frame.minY) - + strongSelf.authorNode.assignParentNode(parentNode: compoundTextButtonNode) } else { if strongSelf.textNode.textNode.supernode !== strongSelf.mainContentContainerNode { @@ -4642,17 +4657,17 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } strongSelf.textNode.textNode.frame = textNodeFrame - + strongSelf.authorNode.assignParentNode(parentNode: nil) } - + if let currentTextSnapshotView { transition.updatePosition(layer: currentTextSnapshotView.layer, position: textNodeFrame.origin) } - + if let trailingTextBadgeLayoutAndApply { let badgeSize = CGSize(width: trailingTextBadgeLayoutAndApply.0.size.width + trailingTextBadgeInsets.left + trailingTextBadgeInsets.right, height: trailingTextBadgeLayoutAndApply.0.size.height + trailingTextBadgeInsets.top + trailingTextBadgeInsets.bottom - UIScreenPixel) - + var badgeFrame: CGRect if textLayout.numberOfLines > 1 { badgeFrame = CGRect(origin: CGPoint(x: textLayout.trailingLineWidth + 4.0, y: textNodeFrame.height - 3.0 - badgeSize.height), size: badgeSize) @@ -4660,11 +4675,11 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { let firstLineFrame = textLayout.linesRects().first ?? CGRect(origin: CGPoint(), size: textNodeFrame.size) badgeFrame = CGRect(origin: CGPoint(x: 0.0, y: firstLineFrame.height + 5.0), size: badgeSize) } - + if badgeFrame.origin.x + badgeFrame.width >= textNodeFrame.width - 2.0 - 10.0 { badgeFrame.origin.x = textNodeFrame.width - 2.0 - badgeFrame.width } - + let trailingTextBadgeBackground: UIImageView if let current = strongSelf.trailingTextBadgeBackground { trailingTextBadgeBackground = current @@ -4674,20 +4689,20 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.textNode.textNode.view.addSubview(trailingTextBadgeBackground) } trailingTextBadgeBackground.tintColor = theme.pinnedItemBackgroundColor.mixedWith(theme.unreadBadgeInactiveBackgroundColor, alpha: 0.1) - + trailingTextBadgeBackground.frame = badgeFrame - + let trailingTextBadgeFrame = CGRect(origin: CGPoint(x: badgeFrame.minX + trailingTextBadgeInsets.left, y: badgeFrame.minY + trailingTextBadgeInsets.top), size: trailingTextBadgeLayoutAndApply.0.size) let trailingTextBadgeNode = trailingTextBadgeLayoutAndApply.1() if strongSelf.trailingTextBadgeNode !== trailingTextBadgeNode { strongSelf.trailingTextBadgeNode?.removeFromSupernode() strongSelf.trailingTextBadgeNode = trailingTextBadgeNode - + strongSelf.textNode.textNode.addSubnode(trailingTextBadgeNode) - + trailingTextBadgeNode.layer.anchorPoint = CGPoint() } - + trailingTextBadgeNode.frame = trailingTextBadgeFrame } else { if let trailingTextBadgeNode = strongSelf.trailingTextBadgeNode { @@ -4699,12 +4714,12 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { trailingTextBadgeBackground.removeFromSuperview() } } - + if !itemTags.isEmpty { let sizeFactor = item.presentationData.fontSize.itemListBaseFontSize / 17.0 - + let itemTagListFrame = CGRect(origin: CGPoint(x: contentRect.minX, y: contentRect.minY + measureLayout.size.height * 2.0 + floorToScreenPixels(2.0 * sizeFactor)), size: CGSize(width: contentRect.width, height: floorToScreenPixels(20.0 * sizeFactor))) - + var itemTagListTransition = transition let itemTagList: ComponentView if let current = strongSelf.itemTagList { @@ -4730,7 +4745,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { itemTagListView.isUserInteractionEnabled = false strongSelf.mainContentContainerNode.view.addSubview(itemTagListView) } - + itemTagListTransition.updateFrame(view: itemTagListView, frame: itemTagListFrame) itemTagListView.isVisible = strongSelf.visibilityStatus && item.context.sharedContext.energyUsageSettings.loopEmoji } @@ -4740,7 +4755,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { itemTagList.view?.removeFromSuperview() } } - + if !textLayout.spoilers.isEmpty { let dustNode: InvisibleInkDustNode if let current = strongSelf.dustNode { @@ -4749,17 +4764,17 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { dustNode = InvisibleInkDustNode(textNode: nil, enableAnimations: item.context.sharedContext.energyUsageSettings.fullTranslucency) dustNode.isUserInteractionEnabled = false strongSelf.dustNode = dustNode - + strongSelf.textNode.textNode.supernode?.insertSubnode(dustNode, aboveSubnode: strongSelf.textNode.textNode) } dustNode.update(size: textNodeFrame.size, color: theme.messageTextColor, textColor: theme.messageTextColor, rects: textLayout.spoilers.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 0.0, dy: 1.0) }, wordRects: textLayout.spoilerWords.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 0.0, dy: 1.0) }) dustNode.frame = textNodeFrame.insetBy(dx: -3.0, dy: -3.0).offsetBy(dx: 0.0, dy: 3.0) - + } else if let dustNode = strongSelf.dustNode { strongSelf.dustNode = nil dustNode.removeFromSupernode() } - + var animateInputActivitiesFrame = false let inputActivities = inputActivities?.filter({ switch $0.1 { @@ -4769,21 +4784,21 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { return true } }) - + if let inputActivities = inputActivities, !inputActivities.isEmpty { if strongSelf.inputActivitiesNode.supernode == nil { strongSelf.mainContentContainerNode.addSubnode(strongSelf.inputActivitiesNode) } else { animateInputActivitiesFrame = true } - + if strongSelf.inputActivitiesNode.alpha.isZero { strongSelf.inputActivitiesNode.alpha = 1.0 strongSelf.textNode.textNode.alpha = 0.0 strongSelf.authorNode.alpha = 0.0 strongSelf.compoundHighlightingNode?.alpha = 0.0 strongSelf.forwardedIconNode.alpha = 0.0 - + if animated || animateContent { strongSelf.inputActivitiesNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) strongSelf.textNode.textNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15) @@ -4827,12 +4842,12 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } inputActivitiesApply?() - + var mediaPreviewOffset = textNodeFrame.origin.offsetBy(dx: 1.0, dy: 1.0 + floor((measureLayout.size.height - contentImageSize.height) / 2.0)) - + let messageTypeIconImage = currentMessageTypeIcon let messageTypeIconOffset = CGPoint(x: mediaPreviewOffset.x + currentMessageTypeIconOffset.x, y: mediaPreviewOffset.y + currentMessageTypeIconOffset.y) - + if let messageTypeIconImage { strongSelf.forwardedIconNode.image = messageTypeIconImage if strongSelf.forwardedIconNode.supernode == nil { @@ -4844,13 +4859,13 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else if strongSelf.forwardedIconNode.supernode != nil { strongSelf.forwardedIconNode.removeFromSupernode() } - + var validMediaIds: [EngineMedia.Id] = [] for spec in contentImageSpecs { let message = spec.message let media = spec.media let mediaSize = spec.size - + var mediaId = media.id if mediaId == nil, case let .action(action) = media, case let .suggestedProfilePhoto(image) = action.action { mediaId = image?.id @@ -4888,15 +4903,15 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } strongSelf.currentMediaPreviewSpecs = contentImageSpecs strongSelf.currentTextLeftCutout = textLeftCutout - + if let avatarContentImageSpec { strongSelf.avatarNode.isHidden = true - + if let previous = strongSelf.avatarMediaNode, previous.media != avatarContentImageSpec.media { strongSelf.avatarMediaNode = nil previous.removeFromSupernode() } - + var avatarMediaNodeTransition = transition let avatarMediaNode: ChatListMediaPreviewNode if let current = strongSelf.avatarMediaNode { @@ -4907,29 +4922,29 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.avatarMediaNode = avatarMediaNode strongSelf.contextContainer.addSubnode(avatarMediaNode) } - + avatarMediaNodeTransition.updateFrame(node: avatarMediaNode, frame: avatarFrame) avatarMediaNode.updateLayout(size: avatarFrame.size, synchronousLoads: synchronousLoads) } else { strongSelf.avatarNode.isHidden = false - + if let avatarMediaNode = strongSelf.avatarMediaNode { strongSelf.avatarMediaNode = nil avatarMediaNode.removeFromSupernode() } } - + if !contentDelta.x.isZero || !contentDelta.y.isZero { let titlePosition = strongSelf.titleNode.position transition.animatePosition(node: strongSelf.titleNode, from: CGPoint(x: titlePosition.x - contentDelta.x, y: titlePosition.y - contentDelta.y)) - + if strongSelf.textNode.textNode.supernode === strongSelf.mainContentContainerNode { transition.animatePositionAdditive(node: strongSelf.textNode.textNode, offset: CGPoint(x: -contentDelta.x, y: -contentDelta.y)) if let dustNode = strongSelf.dustNode { transition.animatePositionAdditive(node: dustNode, offset: CGPoint(x: -contentDelta.x, y: -contentDelta.y)) } } - + let authorPosition = strongSelf.authorNode.position transition.animatePosition(node: strongSelf.authorNode, from: CGPoint(x: authorPosition.x - contentDelta.x, y: authorPosition.y - contentDelta.y)) if let compoundHighlightingNode = strongSelf.compoundHighlightingNode { @@ -4937,13 +4952,13 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { transition.animatePosition(node: compoundHighlightingNode, from: CGPoint(x: compoundHighlightingPosition.x - contentDelta.x, y: compoundHighlightingPosition.y - contentDelta.y)) } } - + if crossfadeContent { strongSelf.authorNode.recursivelyEnsureDisplaySynchronously(true) strongSelf.titleNode.recursivelyEnsureDisplaySynchronously(true) strongSelf.textNode.textNode.recursivelyEnsureDisplaySynchronously(true) } - + var nextTitleIconOrigin: CGFloat = contentRect.origin.x + titleLayout.trailingLineWidth + 3.0 + titleOffset let lastLineRect: CGRect if let rect = titleLayout.linesRects().last { @@ -4951,7 +4966,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { lastLineRect = CGRect(origin: CGPoint(), size: titleLayout.size) } - + if let currentStatusIconContent { let statusIconView: ComponentHostView if let current = strongSelf.statusIconView { @@ -4961,7 +4976,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.statusIconView = statusIconView strongSelf.mainContentContainerNode.view.addSubview(statusIconView) } - + let statusIconComponent = EmojiStatusComponent( context: item.context, animationCache: item.interaction.animationCache, @@ -4972,7 +4987,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { action: nil ) strongSelf.statusIconComponent = statusIconComponent - + let iconOrigin: CGFloat = nextTitleIconOrigin let containerSize = CGSize(width: 20.0, height: 20.0) let iconSize = statusIconView.update( @@ -4987,7 +5002,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.statusIconView = nil statusIconView.removeFromSuperview() } - + if let currentCredibilityIconContent { let credibilityIconView: ComponentHostView if let current = strongSelf.credibilityIconView { @@ -4997,7 +5012,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.credibilityIconView = credibilityIconView strongSelf.mainContentContainerNode.view.addSubview(credibilityIconView) } - + let credibilityIconComponent = EmojiStatusComponent( context: item.context, animationCache: item.interaction.animationCache, @@ -5007,7 +5022,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { action: nil ) strongSelf.credibilityIconComponent = credibilityIconComponent - + let iconOrigin: CGFloat = nextTitleIconOrigin let containerSize: CGSize if case .verified = currentCredibilityIconContent { @@ -5027,7 +5042,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.credibilityIconView = nil credibilityIconView.removeFromSuperview() } - + if let currentVerifiedIconContent { let verifiedIconView: ComponentHostView if let current = strongSelf.verifiedIconView { @@ -5037,7 +5052,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.verifiedIconView = verifiedIconView strongSelf.mainContentContainerNode.view.addSubview(verifiedIconView) } - + let verifiedIconComponent = EmojiStatusComponent( context: item.context, animationCache: item.interaction.animationCache, @@ -5047,7 +5062,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { action: nil ) strongSelf.verifiedIconComponent = verifiedIconComponent - + let iconOrigin: CGFloat if case .animation = currentVerifiedIconContent { iconOrigin = contentRect.origin.x @@ -5055,7 +5070,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { iconOrigin = nextTitleIconOrigin } let containerSize = CGSize(width: 16.0, height: 16.0) - + let iconSize = verifiedIconView.update( transition: .immediate, component: AnyComponent(verifiedIconComponent), @@ -5063,11 +5078,51 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { containerSize: containerSize ) transition.updateFrame(view: verifiedIconView, frame: CGRect(origin: CGPoint(x: iconOrigin, y: floorToScreenPixels(titleFrame.maxY - lastLineRect.height * 0.5 - iconSize.height / 2.0) - UIScreenPixel), size: iconSize)) + if case .animation = currentVerifiedIconContent { + } else { + nextTitleIconOrigin += verifiedIconView.bounds.width + 4.0 + } } else if let verifiedIconView = strongSelf.verifiedIconView { strongSelf.verifiedIconView = nil verifiedIconView.removeFromSuperview() } - + + // WinterGram: snowflake badge for official peers, placed after the premium/emoji status. + if currentWinterGramOfficial { + let winterGramIconView: ComponentHostView + if let current = strongSelf.winterGramIconView { + winterGramIconView = current + } else { + winterGramIconView = ComponentHostView() + strongSelf.winterGramIconView = winterGramIconView + strongSelf.mainContentContainerNode.view.addSubview(winterGramIconView) + } + + let winterGramIconComponent = EmojiStatusComponent( + context: item.context, + animationCache: item.interaction.animationCache, + animationRenderer: item.interaction.animationRenderer, + content: currentWinterGramOfficial ? .winterGramBadge(backplateColor: winterGramBadgeBackplateColor(theme: item.presentationData.theme)) : EmojiStatusComponent.Content.none, + isVisibleForAnimations: false, + action: nil + ) + strongSelf.winterGramIconComponent = winterGramIconComponent + + let iconOrigin: CGFloat = nextTitleIconOrigin + let containerSize = CGSize(width: 18.0, height: 18.0) + let iconSize = winterGramIconView.update( + transition: .immediate, + component: AnyComponent(winterGramIconComponent), + environment: {}, + containerSize: containerSize + ) + transition.updateFrame(view: winterGramIconView, frame: CGRect(origin: CGPoint(x: iconOrigin, y: floorToScreenPixels(titleFrame.maxY - lastLineRect.height * 0.5 - iconSize.height / 2.0) - UIScreenPixel), size: iconSize)) + nextTitleIconOrigin += winterGramIconView.bounds.width + 4.0 + } else if let winterGramIconView = strongSelf.winterGramIconView { + strongSelf.winterGramIconView = nil + winterGramIconView.removeFromSuperview() + } + if let currentMutedIconImage = currentMutedIconImage { strongSelf.mutedIconNode.image = currentMutedIconImage strongSelf.mutedIconNode.isHidden = false @@ -5077,7 +5132,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.mutedIconNode.image = nil strongSelf.mutedIconNode.isHidden = true } - + if let (titleBadgeLayout, titleBadgeApply) = titleBadgeLayoutAndApply { let titleBadgeNode = titleBadgeApply() let backgroundView: UIImageView @@ -5086,7 +5141,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { backgroundView = UIImageView(image: generateStretchableFilledCircleImage(radius: 4.0, color: .white)?.withRenderingMode(.alwaysTemplate)) strongSelf.titleBadge = (backgroundView, titleBadgeNode) - + strongSelf.mainContentContainerNode.view.addSubview(backgroundView) strongSelf.mainContentContainerNode.addSubnode(titleBadgeNode) } @@ -5097,7 +5152,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { let titleBadgeFrame = CGRect(origin: CGPoint(x: nextTitleIconOrigin, y: titleFrame.minY + floor((titleFrame.height - titleBadgeLayout.size.height) * 0.5)), size: titleBadgeLayout.size) nextTitleIconOrigin += titleBadgeLayout.size.width + 4.0 transition.updateFrame(node: titleBadgeNode, frame: titleBadgeFrame) - + var titleBadgeBackgroundFrame = titleBadgeFrame.insetBy(dx: -4.0, dy: -2.0) titleBadgeBackgroundFrame.size.height -= 1.0 transition.updateFrame(view: backgroundView, frame: titleBadgeBackgroundFrame) @@ -5111,7 +5166,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { titleBadge.backgroundView.removeFromSuperview() titleBadge.textNode.removeFromSupernode() } - + let leftSeparatorInset: CGFloat let rightSeparatorInset: CGFloat if case let .groupReference(groupReferenceData) = item.content, groupReferenceData.hiddenByDefault { @@ -5124,20 +5179,20 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { leftSeparatorInset = editingOffset + leftInset + rawContentRect.origin.x rightSeparatorInset = 16.0 } - + transition.updateFrame(node: strongSelf.separatorNode, frame: CGRect(origin: CGPoint(x: leftSeparatorInset, y: layoutOffset + itemHeight - separatorHeight), size: CGSize(width: params.width - leftSeparatorInset - rightSeparatorInset, height: separatorHeight))) if let inlineNavigationLocation = item.interaction.inlineNavigationLocation { strongSelf.updateSeparatorAlpha(transition: transition, inlineNavigationProgress: inlineNavigationLocation.progress) } else { strongSelf.updateSeparatorAlpha(transition: transition) } - + if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData { if customMessageListData.hideSeparator { strongSelf.separatorNode.isHidden = true } } - + transition.updateFrame(node: strongSelf.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: layout.contentSize.width, height: itemHeight))) let backgroundColor: UIColor let highlightedBackgroundColor: UIColor @@ -5160,30 +5215,30 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } highlightedBackgroundColor = theme.itemHighlightedBackgroundColor } - + if animated { transition.updateBackgroundColor(node: strongSelf.backgroundNode, color: backgroundColor) } else { strongSelf.backgroundNode.backgroundColor = backgroundColor } - + if let inlineNavigationLocation = item.interaction.inlineNavigationLocation { transition.updateAlpha(node: strongSelf.backgroundNode, alpha: 1.0 - inlineNavigationLocation.progress) } else { transition.updateAlpha(node: strongSelf.backgroundNode, alpha: 1.0) } - + strongSelf.highlightedBackgroundNode.backgroundColor = highlightedBackgroundColor let topNegativeInset: CGFloat = 0.0 strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: strongSelf.revealOffset, y: layoutOffset - separatorHeight - topNegativeInset), size: CGSize(width: layout.contentSize.width, height: layout.contentSize.height + separatorHeight + topNegativeInset)) transition.updateCornerRadius(node: strongSelf.highlightedBackgroundNode, cornerRadius: strongSelf.isRevealOptionsActive ? 26.0 : 0.0) - + if let peerPresence = peerPresence { strongSelf.peerPresenceManager?.reset(presence: EnginePeer.Presence(status: peerPresence.status, lastActivity: 0), isOnline: online) } - + strongSelf.updateLayout(size: CGSize(width: layout.contentSize.width, height: itemHeight), leftInset: params.leftInset, rightInset: params.rightInset) - + if item.editing { strongSelf.setRevealOptions((left: [], right: []), enableAnimations: item.context.sharedContext.energyUsageSettings.fullTranslucency) } else { @@ -5192,10 +5247,10 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { if !strongSelf.customAnimationInProgress { strongSelf.setRevealOptionsOpened(item.hasActiveRevealControls, animated: true) } - + strongSelf.view.accessibilityLabel = strongSelf.accessibilityLabel strongSelf.view.accessibilityValue = strongSelf.accessibilityValue - + if !customActions.isEmpty { strongSelf.view.accessibilityCustomActions = customActions.map({ action -> UIAccessibilityCustomAction in return ChatListItemAccessibilityCustomAction(name: action.name, target: strongSelf, selector: #selector(strongSelf.performLocalAccessibilityCustomAction(_:)), key: action.key) @@ -5203,9 +5258,9 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { strongSelf.view.accessibilityCustomActions = nil } - + strongSelf.avatarTapRecognizer?.isEnabled = item.interaction.inlineNavigationLocation == nil - + if case .loading = item.content { let shimmerNode: ShimmerEffectNode if let current = strongSelf.placeholderNode { @@ -5219,27 +5274,27 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { if let (rect, size) = strongSelf.absoluteLocation { shimmerNode.updateAbsoluteRect(rect, within: size) } - + var shapes: [ShimmerEffectNode.Shape] = [] - + let titleLineWidth: CGFloat = 180.0 let dateLineWidth: CGFloat = 36.0 let textFirstLineWidth: CGFloat = 240.0 let textSecondLineWidth: CGFloat = 200.0 let lineDiameter: CGFloat = 10.0 - + shapes.append(.circle(avatarFrame)) - + let titleFrame = strongSelf.titleNode.frame shapes.append(.roundedRectLine(startPoint: CGPoint(x: titleFrame.minX, y: titleFrame.minY + floor((titleFrame.height - lineDiameter) / 2.0)), width: titleLineWidth, diameter: lineDiameter)) - + let textFrame = strongSelf.textNode.textNode.frame shapes.append(.roundedRectLine(startPoint: CGPoint(x: textFrame.minX, y: textFrame.minY + 7.0), width: textFirstLineWidth, diameter: lineDiameter)) shapes.append(.roundedRectLine(startPoint: CGPoint(x: textFrame.minX, y: textFrame.minY + 7.0 + lineDiameter + 9.0), width: textSecondLineWidth, diameter: lineDiameter)) - + let dateFrame = strongSelf.dateNode.frame shapes.append(.roundedRectLine(startPoint: CGPoint(x: dateFrame.maxX - dateLineWidth, y: dateFrame.minY + 3.0), width: dateLineWidth, diameter: lineDiameter)) - + shimmerNode.update(backgroundColor: item.presentationData.theme.list.plainBackgroundColor, foregroundColor: item.presentationData.theme.list.mediaPlaceholderColor, shimmeringColor: item.presentationData.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.4), shapes: shapes, size: shimmerNode.frame.size) } else if let shimmerNode = strongSelf.placeholderNode { strongSelf.placeholderNode = nil @@ -5249,7 +5304,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { }) } } - + override public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { var rect = rect rect.origin.y += self.insets.top @@ -5258,7 +5313,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { shimmerNode.updateAbsoluteRect(rect, within: containerSize) } } - + @objc private func compoundTextButtonPressed() { guard let item else { return @@ -5274,7 +5329,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } item.interaction.openForumThread(index.messageIndex.id.peerId, topicItem.id) } - + @objc private func actionButtonPressed() { guard let item else { return @@ -5286,11 +5341,11 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { item.interaction.openWebApp(user) } } - + override public func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } - + override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.clipsToBounds = true if self.skipFadeout { @@ -5299,7 +5354,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) } } - + override public func headers() -> [ListViewItemHeader]? { if let item = self.layoutParams?.0 { return item.header.flatMap { [$0] } @@ -5307,17 +5362,17 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { return nil } } - + private func updateVideoVisibility() { let isVisible = self.visibilityStatus && self.trackingIsInHierarchy self.avatarVideoNode?.updateVisibility(isVisible) - + if let videoNode = self.avatarVideoNode { videoNode.updateLayout(size: self.avatarNode.frame.size, cornerRadius: self.avatarNode.frame.size.width / 2.0, transition: .immediate) videoNode.frame = self.avatarNode.bounds } } - + override public func updateRevealOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) { super.updateRevealOffset(offset: offset, transition: transition) @@ -5338,14 +5393,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { self.updateSeparatorAlpha(transition: transition) } - + override public func touchesToOtherItemsPrevented() { super.touchesToOtherItemsPrevented() if let item = self.item { item.interaction.setPeerIdWithRevealedOptions(nil, nil) } } - + override public func revealOptionsInteractivelyOpened() { if let item = self.item { switch item.index { @@ -5356,7 +5411,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + override public func revealOptionsInteractivelyClosed() { if let item = self.item { switch item.index { @@ -5367,12 +5422,12 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + override public func revealOptionSelected(_ option: ItemListRevealOption, animated: Bool) { guard let item = self.item else { return } - + var close = true if case let .chatList(index) = item.index { switch option.key { @@ -5498,14 +5553,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { self.revealOptionsInteractivelyClosed() } } - + override public func isReorderable(at point: CGPoint) -> Bool { if let reorderControlNode = self.reorderControlNode, reorderControlNode.frame.contains(point) { return true } return false } - + func flashHighlight() { if self.highlightedBackgroundNode.supernode == nil { self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode) @@ -5516,17 +5571,17 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { self?.updateIsHighlighted(transition: .immediate) }) } - + func playArchiveAnimation() { guard let item = self.item, case .groupReference = item.content else { return } self.avatarNode.playArchiveAnimation() } - + override public func animateFrameTransition(_ progress: CGFloat, _ currentValue: CGFloat) { super.animateFrameTransition(progress, currentValue) - + if let item = self.item { if case .groupReference = item.content { self.layer.sublayerTransform = CATransform3DMakeTranslation(0.0, currentValue - (self.currentItemHeight ?? 0.0), 0.0) @@ -5537,25 +5592,25 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) { if let action = action as? ChatListItemAccessibilityCustomAction { self.revealOptionSelected(ItemListRevealOption(key: action.key, title: "", icon: .none, color: .black, iconColor: .white, textColor: .white), animated: false) } } - + override public func snapshotForReordering() -> UIView? { self.backgroundNode.alpha = 0.9 let result = self.view.snapshotContentTree() self.backgroundNode.alpha = 1.0 return result } - + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { guard let item = self.item, self.frame.height > 0.0 else { return nil } - + if let compoundTextButtonNode = self.compoundTextButtonNode, let compoundHighlightingNode = self.compoundHighlightingNode, compoundHighlightingNode.alpha != 0.0 { let localPoint = self.view.convert(point, to: compoundHighlightingNode.view) var matches = false @@ -5569,7 +5624,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { return compoundTextButtonNode.view } } - + if let _ = item.interaction.inlineNavigationLocation { } else { if self.avatarNode.storyStats != nil { @@ -5578,10 +5633,10 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } } } - + return super.hitTest(point, with: event) } - + @objc private func avatarStoryTapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { guard let item = self.item else { @@ -5602,27 +5657,27 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { private class StarView: UIView { let outline = SimpleLayer() let foreground = SimpleLayer() - + var outlineColor: UIColor = .white { didSet { self.outline.layerTintColor = self.outlineColor.cgColor } } - + override init(frame: CGRect) { self.outline.contents = UIImage(bundleImageName: "Premium/Stars/StarMediumOutline")?.cgImage self.foreground.contents = UIImage(bundleImageName: "Premium/Stars/StarMedium")?.cgImage - + super.init(frame: frame) - + self.layer.addSublayer(self.outline) self.layer.addSublayer(self.foreground) } - + required init?(coder: NSCoder) { preconditionFailure() } - + func setOutlineColor(_ color: UIColor, transition: ContainedViewLayoutTransition) { if case let .animated(duration, curve) = transition, color != self.outlineColor { let snapshotLayer = SimpleLayer() @@ -5636,7 +5691,7 @@ private class StarView: UIView { } self.outlineColor = color } - + override func layoutSubviews() { self.outline.frame = self.bounds self.foreground.frame = self.bounds diff --git a/submodules/ChatListUI/Sources/Node/ChatListNode.swift b/submodules/ChatListUI/Sources/Node/ChatListNode.swift index 2dc44bb69b..981671ffea 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNode.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNode.swift @@ -45,16 +45,16 @@ struct ChatListNodeListViewTransition { final class ChatListHighlightedLocation: Equatable { let location: ChatLocation let progress: CGFloat - + init(location: ChatLocation, progress: CGFloat) { self.location = location self.progress = progress } - + func withUpdatedProgress(_ progress: CGFloat) -> ChatListHighlightedLocation { return ChatListHighlightedLocation(location: location, progress: progress) } - + static func ==(lhs: ChatListHighlightedLocation, rhs: ChatListHighlightedLocation) -> Bool { if lhs.location != rhs.location { return false @@ -71,7 +71,7 @@ public final class ChatListNodeInteraction { case peerId(EnginePeer.Id) case peer(EnginePeer) } - + let activateSearch: () -> Void let peerSelected: (EnginePeer, EnginePeer?, Int64?, ChatListNodeEntryPromoInfo?, Bool) -> Void let disabledPeerSelected: (EnginePeer, Int64?, ChatListDisabledPeerReason) -> Void @@ -117,18 +117,18 @@ public final class ChatListNodeInteraction { let openAdInfo: (ASDisplayNode, AdPeer) -> Void let openAccountFreezeInfo: () -> Void let openUrl: (String) -> Void - + public var searchTextHighightState: String? var highlightedChatLocation: ChatListHighlightedLocation? - + var isSearchMode: Bool = false - + var isInlineMode: Bool = false var inlineNavigationLocation: ChatListHighlightedLocation? - + let animationCache: AnimationCache let animationRenderer: MultiAnimationRenderer - + public init( context: AccountContext, animationCache: AnimationCache, @@ -233,15 +233,15 @@ public final class ChatListNodePeerInputActivities { public struct ItemId: Hashable { public var peerId: EnginePeer.Id public var threadId: Int64? - + public init(peerId: EnginePeer.Id, threadId: Int64?) { self.peerId = peerId self.threadId = threadId } } - + public let activities: [ItemId: [(EnginePeer, PeerInputActivity)]] - + public init(activities: [ItemId: [(EnginePeer, PeerInputActivity)]]) { self.activities = activities } @@ -263,23 +263,23 @@ public struct ChatListNodeState: Equatable { public struct StoryState: Equatable { public var stats: EngineChatList.StoryStats public var hasUnseenCloseFriends: Bool - + public init(stats: EngineChatList.StoryStats, hasUnseenCloseFriends: Bool) { self.stats = stats self.hasUnseenCloseFriends = hasUnseenCloseFriends } } - + public struct ItemId: Hashable { public var peerId: EnginePeer.Id public var threadId: Int64? - + public init(peerId: EnginePeer.Id, threadId: Int64?) { self.peerId = peerId self.threadId = threadId } } - + public var presentationData: ChatListPresentationData public var editing: Bool public var peerIdWithRevealedOptions: ItemId? @@ -294,7 +294,7 @@ public struct ChatListNodeState: Equatable { public var selectedPeerMap: [EnginePeer.Id: EnginePeer] public var selectedThreadIds: Set public var archiveStoryState: StoryState? - + public init( presentationData: ChatListPresentationData, editing: Bool, @@ -326,7 +326,7 @@ public struct ChatListNodeState: Equatable { self.selectedThreadIds = selectedThreadIds self.archiveStoryState = archiveStoryState } - + public static func ==(lhs: ChatListNodeState, rhs: ChatListNodeState) -> Bool { if lhs.presentationData !== rhs.presentationData { return false @@ -444,7 +444,7 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL let forumTopicData = peerEntry.forumTopicData let topForumTopicItems = peerEntry.topForumTopicItems let revealed = peerEntry.revealed - + switch mode { case .chatList: return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListItem( @@ -514,7 +514,7 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL } else { enabled = false } - + if let threadInfo, threadInfo.isClosed, case let .channel(channel) = itemPeer { if threadInfo.isOwnedByMe || channel.hasPermission(.manageTopics) { } else { @@ -577,7 +577,7 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL } } } - + var header: ChatListSearchItemHeader? switch mode { case let .peers(_, _, additionalCategories, _, _, _, _): @@ -594,7 +594,7 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL default: break } - + var status: ContactsPeerItemStatus = .none if isSelecting, let itemPeer = itemPeer { if displayPresence, let presence = presence { @@ -605,14 +605,14 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL status = .none } } - + let peerContent: ContactsPeerItemPeer if let threadInfo = threadInfo, let itemPeer = itemPeer { peerContent = .thread(peer: itemPeer, title: threadInfo.info.title, icon: threadInfo.info.icon, color: threadInfo.info.iconColor) } else { peerContent = .peer(peer: itemPeer, chatPeer: chatPeer) } - + var threadId: Int64? switch index { case let .forum(_, _, threadIdValue, _, _): @@ -620,7 +620,7 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL case .chatList: break } - + var isForum = false if let peer = chatPeer, case let .channel(channel) = peer, channel.isForumOrMonoForum { isForum = true @@ -628,7 +628,7 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL enabled = false } } - + var selectable = editing if case .chatList = mode { if isForum { @@ -672,10 +672,10 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL if let peer = peer.peers[peer.peerId] { chatPeer = peer } - + let peerContent: ContactsPeerItemPeer = .peer(peer: itemPeer, chatPeer: chatPeer) let status: ContactsPeerItemStatus = .none - + return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ContactsPeerItem( presentationData: ItemListPresentationData(theme: presentationData.theme, fontSize: presentationData.fontSize, strings: presentationData.strings, nameDisplayOrder: presentationData.nameDisplayOrder, dateTimeFormat: presentationData.dateTimeFormat), sortOrder: presentationData.nameSortOrder, @@ -740,12 +740,12 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL ), directionHint: entry.directionHint) case let .ContactEntry(contactEntry): let header: ChatListSearchItemHeader? = nil - + var status: ContactsPeerItemStatus = .none status = .presence(contactEntry.presence, contactEntry.presentationData.dateTimeFormat) - + let presentationData = contactEntry.presentationData - + let peerContent: ContactsPeerItemPeer = .peer(peer: contactEntry.peer, chatPeer: contactEntry.peer) return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ContactsPeerItem( @@ -805,7 +805,7 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL let forumTopicData = peerEntry.forumTopicData let topForumTopicItems = peerEntry.topForumTopicItems let revealed = peerEntry.revealed - + switch mode { case .chatList: return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListItem( @@ -875,7 +875,7 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL } else { enabled = false } - + if let threadInfo, threadInfo.isClosed, case let .channel(channel) = itemPeer { if threadInfo.isOwnedByMe || channel.hasPermission(.manageTopics) { } else { @@ -889,7 +889,7 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL } } } - + var header: ChatListSearchItemHeader? switch mode { case let .peers(_, _, additionalCategories, _, _, _, _): @@ -906,7 +906,7 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL default: break } - + var status: ContactsPeerItemStatus = .none if isSelecting, let itemPeer = itemPeer { if displayPresence, let presence = presence { @@ -917,14 +917,14 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL status = .none } } - + let peerContent: ContactsPeerItemPeer if let threadInfo = threadInfo, let itemPeer = itemPeer { peerContent = .thread(peer: itemPeer, title: threadInfo.info.title, icon: threadInfo.info.icon, color: threadInfo.info.iconColor) } else { peerContent = .peer(peer: itemPeer, chatPeer: chatPeer) } - + var threadId: Int64? switch index { case let .forum(_, _, threadIdValue, _, _): @@ -932,7 +932,7 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL case .chatList: break } - + var isForum = false if let peer = chatPeer, case let .channel(channel) = peer, channel.isForumOrMonoForum { isForum = true @@ -940,14 +940,14 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL enabled = false } } - + var selectable = editing if case .chatList = mode { if isForum { selectable = false } } - + return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ContactsPeerItem( presentationData: ItemListPresentationData(theme: presentationData.theme, fontSize: presentationData.fontSize, strings: presentationData.strings, nameDisplayOrder: presentationData.nameDisplayOrder, dateTimeFormat: presentationData.dateTimeFormat), sortOrder: presentationData.nameSortOrder, @@ -984,10 +984,10 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL if let peer = peer.peers[peer.peerId] { chatPeer = peer } - + let peerContent: ContactsPeerItemPeer = .peer(peer: itemPeer, chatPeer: chatPeer) let status: ContactsPeerItemStatus = .none - + return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ContactsPeerItem( presentationData: ItemListPresentationData(theme: presentationData.theme, fontSize: presentationData.fontSize, strings: presentationData.strings, nameDisplayOrder: presentationData.nameDisplayOrder, dateTimeFormat: presentationData.dateTimeFormat), sortOrder: presentationData.nameSortOrder, @@ -1052,12 +1052,12 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL ), directionHint: entry.directionHint) case let .ContactEntry(contactEntry): let header: ChatListSearchItemHeader? = nil - + var status: ContactsPeerItemStatus = .none status = .presence(contactEntry.presence, contactEntry.presentationData.dateTimeFormat) - + let presentationData = contactEntry.presentationData - + let peerContent: ContactsPeerItemPeer = .peer(peer: contactEntry.peer, chatPeer: contactEntry.peer) return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ContactsPeerItem( @@ -1143,7 +1143,7 @@ private func mappedChatListNodeViewListTransition(context: AccountContext, nodeI private final class ChatListOpaqueTransactionState { let chatListView: ChatListNodeView - + init(chatListView: ChatListNodeView) { self.chatListView = chatListView } @@ -1176,26 +1176,26 @@ public final class ChatListNode: ListViewImpl { case peer(EnginePeer.Id) case archive } - + private let fillPreloadItems: Bool private let context: AccountContext private let location: ChatListControllerLocation private let mode: ChatListNodeMode private let animationCache: AnimationCache private let animationRenderer: MultiAnimationRenderer - + private let _ready = ValuePromise() private var didSetReady = false public var ready: Signal { return _ready.get() } - + private let _contentsReady = ValuePromise() private var didSetContentsReady = false public var contentsReady: Signal { return _contentsReady.get() } - + public var peerSelected: ((EnginePeer, Int64?, Bool, Bool, ChatListNodeEntryPromoInfo?) -> Void)? public var disabledPeerSelected: ((EnginePeer, Int64?, ChatListDisabledPeerReason) -> Void)? public var additionalCategorySelected: ((Int) -> Void)? @@ -1222,9 +1222,9 @@ public final class ChatListNode: ListViewImpl { public var openPhotoSetup: (() -> Void)? public var openAdInfo: ((ASDisplayNode, AdPeer) -> Void)? public var openAccountFreezeInfo: (() -> Void)? - + private var theme: PresentationTheme - + private let viewProcessingQueue = Queue() private var chatListView: ChatListNodeView? public var entriesCount: Int { @@ -1251,21 +1251,21 @@ public final class ChatListNode: ListViewImpl { } } public private(set) var interaction: ChatListNodeInteraction? - + private var dequeuedInitialTransitionOnLayout = false private var enqueuedTransition: (ChatListNodeListViewTransition, () -> Void)? - + public private(set) var currentState: ChatListNodeState private let statePromise: ValuePromise public var state: Signal { return self.statePromise.get() } - + private var currentLocation: ChatListNodeLocation? public private(set) var chatListFilter: ChatListFilter? { didSet { self.chatListFilterValue.set(.single(self.chatListFilter)) - + if self.chatListFilter != oldValue { self.setChatListLocation(.initial(count: 50, filter: self.chatListFilter)) } @@ -1285,12 +1285,12 @@ public final class ChatListNode: ListViewImpl { private let chatListLocation = ValuePromise() private let chatListDisposable = MetaDisposable() private var activityStatusesDisposable: Disposable? - + private let scrollToTopOptionPromise = Promise(.none) public var scrollToTopOption: Signal { return self.scrollToTopOptionPromise.get() } - + private let scrolledAtTop = ValuePromise(true) private var scrolledAtTopValue: Bool = true { didSet { @@ -1299,63 +1299,63 @@ public final class ChatListNode: ListViewImpl { } } } - + public var contentOffsetChanged: ((ListViewVisibleContentOffset) -> Void)? public private(set) var pinnedScrollFraction: CGFloat = 0.0 public var pinnedHeaderDisplayFractionUpdated: ((ContainedViewLayoutTransition) -> Void)? public var contentScrollingEnded: ((ListView) -> Bool)? public var didBeginInteractiveDragging: ((ListView) -> Void)? - + public var isEmptyUpdated: ((ChatListNodeEmptyState, Bool, ContainedViewLayoutTransition) -> Void)? private var currentIsEmptyState: ChatListNodeEmptyState? - + public var canExpandHiddenItems: (() -> Bool)? - + public var addedVisibleChatsWithPeerIds: (([EnginePeer.Id]) -> Void)? - + private let currentRemovingItemId = Atomic(value: nil) public func setCurrentRemovingItemId(_ itemId: ChatListNodeState.ItemId?) { let _ = self.currentRemovingItemId.swap(itemId) } - + private var hapticFeedback: HapticFeedback? - + let preloadItems = Promise<[ChatHistoryPreloadItem]>([]) - + var didBeginSelectingChats: (() -> Void)? public var selectionCountChanged: ((Int) -> Void)? - + var isSelectionGestureEnabled = true - + public var selectionLimit: Int32 = 100 public var reachedSelectionLimit: ((Int32) -> Void)? - + private var visibleTopInset: CGFloat? private var originalTopInset: CGFloat? - + public var passthroughPeerSelection = false - + let hideArhiveIntro = ValuePromise(false, ignoreRepeated: true) - + private let chatFolderUpdates = Promise() private var pollFilterUpdatesDisposable: Disposable? private var chatFilterUpdatesDisposable: Disposable? private var updateIsMainTabDisposable: Disposable? - + public var scrollHeightTopInset: CGFloat { didSet { self.keepMinimalScrollHeightWithTopInset = self.scrollHeightTopInset } } - + public var startedScrollingAtUpperBound: Bool = false - + private let autoSetReady: Bool - + public let isMainTab = ValuePromise(false, ignoreRepeated: true) - + public var synchronousDrawingWhenNotAnimated: Bool = false - + public init(context: AccountContext, location: ChatListControllerLocation, chatListFilter: ChatListFilter? = nil, previewing: Bool, fillPreloadItems: Bool, mode: ChatListNodeMode, isPeerEnabled: ((EnginePeer) -> Bool)? = nil, theme: PresentationTheme, fontSize: PresentationFontSize, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, disableAnimations: Bool, isInlineMode: Bool, autoSetReady: Bool, isMainTab: Bool?) { self.context = context self.location = location @@ -1366,34 +1366,34 @@ public final class ChatListNode: ListViewImpl { self.animationCache = animationCache self.animationRenderer = animationRenderer self.autoSetReady = autoSetReady - + if let isMainTab { self.isMainTab.set(isMainTab) } - + var isSelecting = false if case .peers(_, true, _, _, _, _, _) = mode { isSelecting = true } - + self.currentState = ChatListNodeState(presentationData: ChatListPresentationData(theme: theme, fontSize: fontSize, strings: strings, dateTimeFormat: dateTimeFormat, nameSortOrder: nameSortOrder, nameDisplayOrder: nameDisplayOrder, disableAnimations: disableAnimations), editing: isSelecting, peerIdWithRevealedOptions: nil, selectedPeerIds: Set(), foundPeers: [], selectedPeerMap: [:], selectedAdditionalCategoryIds: Set(), peerInputActivities: nil, pendingRemovalItemIds: Set(), pendingClearHistoryPeerIds: Set(), hiddenItemShouldBeTemporaryRevealed: false, hiddenPsaPeerId: nil, selectedThreadIds: Set(), archiveStoryState: nil) self.statePromise = ValuePromise(self.currentState, ignoreRepeated: true) - + self.theme = theme - + self.scrollHeightTopInset = ChatListNavigationBar.searchScrollHeight - + super.init() - + if case .internal = context.sharedContext.applicationBindings.appBuildType { //self.useMainQueueTransactions = true } - + self.verticalScrollIndicatorColor = theme.list.scrollIndicatorColor self.verticalScrollIndicatorFollowsOverscroll = true - + self.keepMinimalScrollHeightWithTopInset = self.scrollHeightTopInset - + let nodeInteraction = ChatListNodeInteraction(context: context, animationCache: self.animationCache, animationRenderer: self.animationRenderer, activateSearch: { [weak self] in if let strongSelf = self, let activateSearch = strongSelf.activateSearch { activateSearch() @@ -1537,7 +1537,7 @@ public final class ChatListNode: ListViewImpl { guard case let .chatList(groupId) = strongSelf.location else { return } - + let isPremium = peer?.isPremium ?? false let location: TogglePeerChatPinnedLocation if let chatListFilter = chatListFilter { @@ -1745,7 +1745,7 @@ public final class ChatListNode: ListViewImpl { guard let self else { return } - + let activeSessionsContext = self.context.engine.privacy.activeSessions() let _ = (activeSessionsContext.state |> filter { state in @@ -1756,7 +1756,7 @@ public final class ChatListNode: ListViewImpl { guard let self else { return } - + let recentSessionsController = self.context.sharedContext.makeRecentSessionsController(context: self.context, activeSessionsContext: activeSessionsContext) self.push?(recentSessionsController) }) @@ -1769,10 +1769,10 @@ public final class ChatListNode: ListViewImpl { guard let self else { return } - + if isPositive { let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } - + let animationBackgroundColor: UIColor if presentationData.theme.overallDarkAppearance { animationBackgroundColor = presentationData.theme.rootController.tabBar.backgroundColor @@ -1786,14 +1786,14 @@ public final class ChatListNode: ListViewImpl { default: break } - + return true })) - + let _ = self.context.engine.privacy.confirmNewSessionReview(id: newSessionReview.id).startStandalone() } else { self.push?(NewSessionInfoScreen(context: self.context, newSessionReview: newSessionReview)) - + #if DEBUG #else let _ = self.context.engine.privacy.terminateAnotherSession(id: newSessionReview.id).startStandalone() @@ -1803,7 +1803,7 @@ public final class ChatListNode: ListViewImpl { guard let self else { return } - + if isPositive { let _ = self.context.engine.accountData.confirmBotConnectionReview(botId: newBotConnectionReview.botId).startStandalone() } else { @@ -1820,7 +1820,7 @@ public final class ChatListNode: ListViewImpl { guard let self, let result else { return } - + self.push?(ChatFolderLinkPreviewScreen(context: self.context, subject: .updates(result), contents: result.chatFolderLinkContents)) }) }, hideChatFolderUpdates: { [weak self] in @@ -1833,7 +1833,7 @@ public final class ChatListNode: ListViewImpl { guard let self, let result else { return } - + if let localFilterId = result.chatFolderLinkContents.localFilterId { let _ = self.context.engine.peers.hideChatFolderUpdates(folderId: localFilterId).startStandalone() } @@ -1871,16 +1871,16 @@ public final class ChatListNode: ListViewImpl { context.sharedContext.openExternalUrl(context: self.context, urlContext: .generic, url: url, forceExternal: false, presentationData: presentationData, navigationController: self.context.sharedContext.mainWindow?.viewController as? NavigationController, dismissInput: {}) }) nodeInteraction.isInlineMode = isInlineMode - + let viewProcessingQueue = self.viewProcessingQueue - + let shouldLoadCanMessagePeer: Bool if case .peers = mode { shouldLoadCanMessagePeer = true } else { shouldLoadCanMessagePeer = false } - + let chatListViewUpdate = self.chatListLocation.get() |> distinctUntilChanged |> mapToSignal { listLocation -> Signal<(ChatListNodeViewUpdate, ChatListFilter?), NoError> in @@ -1889,12 +1889,12 @@ public final class ChatListNode: ListViewImpl { return (update, listLocation.filter) } } - + let previousState = Atomic(value: self.currentState) let previousView = Atomic(value: nil) let previousHideArchivedFolderByDefault = Atomic(value: nil) let currentRemovingItemId = self.currentRemovingItemId - + let savedMessagesPeer: Signal if case let .peers(filter, _, _, _, _, _, _) = mode, filter.contains(.onlyWriteable), case .chatList = location, self.chatListFilter == nil { savedMessagesPeer = context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) @@ -1909,14 +1909,14 @@ public final class ChatListNode: ListViewImpl { } else { savedMessagesPeer = .single(nil) } - + let hideArchivedFolderByDefault = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key: ApplicationSpecificPreferencesKeys.chatArchiveSettings)) |> map { view -> Bool in let settings: ChatArchiveSettings = view?.get(ChatArchiveSettings.self) ?? .default return settings.isHiddenByDefault } |> distinctUntilChanged - + let displayArchiveIntro: Signal if case .chatList(.archive) = location { let displayArchiveIntroData = context.sharedContext.accountManager.noticeEntry(key: ApplicationSpecificNotice.archiveIntroDismissedKey()) @@ -1944,21 +1944,21 @@ public final class ChatListNode: ListViewImpl { } else { displayArchiveIntro = .single(false) } - + self.updateIsMainTabDisposable = (self.isMainTab.get() |> deliverOnMainQueue).startStrict(next: { isMainTab in if isMainTab { let _ = context.engine.privacy.cleanupSessionReviews().startStandalone() } }).strict() - + let storageInfo: Signal if !"".isEmpty, case .chatList(groupId: .root) = location, chatListFilter == nil { let totalSizeSignal = combineLatest(context.account.postbox.mediaBox.storageBox.totalSize(), context.account.postbox.mediaBox.cacheStorageBox.totalSize()) |> map { a, b -> Int64 in return a + b } - + storageInfo = totalSizeSignal |> take(1) |> mapToSignal { initialSize -> Signal in @@ -1967,32 +1967,32 @@ public final class ChatListNode: ListViewImpl { #else let fractionLimit: Double = 0.3 #endif - + let systemAttributes = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory() as String) let deviceFreeSpace = (systemAttributes?[FileAttributeKey.systemFreeSize] as? NSNumber)?.int64Value ?? 0 - + let initialFraction: Double if deviceFreeSpace != 0 && initialSize != 0 { initialFraction = Double(initialSize) / Double(deviceFreeSpace + initialSize) } else { initialFraction = 0.0 } - + let initialReportSize: Double? if initialFraction > fractionLimit { initialReportSize = Double(initialSize) } else { initialReportSize = nil } - + final class ReportState { var lastSize: Int64 - + init(lastSize: Int64) { self.lastSize = lastSize } } - + let state = Atomic(value: ReportState(lastSize: initialSize)) let updatedReportSize: Signal = Signal { subscriber in let disposable = totalSizeSignal.start(next: { size in @@ -2006,30 +2006,30 @@ public final class ChatListNode: ListViewImpl { } if updatedSize >= 0 { let deviceFreeSpace = (systemAttributes?[FileAttributeKey.systemFreeSize] as? NSNumber)?.int64Value ?? 0 - + let updatedFraction: Double if deviceFreeSpace != 0 && updatedSize != 0 { updatedFraction = Double(updatedSize) / Double(deviceFreeSpace + updatedSize) } else { updatedFraction = 0.0 } - + let updatedReportSize: Double? if updatedFraction > fractionLimit { updatedReportSize = Double(updatedSize) } else { updatedReportSize = nil } - + subscriber.putNext(updatedReportSize) } }) - + return ActionDisposable { disposable.dispose() } } - + return .single(initialReportSize) |> then( updatedReportSize @@ -2038,9 +2038,9 @@ public final class ChatListNode: ListViewImpl { } else { storageInfo = .single(nil) } - + let currentPeerId: EnginePeer.Id = context.account.peerId - + let contacts: Signal<[ChatListContactPeer], NoError> if case .chatList(groupId: .root) = location, chatListFilter == nil, case .chatList = mode { contacts = ApplicationSpecificNotice.displayChatListContacts(accountManager: context.sharedContext.accountManager) @@ -2049,7 +2049,7 @@ public final class ChatListNode: ListViewImpl { if value { return .single([]) } - + return context.engine.messages.chatList(group: .root, count: 10) |> map { chatList -> Bool in if chatList.items.count >= 5 { @@ -2063,7 +2063,7 @@ public final class ChatListNode: ListViewImpl { if hasChats { return .single([]) } - + return context.engine.data.subscribe( TelegramEngine.EngineData.Item.Contacts.List(includePresences: true) ) @@ -2099,9 +2099,9 @@ public final class ChatListNode: ListViewImpl { } else { contacts = .single([]) } - + let accountPeerId = context.account.peerId - + let chatListFilters: Signal<[ChatListFilter]?, NoError> if case .chatList = mode { chatListFilters = combineLatest(queue: .mainQueue(), @@ -2123,9 +2123,9 @@ public final class ChatListNode: ListViewImpl { chatListFilters = .single(nil) } let previousChatListFilters = Atomic<[ChatListFilter]?>(value: nil) - + let previousAccountIsPremium = Atomic(value: nil) - + let accountIsPremium = context.engine.data.subscribe( TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId) ) @@ -2133,7 +2133,13 @@ public final class ChatListNode: ListViewImpl { return peer?.isPremium ?? false } |> distinctUntilChanged - + + // WinterGram: a reactive snapshot of the hidden-archive peer ids so the chat list re-filters + // immediately when a chat is stashed/unstashed (rather than only on the next view update). + let winterGramStashedPeerIds = winterGramSettings(accountManager: context.sharedContext.accountManager) + |> map { Set($0.stashedPeerIds) } + |> distinctUntilChanged + let chatListNodeViewTransition = combineLatest(queue: viewProcessingQueue, hideArchivedFolderByDefault, displayArchiveIntro, @@ -2143,22 +2149,23 @@ public final class ChatListNode: ListViewImpl { self.statePromise.get(), contacts, chatListFilters, - accountIsPremium + accountIsPremium, + winterGramStashedPeerIds ) - |> mapToQueue { (hideArchivedFolderByDefault, displayArchiveIntro, storageInfo, savedMessagesPeer, updateAndFilter, state, contacts, chatListFilters, accountIsPremium) -> Signal in + |> mapToQueue { (hideArchivedFolderByDefault, displayArchiveIntro, storageInfo, savedMessagesPeer, updateAndFilter, state, contacts, chatListFilters, accountIsPremium, winterGramStashedPeerIds) -> Signal in let (update, filter) = updateAndFilter - + let previousHideArchivedFolderByDefaultValue = previousHideArchivedFolderByDefault.swap(hideArchivedFolderByDefault) - + let innerIsMainTab = location == .chatList(groupId: .root) && chatListFilter == nil - - let (rawEntries, isLoading) = chatListNodeEntriesForView(view: update.list, state: state, savedMessagesPeer: savedMessagesPeer, foundPeers: state.foundPeers, hideArchivedFolderByDefault: hideArchivedFolderByDefault, displayArchiveIntro: displayArchiveIntro, mode: mode, chatListLocation: location, contacts: contacts, accountPeerId: accountPeerId, isMainTab: innerIsMainTab) + + let (rawEntries, isLoading) = chatListNodeEntriesForView(view: update.list, state: state, savedMessagesPeer: savedMessagesPeer, foundPeers: state.foundPeers, hideArchivedFolderByDefault: hideArchivedFolderByDefault, displayArchiveIntro: displayArchiveIntro, mode: mode, chatListLocation: location, contacts: contacts, accountPeerId: accountPeerId, isMainTab: innerIsMainTab, stashedPeerIds: winterGramStashedPeerIds) var isEmpty = true var entries = rawEntries.filter { entry in switch entry { case let .PeerEntry(peerEntry): let peer = peerEntry.peer - + switch mode { case .chatList: isEmpty = false @@ -2168,12 +2175,12 @@ public final class ChatListNode: ListViewImpl { guard !filter.contains(.excludeSavedMessages) || !peer.peerId.isReplies else { return false } guard !filter.contains(.excludeSecretChats) || peer.peerId.namespace != Namespaces.Peer.SecretChat else { return false } guard !filter.contains(.onlyPrivateChats) || peer.peerId.namespace == Namespaces.Peer.CloudUser else { return false } - + if let peer = peer.peer { if peer.id.isRepliesOrVerificationCodes { return false } - + switch peer { case let .user(user): if user.botInfo != nil { @@ -2204,7 +2211,7 @@ public final class ChatListNode: ListViewImpl { break } } - + if filter.contains(.onlyGroupsAndChannels) { if case .channel = peer.chatMainPeer { } else if case .legacyGroup = peer.chatMainPeer { @@ -2223,7 +2230,7 @@ public final class ChatListNode: ListViewImpl { return false } } - + if filter.contains(.onlyChannels) { if case let .channel(peer) = peer.chatMainPeer, case .broadcast = peer.info { } else { @@ -2231,12 +2238,12 @@ public final class ChatListNode: ListViewImpl { } } } - + if filter.contains(.excludeChannels) { if case let .channel(peer) = peer.chatMainPeer, case .broadcast = peer.info { } } - + if filter.contains(.onlyWriteable) && filter.contains(.excludeDisabled) { if let peer = peer.peers[peer.peerId] { if !canSendMessagesToPeer(peer) { @@ -2246,7 +2253,7 @@ public final class ChatListNode: ListViewImpl { return false } } - + if filter.contains(.onlyManageable) && filter.contains(.excludeDisabled) { if let peer = peer.peers[peer.peerId] { var canManage = false @@ -2262,7 +2269,7 @@ public final class ChatListNode: ListViewImpl { } else if case let .channel(peer) = peer, case .broadcast = peer.info, peer.hasPermission(.addAdmins) { canManage = true } - + if canManage { } else { return false @@ -2271,7 +2278,7 @@ public final class ChatListNode: ListViewImpl { return false } } - + isEmpty = false return true case let .peerType(peerTypes, _): @@ -2418,14 +2425,14 @@ public final class ChatListNode: ListViewImpl { if isEmpty { entries = [.HeaderEntry] } - + let processedView = ChatListNodeView(originalList: update.list, filteredEntries: entries, isLoading: isLoading, filter: filter) let previousView = previousView.swap(processedView) let previousState = previousState.swap(state) - + let reason: ChatListNodeViewTransitionReason var prepareOnMainQueue = false - + var previousWasEmptyOrSingleHole = false if let previous = previousView { if previous.filteredEntries.count == 1 { @@ -2440,9 +2447,9 @@ public final class ChatListNode: ListViewImpl { } else { previousWasEmptyOrSingleHole = true } - + var updatedScrollPosition = update.scrollPosition - + if previousWasEmptyOrSingleHole { reason = .initial if previousView == nil { @@ -2466,9 +2473,9 @@ public final class ChatListNode: ListViewImpl { } } } - + let removingItemId = currentRemovingItemId.with { $0 } - + var disableAnimations = true if previousState.editing != state.editing { disableAnimations = false @@ -2477,7 +2484,7 @@ public final class ChatListNode: ListViewImpl { var updatedPinnedChats: [EnginePeer.Id] = [] var previousPinnedThreads: [Int64] = [] var updatedPinnedThreads: [Int64] = [] - + var didIncludeRemovingPeerId = false var didIncludeHiddenByDefaultArchive = false var didIncludeHiddenThread = false @@ -2486,7 +2493,7 @@ public final class ChatListNode: ListViewImpl { if case let .PeerEntry(peerEntry) = entry { let index = peerEntry.index let threadInfo = peerEntry.threadInfo - + if let threadInfo, threadInfo.isHidden { didIncludeHiddenThread = true } @@ -2513,13 +2520,13 @@ public final class ChatListNode: ListViewImpl { var doesIncludeRemovingPeerId = false var doesIncludeArchive = false var doesIncludeHiddenByDefaultArchive = false - + var doesIncludeHiddenThread = false for entry in processedView.filteredEntries { if case let .PeerEntry(peerEntry) = entry { let index = peerEntry.index let threadInfo = peerEntry.threadInfo - + if let threadInfo, threadInfo.isHidden { doesIncludeHiddenThread = true } @@ -2530,7 +2537,7 @@ public final class ChatListNode: ListViewImpl { updatedPinnedThreads.append(threadId) } } - + if case let .chatList(index) = index, ChatListNodeState.ItemId(peerId: index.messageIndex.id.peerId, threadId: nil) == removingItemId { doesIncludeRemovingPeerId = true } else if case let .forum(_, _, threadId, _, _) = index { @@ -2568,21 +2575,21 @@ public final class ChatListNode: ListViewImpl { disableAnimations = false } } - + if let _ = previousHideArchivedFolderByDefaultValue, previousHideArchivedFolderByDefaultValue != hideArchivedFolderByDefault { disableAnimations = false } - + var searchMode = false if case .peers = mode { searchMode = true } - + if filter != previousView?.filter { disableAnimations = true updatedScrollPosition = nil } - + let filterData = filter.flatMap { filter -> ChatListItemFilterData? in if case let .filter(_, _, _, data) = filter { return ChatListItemFilterData(excludesArchived: data.excludeArchived) @@ -2590,7 +2597,7 @@ public final class ChatListNode: ListViewImpl { return nil } } - + var forceAllUpdated = false let previousChatListFiltersValue = previousChatListFilters.swap(chatListFilters) if chatListFilters != previousChatListFiltersValue { @@ -2600,19 +2607,19 @@ public final class ChatListNode: ListViewImpl { forceAllUpdated = true } let presentationData = state.presentationData - + return preparedChatListNodeViewTransition(from: previousView, to: processedView, reason: reason, previewing: previewing, disableAnimations: disableAnimations, scrollPosition: updatedScrollPosition, searchMode: searchMode, forceAllUpdated: forceAllUpdated) |> map({ mappedChatListNodeViewListTransition(context: context, nodeInteraction: nodeInteraction, location: location, isPremium: accountIsPremium, filterData: filterData, chatListFilters: chatListFilters, mode: mode, isPeerEnabled: isPeerEnabled, presentationData: presentationData, transition: $0) }) |> runOn(prepareOnMainQueue ? Queue.mainQueue() : viewProcessingQueue) } - + let appliedTransition = chatListNodeViewTransition |> deliverOnMainQueue |> mapToQueue { [weak self] transition -> Signal in if let strongSelf = self { return strongSelf.enqueueTransition(transition) } return .complete() } - + self.displayedItemRangeChanged = { [weak self] range, transactionOpaqueState in if let strongSelf = self, let chatListView = (transactionOpaqueState as? ChatListOpaqueTransactionState)?.chatListView { let originalList = chatListView.originalList @@ -2623,14 +2630,14 @@ public final class ChatListNode: ListViewImpl { } else if range.firstIndex >= 5, range.lastIndex >= originalList.items.count - 5, originalList.hasEarlier, let firstItem = originalList.items.first { location = .navigation(index: firstItem.index, filter: strongSelf.chatListFilter) } - + if let location = location, location != strongSelf.currentLocation { strongSelf.setChatListLocation(location) } - + strongSelf.enqueueHistoryPreloadUpdate() } - + var refreshStoryPeerIds: [EnginePeer.Id] = [] var isHiddenItemVisible = false if let range = range.visibleRange { @@ -2643,15 +2650,15 @@ public final class ChatListNode: ListViewImpl { switch chatListView.filteredEntries[entryCount - i - 1] { case let .PeerEntry(peerEntry): let threadInfo = peerEntry.threadInfo - + if let threadInfo, threadInfo.isHidden { isHiddenItemVisible = true } - + if let peer = peerEntry.peer.chatMainPeer, !peerEntry.isContact, case let .user(user) = peer { refreshStoryPeerIds.append(user.id) } - + break case .GroupReferenceEntry: isHiddenItemVisible = true @@ -2672,11 +2679,11 @@ public final class ChatListNode: ListViewImpl { } } } - + self.interaction = nodeInteraction - + self.chatListDisposable.set(appliedTransition.startStrict()) - + let initialLocation: ChatListNodeLocation switch mode { case .chatList: @@ -2685,7 +2692,7 @@ public final class ChatListNode: ListViewImpl { initialLocation = .initial(count: 200, filter: self.chatListFilter) } self.setChatListLocation(initialLocation) - + let engine = context.engine let previousPeerCache = Atomic<[EnginePeer.Id: EnginePeer]>(value: [:]) let previousActivities = Atomic(value: nil) @@ -2704,7 +2711,7 @@ public final class ChatListNode: ListViewImpl { } }) } - + var foundAllPeers = true var cachedResult: [ChatListNodePeerInputActivities.ItemId: [(EnginePeer, PeerInputActivity)]] = [:] previousPeerCache.with { dict -> Void in @@ -2778,16 +2785,16 @@ public final class ChatListNode: ListViewImpl { case .savedMessagesChats: return [:] } - + var chatResult: [(EnginePeer, PeerInputActivity)] = [] - + for (peerId, activity) in activities { if let maybePeer = peerMap[peerId], let peer = maybePeer { chatResult.append((peer, activity)) peerCache[peerId] = peer } } - + result[itemId] = chatResult } let _ = previousPeerCache.swap(peerCache) @@ -2845,7 +2852,7 @@ public final class ChatListNode: ListViewImpl { } } }) - + self.reorderItem = { [weak self] fromIndex, toIndex, transactionOpaqueState -> Signal in guard let strongSelf = self, let filteredEntries = (transactionOpaqueState as? ChatListOpaqueTransactionState)?.chatListView.filteredEntries else { return .single(false) @@ -2853,19 +2860,19 @@ public final class ChatListNode: ListViewImpl { guard fromIndex >= 0 && fromIndex < filteredEntries.count && toIndex >= 0 && toIndex < filteredEntries.count else { return .single(false) } - + switch strongSelf.location { case let .chatList(groupId): let fromEntry = filteredEntries[filteredEntries.count - 1 - fromIndex] let toEntry = filteredEntries[filteredEntries.count - 1 - toIndex] - + var referenceId: EngineChatList.PinnedItem.Id? var beforeAll = false switch toEntry { case let .PeerEntry(peerEntry): let index = peerEntry.index let promoInfo = peerEntry.promoInfo - + if promoInfo != nil { beforeAll = true } else { @@ -2876,7 +2883,7 @@ public final class ChatListNode: ListViewImpl { default: break } - + if case let .index(index) = fromEntry.sortIndex, case let .chatList(chatListIndex) = index, let _ = chatListIndex.pinningIndex { let location: TogglePeerChatPinnedLocation if let chatListFilter = chatListFilter { @@ -2884,12 +2891,12 @@ public final class ChatListNode: ListViewImpl { } else { location = .group(groupId._asGroup()) } - + let engine = strongSelf.context.engine return engine.peers.getPinnedItemIds(location: location) |> mapToSignal { itemIds -> Signal in var itemIds = itemIds - + var itemId: EngineChatList.PinnedItem.Id? switch fromEntry { case let .PeerEntry(peerEntry): @@ -2899,7 +2906,7 @@ public final class ChatListNode: ListViewImpl { default: break } - + if let itemId = itemId { itemIds = itemIds.filter({ $0 != itemId }) if let referenceId = referenceId { @@ -2934,7 +2941,7 @@ public final class ChatListNode: ListViewImpl { case let .forum(peerId): let fromEntry = filteredEntries[filteredEntries.count - 1 - fromIndex] let toEntry = filteredEntries[filteredEntries.count - 1 - toIndex] - + var referenceId: Int64? var beforeAll = false switch toEntry { @@ -2949,13 +2956,13 @@ public final class ChatListNode: ListViewImpl { default: break } - + if case let .index(index) = fromEntry.sortIndex, case let .forum(pinningIndex, _, _, _, _) = index, case .index = pinningIndex { let engine = strongSelf.context.engine return engine.peers.getForumChannelPinnedTopics(id: peerId) |> mapToSignal { itemIds -> Signal in var itemIds = itemIds - + var itemId: Int64? switch fromEntry { case let .PeerEntry(peerEntry): @@ -2965,7 +2972,7 @@ public final class ChatListNode: ListViewImpl { default: break } - + if let itemId = itemId { itemIds = itemIds.filter({ $0 != itemId }) if let referenceId = referenceId { @@ -3007,7 +3014,7 @@ public final class ChatListNode: ListViewImpl { return .single(false) } } - + self.beganInteractiveDragging = { [weak self] _ in guard let strongSelf = self else { return @@ -3018,7 +3025,7 @@ public final class ChatListNode: ListViewImpl { case let .known(value): strongSelf.startedScrollingAtUpperBound = value <= 0.001 } - + if strongSelf.currentState.peerIdWithRevealedOptions != nil { strongSelf.updateState { state in var state = state @@ -3026,10 +3033,10 @@ public final class ChatListNode: ListViewImpl { return state } } - + strongSelf.didBeginInteractiveDragging?(strongSelf) } - + self.didEndScrolling = { [weak self] _ in guard let strongSelf = self else { return @@ -3050,7 +3057,7 @@ public final class ChatListNode: ListViewImpl { }*/ } } - + self.scrollToTopOptionPromise.set(combineLatest( renderedTotalUnreadCount(accountManager: self.context.sharedContext.accountManager, engine: self.context.engine) |> deliverOnMainQueue, self.scrolledAtTop.get() @@ -3061,7 +3068,7 @@ public final class ChatListNode: ListViewImpl { return .top } }) - + self.visibleContentOffsetChanged = { [weak self] offset, transition in guard let strongSelf = self else { return @@ -3077,7 +3084,7 @@ public final class ChatListNode: ListViewImpl { atTop = value <= -strongSelf.tempTopInset } strongSelf.scrolledAtTopValue = atTop - + var maxPinnedOffset: CGFloat = 0.0 strongSelf.forEachItemNode { itemNode in if let itemNode = itemNode as? ChatListItemNode, let item = itemNode.item { @@ -3098,24 +3105,24 @@ public final class ChatListNode: ListViewImpl { pinnedScrollFraction = maxPinnedOffset / strongSelf.insets.top } } - + strongSelf.contentOffsetChanged?(offset) strongSelf.pinnedScrollFraction = pinnedScrollFraction strongSelf.pinnedHeaderDisplayFractionUpdated?(transition) } - + self.dynamicVisualInsets = { [weak self] in guard let self else { return UIEdgeInsets() } - + let _ = self return UIEdgeInsets() } - + self.pollFilterUpdates() self.resetFilter() - + let selectionRecognizer = ChatHistoryListSelectionRecognizer(target: self, action: #selector(self.selectionPanGesture(_:))) selectionRecognizer.shouldBegin = { [weak self] in guard let strongSelf = self else { @@ -3125,7 +3132,7 @@ public final class ChatListNode: ListViewImpl { } self.view.addGestureRecognizer(selectionRecognizer) } - + deinit { self.chatListDisposable.dispose() self.activityStatusesDisposable?.dispose() @@ -3134,14 +3141,14 @@ public final class ChatListNode: ListViewImpl { self.chatFilterUpdatesDisposable?.dispose() self.updateIsMainTabDisposable?.dispose() } - + func updateFilter(_ filter: ChatListFilter?) { if filter?.id != self.chatListFilter?.id { self.chatListFilter = filter self.resetFilter() } } - + func hasItemsToBeRevealed() -> Bool { if self.currentState.hiddenItemShouldBeTemporaryRevealed { return false @@ -3161,10 +3168,10 @@ public final class ChatListNode: ListViewImpl { } } }) - + return isHiddenItemVisible } - + func revealScrollHiddenItem() { var isHiddenItemVisible = false self.forEachItemNode({ itemNode in @@ -3193,10 +3200,10 @@ public final class ChatListNode: ListViewImpl { } } } - + private func pollFilterUpdates() { self.chatFolderUpdates.set(.single(nil)) - + /*guard let chatListFilter, case let .filter(id, _, _, data) = chatListFilter, data.isShared else { self.chatFolderUpdates.set(.single(nil)) return @@ -3210,7 +3217,7 @@ public final class ChatListNode: ListViewImpl { self.chatFolderUpdates.set(.single(result)) })*/ } - + private func resetFilter() { if let chatListFilter = self.chatListFilter, chatListFilter.id != Int32.max { self.updatedFilterDisposable.set((self.context.engine.peers.updatedChatListFilters() @@ -3234,7 +3241,7 @@ public final class ChatListNode: ListViewImpl { self.updatedFilterDisposable.set(nil) } } - + public func updateThemeAndStrings(theme: PresentationTheme, fontSize: PresentationFontSize, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, disableAnimations: Bool) { if theme !== self.currentState.presentationData.theme || strings !== self.currentState.presentationData.strings || dateTimeFormat != self.currentState.presentationData.dateTimeFormat { self.theme = theme @@ -3242,7 +3249,7 @@ public final class ChatListNode: ListViewImpl { self.keepTopItemOverscrollBackground = ListViewKeepTopItemOverscrollBackground(color: theme.chatList.pinnedItemBackgroundColor, direction: true) } self.verticalScrollIndicatorColor = theme.list.scrollIndicatorColor - + self.updateState { state in var state = state state.presentationData = ChatListPresentationData(theme: theme, fontSize: fontSize, strings: strings, dateTimeFormat: dateTimeFormat, nameSortOrder: nameSortOrder, nameDisplayOrder: nameDisplayOrder, disableAnimations: disableAnimations) @@ -3250,7 +3257,7 @@ public final class ChatListNode: ListViewImpl { } } } - + public func updateState(_ f: (ChatListNodeState) -> ChatListNodeState) { let state = f(self.currentState) if state != self.currentState { @@ -3259,18 +3266,18 @@ public final class ChatListNode: ListViewImpl { self.selectionCountChanged?(state.selectedPeerIds.count) } } - + private func enqueueTransition(_ transition: ChatListNodeListViewTransition) -> Signal { return Signal { [weak self] subscriber in if let strongSelf = self { if let _ = strongSelf.enqueuedTransition { preconditionFailure() } - + strongSelf.enqueuedTransition = (transition, { subscriber.putCompletion() }) - + if strongSelf.isNodeLoaded, strongSelf.dequeuedInitialTransitionOnLayout { strongSelf.dequeueTransition() } else { @@ -3282,19 +3289,19 @@ public final class ChatListNode: ListViewImpl { } else { subscriber.putCompletion() } - + return EmptyDisposable } |> runOn(Queue.mainQueue()) } - + private func dequeueTransition() { if let (transition, completion) = self.enqueuedTransition { self.enqueuedTransition = nil - + let completion: (ListViewDisplayedItemRange) -> Void = { [weak self] visibleRange in if let strongSelf = self { strongSelf.chatListView = transition.chatListView - + if strongSelf.fillPreloadItems { let filteredEntries = transition.chatListView.filteredEntries var preloadItems: [ChatHistoryPreloadItem] = [] @@ -3324,7 +3331,7 @@ public final class ChatListNode: ListViewImpl { } strongSelf.preloadItems.set(.single(preloadItems)) } - + var pinnedOverscroll = false if case .chatList = strongSelf.mode { let entryCount = transition.chatListView.filteredEntries.count @@ -3352,7 +3359,7 @@ public final class ChatListNode: ListViewImpl { } } } - + if pinnedOverscroll != (strongSelf.keepTopItemOverscrollBackground != nil) { if pinnedOverscroll { strongSelf.keepTopItemOverscrollBackground = ListViewKeepTopItemOverscrollBackground(color: strongSelf.theme.chatList.pinnedItemBackgroundColor, direction: true) @@ -3360,13 +3367,13 @@ public final class ChatListNode: ListViewImpl { strongSelf.keepTopItemOverscrollBackground = nil } } - + if let scrollToItem = transition.scrollToItem, case .center = scrollToItem.position { if let itemNode = strongSelf.itemNodeAtIndex(scrollToItem.index) as? ChatListItemNode { itemNode.flashHighlight() } } - + if !strongSelf.didSetReady { strongSelf.didSetReady = true strongSelf._ready.set(true) @@ -3375,7 +3382,7 @@ public final class ChatListNode: ListViewImpl { strongSelf.didSetContentsReady = true strongSelf._contentsReady.set(true) } - + var isEmpty = false var isLoading = false var hasArchiveInfo = false @@ -3421,7 +3428,7 @@ public final class ChatListNode: ListViewImpl { } } } - + let isEmptyState: ChatListNodeEmptyState if transition.chatListView.isLoading { isEmptyState = .empty(isLoading: true, hasArchiveInfo: hasArchiveInfo) @@ -3459,7 +3466,7 @@ public final class ChatListNode: ListViewImpl { } isEmptyState = .notEmpty(containsChats: containsChats || hasArchive, onlyArchive: hasArchive && !containsChats, onlyGeneralThread: hasGeneral && threadCount == 1) } - + var insertedPeerIds: [EnginePeer.Id] = [] for item in transition.insertItems { if let item = item.item as? ChatListItem { @@ -3476,26 +3483,26 @@ public final class ChatListNode: ListViewImpl { if !insertedPeerIds.isEmpty { strongSelf.addedVisibleChatsWithPeerIds?(insertedPeerIds) } - + var isEmptyUpdate: ContainedViewLayoutTransition = .immediate if transition.options.contains(.AnimateInsertion) || transition.animateCrossfade { isEmptyUpdate = .animated(duration: 0.25, curve: .easeInOut) } - + if strongSelf.currentIsEmptyState != isEmptyState { strongSelf.currentIsEmptyState = isEmptyState strongSelf.isEmptyUpdated?(isEmptyState, transition.chatListView.filter != nil, isEmptyUpdate) } - + if !strongSelf.hasUpdatedAppliedChatListFilterValueOnce || transition.chatListView.filter != strongSelf.currentAppliedChatListFilterValue { strongSelf.currentAppliedChatListFilterValue = transition.chatListView.filter strongSelf.appliedChatListFilterValue.set(.single(transition.chatListView.filter)) } - + completion() } } - + var options = transition.options //options.insert(.Synchronous) if self.view.window != nil || self.synchronousDrawingWhenNotAnimated { @@ -3507,7 +3514,7 @@ public final class ChatListNode: ListViewImpl { options.insert(.PreferSynchronousDrawing) } } - + var scrollToItem = transition.scrollToItem if transition.adjustScrollToFirstItem { var offset: CGFloat = 0.0 @@ -3523,12 +3530,12 @@ public final class ChatListNode: ListViewImpl { } scrollToItem = ListViewScrollToItem(index: 0, position: .top(offset), animated: false, curve: .Default(duration: 0.0), directionHint: .Up) } - + let updatedOpaqueState: Any? = ChatListOpaqueTransactionState(chatListView: transition.chatListView) self.transaction(deleteIndices: transition.deleteItems, insertIndicesAndItems: transition.insertItems, updateIndicesAndItems: transition.updateItems, options: options, scrollToItem: scrollToItem, stationaryItemRange: transition.stationaryItemRange, updateOpaqueState: updatedOpaqueState, completion: completion) } } - + var isNavigationHidden: Bool { switch self.visibleContentOffset() { case let .known(value) where abs(value) < self.scrollHeightTopInset - 1.0: @@ -3539,7 +3546,7 @@ public final class ChatListNode: ListViewImpl { return true } } - + var isNavigationInAFinalState: Bool { switch self.visibleContentOffset() { case let .known(value): @@ -3559,7 +3566,7 @@ public final class ChatListNode: ListViewImpl { return true } } - + func adjustScrollOffsetForNavigation(isNavigationHidden: Bool) { if self.isNavigationHidden == isNavigationHidden { return @@ -3579,20 +3586,20 @@ public final class ChatListNode: ListViewImpl { self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: scrollToItem, updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) } } - + public func fixContentOffset(offset: CGFloat) { let _ = self.scrollToOffsetFromTop(offset, animated: false) - + /*let scrollToItem: ListViewScrollToItem = ListViewScrollToItem(index: 0, position: .top(-offset), animated: false, curve: .Default(duration: 0.0), directionHint: .Up) self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: scrollToItem, updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })*/ } - + public var ignoreStoryInsetAdjustment: Bool = false private var previousStoriesInset: CGFloat? - + public func updateLayout(transition: ContainedViewLayoutTransition, updateSizeAndInsets: ListViewUpdateSizeAndInsets, visibleTopInset: CGFloat, originalTopInset: CGFloat, storiesInset: CGFloat, inlineNavigationLocation: ChatListControllerLocation?, inlineNavigationTransitionFraction: CGFloat) { //print("inset: \(updateSizeAndInsets.insets.top)") - + var highlightedLocation: ChatListHighlightedLocation? if case let .forum(peerId) = inlineNavigationLocation { highlightedLocation = ChatListHighlightedLocation(location: .peer(id: peerId), progress: inlineNavigationTransitionFraction) @@ -3601,13 +3608,13 @@ public final class ChatListNode: ListViewImpl { if (self.interaction?.inlineNavigationLocation == nil) != (highlightedLocation == nil) { navigationLocationPresenceUpdated = true } - + var navigationLocationUpdated = false if self.interaction?.inlineNavigationLocation != highlightedLocation { self.interaction?.inlineNavigationLocation = highlightedLocation navigationLocationUpdated = true } - + let insetDelta: CGFloat = 0.0 if navigationLocationPresenceUpdated { let targetTopInset: CGFloat @@ -3617,17 +3624,17 @@ public final class ChatListNode: ListViewImpl { targetTopInset = self.originalTopInset ?? self.insets.top } let immediateInsetDelta = self.insets.top - targetTopInset - + self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: nil, additionalScrollDistance: immediateInsetDelta, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: self.visibleSize, insets: UIEdgeInsets(top: targetTopInset, left: self.insets.left, bottom: self.insets.bottom, right: self.insets.right), duration: 0.0, curve: .Default(duration: 0.0)), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) } - + self.visualInsets = UIEdgeInsets(top: visibleTopInset, left: 0.0, bottom: 0.0, right: 0.0) - + self.visibleTopInset = visibleTopInset self.originalTopInset = originalTopInset - + var additionalScrollDistance: CGFloat = 0.0 - + if let previousStoriesInset = self.previousStoriesInset { if self.ignoreStoryInsetAdjustment { //additionalScrollDistance += -20.0 @@ -3644,28 +3651,28 @@ public final class ChatListNode: ListViewImpl { } self.previousStoriesInset = storiesInset //print("storiesInset: \(storiesInset), additionalScrollDistance: \(additionalScrollDistance)") - + var options: ListViewDeleteAndInsertOptions = [.Synchronous, .LowLatency] if navigationLocationUpdated { options.insert(.ForceUpdate) - + if transition.isAnimated { options.insert(.AnimateInsertion) } - + additionalScrollDistance += insetDelta } self.ignoreStopScrolling = true self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: options, scrollToItem: nil, additionalScrollDistance: additionalScrollDistance, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) self.ignoreStopScrolling = false - + if !self.dequeuedInitialTransitionOnLayout { self.dequeuedInitialTransitionOnLayout = true - + self.dequeueTransition() } } - + public func scrollToPosition(_ position: ChatListNodeScrollPosition, animated: Bool = true) { var additionalDelta: CGFloat = 0.0 switch position { @@ -3675,7 +3682,7 @@ public final class ChatListNode: ListViewImpl { self.tempTopInset = ChatListNavigationBar.storiesScrollHeight } } - + if let list = self.chatListView?.originalList { if !list.hasLater { self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: ListViewScrollToItem(index: 0, position: .top(additionalDelta), animated: animated, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) @@ -3688,17 +3695,17 @@ public final class ChatListNode: ListViewImpl { self.setChatListLocation(location) } } - + private func setChatListLocation(_ location: ChatListNodeLocation) { self.currentLocation = location self.chatListLocation.set(location) } - + private func relativeUnreadChatListIndex(position: EngineChatList.RelativePosition) -> Signal { guard case let .chatList(groupId) = self.location else { return .single(nil) } - + let engine = self.context.engine return self.context.sharedContext.accountManager.transaction { transaction -> Signal in var filter = true @@ -3712,25 +3719,25 @@ public final class ChatListNode: ListViewImpl { } |> switchToLatest } - + public func selectChat(_ option: ChatListSelectionOption) { guard let interaction = self.interaction else { return } - + guard let chatListView = (self.opaqueTransactionState as? ChatListOpaqueTransactionState)?.chatListView else { return } - + guard let range = self.displayedItemRange.loadedRange else { return } - + let entryCount = chatListView.filteredEntries.count var current: (EngineChatList.Item.Index, EnginePeer, Int)? = nil var previous: (EngineChatList.Item.Index, EnginePeer)? = nil var next: (EngineChatList.Item.Index, EnginePeer)? = nil - + outer: for i in range.firstIndex ..< range.lastIndex { if i < 0 || i >= entryCount { assertionFailure() @@ -3746,7 +3753,7 @@ public final class ChatListNode: ListViewImpl { break } } - + switch option { case .previous(unread: true), .next(unread: true): let position: EngineChatList.RelativePosition @@ -3826,14 +3833,14 @@ public final class ChatListNode: ListViewImpl { guard case let .chatList(groupId) = self.location else { return } - + let shouldLoadCanMessagePeer: Bool if case .peers = self.mode { shouldLoadCanMessagePeer = true } else { shouldLoadCanMessagePeer = false } - + let _ = (chatListViewForLocation(chatListLocation: .chatList(groupId: groupId), location: .initial(count: 10, filter: filter), account: self.context.account, shouldLoadCanMessagePeer: shouldLoadCanMessagePeer) |> take(1) |> deliverOnMainQueue).startStandalone(next: { update in @@ -3848,28 +3855,28 @@ public final class ChatListNode: ListViewImpl { }) } } - + private func enqueueHistoryPreloadUpdate() { } - + public func updateSelectedChatLocation(_ chatLocation: ChatLocation?, progress: CGFloat, transition: ContainedViewLayoutTransition) { guard let interaction = self.interaction else { return } - + if let chatLocation = chatLocation { interaction.highlightedChatLocation = ChatListHighlightedLocation(location: chatLocation, progress: progress) } else { interaction.highlightedChatLocation = nil } - + self.forEachItemNode { itemNode in if let itemNode = itemNode as? ChatListItemNode { itemNode.updateIsHighlighted(transition: transition) } } } - + private func currentlyVisibleLatestChatListIndex() -> EngineChatList.Item.Index? { guard let chatListView = (self.opaqueTransactionState as? ChatListOpaqueTransactionState)?.chatListView else { return nil @@ -3891,7 +3898,7 @@ public final class ChatListNode: ListViewImpl { } return nil } - + private func peerAtPoint(_ point: CGPoint) -> EnginePeer? { var resultPeer: EnginePeer? self.forEachVisibleItemNode { itemNode in @@ -3908,7 +3915,7 @@ public final class ChatListNode: ListViewImpl { } return resultPeer } - + private func threadIdAtPoint(_ point: CGPoint) -> Int64? { var resultThreadId: Int64? self.forEachVisibleItemNode { itemNode in @@ -3925,14 +3932,14 @@ public final class ChatListNode: ListViewImpl { } return resultThreadId } - + private var selectionPanState: (selecting: Bool, initialPeerId: EnginePeer.Id, toggledPeerIds: [[EnginePeer.Id]])? private var threadSelectionPanState: (selecting: Bool, initialThreadId: Int64, toggledThreadIds: [[Int64]])? private var selectionScrollActivationTimer: SwiftSignalKit.Timer? private var selectionScrollDisplayLink: ConstantDisplayLinkAnimator? private var selectionScrollDelta: CGFloat? private var selectionLastLocation: CGPoint? - + @objc private func selectionPanGesture(_ recognizer: UIGestureRecognizer) -> Void { let location = recognizer.location(in: self.view) switch recognizer.state { @@ -3971,7 +3978,7 @@ public final class ChatListNode: ListViewImpl { fatalError() } } - + private func handlePanSelection(location: CGPoint) { var location = location if location.y < self.insets.top { @@ -3979,7 +3986,7 @@ public final class ChatListNode: ListViewImpl { } else if location.y > self.frame.height - self.insets.bottom { location.y = self.frame.height - self.insets.bottom - 5.0 } - + var hasState = false switch self.location { case .chatList: @@ -3999,14 +4006,14 @@ public final class ChatListNode: ListViewImpl { if peerId == peer.id { previouslyToggled = true updatedToggledPeerIds = Array(state.toggledPeerIds.prefix(i + 1)) - + let peerIdsToToggle = Array(state.toggledPeerIds.suffix(state.toggledPeerIds.count - i - 1)).flatMap { $0 } self.interaction?.togglePeersSelection(peerIdsToToggle.compactMap { .peerId($0) }, !state.selecting) break } } } - + if !previouslyToggled { updatedToggledPeerIds = state.toggledPeerIds let isSelected = self.currentState.selectedPeerIds.contains(peer.id) @@ -4015,7 +4022,7 @@ public final class ChatListNode: ListViewImpl { self.interaction?.togglePeersSelection([.peer(peer)], state.selecting) } } - + self.selectionPanState = (state.selecting, state.initialPeerId, updatedToggledPeerIds) } } @@ -4037,14 +4044,14 @@ public final class ChatListNode: ListViewImpl { if toggledThreadId == threadId { previouslyToggled = true updatedToggledThreadIds = Array(state.toggledThreadIds.prefix(i + 1)) - + let threadIdsToToggle = Array(state.toggledThreadIds.suffix(state.toggledThreadIds.count - i - 1)).flatMap { $0 } self.interaction?.toggleThreadsSelection(threadIdsToToggle.compactMap { $0 }, !state.selecting) break } } } - + if !previouslyToggled { updatedToggledThreadIds = state.toggledThreadIds let isSelected = self.currentState.selectedThreadIds.contains(threadId) @@ -4053,7 +4060,7 @@ public final class ChatListNode: ListViewImpl { self.interaction?.toggleThreadsSelection([threadId], state.selecting) } } - + self.threadSelectionPanState = (state.selecting, state.initialThreadId, updatedToggledThreadIds) } } @@ -4089,7 +4096,7 @@ public final class ChatListNode: ListViewImpl { self.selectionScrollActivationTimer = nil } } - + private var selectionScrollSkipUpdate = false private func setupSelectionScrolling() { self.selectionScrollDisplayLink = ConstantDisplayLinkAnimator(update: { [weak self] in @@ -4098,7 +4105,7 @@ public final class ChatListNode: ListViewImpl { let distance: CGFloat = 15.0 * min(1.0, 0.15 + abs(delta * delta)) let direction: ListViewScrollDirection = delta > 0.0 ? .up : .down let _ = strongSelf.scrollWithDirection(direction, distance: distance) - + if let location = strongSelf.selectionLastLocation { if !strongSelf.selectionScrollSkipUpdate { strongSelf.handlePanSelection(location: location) @@ -4115,7 +4122,7 @@ private func statusStringForPeerType(accountPeerId: EnginePeer.Id, strings: Pres if accountPeerId == peer.id { return nil } - + if displayAutoremoveTimeout { if let autoremoveTimeout = autoremoveTimeout { return (NSAttributedString(string: strings.ChatList_LabelAutodeleteAfter(timeIntervalString(strings: strings, value: autoremoveTimeout, usage: .afterTime)).string), false, true, .autoremove) @@ -4123,7 +4130,7 @@ private func statusStringForPeerType(accountPeerId: EnginePeer.Id, strings: Pres return (NSAttributedString(string: strings.ChatList_LabelAutodeleteDisabled), false, false, .autoremove) } } - + if let chatListFilters = chatListFilters { let result = NSMutableAttributedString(string: "") for case let .filter(_, title, _, data) in chatListFilters { @@ -4135,14 +4142,14 @@ private func statusStringForPeerType(accountPeerId: EnginePeer.Id, strings: Pres result.append(title.rawAttributedString) } } - + if result.length == 0 { return nil } else { return (result, true, false, nil) } } - + if peer.id.isReplies || peer.id.isVerificationCodes { return nil } else if case let .user(user) = peer { @@ -4175,28 +4182,28 @@ private func statusStringForPeerType(accountPeerId: EnginePeer.Id, strings: Pres public class ChatHistoryListSelectionRecognizer: UIPanGestureRecognizer { private let selectionGestureActivationThreshold: CGFloat = 5.0 - + var recognized: Bool? = nil var initialLocation: CGPoint = CGPoint() - + public var shouldBegin: (() -> Bool)? - + public override init(target: Any?, action: Selector?) { super.init(target: target, action: action) - + self.minimumNumberOfTouches = 2 self.maximumNumberOfTouches = 2 } - + public override func reset() { super.reset() - + self.recognized = nil } - + public override func touchesBegan(_ touches: Set, with event: UIEvent) { super.touchesBegan(touches, with: event) - + if let shouldBegin = self.shouldBegin, !shouldBegin() { self.state = .failed } else { @@ -4204,17 +4211,17 @@ public class ChatHistoryListSelectionRecognizer: UIPanGestureRecognizer { self.initialLocation = touch.location(in: self.view) } } - + public override func touchesMoved(_ touches: Set, with event: UIEvent) { let location = touches.first!.location(in: self.view) let translation = location.offsetBy(dx: -self.initialLocation.x, dy: -self.initialLocation.y) - + let touchesArray = Array(touches) if self.recognized == nil, touchesArray.count == 2 { if let firstTouch = touchesArray.first, let secondTouch = touchesArray.last { let firstLocation = firstTouch.location(in: self.view) let secondLocation = secondTouch.location(in: self.view) - + func distance(_ v1: CGPoint, _ v2: CGPoint) -> CGFloat { let dx = v1.x - v2.x let dy = v1.y - v2.y @@ -4228,7 +4235,7 @@ public class ChatHistoryListSelectionRecognizer: UIPanGestureRecognizer { self.recognized = true } } - + if let recognized = self.recognized, recognized { super.touchesMoved(touches, with: event) } @@ -4253,7 +4260,7 @@ func chatListItemTags(location: ChatListControllerLocation, accountPeerId: Engin guard let peer else { return [] } - + var result: [ChatListItemContent.Tag] = [] for case let .filter(id, title, _, data) in chatListFilters { if data.color != nil { diff --git a/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift b/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift index f9c4efa872..91d873dba9 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift @@ -2,6 +2,7 @@ import Foundation import UIKit import TelegramCore import TelegramPresentationData +import TelegramUIPreferences import MergeLists import AccountContext @@ -26,7 +27,7 @@ enum ChatListNodeEntrySortIndex: Comparable { case sectionHeader case contact(id: EnginePeer.Id, presence: EnginePeer.Presence) case topPeer(Int) - + static func <(lhs: ChatListNodeEntrySortIndex, rhs: ChatListNodeEntrySortIndex) -> Bool { switch lhs { case let .index(lhsIndex): @@ -122,7 +123,7 @@ enum ChatListNodeEntry: Comparable, Identifiable { var storyState: ChatListNodeState.StoryState? var requiresPremiumForMessaging: Bool var displayAsTopicList: Bool - + init( index: EngineChatList.Item.Index, presentationData: ChatListPresentationData, @@ -180,7 +181,7 @@ enum ChatListNodeEntry: Comparable, Identifiable { self.requiresPremiumForMessaging = requiresPremiumForMessaging self.displayAsTopicList = displayAsTopicList } - + static func ==(lhs: PeerEntryData, rhs: PeerEntryData) -> Bool { if lhs.index != rhs.index { return false @@ -306,18 +307,18 @@ enum ChatListNodeEntry: Comparable, Identifiable { return true } } - + struct ContactEntryData: Equatable { var presentationData: ChatListPresentationData var peer: EnginePeer var presence: EnginePeer.Presence - + init(presentationData: ChatListPresentationData, peer: EnginePeer, presence: EnginePeer.Presence) { self.presentationData = presentationData self.peer = peer self.presence = presence } - + static func ==(lhs: ContactEntryData, rhs: ContactEntryData) -> Bool { if lhs.presentationData !== rhs.presentationData { return false @@ -331,7 +332,7 @@ enum ChatListNodeEntry: Comparable, Identifiable { return true } } - + struct GroupReferenceEntryData: Equatable { var index: EngineChatList.Item.Index var presentationData: ChatListPresentationData @@ -344,7 +345,7 @@ enum ChatListNodeEntry: Comparable, Identifiable { var hiddenByDefault: Bool var appearsPinned: Bool var storyState: ChatListNodeState.StoryState? - + init( index: EngineChatList.Item.Index, presentationData: ChatListPresentationData, @@ -370,7 +371,7 @@ enum ChatListNodeEntry: Comparable, Identifiable { self.appearsPinned = appearsPinned self.storyState = storyState } - + static func ==(lhs: GroupReferenceEntryData, rhs: GroupReferenceEntryData) -> Bool { if lhs.index != rhs.index { return false @@ -405,11 +406,11 @@ enum ChatListNodeEntry: Comparable, Identifiable { if lhs.storyState != rhs.storyState { return false } - + return true } } - + case HeaderEntry case PeerEntry(PeerEntryData) case HoleEntry(EngineMessage.Index, theme: PresentationTheme) @@ -420,7 +421,7 @@ enum ChatListNodeEntry: Comparable, Identifiable { case SectionHeader(presentationData: ChatListPresentationData, displayHide: Bool) case AdditionalCategory(index: Int, id: Int, title: String, image: UIImage?, appearance: ChatListNodeAdditionalCategory.Appearance, selected: Bool, presentationData: ChatListPresentationData) case TopPeer(index: Int, peer: EnginePeer) - + var sortIndex: ChatListNodeEntrySortIndex { switch self { case .HeaderEntry: @@ -445,7 +446,7 @@ enum ChatListNodeEntry: Comparable, Identifiable { return .topPeer(index) } } - + var stableId: ChatListNodeEntryId { switch self { case .HeaderEntry: @@ -475,11 +476,11 @@ enum ChatListNodeEntry: Comparable, Identifiable { return .TopPeer(peer.id) } } - + static func <(lhs: ChatListNodeEntry, rhs: ChatListNodeEntry) -> Bool { return lhs.sortIndex < rhs.sortIndex } - + static func ==(lhs: ChatListNodeEntry, rhs: ChatListNodeEntry) -> Bool { switch lhs { case .HeaderEntry: @@ -591,14 +592,14 @@ private func offsetPinnedIndex(_ index: EngineChatList.Item.Index, offset: UInt1 struct ChatListContactPeer { var peer: EnginePeer var presence: EnginePeer.Presence - + init(peer: EnginePeer, presence: EnginePeer.Presence) { self.peer = peer self.presence = presence } } -func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, savedMessagesPeer: EnginePeer?, foundPeers: [(EnginePeer, EnginePeer?)], hideArchivedFolderByDefault: Bool, displayArchiveIntro: Bool, mode: ChatListNodeMode, chatListLocation: ChatListControllerLocation, contacts: [ChatListContactPeer], accountPeerId: EnginePeer.Id, isMainTab: Bool) -> (entries: [ChatListNodeEntry], loading: Bool) { +func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, savedMessagesPeer: EnginePeer?, foundPeers: [(EnginePeer, EnginePeer?)], hideArchivedFolderByDefault: Bool, displayArchiveIntro: Bool, mode: ChatListNodeMode, chatListLocation: ChatListControllerLocation, contacts: [ChatListContactPeer], accountPeerId: EnginePeer.Id, isMainTab: Bool, stashedPeerIds: Set) -> (entries: [ChatListNodeEntry], loading: Bool) { var groupItems = view.groupItems if isMainTab && state.archiveStoryState != nil && groupItems.isEmpty { groupItems.append(EngineChatList.GroupItem( @@ -608,16 +609,16 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, unreadCount: 0 )) } - + var result: [ChatListNodeEntry] = [] - + var hasContacts = false if !view.hasEarlier { var existingPeerIds = Set() for item in view.items { existingPeerIds.insert(item.renderedPeer.peerId) } - + for contact in contacts { if existingPeerIds.contains(contact.peer.id) { continue @@ -633,9 +634,9 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, result.append(.SectionHeader(presentationData: state.presentationData, displayHide: !view.items.isEmpty)) } } - + var pinnedIndexOffset: UInt16 = 0 - + if !view.hasLater, case .chatList = mode { var groupEntryCount = 0 for _ in groupItems { @@ -643,24 +644,24 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, } pinnedIndexOffset += UInt16(groupEntryCount) } - + let filteredAdditionalItemEntries = view.additionalItems.filter { item -> Bool in return item.item.renderedPeer.peerId != state.hiddenPsaPeerId } - + var foundPeerIds = Set() for peer in foundPeers { foundPeerIds.insert(peer.0.id) } - + if !view.hasLater && savedMessagesPeer == nil { pinnedIndexOffset += UInt16(filteredAdditionalItemEntries.count) } - + var hiddenGeneralThread: ChatListNodeEntry? - + var hasPinned = false - + loop: for entry in view.items { var peerId: EnginePeer.Id? var threadId: Int64? @@ -673,13 +674,16 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, activityItemId = ChatListNodePeerInputActivities.ItemId(peerId: peerIdValue, threadId: threadIdValue) threadId = threadIdValue } - + if let savedMessagesPeer = savedMessagesPeer, let peerId = peerId, savedMessagesPeer.id == peerId || foundPeerIds.contains(peerId) { continue loop } if let peerId = peerId, state.pendingRemovalItemIds.contains(ChatListNodeState.ItemId(peerId: peerId, threadId: threadId)) { continue loop } + if let peerId = peerId, stashedPeerIds.contains(peerId.toInt64()) { + continue loop + } var updatedMessages = entry.messages var updatedCombinedReadState = entry.readCounters if let peerId = peerId, state.pendingClearHistoryPeerIds.contains(ChatListNodeState.ItemId(peerId: peerId, threadId: threadId)) { @@ -691,7 +695,7 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, if let draft = entry.draft { draftState = ChatListItemContent.DraftState(draft: draft) } - + var hasActiveRevealControls = false if let peerId { hasActiveRevealControls = ChatListNodeState.ItemId(peerId: peerId, threadId: threadId) == state.peerIdWithRevealedOptions @@ -700,19 +704,19 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, if let activityItemId { inputActivities = state.peerInputActivities?.activities[activityItemId] } - + var isSelected = false if let threadId, threadId != 0 { isSelected = state.selectedThreadIds.contains(threadId) } else if let peerId { isSelected = state.selectedPeerIds.contains(peerId) } - + var threadInfo: ChatListItemContent.ThreadInfo? if let threadData = entry.threadData, let threadId { threadInfo = ChatListItemContent.ThreadInfo(id: threadId, info: threadData.info, isOwnedByMe: threadData.isOwnedByMe, isClosed: threadData.isClosed, isHidden: threadData.isHidden, threadPeer: nil) } - + switch entry.index { case let .chatList(chatList): if chatList.pinningIndex != nil { @@ -758,21 +762,21 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, requiresPremiumForMessaging: entry.isPremiumRequiredToMessage, displayAsTopicList: entry.displayAsTopicList )) - + if let threadInfo, threadInfo.isHidden { hiddenGeneralThread = entry } else { result.append(entry) } } - + if let hiddenGeneralThread { result.append(hiddenGeneralThread) } - + if !view.hasLater { var pinningIndex: UInt16 = UInt16(pinnedIndexOffset == 0 ? 0 : (pinnedIndexOffset - 1)) - + if let savedMessagesPeer = savedMessagesPeer { if !foundPeers.isEmpty { var foundPinningIndex: UInt16 = UInt16(foundPeers.count) @@ -781,7 +785,7 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, if let chatPeer = peer.1 { peers[chatPeer.id] = chatPeer } - + let messageIndex = EngineMessage.Index(id: EngineMessage.Id(peerId: peer.0.id, namespace: 0, id: 0), timestamp: 1) result.append(.PeerEntry(ChatListNodeEntry.PeerEntryData( index: .chatList(EngineChatList.Item.Index.ChatList(pinningIndex: foundPinningIndex, messageIndex: messageIndex)), @@ -818,7 +822,7 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, } } } - + result.append(.PeerEntry(ChatListNodeEntry.PeerEntryData( index: .chatList(EngineChatList.Item.Index.ChatList.absoluteUpperBound.predecessor), presentationData: state.presentationData, @@ -854,7 +858,7 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, guard case let .chatList(index) = item.item.index else { continue } - + let promoInfo: ChatListNodeEntryPromoInfo switch item.promoInfo.content { case .proxy: @@ -863,10 +867,10 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, promoInfo = .psa(type: type, message: message) } let draftState = item.item.draft.flatMap(ChatListItemContent.DraftState.init) - + let peerId = index.messageIndex.id.peerId let isSelected = state.selectedPeerIds.contains(peerId) - + var threadId: Int64 = 0 switch item.item.index { case let .forum(_, _, threadIdValue, _, _): @@ -912,7 +916,7 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, } } } - + if !view.hasLater, case .chatList = mode { for groupReference in groupItems { let messageIndex = EngineMessage.Index(id: EngineMessage.Id(peerId: EnginePeer.Id(0), namespace: 0, id: 0), timestamp: 1) @@ -938,7 +942,7 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, hasPinned = true } } - + if displayArchiveIntro { //result.append(.ArchiveIntro(presentationData: state.presentationData)) } else if !contacts.isEmpty && !result.contains(where: { entry in @@ -950,10 +954,10 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, }) { result.append(.EmptyIntro(presentationData: state.presentationData)) } - + result.append(.HeaderEntry) } - + if !view.hasLater { if case let .peers(_, _, additionalCategories, topPeers, _, _, _) = mode { var index = 0 @@ -961,7 +965,7 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, result.append(.AdditionalCategory(index: index, id: category.id, title: category.title, image: category.icon, appearance: category.appearance, selected: state.selectedAdditionalCategoryIds.contains(category.id), presentationData: state.presentationData)) index += 1 } - + index = 0 for topPeer in topPeers { result.append(.TopPeer(index: index, peer: topPeer)) diff --git a/submodules/Display/Source/Font.swift b/submodules/Display/Source/Font.swift index 4f1a4c0cf8..5fd0f1277e 100644 --- a/submodules/Display/Source/Font.swift +++ b/submodules/Display/Source/Font.swift @@ -1,6 +1,11 @@ import Foundation import UIKit +// WinterGram: optional custom font overrides. Display is too low-level to read the settings +// store, so the UI layer pushes the chosen font family names here at startup / on change. +public var winterGramCustomFontName: String? +public var winterGramMonoFontName: String? + public struct Font { public enum Design { case regular @@ -8,7 +13,7 @@ public struct Font { case monospace case round case camera - + var key: String { switch self { case .regular: @@ -24,28 +29,28 @@ public struct Font { } } } - + public struct Traits: OptionSet { public var rawValue: Int32 - + public init(rawValue: Int32) { self.rawValue = rawValue } - + public init() { self.rawValue = 0 } - + public static let italic = Traits(rawValue: 1 << 0) public static let monospacedNumbers = Traits(rawValue: 1 << 1) } - + public enum Width { case standard case condensed case compressed case expanded - + @available(iOS 16.0, *) var width: UIFont.Width { switch self { @@ -59,7 +64,7 @@ public struct Font { return .expanded } } - + var key: String { switch self { case .standard: @@ -73,7 +78,7 @@ public struct Font { } } } - + public enum Weight { case regular case thin @@ -82,7 +87,7 @@ public struct Font { case semibold case bold case heavy - + var isBold: Bool { switch self { case .medium, .semibold, .bold, .heavy: @@ -91,7 +96,7 @@ public struct Font { return false } } - + var weight: UIFont.Weight { switch self { case .thin: @@ -110,7 +115,7 @@ public struct Font { return .regular } } - + var key: String { switch self { case .regular: @@ -130,17 +135,17 @@ public struct Font { } } } - + private final class Cache { private var lock: pthread_rwlock_t private var fonts: [String: UIFont] = [:] - + init() { self.lock = pthread_rwlock_t() let status = pthread_rwlock_init(&self.lock, nil) assert(status == 0) } - + func get(_ key: String) -> UIFont? { let font: UIFont? pthread_rwlock_rdlock(&self.lock) @@ -148,7 +153,7 @@ public struct Font { pthread_rwlock_unlock(&self.lock) return font } - + func set(_ font: UIFont, key: String) { pthread_rwlock_wrlock(&self.lock) self.fonts[key] = font @@ -157,13 +162,38 @@ public struct Font { } private static let cache = Cache() - + public static func with(size: CGFloat, design: Design = .regular, weight: Weight = .regular, width: Width = .standard, traits: Traits = []) -> UIFont { let key = "\(size)_\(design.key)_\(weight.key)_\(width.key)_\(traits.rawValue)" - + if let cachedFont = self.cache.get(key) { return cachedFont } + + // WinterGram: apply a user-chosen font family, if set, for regular and monospace designs. + let winterGramOverrideName: String? + switch design { + case .regular: + winterGramOverrideName = winterGramCustomFontName + case .monospace: + winterGramOverrideName = winterGramMonoFontName + default: + winterGramOverrideName = nil + } + if let overrideName = winterGramOverrideName, !overrideName.isEmpty, let baseFont = UIFont(name: overrideName, size: size) { + var descriptor = baseFont.fontDescriptor + if traits.contains(.italic), let italicDescriptor = descriptor.withSymbolicTraits([descriptor.symbolicTraits, .traitItalic]) { + descriptor = italicDescriptor + } + if weight != .regular { + descriptor = descriptor.addingAttributes([ + UIFontDescriptor.AttributeName.traits: [UIFontDescriptor.TraitKey.weight: weight.weight] + ]) + } + let resultFont = UIFont(descriptor: descriptor, size: size) + self.cache.set(resultFont, key: key) + return resultFont + } if #available(iOS 13.0, *), design != .camera { let descriptor: UIFontDescriptor if #available(iOS 14.0, *) { @@ -210,16 +240,16 @@ public struct Font { ]) } } - + let font: UIFont if let updatedDescriptor = updatedDescriptor { font = UIFont(descriptor: updatedDescriptor, size: size) } else { font = UIFont(descriptor: descriptor, size: size) } - + self.cache.set(font, key: key) - + return font } else { let font: UIFont @@ -271,25 +301,25 @@ public struct Font { font = UIFont(name: encodeText(string: "TGDbnfsb.Sfhvmbs", key: -1), size: size) ?? UIFont.systemFont(ofSize: size, weight: weight.weight) } } - + self.cache.set(font, key: key) - + return font } } - + public static func regular(_ size: CGFloat) -> UIFont { return UIFont.systemFont(ofSize: size) } - + public static func medium(_ size: CGFloat) -> UIFont { return UIFont.systemFont(ofSize: size, weight: UIFont.Weight.medium) } - + public static func semibold(_ size: CGFloat) -> UIFont { return UIFont.systemFont(ofSize: size, weight: UIFont.Weight.semibold) } - + public static func bold(_ size: CGFloat) -> UIFont { if #available(iOS 8.2, *) { return UIFont.boldSystemFont(ofSize: size) @@ -297,15 +327,15 @@ public struct Font { return CTFontCreateWithName("HelveticaNeue-Bold" as CFString, size, nil) } } - + public static func heavy(_ size: CGFloat) -> UIFont { return self.with(size: size, design: .regular, weight: .heavy, traits: []) } - + public static func light(_ size: CGFloat) -> UIFont { return UIFont.systemFont(ofSize: size, weight: UIFont.Weight.light) } - + public static func semiboldItalic(_ size: CGFloat) -> UIFont { if let descriptor = UIFont.systemFont(ofSize: size).fontDescriptor.withSymbolicTraits([.traitBold, .traitItalic]) { return UIFont(descriptor: descriptor, size: size) @@ -313,23 +343,23 @@ public struct Font { return UIFont.italicSystemFont(ofSize: size) } } - + public static func monospace(_ size: CGFloat) -> UIFont { return UIFont(name: "Menlo-Regular", size: size - 1.0) ?? UIFont.systemFont(ofSize: size) } - + public static func semiboldMonospace(_ size: CGFloat) -> UIFont { return UIFont(name: "Menlo-Bold", size: size - 1.0) ?? UIFont.systemFont(ofSize: size) } - + public static func italicMonospace(_ size: CGFloat) -> UIFont { return UIFont(name: "Menlo-Italic", size: size - 1.0) ?? UIFont.systemFont(ofSize: size) } - + public static func semiboldItalicMonospace(_ size: CGFloat) -> UIFont { return UIFont(name: "Menlo-BoldItalic", size: size - 1.0) ?? UIFont.systemFont(ofSize: size) } - + public static func italic(_ size: CGFloat) -> UIFont { return UIFont.italicSystemFont(ofSize: size) } diff --git a/submodules/Display/Source/NavigationBackgroundView.swift b/submodules/Display/Source/NavigationBackgroundView.swift index 2fff1fe113..3772792028 100644 --- a/submodules/Display/Source/NavigationBackgroundView.swift +++ b/submodules/Display/Source/NavigationBackgroundView.swift @@ -4,26 +4,49 @@ import AsyncDisplayKit private var sharedIsReduceTransparencyEnabled = UIAccessibility.isReduceTransparencyEnabled +// WinterGram Liquid Glass: when enabled, opaque bar backgrounds are made translucent, +// which activates the blur path below. Display cannot depend on TelegramUIPreferences, +// so the settings observer in the UI layer pushes values here (main thread). +public extension Notification.Name { + static let winterGramLiquidGlassChanged = Notification.Name("winterGramLiquidGlassChanged") +} + +public struct DisplayLiquidGlass { + public static var enabled: Bool = false + // Alpha applied to otherwise-opaque backgrounds; (1 - transparency) from settings. + public static var alpha: CGFloat = 0.65 + public static var blurRadius: CGFloat? = nil + public static var vibrancy: Bool = true +} + +private func applyLiquidGlass(to color: UIColor) -> UIColor { + if DisplayLiquidGlass.enabled, color.alpha >= 0.95 { + return color.withAlphaComponent(max(0.05, min(0.9, DisplayLiquidGlass.alpha))) + } + return color +} + public final class NavigationBackgroundNode: ASDisplayNode { private var _color: UIColor + private var _originalColor: UIColor = .clear public var color: UIColor { return self._color } - + private var enableBlur: Bool private var enableSaturation: Bool private var customBlurRadius: CGFloat? public var effectView: UIVisualEffectView? private let backgroundNode: ASDisplayNode - + public var backgroundView: UIView { return self.backgroundNode.view } private var validLayout: (CGSize, CGFloat)? - + public var backgroundCornerRadius: CGFloat { if let (_, cornerRadius) = self.validLayout { return cornerRadius @@ -32,6 +55,8 @@ public final class NavigationBackgroundNode: ASDisplayNode { } } + private var liquidGlassDisposable: Any? + public init(color: UIColor, enableBlur: Bool = true, enableSaturation: Bool = true, customBlurRadius: CGFloat? = nil) { self._color = .clear self.enableBlur = enableBlur @@ -45,20 +70,32 @@ public final class NavigationBackgroundNode: ASDisplayNode { self.addSubnode(self.backgroundNode) self.updateColor(color: color, transition: .immediate) + + self.liquidGlassDisposable = NotificationCenter.default.addObserver(forName: .winterGramLiquidGlassChanged, object: nil, queue: .main, using: { [weak self] _ in + if let strongSelf = self { + strongSelf.updateColor(color: strongSelf._originalColor, transition: .animated(duration: 0.2, curve: .easeInOut)) + } + }) } - + deinit { + if let liquidGlassDisposable = self.liquidGlassDisposable { + NotificationCenter.default.removeObserver(liquidGlassDisposable) + } + } + + public override func didLoad() { super.didLoad() - + if self.scheduledUpdate { self.scheduledUpdate = false self.updateBackgroundBlur(forceKeepBlur: false) } } - + private var scheduledUpdate = false - + private func updateBackgroundBlur(forceKeepBlur: Bool) { guard self.isNodeLoaded else { self.scheduledUpdate = true @@ -113,9 +150,11 @@ public final class NavigationBackgroundNode: ASDisplayNode { } public func updateColor(color: UIColor, enableBlur: Bool? = nil, enableSaturation: Bool? = nil, forceKeepBlur: Bool = false, transition: ContainedViewLayoutTransition) { + self._originalColor = color + let color = applyLiquidGlass(to: color) let effectiveEnableBlur = enableBlur ?? self.enableBlur let effectiveEnableSaturation = enableSaturation ?? self.enableSaturation - + if self._color.isEqual(color) && self.enableBlur == effectiveEnableBlur && self.enableSaturation == effectiveEnableSaturation { return } @@ -152,7 +191,7 @@ public final class NavigationBackgroundNode: ASDisplayNode { effectView.clipsToBounds = !cornerRadius.isZero } } - + public func update(size: CGSize, cornerRadius: CGFloat = 0.0, animator: ControlledTransitionAnimator) { self.validLayout = (size, cornerRadius) @@ -177,6 +216,7 @@ public final class NavigationBackgroundNode: ASDisplayNode { open class BlurredBackgroundView: UIView { private var _color: UIColor? + private var _originalColor: UIColor? private var enableBlur: Bool private var customBlurRadius: CGFloat? @@ -185,7 +225,7 @@ open class BlurredBackgroundView: UIView { private let backgroundView: UIView private var validLayout: (CGSize, CGFloat)? - + public var backgroundCornerRadius: CGFloat { if let (_, cornerRadius) = self.validLayout { return cornerRadius @@ -194,6 +234,8 @@ open class BlurredBackgroundView: UIView { } } + private var liquidGlassDisposable: Any? + public init(color: UIColor?, enableBlur: Bool = true, customBlurRadius: CGFloat? = nil) { self._color = nil self.enableBlur = enableBlur @@ -208,12 +250,24 @@ open class BlurredBackgroundView: UIView { if let color = color { self.updateColor(color: color, transition: .immediate) } + + self.liquidGlassDisposable = NotificationCenter.default.addObserver(forName: .winterGramLiquidGlassChanged, object: nil, queue: .main, using: { [weak self] _ in + if let strongSelf = self, let color = strongSelf._originalColor { + strongSelf.updateColor(color: color, transition: .animated(duration: 0.2, curve: .easeInOut)) + } + }) } - + + deinit { + if let liquidGlassDisposable = self.liquidGlassDisposable { + NotificationCenter.default.removeObserver(liquidGlassDisposable) + } + } + required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private func updateBackgroundBlur(forceKeepBlur: Bool) { if let color = self._color, self.enableBlur && !sharedIsReduceTransparencyEnabled && ((color.alpha > .ulpOfOne && color.alpha < 0.95) || forceKeepBlur) { if self.effectView == nil { @@ -263,6 +317,8 @@ open class BlurredBackgroundView: UIView { } public func updateColor(color: UIColor, enableBlur: Bool? = nil, forceKeepBlur: Bool = false, transition: ContainedViewLayoutTransition) { + self._originalColor = color + let color = applyLiquidGlass(to: color) let effectiveEnableBlur = enableBlur ?? self.enableBlur if self._color == color && self.enableBlur == effectiveEnableBlur { @@ -293,7 +349,7 @@ open class BlurredBackgroundView: UIView { } } } - + if #available(iOS 11.0, *) { self.backgroundView.layer.maskedCorners = maskedCorners } @@ -302,13 +358,13 @@ open class BlurredBackgroundView: UIView { if let effectView = self.effectView { transition.updateCornerRadius(layer: effectView.layer, cornerRadius: cornerRadius) effectView.clipsToBounds = !cornerRadius.isZero - + if #available(iOS 11.0, *) { effectView.layer.maskedCorners = maskedCorners } } } - + public func update(size: CGSize, cornerRadius: CGFloat = 0.0, animator: ControlledTransitionAnimator) { self.validLayout = (size, cornerRadius) diff --git a/submodules/GalleryUI/Sources/GalleryController.swift b/submodules/GalleryUI/Sources/GalleryController.swift index 018b5fc9f8..ffad986e61 100644 --- a/submodules/GalleryUI/Sources/GalleryController.swift +++ b/submodules/GalleryUI/Sources/GalleryController.swift @@ -16,6 +16,7 @@ import OpenInExternalAppUI import ScreenCaptureDetection import UndoUI import TranslateUI +import TelegramUIPreferences private func tagsForMessage(_ message: Message) -> MessageTags? { //TODO:rewrite to take all media (effectiveMedia returns all rich-text media; we stop at the first) @@ -180,7 +181,7 @@ public func internalDocumentItemSupportsMimeType(_ type: String, fileName: Strin return false } } - + if internalMimeTypes.contains(type) { return true } @@ -216,7 +217,7 @@ public func galleryCaptionStringWithAppliedEntities(context: AccountContext, tex baseQuoteTertiaryTintColor = .clear } } - + return stringWithAppliedEntities( text, entities: entities, @@ -288,18 +289,18 @@ public func galleryItemForEntry( guard let (media, mediaImage) = mediaAndMediaImage else { return nil } - + if let image = media as? TelegramMediaImage { if let file = image.video { - let captureProtected = message.isCopyProtected() || message.containsSecretMedia || message.minAutoremoveOrClearTimeout == viewOnceTimeout || message.paidContent != nil || peerIsCopyProtected - + let captureProtected = (message.isCopyProtected() || message.containsSecretMedia || message.minAutoremoveOrClearTimeout == viewOnceTimeout || message.paidContent != nil || peerIsCopyProtected) && !currentWinterGramSettings.allowScreenshots + var originData = GalleryItemOriginData(title: message.effectiveAuthor.flatMap(EnginePeer.init)?.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), timestamp: message.timestamp) if Namespaces.Message.allNonRegular.contains(message.id.namespace) { originData = GalleryItemOriginData(title: nil, timestamp: nil) } - + let content = NativeVideoContent(id: .message(message.stableId, file.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: file), imageReference: mediaImage.flatMap({ ImageMediaReference.message(message: MessageReference(message), media: $0) }), streamVideo: .conservative, loopVideo: loopVideos, tempFilePath: tempFilePath, captureProtected: captureProtected, storeAfterDownload: generateStoreAfterDownload?(message, file)) - + var (text, entities) = galleryMessageCaptionText(message, mediaSubject: entry.mediaSubject) if entry.mediaSubject == nil { if let translateToLanguage, !text.isEmpty { @@ -348,7 +349,7 @@ public func galleryItemForEntry( } else if let file = media as? TelegramMediaFile { if file.isVideo { let content: UniversalVideoContent - let captureProtected = message.isCopyProtected() || message.containsSecretMedia || message.minAutoremoveOrClearTimeout == viewOnceTimeout || message.paidContent != nil || peerIsCopyProtected + let captureProtected = (message.isCopyProtected() || message.containsSecretMedia || message.minAutoremoveOrClearTimeout == viewOnceTimeout || message.paidContent != nil || peerIsCopyProtected) && !currentWinterGramSettings.allowScreenshots if file.isAnimated { content = NativeVideoContent(id: .message(message.stableId, file.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: file), imageReference: mediaImage.flatMap({ ImageMediaReference.message(message: MessageReference(message), media: $0) }), loopVideo: true, enableSound: false, tempFilePath: tempFilePath, captureProtected: captureProtected, storeAfterDownload: generateStoreAfterDownload?(message, file)) } else { @@ -357,7 +358,7 @@ public func galleryItemForEntry( if #available(iOS 13.0, *) { if NativeVideoContent.isHLSVideo(file: file) { isHLS = true - + if let data = context.currentAppConfiguration.with({ $0 }).data, let disableHLS = data["video_ignore_alt_documents"] as? Double { if Int(disableHLS) != 0 { isHLS = false @@ -365,7 +366,7 @@ public func galleryItemForEntry( } } } - + if isHLS { content = HLSVideoContent(id: .message(message.stableId, file.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: file), streamVideo: streamVideos, loopVideo: loopVideos, autoFetchFullSizeThumbnail: true, codecConfiguration: HLSCodecConfiguration(context: context)) } else { @@ -375,7 +376,7 @@ public func galleryItemForEntry( content = PlatformVideoContent(id: .message(message.id, message.stableId, file.fileId), userLocation: .peer(message.id.peerId), content: .file(.message(message: MessageReference(message), media: file)), streamVideo: streamVideos, loopVideo: loopVideos) } } - + var (text, entities) = galleryMessageCaptionText(message, mediaSubject: entry.mediaSubject) if entry.mediaSubject == nil { if let translateToLanguage, !text.isEmpty { @@ -388,16 +389,16 @@ public func galleryItemForEntry( } } } - + if let result = addLocallyGeneratedEntities(text, enabledTypes: [.timecode], entities: entities, mediaDuration: file.duration.flatMap(Double.init)) { entities = result } - + var originData = GalleryItemOriginData(title: message.effectiveAuthor.flatMap(EnginePeer.init)?.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), timestamp: message.timestamp) if Namespaces.Message.allNonRegular.contains(message.id.namespace) { originData = GalleryItemOriginData(title: nil, timestamp: nil) } - + let caption = galleryCaptionStringWithAppliedEntities(context: context, text: text, entities: entities, message: message) return UniversalVideoGalleryItem( context: context, @@ -502,12 +503,12 @@ public func galleryItemForEntry( } description = galleryCaptionStringWithAppliedEntities(context: context, text: descriptionText, entities: entities, message: message) } - + var originData = GalleryItemOriginData(title: message.effectiveAuthor.flatMap(EnginePeer.init)?.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), timestamp: message.timestamp) if Namespaces.Message.allNonRegular.contains(message.id.namespace) { originData = GalleryItemOriginData(title: nil, timestamp: nil) } - + return UniversalVideoGalleryItem( context: context, presentationData: presentationData, @@ -531,14 +532,14 @@ public func galleryItemForEntry( ) } } - + return nil } public final class GalleryTransitionArguments { public let transitionNode: (ASDisplayNode, CGRect, () -> (UIView?, UIView?)) public let addToTransitionSurface: (UIView) -> Void - + public init(transitionNode: (ASDisplayNode, CGRect, () -> (UIView?, UIView?)), addToTransitionSurface: @escaping (UIView) -> Void) { self.transitionNode = transitionNode self.addToTransitionSurface = addToTransitionSurface @@ -548,7 +549,7 @@ public final class GalleryTransitionArguments { public final class GalleryControllerPresentationArguments { public let animated: Bool public let transitionArguments: (MessageId, Media) -> GalleryTransitionArguments? - + public init(animated: Bool = true, transitionArguments: @escaping (MessageId, Media) -> GalleryTransitionArguments?) { self.animated = animated self.transitionArguments = transitionArguments @@ -558,7 +559,7 @@ public final class GalleryControllerPresentationArguments { private enum GalleryMessageHistoryView { case view(MessageHistoryView, Bool) case entries([MessageHistoryEntry], Bool, Bool) - + var entries: [MessageHistoryEntry] { switch self { case let .view(view, _): @@ -567,7 +568,7 @@ private enum GalleryMessageHistoryView { return entries } } - + var tag: HistoryViewInputTag? { switch self { case .entries: @@ -576,7 +577,7 @@ private enum GalleryMessageHistoryView { return view.tag } } - + var hasEarlier: Bool { switch self { case let .entries(_, hasEarlier, _): @@ -585,7 +586,7 @@ private enum GalleryMessageHistoryView { return view.earlierId != nil } } - + var hasLater: Bool { switch self { case let .entries(_ , _, hasLater): @@ -594,7 +595,7 @@ private enum GalleryMessageHistoryView { return view.laterId != nil } } - + var peerIsCopyProtected: Bool { switch self { case let .view(_, peerIsCopyProtected): @@ -623,13 +624,13 @@ public struct GalleryConfiguration { static var defaultValue: GalleryConfiguration { return GalleryConfiguration(youtubePictureInPictureEnabled: false) } - + public let youtubePictureInPictureEnabled: Bool - + fileprivate init(youtubePictureInPictureEnabled: Bool) { self.youtubePictureInPictureEnabled = youtubePictureInPictureEnabled } - + static func with(appConfiguration: AppConfiguration) -> GalleryConfiguration { if let data = appConfiguration.data, let value = data["youtube_pip"] as? String { return GalleryConfiguration(youtubePictureInPictureEnabled: value != "disabled") @@ -648,7 +649,7 @@ public struct GalleryEntry { public var entry: MessageHistoryEntry public var mediaSubject: GalleryMediaSubject? public var location: MessageHistoryEntryLocation? - + public var stableId: GalleryEntryStableId { return GalleryEntryStableId(stableId: self.entry.message.stableId, mediaSubject: self.mediaSubject) } @@ -694,38 +695,38 @@ private func galleryEntriesForMessageHistoryEntries(_ entries: [MessageHistoryEn public class GalleryController: ViewController, StandalonePresentableController, KeyShortcutResponder, GalleryControllerProtocol { public static let darkNavigationTheme = NavigationBarTheme(overallDarkAppearance: true, buttonColor: .white, disabledButtonColor: UIColor(rgb: 0x525252), primaryTextColor: .white, backgroundColor: UIColor(white: 0.0, alpha: 0.6), enableBackgroundBlur: false, separatorColor: UIColor(white: 0.0, alpha: 0.8), badgeBackgroundColor: .clear, badgeStrokeColor: .clear, badgeTextColor: .clear, edgeEffectColor: .clear, accentButtonColor: .white, accentDisabledButtonColor: .white, accentForegroundColor: .black, style: .glass) - + private var galleryNode: GalleryControllerNode { return self.displayNode as! GalleryControllerNode } - + private let context: AccountContext private var presentationData: PresentationData private let source: GalleryControllerItemSource private let invertItemOrder: Bool - + private let titleView: GalleryTitleView - + private let streamVideos: Bool - + private let _ready = Promise() override public var ready: Promise { return self._ready } private var didSetReady = false - + private var adjustedForInitialPreviewingLayout = false - + public var temporaryDoNotWaitForReady = false private let fromPlayingVideo: Bool private let landscape: Bool private let timecode: Double? private var playbackRate: Double? private var videoQuality: UniversalVideoContentVideoQuality = .auto - + private let accountInUseDisposable = MetaDisposable() private let disposable = MetaDisposable() - + private var peerIsCopyProtected = false private var entries: [GalleryEntry] = [] private var hasLeftEntries: Bool = false @@ -734,7 +735,7 @@ public class GalleryController: ViewController, StandalonePresentableController, private var tag: HistoryViewInputTag? private var centralEntryStableId: GalleryEntryStableId? private var configuration: GalleryConfiguration? - + private let centralItemTitle = Promise() private let centralItemTitleContent = Promise() private let centralItemRightBarButtonItem = Promise() @@ -742,32 +743,32 @@ public class GalleryController: ViewController, StandalonePresentableController, private let centralItemNavigationStyle = Promise() private let centralItemFooterContentNode = Promise<(GalleryFooterContentNode?, GalleryOverlayContentNode?)>() private let centralItemAttributesDisposable = DisposableSet(); - + private let _hiddenMedia = Promise<(MessageId, Media)?>(nil) - + private let replaceRootController: (ViewController, Promise?) -> Void private let baseNavigationController: NavigationController? - + private var hiddenMediaManagerIndex: Int? - + private let actionInteraction: GalleryControllerActionInteraction? private var performAction: (GalleryControllerInteractionTapAction) -> Void private var openActionOptions: (GalleryControllerInteractionTapAction, Message) -> Void - + private let updateVisibleDisposable = MetaDisposable() - + private var screenCaptureEventsDisposable: Disposable? - + private let generateStoreAfterDownload: ((Message, TelegramMediaFile) -> (() -> Void)?)? - + public var centralItemUpdated: ((MessageId) -> Void)? public var onDidAppear: (() -> Void)? public var useSimpleAnimation: Bool = false - + public var navigateToMessageContext: ((EngineMessage) -> Void)? - + private var initialOrientation: UIInterfaceOrientation? - + public init( context: AccountContext, source: GalleryControllerItemSource, @@ -802,28 +803,28 @@ public class GalleryController: ViewController, StandalonePresentableController, let _ = storeDownloadedMedia(storeManager: context.downloadedMediaStoreManager, media: .message(message: MessageReference(message), media: file), peerId: message.id.peerId).start() } } - + self.presentationData = context.sharedContext.currentPresentationData.with { $0 } - + var performActionImpl: ((GalleryControllerInteractionTapAction) -> Void)? self.performAction = { action in performActionImpl?(action) } - + var openActionOptionsImpl: ((GalleryControllerInteractionTapAction, Message) -> Void)? self.openActionOptions = { action, message in openActionOptionsImpl?(action, message) } - + self.titleView = GalleryTitleView(context: context, presentationData: self.presentationData) - + super.init(navigationBarPresentationData: NavigationBarPresentationData(theme: GalleryController.darkNavigationTheme, strings: NavigationBarStrings(presentationStrings: self.presentationData.strings))) - + let backItem = UIBarButtonItem(backButtonAppearanceWithTitle: self.presentationData.strings.Common_Back, target: self, action: #selector(self.donePressed)) self.navigationItem.leftBarButtonItem = backItem - + self.statusBar.statusBarStyle = .White - + let baseLanguageCode = self.presentationData.strings.baseLanguageCode let message: Signal<(Message, Bool)?, NoError> var translateToLanguage: Signal = .single(nil) @@ -843,7 +844,7 @@ public class GalleryController: ViewController, StandalonePresentableController, if peerIdValue == context.account.peerId, let customTag { context.engine.messages.internalReindexSavedMessagesCustomTagsIfNeeded(threadId: threadIdValue, tag: customTag) } - + message = context.account.postbox.transaction { transaction -> (Message, Bool)? in guard let message = transaction.getMessage(messageId) else { return nil @@ -918,14 +919,14 @@ public class GalleryController: ViewController, StandalonePresentableController, } } |> take(1) - + let semaphore: DispatchSemaphore? if synchronousLoad { semaphore = DispatchSemaphore(value: 0) } else { semaphore = nil } - + var displayInfoOnTop = false if case .custom = source { displayInfoOnTop = true @@ -942,18 +943,18 @@ public class GalleryController: ViewController, StandalonePresentableController, if let strongSelf = self { if let view = view { strongSelf.peerIsCopyProtected = view.peerIsCopyProtected - + let appConfiguration: AppConfiguration = preferencesView?.get(AppConfiguration.self) ?? .defaultValue let configuration = GalleryConfiguration.with(appConfiguration: appConfiguration) strongSelf.configuration = configuration - + var mediaSubject: GalleryMediaSubject? if case let .standaloneMessage(_, mediaSubjectValue) = source { mediaSubject = mediaSubjectValue } - + let entries = galleryEntriesForMessageHistoryEntries(view.entries, mediaSubject: mediaSubject) - + var centralEntryStableId: GalleryEntryStableId? loop: for i in 0 ..< entries.count { let entry = entries[i] @@ -976,9 +977,9 @@ public class GalleryController: ViewController, StandalonePresentableController, } } } - + strongSelf.tag = view.tag - + if invertItemOrder { strongSelf.entries = entries.reversed() strongSelf.hasLeftEntries = view.hasLater @@ -1027,7 +1028,7 @@ public class GalleryController: ViewController, StandalonePresentableController, ) { if isCentral { centralItemIndex = items.count - + if isFirstTime { isFirstTime = false if item is UniversalVideoGalleryItem { @@ -1039,9 +1040,9 @@ public class GalleryController: ViewController, StandalonePresentableController, items.append(item) } } - + strongSelf.galleryNode.pager.replaceItems(items, centralItemIndex: centralItemIndex) - + if strongSelf.temporaryDoNotWaitForReady { strongSelf.didSetReady = true strongSelf._ready.set(.single(true)) @@ -1070,27 +1071,27 @@ public class GalleryController: ViewController, StandalonePresentableController, } } })) - + if let semaphore = semaphore { let _ = semaphore.wait(timeout: DispatchTime.now() + 1.0) } - + var syncResultApply: (() -> Void)? let _ = syncResult.modify { processed, f in syncResultApply = f return (true, nil) } - + syncResultApply?() - + self.centralItemAttributesDisposable.add(self.centralItemTitle.get().start(next: { [weak self] title in self?.navigationItem.title = title })) - + self.centralItemAttributesDisposable.add(self.centralItemTitleContent.get().start(next: { [weak self] titleContent in self?.titleView.setContent(content: titleContent) })) - + self.centralItemAttributesDisposable.add(combineLatest(self.centralItemRightBarButtonItem.get(), self.centralItemRightBarButtonItems.get()).start(next: { [weak self] rightBarButtonItem, rightBarButtonItems in if let rightBarButtonItem = rightBarButtonItem { self?.navigationItem.rightBarButtonItem = rightBarButtonItem @@ -1101,13 +1102,13 @@ public class GalleryController: ViewController, StandalonePresentableController, self?.navigationItem.rightBarButtonItems = nil } })) - + self.centralItemAttributesDisposable.add(self.centralItemFooterContentNode.get().start(next: { [weak self] footerContentNode, overlayContentNode in self?.galleryNode.updatePresentationState({ $0.withUpdatedFooterContentNode(footerContentNode).withUpdatedOverlayContentNode(overlayContentNode) }, transition: .animated(duration: 0.4, curve: .spring)) })) - + self.centralItemAttributesDisposable.add(self.centralItemNavigationStyle.get().start(next: { [weak self] style in if let strongSelf = self { switch style { @@ -1124,7 +1125,7 @@ public class GalleryController: ViewController, StandalonePresentableController, } } })) - + let mediaManager = context.sharedContext.mediaManager self.hiddenMediaManagerIndex = mediaManager.galleryHiddenMediaManager.addSource(self._hiddenMedia.get() |> map { messageIdAndMedia in @@ -1134,7 +1135,7 @@ public class GalleryController: ViewController, StandalonePresentableController, return nil } }) - + performActionImpl = { [weak self] action in if let strongSelf = self { if case let .url(_, _, _, dismiss) = action, !dismiss { @@ -1165,7 +1166,7 @@ public class GalleryController: ViewController, StandalonePresentableController, } } } - + openActionOptionsImpl = { [weak self] action, message in if let strongSelf = self { var presentationData = strongSelf.presentationData @@ -1181,7 +1182,7 @@ public class GalleryController: ViewController, StandalonePresentableController, let telString = "tel:" var openText = presentationData.strings.Conversation_LinkDialogOpen var phoneNumber: String? - + var isEmail = false var isPhoneNumber = false if cleanUrl.hasPrefix(mailtoString) { @@ -1198,7 +1199,7 @@ public class GalleryController: ViewController, StandalonePresentableController, openText = presentationData.strings.Conversation_FileOpenIn } let actionSheet = ActionSheetController(presentationData: presentationData) - + var items: [ActionSheetItem] = [] items.append(ActionSheetTextItem(title: cleanUrl)) items.append(ActionSheetButtonItem(title: openText, color: .accent, action: { [weak actionSheet] in @@ -1224,7 +1225,7 @@ public class GalleryController: ViewController, StandalonePresentableController, items.append(ActionSheetButtonItem(title: canAddToReadingList ? presentationData.strings.ShareMenu_CopyShareLink : presentationData.strings.Conversation_ContextMenuCopy, color: .accent, action: { [weak actionSheet, weak self] in actionSheet?.dismissAnimated() UIPasteboard.general.string = cleanUrl - + let content: UndoOverlayContent if isPhoneNumber { content = .copy(text: presentationData.strings.Conversation_PhoneCopied) @@ -1261,7 +1262,7 @@ public class GalleryController: ViewController, StandalonePresentableController, actionSheet?.dismissAnimated() if let strongSelf = self { strongSelf.dismiss(forceAway: false) - + let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) |> deliverOnMainQueue).start(next: { peer in if let strongSelf = self, let peer = peer { @@ -1274,7 +1275,7 @@ public class GalleryController: ViewController, StandalonePresentableController, items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.Conversation_LinkDialogCopy, color: .accent, action: { [weak actionSheet, weak self] in actionSheet?.dismissAnimated() UIPasteboard.general.string = mention - + let content: UndoOverlayContent = .copy(text: presentationData.strings.Conversation_UsernameCopied) self?.present(UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) })) @@ -1299,7 +1300,7 @@ public class GalleryController: ViewController, StandalonePresentableController, ActionSheetButtonItem(title: strongSelf.presentationData.strings.Conversation_LinkDialogCopy, color: .accent, action: { [weak actionSheet, weak self] in actionSheet?.dismissAnimated() UIPasteboard.general.string = mention - + let content: UndoOverlayContent = .copy(text: presentationData.strings.Conversation_TextCopied) self?.present(UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) }) @@ -1316,7 +1317,7 @@ public class GalleryController: ViewController, StandalonePresentableController, items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.Conversation_LinkDialogCopy, color: .accent, action: { [weak actionSheet, weak self] in actionSheet?.dismissAnimated() UIPasteboard.general.string = command - + let content: UndoOverlayContent = .copy(text: presentationData.strings.Conversation_TextCopied) self?.present(UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) })) @@ -1340,7 +1341,7 @@ public class GalleryController: ViewController, StandalonePresentableController, ActionSheetButtonItem(title: strongSelf.presentationData.strings.Conversation_LinkDialogCopy, color: .accent, action: { [weak actionSheet, weak self] in actionSheet?.dismissAnimated() UIPasteboard.general.string = hashtag - + let content: UndoOverlayContent = .copy(text: presentationData.strings.Conversation_HashtagCopied) self?.present(UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) }) @@ -1358,7 +1359,7 @@ public class GalleryController: ViewController, StandalonePresentableController, } else { isCopyLink = false } - + let actionSheet = ActionSheetController(presentationData: presentationData) actionSheet.setItemGroups([ActionSheetItemGroup(items: [ ActionSheetTextItem(title: text), @@ -1379,14 +1380,14 @@ public class GalleryController: ViewController, StandalonePresentableController, |> deliverOnMainQueue).start(next: { link in if let link = link { UIPasteboard.general.string = link + "?t=\(Int32(timecode))" - + let presentationData = context.sharedContext.currentPresentationData.with { $0 } - + var warnAboutPrivate = false if channel.addressName == nil { warnAboutPrivate = true } - + Queue.mainQueue().after(0.2, { let content: UndoOverlayContent if warnAboutPrivate { @@ -1398,14 +1399,14 @@ public class GalleryController: ViewController, StandalonePresentableController, }) } else { UIPasteboard.general.string = text - + let content: UndoOverlayContent = .copy(text: presentationData.strings.Conversation_TextCopied) self?.present(UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current) } }) } else { UIPasteboard.general.string = text - + let content: UndoOverlayContent = .copy(text: presentationData.strings.Conversation_TextCopied) self?.present(UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) } @@ -1422,14 +1423,14 @@ public class GalleryController: ViewController, StandalonePresentableController, } } } - + self.blocksBackgroundWhenInOverlay = true self.acceptsFocusWhenInOverlay = true self.isOpaqueWhenInOverlay = true - + switch source { case let .peerMessagesAtId(id, _, _, _): - if id.peerId.namespace == Namespaces.Peer.SecretChat { + if id.peerId.namespace == Namespaces.Peer.SecretChat && !currentWinterGramSettings.allowScreenshots { self.screenCaptureEventsDisposable = (screenCaptureEvents() |> deliverOnMainQueue).start(next: { [weak self] _ in if let strongSelf = self, strongSelf.traceVisibility() { @@ -1441,16 +1442,16 @@ public class GalleryController: ViewController, StandalonePresentableController, break } } - + required init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + deinit { if let initialOrientation = self.initialOrientation { self.context.sharedContext.applicationBindings.forceOrientation(initialOrientation) } - + self.accountInUseDisposable.dispose() self.disposable.dispose() self.centralItemAttributesDisposable.dispose() @@ -1460,25 +1461,25 @@ public class GalleryController: ViewController, StandalonePresentableController, self.updateVisibleDisposable.dispose() self.screenCaptureEventsDisposable?.dispose() } - + @objc private func donePressed() { self.dismiss(forceAway: false) } - + func willDismiss() { if let chatController = self.baseNavigationController?.topViewController as? ChatController { chatController.updatePushedTransition(0.0, transition: .immediate) } } - + func dismiss(forceAway: Bool) { var animatedOutNode = true var animatedOutInterface = false - + if forceAway { self._hiddenMedia.set(.single(nil)) } - + let completion = { [weak self] in if animatedOutNode && animatedOutInterface { self?.actionInteraction?.updateCanReadHistory(true) @@ -1486,11 +1487,11 @@ public class GalleryController: ViewController, StandalonePresentableController, self?.presentingViewController?.dismiss(animated: false, completion: nil) } } - + if let chatController = self.baseNavigationController?.topViewController as? ChatController { chatController.updatePushedTransition(0.0, transition: .animated(duration: 0.45, curve: .customSpring(damping: 180.0, initialVelocity: 0.0))) } - + if let centralItemNode = self.galleryNode.pager.centralItemNode(), let presentationArguments = self.presentationArguments as? GalleryControllerPresentationArguments { let entry = self.entries[centralItemNode.index] let message = entry.entry.message @@ -1502,13 +1503,13 @@ public class GalleryController: ViewController, StandalonePresentableController, }) } } - + self.galleryNode.animateOut(animateContent: animatedOutNode, completion: { animatedOutInterface = true completion() }) } - + override public func loadDisplayNode() { let controllerInteraction = GalleryControllerInteraction(presentController: { [weak self] controller, arguments in if let strongSelf = self { @@ -1534,7 +1535,7 @@ public class GalleryController: ViewController, StandalonePresentableController, snapshotView.frame = strongSelf.galleryNode.footerNode.frame snapshots.append(snapshotView) } - + strongSelf.actionInteraction?.editMedia(messageId, snapshots, { [weak self] in self?.dismiss(forceAway: true) }) @@ -1544,14 +1545,14 @@ public class GalleryController: ViewController, StandalonePresentableController, }, currentItemNode: { [weak self] in return self?.galleryNode.pager.centralItemNode() }) - + let disableTapNavigation = !(self.context.sharedContext.currentMediaDisplaySettings.with { $0 }.showNextMediaOnTap) self.displayNode = GalleryControllerNode(context: self.context, controllerInteraction: controllerInteraction, titleView: titleView, disableTapNavigation: disableTapNavigation) self.displayNodeDidLoad() - + self.galleryNode.statusBar = self.statusBar self.galleryNode.navigationBar = self.navigationBar - + self.galleryNode.transitionDataForCentralItem = { [weak self] in if let strongSelf = self { if let centralItemNode = strongSelf.galleryNode.pager.centralItemNode(), let presentationArguments = strongSelf.presentationArguments as? GalleryControllerPresentationArguments { @@ -1569,25 +1570,25 @@ public class GalleryController: ViewController, StandalonePresentableController, self?._hiddenMedia.set(.single(nil)) self?.presentingViewController?.dismiss(animated: false, completion: nil) } - + self.galleryNode.beginCustomDismiss = { [weak self] animationType in if let strongSelf = self { strongSelf.actionInteraction?.updateCanReadHistory(true) strongSelf._hiddenMedia.set(.single(nil)) - + if let hiddenMediaManagerIndex = strongSelf.hiddenMediaManagerIndex { strongSelf.hiddenMediaManagerIndex = nil strongSelf.context.sharedContext.mediaManager.galleryHiddenMediaManager.removeSource(hiddenMediaManagerIndex) } - + switch animationType { case .default, .simpleAnimation: let animatedOutNode = animationType != .simpleAnimation - + if let chatController = strongSelf.baseNavigationController?.topViewController as? ChatController { chatController.updatePushedTransition(0.0, transition: .animated(duration: 0.45, curve: .customSpring(damping: 180.0, initialVelocity: 0.0))) } - + strongSelf.galleryNode.animateOut(animateContent: animatedOutNode, completion: { }) case .pip: @@ -1595,17 +1596,17 @@ public class GalleryController: ViewController, StandalonePresentableController, } } } - + self.galleryNode.completeCustomDismiss = { [weak self] isPictureInPicture in guard let self else { return } - + if let hiddenMediaManagerIndex = self.hiddenMediaManagerIndex { self.hiddenMediaManagerIndex = nil self.context.sharedContext.mediaManager.galleryHiddenMediaManager.removeSource(hiddenMediaManagerIndex) } - + if isPictureInPicture { if let chatController = self.baseNavigationController?.topViewController as? ChatController { chatController.updatePushedTransition(0.0, transition: .animated(duration: 0.45, curve: .customSpring(damping: 180.0, initialVelocity: 0.0))) @@ -1613,21 +1614,21 @@ public class GalleryController: ViewController, StandalonePresentableController, } else { self._hiddenMedia.set(.single(nil)) } - + self.presentingViewController?.dismiss(animated: false, completion: nil) } - + self.galleryNode.controlsVisibilityChanged = { [weak self] visible in guard let self else { return } self.prefersOnScreenNavigationHidden = !visible - + self.galleryNode.pager.forEachItemNode { itemNode in itemNode.controlsVisibilityUpdated(isVisible: visible, animated: true) } } - + self.galleryNode.updateOrientation = { [weak self] orientation in if let strongSelf = self { if strongSelf.initialOrientation == nil { @@ -1638,7 +1639,7 @@ public class GalleryController: ViewController, StandalonePresentableController, strongSelf.context.sharedContext.applicationBindings.forceOrientation(orientation) } } - + let baseNavigationController = self.baseNavigationController self.galleryNode.baseNavigationController = { [weak baseNavigationController] in return baseNavigationController @@ -1646,12 +1647,12 @@ public class GalleryController: ViewController, StandalonePresentableController, self.galleryNode.galleryController = { [weak self] in return self } - + var displayInfoOnTop = false if case .custom = source { displayInfoOnTop = true } - + var items: [GalleryItem] = [] var centralItemIndex: Int? for entry in self.entries { @@ -1688,21 +1689,21 @@ public class GalleryController: ViewController, StandalonePresentableController, items.append(item) } } - + self.galleryNode.pager.replaceItems(items, centralItemIndex: centralItemIndex) - + self.galleryNode.pager.centralItemIndexUpdated = { [weak self] index in if let strongSelf = self { var hiddenItem: (MessageId, Media)? if let index = index { let entry = strongSelf.entries[index] let message = strongSelf.entries[index].entry.message - + strongSelf.centralEntryStableId = entry.stableId if let selectedMedia = selectedMediaForMessage(message: message, mediaSubject: entry.mediaSubject) { hiddenItem = (message.id, selectedMedia) } - + if let node = strongSelf.galleryNode.pager.centralItemNode() { strongSelf.centralItemTitle.set(node.title()) strongSelf.centralItemTitleContent.set(node.titleContent()) @@ -1712,7 +1713,7 @@ public class GalleryController: ViewController, StandalonePresentableController, strongSelf.centralItemFooterContentNode.set(node.footerContent()) strongSelf.galleryNode.pager.pagingEnabledPromise.set(node.isPagingEnabled()) } - + switch strongSelf.source { case let .peerMessagesAtId(_, chatLocation, _, chatLocationContextHolder): var reloadAroundIndex: MessageIndex? @@ -1737,15 +1738,15 @@ public class GalleryController: ViewController, StandalonePresentableController, return .single(mapped) } |> take(1) - + strongSelf.updateVisibleDisposable.set((signal |> deliverOnMainQueue).start(next: { view in guard let strongSelf = self, let view = view else { return } - + let entries = galleryEntriesForMessageHistoryEntries(view.entries, mediaSubject: nil) - + if strongSelf.invertItemOrder { strongSelf.entries = entries.reversed() strongSelf.hasLeftEntries = view.hasLater @@ -1774,7 +1775,7 @@ public class GalleryController: ViewController, StandalonePresentableController, items.append(item) } } - + strongSelf.galleryNode.pager.replaceItems(items, centralItemIndex: centralItemIndex) } })) @@ -1783,13 +1784,13 @@ public class GalleryController: ViewController, StandalonePresentableController, if index >= strongSelf.entries.count - 3 && strongSelf.hasRightEntries && !strongSelf.loadingMore { strongSelf.loadingMore = true loadMore?() - + strongSelf.updateVisibleDisposable.set((messages |> deliverOnMainQueue).start(next: { messages, totalCount, hasMore in guard let strongSelf = self else { return } - + var messageEntries: [MessageHistoryEntry] = [] var index = messages.count for message in messages.reversed() { @@ -1797,7 +1798,7 @@ public class GalleryController: ViewController, StandalonePresentableController, index -= 1 } let entries = galleryEntriesForMessageHistoryEntries(messageEntries, mediaSubject: nil) - + if entries.count > strongSelf.entries.count { if strongSelf.invertItemOrder { strongSelf.entries = entries.reversed() @@ -1827,10 +1828,10 @@ public class GalleryController: ViewController, StandalonePresentableController, items.append(item) } } - + strongSelf.galleryNode.pager.replaceItems(items, centralItemIndex: centralItemIndex) } - + strongSelf.updateVisibleDisposable.set(nil) strongSelf.loadingMore = false } @@ -1848,7 +1849,7 @@ public class GalleryController: ViewController, StandalonePresentableController, } } } - + if !self.entries.isEmpty && !self.didSetReady { if self.temporaryDoNotWaitForReady { self.didSetReady = true @@ -1861,19 +1862,19 @@ public class GalleryController: ViewController, StandalonePresentableController, } } } - + override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) } - + override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - + if let hiddenMediaManagerIndex = self.hiddenMediaManagerIndex { self.hiddenMediaManagerIndex = nil self.context.sharedContext.mediaManager.galleryHiddenMediaManager.removeSource(hiddenMediaManagerIndex) } - + let context = self.context let mediaManager = context.sharedContext.mediaManager self.hiddenMediaManagerIndex = mediaManager.galleryHiddenMediaManager.addSource(self._hiddenMedia.get() @@ -1884,12 +1885,12 @@ public class GalleryController: ViewController, StandalonePresentableController, return nil } }) - + var nodeAnimatesItself = false - + if let centralItemNode = self.galleryNode.pager.centralItemNode() { let entry = self.entries[centralItemNode.index] - + self.centralItemTitle.set(centralItemNode.title()) self.centralItemTitleContent.set(centralItemNode.titleContent()) self.centralItemRightBarButtonItem.set(centralItemNode.rightBarButtonItem()) @@ -1905,7 +1906,7 @@ public class GalleryController: ViewController, StandalonePresentableController, if presentationArguments.animated { centralItemNode.animateIn(from: transitionArguments.transitionNode, addToTransitionSurface: transitionArguments.addToTransitionSurface, completion: {}) } - + self._hiddenMedia.set(.single((message.id, selectedMedia))) } centralItemNode.activateAsInitial() @@ -1913,7 +1914,7 @@ public class GalleryController: ViewController, StandalonePresentableController, self.onDidAppear?() } - + if !self.isPresentedInPreviewingContext() { //self.galleryNode.setControlsHidden(self.landscape, animated: false) if let presentationArguments = self.presentationArguments as? GalleryControllerPresentationArguments { @@ -1922,12 +1923,12 @@ public class GalleryController: ViewController, StandalonePresentableController, } } } - + self.accountInUseDisposable.set(self.context.sharedContext.setAccountUserInterfaceInUse(self.context.account.id)) - + self.actionInteraction?.updateCanReadHistory(false) } - + override public func didAppearInContextPreview() { if let centralItemNode = self.galleryNode.pager.centralItemNode() { let message = self.entries[centralItemNode.index].entry.message @@ -1938,19 +1939,19 @@ public class GalleryController: ViewController, StandalonePresentableController, self.centralItemNavigationStyle.set(centralItemNode.navigationStyle()) self.centralItemFooterContentNode.set(centralItemNode.footerContent()) self.galleryNode.pager.pagingEnabledPromise.set(centralItemNode.isPagingEnabled()) - + if !mediaForMessage(message: message).isEmpty { centralItemNode.activateAsInitial() } } } - + override public func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - + self.accountInUseDisposable.set(nil) } - + override public func preferredContentSizeForLayout(_ layout: ContainerViewLayout) -> CGSize? { if let centralItemNode = self.galleryNode.pager.centralItemNode(), let itemSize = centralItemNode.contentSize() { return itemSize.aspectFitted(layout.size) @@ -1958,16 +1959,16 @@ public class GalleryController: ViewController, StandalonePresentableController, return nil } } - + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) - + self.galleryNode.frame = CGRect(origin: CGPoint(), size: layout.size) self.galleryNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition) - + if !self.adjustedForInitialPreviewingLayout && self.isPresentedInPreviewingContext() { self.navigationBar?.isHidden = true - + self.adjustedForInitialPreviewingLayout = true self.galleryNode.setControlsHidden(true, animated: false) if let centralItemNode = self.galleryNode.pager.centralItemNode(), let itemSize = centralItemNode.contentSize() { @@ -1987,7 +1988,7 @@ public class GalleryController: ViewController, StandalonePresentableController, } } } - + func updateSharedVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) { self.videoQuality = videoQuality @@ -1997,7 +1998,7 @@ public class GalleryController: ViewController, StandalonePresentableController, } } } - + public var keyShortcuts: [KeyShortcut] { var keyShortcuts: [KeyShortcut] = [] keyShortcuts.append( @@ -2060,7 +2061,7 @@ public class GalleryController: ViewController, StandalonePresentableController, keyShortcuts.append(contentsOf: itemNodeShortcuts) return keyShortcuts } - + public static func maybeExpandPIP(context: AccountContext, messageId: EngineMessage.Id) -> Bool { guard let currentPictureInPictureNode = context.sharedContext.mediaManager.currentPictureInPictureNode as? UniversalVideoGalleryItemNode else { return false @@ -2074,12 +2075,12 @@ public class GalleryController: ViewController, StandalonePresentableController, if message.id != messageId { return false } - + currentPictureInPictureNode.expandPIP() - + return true } - + func dismissAndNavigateToMessageContext(message: Message) { if let navigateToMessageContext = self.navigateToMessageContext { navigateToMessageContext(EngineMessage(message)) diff --git a/submodules/GalleryUI/Sources/SecretMediaPreviewController.swift b/submodules/GalleryUI/Sources/SecretMediaPreviewController.swift index 3390fbcace..afb27fdffd 100644 --- a/submodules/GalleryUI/Sources/SecretMediaPreviewController.swift +++ b/submodules/GalleryUI/Sources/SecretMediaPreviewController.swift @@ -13,6 +13,7 @@ import AppBundle import LocalizedPeerData import TooltipUI import TelegramNotices +import TelegramUIPreferences private func galleryMediaForMedia(media: Media) -> Media? { if let media = media as? TelegramMediaImage { @@ -57,9 +58,9 @@ private func mediaForMessage(message: Message) -> Media? { private final class SecretMediaPreviewControllerNode: GalleryControllerNode { fileprivate var timeoutNode: RadialStatusNode? - + private var validLayout: (ContainerViewLayout, CGFloat)? - + var beginTimeAndTimeout: (Double, Double, Bool)? { didSet { if let (beginTime, timeout, isOutgoing) = self.beginTimeAndTimeout { @@ -69,13 +70,13 @@ private final class SecretMediaPreviewControllerNode: GalleryControllerNode { self.timeoutNode = timeoutNode let icon: RadialStatusNodeState.SecretTimeoutIcon let timeoutValue = Int32(timeout) - + let state: RadialStatusNodeState if timeoutValue == 0 && isOutgoing { state = .staticTimeout } else if timeoutValue == viewOnceTimeout { beginTime = CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970 - + if let image = generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/ViewOnce"), color: .white) { icon = .image(image) } else { @@ -87,9 +88,9 @@ private final class SecretMediaPreviewControllerNode: GalleryControllerNode { } timeoutNode.transitionToState(state, completion: {}) self.addSubnode(timeoutNode) - + timeoutNode.addTarget(self, action: #selector(self.statusTapGesture), forControlEvents: .touchUpInside) - + if let (layout, navigationHeight) = self.validLayout { self.layoutTimeoutNode(layout, navigationBarHeight: navigationHeight, transition: .immediate) } @@ -100,42 +101,42 @@ private final class SecretMediaPreviewControllerNode: GalleryControllerNode { } } } - + var statusPressed: (UIView) -> Void = { _ in } @objc private func statusTapGesture() { if let sourceView = self.timeoutNode?.view { self.statusPressed(sourceView) } } - + var onDismissTransitionUpdate: (CGFloat) -> Void = { _ in } - + override func animateIn(animateContent: Bool, useSimpleAnimation: Bool) { super.animateIn(animateContent: animateContent, useSimpleAnimation: useSimpleAnimation) - + self.timeoutNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } - + override func animateOut(animateContent: Bool, completion: @escaping () -> Void) { super.animateOut(animateContent: animateContent, completion: completion) - + if let timeoutNode = self.timeoutNode { timeoutNode.layer.animateAlpha(from: timeoutNode.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) } } - + override func updateDismissTransition(_ value: CGFloat) { self.timeoutNode?.alpha = value self.onDismissTransitionUpdate(value) } - + override func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) - + self.validLayout = (layout, navigationBarHeight) self.layoutTimeoutNode(layout, navigationBarHeight: navigationBarHeight, transition: transition) } - + private func layoutTimeoutNode(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { if let timeoutNode = self.timeoutNode { let diameter: CGFloat = 28.0 @@ -147,51 +148,51 @@ private final class SecretMediaPreviewControllerNode: GalleryControllerNode { public final class SecretMediaPreviewController: ViewController { private let context: AccountContext private let messageId: MessageId - + private let _ready = Promise() override public var ready: Promise { return self._ready } private var didSetReady = false - + private let disposable = MetaDisposable() private let markMessageAsConsumedDisposable = MetaDisposable() - + private var controllerNode: SecretMediaPreviewControllerNode { return self.displayNode as! SecretMediaPreviewControllerNode } - + private var messageView: MessageView? private var currentNodeMessageId: MessageId? private var currentNodeMessageIsVideo = false private var currentNodeMessageIsViewOnce = false private var currentMessageIsDismissed = false private var tempFile: TempBoxFile? - + private let centralItemAttributesDisposable = DisposableSet(); private let footerContentNode = Promise<(GalleryFooterContentNode?, GalleryOverlayContentNode?)>() - + private let _hiddenMedia = Promise<(MessageId, Media)?>(nil) private var hiddenMediaManagerIndex: Int? - + private let presentationData: PresentationData - + private var screenCaptureEventsDisposable: Disposable? - + private weak var tooltipController: TooltipScreen? - + public init(context: AccountContext, messageId: MessageId) { self.context = context self.messageId = messageId self.presentationData = context.sharedContext.currentPresentationData.with { $0 } - + super.init(navigationBarPresentationData: NavigationBarPresentationData(theme: GalleryController.darkNavigationTheme, strings: NavigationBarStrings(presentationStrings: self.presentationData.strings))) - + let backItem = UIBarButtonItem(backButtonAppearanceWithTitle: presentationData.strings.Common_Back, target: self, action: #selector(self.donePressed)) self.navigationItem.leftBarButtonItem = backItem - + self.statusBar.statusBarStyle = .White - + self.disposable.set((context.account.postbox.messageView(messageId) |> deliverOnMainQueue).start(next: { [weak self] view in if let strongSelf = self { strongSelf.messageView = view @@ -200,7 +201,7 @@ public final class SecretMediaPreviewController: ViewController { } } })) - + self.hiddenMediaManagerIndex = self.context.sharedContext.mediaManager.galleryHiddenMediaManager.addSource(self._hiddenMedia.get() |> map { messageIdAndMedia in if let (messageId, media) = messageIdAndMedia { @@ -209,7 +210,7 @@ public final class SecretMediaPreviewController: ViewController { return nil } }) - + self.centralItemAttributesDisposable.add(self.footerContentNode.get().start(next: { [weak self] footerContentNode, _ in guard let self else { return @@ -219,11 +220,11 @@ public final class SecretMediaPreviewController: ViewController { }, transition: .immediate) })) } - + required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + deinit { self.disposable.dispose() self.markMessageAsConsumedDisposable.dispose() @@ -236,11 +237,11 @@ public final class SecretMediaPreviewController: ViewController { } self.centralItemAttributesDisposable.dispose() } - + @objc func donePressed() { self.dismiss(forceAway: false) } - + public override func loadDisplayNode() { let controllerInteraction = GalleryControllerInteraction(presentController: { [weak self] controller, arguments in if let strongSelf = self { @@ -258,22 +259,22 @@ public final class SecretMediaPreviewController: ViewController { }) self.displayNode = SecretMediaPreviewControllerNode(context: self.context, controllerInteraction: controllerInteraction, titleView: nil) self.displayNodeDidLoad() - + self.controllerNode.statusPressed = { [weak self] _ in if let self { self.presentViewOnceTooltip() } } - + self.controllerNode.onDismissTransitionUpdate = { [weak self] _ in if let self { self.dismissAllTooltips() } } - + self.controllerNode.statusBar = self.statusBar self.controllerNode.navigationBar = self.navigationBar - + self.controllerNode.transitionDataForCentralItem = { [weak self] in if let strongSelf = self { if let _ = strongSelf.controllerNode.pager.centralItemNode(), let presentationArguments = strongSelf.presentationArguments as? GalleryControllerPresentationArguments { @@ -290,23 +291,23 @@ public final class SecretMediaPreviewController: ViewController { self?._hiddenMedia.set(.single(nil)) self?.presentingViewController?.dismiss(animated: false, completion: nil) } - + self.controllerNode.beginCustomDismiss = { [weak self] _ in if let strongSelf = self { strongSelf._hiddenMedia.set(.single(nil)) - + let animatedOutNode = true - + strongSelf.controllerNode.animateOut(animateContent: animatedOutNode, completion: { }) } } - + self.controllerNode.completeCustomDismiss = { [weak self] _ in self?._hiddenMedia.set(.single(nil)) self?.presentingViewController?.dismiss(animated: false, completion: nil) } - + self.controllerNode.pager.centralItemIndexUpdated = { [weak self] index in if let strongSelf = self { var hiddenItem: (MessageId, Media)? @@ -319,12 +320,12 @@ public final class SecretMediaPreviewController: ViewController { videoDuration = file.duration } } - + var timerStarted = false let isOutgoing = !message.flags.contains(.Incoming) if let attribute = message.autoclearAttribute { strongSelf.currentNodeMessageIsViewOnce = attribute.timeout == viewOnceTimeout - + if let countdownBeginTime = attribute.countdownBeginTime { timerStarted = true if let videoDuration = videoDuration, attribute.timeout != viewOnceTimeout { @@ -337,7 +338,7 @@ public final class SecretMediaPreviewController: ViewController { } } else if let attribute = message.autoremoveAttribute { strongSelf.currentNodeMessageIsViewOnce = attribute.timeout == viewOnceTimeout - + if let countdownBeginTime = attribute.countdownBeginTime { timerStarted = true if let videoDuration = videoDuration, attribute.timeout != viewOnceTimeout { @@ -349,7 +350,7 @@ public final class SecretMediaPreviewController: ViewController { beginTimeAndTimeout = (CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970, attribute.timeout != viewOnceTimeout ? 0.0 : Double(viewOnceTimeout), isOutgoing) } } - + if let file = media as? TelegramMediaFile { if file.isAnimated { strongSelf.title = strongSelf.presentationData.strings.SecretGif_Title @@ -367,11 +368,11 @@ public final class SecretMediaPreviewController: ViewController { strongSelf.title = strongSelf.presentationData.strings.SecretImage_Title } } - + if let beginTimeAndTimeout = beginTimeAndTimeout { strongSelf.controllerNode.beginTimeAndTimeout = beginTimeAndTimeout } - + if message.flags.contains(.Incoming) || strongSelf.currentNodeMessageIsVideo { if let node = strongSelf.controllerNode.pager.centralItemNode() { strongSelf.footerContentNode.set(node.footerContent()) @@ -408,16 +409,16 @@ public final class SecretMediaPreviewController: ViewController { } } } - + if let _ = self.messageView { self.applyMessageView() } } - + override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - - if self.screenCaptureEventsDisposable == nil { + + if self.screenCaptureEventsDisposable == nil && !currentWinterGramSettings.allowScreenshots { self.screenCaptureEventsDisposable = (screenCaptureEvents() |> deliverOnMainQueue).start(next: { [weak self] _ in if let strongSelf = self, strongSelf.traceVisibility() { @@ -429,33 +430,33 @@ public final class SecretMediaPreviewController: ViewController { } }) } - + var nodeAnimatesItself = false - + if let centralItemNode = self.controllerNode.pager.centralItemNode(), let message = self.messageView?.message { if let media = mediaForMessage(message: message) { if let presentationArguments = self.presentationArguments as? GalleryControllerPresentationArguments, let transitionArguments = presentationArguments.transitionArguments(message.id, media) { nodeAnimatesItself = true centralItemNode.activateAsInitial() - + if presentationArguments.animated { centralItemNode.animateIn(from: transitionArguments.transitionNode, addToTransitionSurface: transitionArguments.addToTransitionSurface, completion: {}) } - + self._hiddenMedia.set(.single((message.id, media))) } else if self.isPresentedInPreviewingContext() { centralItemNode.activateAsInitial() } } } - + self.controllerNode.setControlsHidden(false, animated: false) if let presentationArguments = self.presentationArguments as? GalleryControllerPresentationArguments { if presentationArguments.animated { self.controllerNode.animateIn(animateContent: !nodeAnimatesItself, useSimpleAnimation: false) } } - + if self.currentNodeMessageIsViewOnce { let _ = (ApplicationSpecificNotice.incrementViewOnceTooltip(accountManager: self.context.sharedContext.accountManager) |> deliverOnMainQueue).start(next: { [weak self] count in @@ -468,20 +469,20 @@ public final class SecretMediaPreviewController: ViewController { }) } } - + private func dismiss(forceAway: Bool) { self.dismissAllTooltips() - + var animatedOutNode = true var animatedOutInterface = false - + let completion = { [weak self] in if animatedOutNode && animatedOutInterface { self?._hiddenMedia.set(.single(nil)) self?.presentingViewController?.dismiss(animated: false, completion: nil) } } - + if let centralItemNode = self.controllerNode.pager.centralItemNode(), let presentationArguments = self.presentationArguments as? GalleryControllerPresentationArguments, let message = self.messageView?.message { if let media = mediaForMessage(message: message), let transitionArguments = presentationArguments.transitionArguments(message.id, media), !forceAway { animatedOutNode = false @@ -491,13 +492,13 @@ public final class SecretMediaPreviewController: ViewController { }) } } - + self.controllerNode.animateOut(animateContent: animatedOutNode, completion: { animatedOutInterface = true completion() }) } - + private func applyMessageView() { var message: Message? if let messageView = self.messageView, let m = messageView.message { @@ -526,7 +527,7 @@ public final class SecretMediaPreviewController: ViewController { break } } - + let entry = GalleryEntry(entry: MessageHistoryEntry(message: message, isRead: false, location: nil, monthLocation: nil, attributes: MutableMessageHistoryEntryAttributes(authorIsContact: false))) guard let item = galleryItemForEntry(context: self.context, presentationData: self.presentationData, entry: entry, streamVideos: false, hideControls: true, isSecret: true, playbackRate: { nil }, peerIsCopyProtected: true, tempFilePath: tempFilePath, playbackCompleted: { [weak self] in if let self { @@ -542,7 +543,7 @@ public final class SecretMediaPreviewController: ViewController { self._ready.set(.single(true)) return } - + self.controllerNode.pager.replaceItems([item], centralItemIndex: 0) let ready = self.controllerNode.pager.ready() |> timeout(2.0, queue: Queue.mainQueue(), alternate: .single(Void())) |> afterNext { [weak self] _ in self?.didSetReady = true @@ -557,7 +558,7 @@ public final class SecretMediaPreviewController: ViewController { videoDuration = file.duration } } - + let isOutgoing = !message.flags.contains(.Incoming) if let attribute = message.autoclearAttribute { if let countdownBeginTime = attribute.countdownBeginTime { @@ -580,7 +581,7 @@ public final class SecretMediaPreviewController: ViewController { beginTimeAndTimeout = (CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970, attribute.timeout != viewOnceTimeout ? 0.0 : Double(viewOnceTimeout), isOutgoing) } } - + if self.isNodeLoaded { if let beginTimeAndTimeout = beginTimeAndTimeout { self.controllerNode.beginTimeAndTimeout = beginTimeAndTimeout @@ -597,24 +598,24 @@ public final class SecretMediaPreviewController: ViewController { self.currentMessageIsDismissed = true } } - + private func dismissAllTooltips() { if let tooltipController = self.tooltipController { self.tooltipController = nil tooltipController.dismiss() } } - + private func presentViewOnceTooltip() { guard self.currentNodeMessageIsViewOnce, let sourceView = self.controllerNode.timeoutNode?.view else { return } - + self.dismissAllTooltips() - + let absoluteFrame = sourceView.convert(sourceView.bounds, to: nil) let location = CGRect(origin: CGPoint(x: absoluteFrame.midX, y: absoluteFrame.maxY + 2.0), size: CGSize()) - + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let iconName = "anim_autoremove_on" let text: String @@ -623,7 +624,7 @@ public final class SecretMediaPreviewController: ViewController { } else { text = presentationData.strings.Gallery_ViewOncePhotoTooltip } - + let tooltipController = TooltipScreen( account: self.context.account, sharedContext: self.context.sharedContext, @@ -644,14 +645,14 @@ public final class SecretMediaPreviewController: ViewController { self.tooltipController = tooltipController self.present(tooltipController, in: .window(.root)) } - + public override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) - + self.controllerNode.frame = CGRect(origin: CGPoint(), size: layout.size) self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition) } - + override public func dismiss(completion: (() -> Void)? = nil) { self.presentingViewController?.dismiss(animated: false, completion: completion) } diff --git a/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift b/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift index 23ea3eb290..89fb3dd3a7 100644 --- a/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift +++ b/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift @@ -18,18 +18,18 @@ private let productIdentifiers = [ "org.telegram.telegramPremium.threeMonths.code_x1", "org.telegram.telegramPremium.sixMonths.code_x1", "org.telegram.telegramPremium.twelveMonths.code_x1", - + "org.telegram.telegramPremium.threeMonths.code_x5", "org.telegram.telegramPremium.sixMonths.code_x5", "org.telegram.telegramPremium.twelveMonths.code_x5", - + "org.telegram.telegramPremium.threeMonths.code_x10", "org.telegram.telegramPremium.sixMonths.code_x10", "org.telegram.telegramPremium.twelveMonths.code_x10", - + "org.telegram.telegramPremium.oneWeek.auth", "org.telegram.telegramPremium.threeDays.auth", - + "org.telegram.telegramStars.topup.x15", "org.telegram.telegramStars.topup.x25", "org.telegram.telegramStars.topup.x50", @@ -59,7 +59,7 @@ private extension NSDecimalNumber { raiseOnUnderflow: false, raiseOnDivideByZero: false)) } - + func prettyPrice() -> NSDecimalNumber { return self.multiplying(by: NSDecimalNumber(value: 2)) .rounding(accordingToBehavior: @@ -85,17 +85,17 @@ public final class InAppPurchaseManager: NSObject { numberFormatter.locale = self.skProduct.priceLocale return numberFormatter }() - + let skProduct: SKProduct - + init(skProduct: SKProduct) { self.skProduct = skProduct } - + public var id: String { return self.skProduct.productIdentifier } - + public var isSubscription: Bool { if #available(iOS 12.0, *) { return self.skProduct.subscriptionGroupIdentifier != nil @@ -103,16 +103,16 @@ public final class InAppPurchaseManager: NSObject { return self.skProduct.subscriptionPeriod != nil } } - + public var price: String { return self.numberFormatter.string(from: self.skProduct.price) ?? "" } - + public func pricePerMonth(_ monthsCount: Int) -> String { let price = self.skProduct.price.dividing(by: NSDecimalNumber(value: monthsCount)).round(2) return self.numberFormatter.string(from: price) ?? "" } - + public func defaultPrice(_ value: NSDecimalNumber, monthsCount: Int) -> String { let price = value.multiplying(by: NSDecimalNumber(value: monthsCount)).round(2) let prettierPrice = price @@ -131,7 +131,7 @@ public final class InAppPurchaseManager: NSObject { .subtracting(NSDecimalNumber(value: 0.01)) return self.numberFormatter.string(from: prettierPrice) ?? "" } - + public func multipliedPrice(count: Int) -> String { let price = self.skProduct.price.multiplying(by: NSDecimalNumber(value: count)).round(2) let prettierPrice = price @@ -150,11 +150,11 @@ public final class InAppPurchaseManager: NSObject { .subtracting(NSDecimalNumber(value: 0.01)) return self.numberFormatter.string(from: prettierPrice) ?? "" } - + public var priceValue: NSDecimalNumber { return self.skProduct.price } - + public var priceCurrencyAndAmount: (currency: String, amount: Int64) { if let currencyCode = self.numberFormatter.currencyCode, let amount = fractionalToCurrencyAmount(value: self.priceValue.doubleValue, currency: currencyCode) { @@ -163,7 +163,7 @@ public final class InAppPurchaseManager: NSObject { return ("", 0) } } - + public static func ==(lhs: Product, rhs: Product) -> Bool { if lhs.id != rhs.id { return false @@ -176,13 +176,13 @@ public final class InAppPurchaseManager: NSObject { } return true } - + } - + public enum PurchaseState { case purchased(transactionId: String) } - + public enum PurchaseError { case generic case cancelled @@ -192,23 +192,23 @@ public final class InAppPurchaseManager: NSObject { case assignFailed case tryLater } - + public enum RestoreState { case succeed(Bool) case failed } - + private final class PaymentTransactionContext { var state: SKPaymentTransactionState? let purpose: PendingInAppPurchaseState.Purpose let subscriber: (TransactionState) -> Void - + init(purpose: PendingInAppPurchaseState.Purpose, subscriber: @escaping (TransactionState) -> Void) { self.purpose = purpose self.subscriber = subscriber } } - + private enum TransactionState { case purchased(transactionId: String?) case restored(transactionId: String?) @@ -217,51 +217,51 @@ public final class InAppPurchaseManager: NSObject { case assignFailed case deferred } - + private let engine: SomeTelegramEngine - + private var products: [Product] = [] private var productsPromise = Promise<[Product]>([]) private var productRequest: SKProductsRequest? - + private let stateQueue = Queue() private var paymentContexts: [String: PaymentTransactionContext] = [:] - + private var finishedSuccessfulTransactions = Set() - + private var onRestoreCompletion: ((RestoreState) -> Void)? - + private let disposableSet = DisposableDict() - + private var lastRequestTimestamp: Double? public init(engine: SomeTelegramEngine) { self.engine = engine - + super.init() - + SKPaymentQueue.default().add(self) self.requestProducts() } - + deinit { SKPaymentQueue.default().remove(self) } - + var canMakePayments: Bool { return SKPaymentQueue.canMakePayments() } - + private func requestProducts() { Logger.shared.log("InAppPurchaseManager", "Requesting products") let productRequest = SKProductsRequest(productIdentifiers: Set(productIdentifiers)) productRequest.delegate = self productRequest.start() - + self.productRequest = productRequest self.lastRequestTimestamp = CFAbsoluteTimeGetCurrent() } - + public var availableProducts: Signal<[Product], NoError> { if self.products.isEmpty { if let lastRequestTimestamp, CFAbsoluteTimeGetCurrent() - lastRequestTimestamp > 10.0 { @@ -271,30 +271,36 @@ public final class InAppPurchaseManager: NSObject { } return self.productsPromise.get() } - + public func restorePurchases(completion: @escaping (RestoreState) -> Void) { Logger.shared.log("InAppPurchaseManager", "Restoring purchases") self.onRestoreCompletion = completion - + let paymentQueue = SKPaymentQueue.default() paymentQueue.restoreCompletedTransactions() } - + public func finishAllTransactions() { Logger.shared.log("InAppPurchaseManager", "Finishing all transactions") - + let paymentQueue = SKPaymentQueue.default() let transactions = paymentQueue.transactions for transaction in transactions { paymentQueue.finishTransaction(transaction) } } - + public func buyProduct(_ product: Product, quantity: Int32 = 1, purpose: AppStoreTransactionPurpose) -> Signal { + // WinterGram does not support in-app purchases; subscriptions bought in the + // official Telegram app apply to this account automatically. + return .fail(.cantMakePayments) + } + + private func unusedOriginalBuyProduct(_ product: Product, quantity: Int32 = 1, purpose: AppStoreTransactionPurpose) -> Signal { if !self.canMakePayments { return .fail(.cantMakePayments) } - + let accountPeerId: String switch self.engine { case let .authorized(engine): @@ -302,20 +308,20 @@ public final class InAppPurchaseManager: NSObject { case let .unauthorized(engine): accountPeerId = "\(engine.account.id.int64)" } - + Logger.shared.log("InAppPurchaseManager", "Buying: account \(accountPeerId), product \(product.skProduct.productIdentifier), price \(product.price)") - + let purpose = PendingInAppPurchaseState.Purpose(appStorePurpose: purpose) - + let payment = SKMutablePayment(product: product.skProduct) payment.applicationUsername = accountPeerId payment.quantity = Int(quantity) SKPaymentQueue.default().add(payment) - + let productIdentifier = payment.productIdentifier let signal = Signal { subscriber in let disposable = MetaDisposable() - + self.stateQueue.async { let paymentContext = PaymentTransactionContext(purpose: purpose, subscriber: { state in switch state { @@ -356,7 +362,7 @@ public final class InAppPurchaseManager: NSObject { } }) self.paymentContexts[productIdentifier] = paymentContext - + disposable.set(ActionDisposable { [weak paymentContext] in self.stateQueue.async { if let current = self.paymentContexts[productIdentifier], current === paymentContext { @@ -365,18 +371,18 @@ public final class InAppPurchaseManager: NSObject { } }) } - + return disposable } return signal } - + public struct ReceiptPurchase: Equatable { public let productId: String public let transactionId: String public let expirationDate: Date } - + public func getReceiptPurchases() -> [ReceiptPurchase] { guard let data = getReceiptData(), let receipt = parseReceipt(data) else { return [] @@ -388,10 +394,10 @@ public final class InAppPurchaseManager: NSObject { extension InAppPurchaseManager: SKProductsRequestDelegate { public func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { self.productRequest = nil - + Queue.mainQueue().async { let products = response.products.map { Product(skProduct: $0) } - + Logger.shared.log("InAppPurchaseManager", "Received products \(products.map({ $0.skProduct.productIdentifier }).joined(separator: ", "))") self.productsPromise.set(.single(products)) } @@ -420,15 +426,15 @@ extension InAppPurchaseManager: SKPaymentTransactionObserver { case let .unauthorized(engine): accountPeerId = "\(engine.account.id.int64)" } - + let paymentContexts = self.paymentContexts - + var transactionsToAssign: [SKPaymentTransaction] = [] for transaction in transactions { if let applicationUsername = transaction.payment.applicationUsername, applicationUsername != accountPeerId { continue } - + let productIdentifier = transaction.payment.productIdentifier let transactionState: TransactionState? switch transaction.transactionState { @@ -475,24 +481,24 @@ extension InAppPurchaseManager: SKPaymentTransactionObserver { } } } - + if !transactionsToAssign.isEmpty { let transactionIds = transactionsToAssign.compactMap({ $0.transactionIdentifier }).joined(separator: ", ") Logger.shared.log("InAppPurchaseManager", "Account \(accountPeerId), sending receipt for transactions [\(transactionIds)]") - + guard let transaction = transactionsToAssign.first else { return } let productIdentifier = transaction.payment.productIdentifier - + var completion: Signal = .never() - + let products = self.availableProducts |> filter { products in return !products.isEmpty } |> take(1) - + let product: Signal = products |> map { products in if let product = products.first(where: { $0.id == productIdentifier }) { @@ -501,7 +507,7 @@ extension InAppPurchaseManager: SKPaymentTransactionObserver { return nil } } - + let purpose: Signal if let paymentContext = paymentContexts[productIdentifier] { purpose = product @@ -521,20 +527,20 @@ extension InAppPurchaseManager: SKPaymentTransactionObserver { } } } - + completion = updatePendingInAppPurchaseState(engine: self.engine, productId: productIdentifier, content: nil) - + let receiptData = getReceiptData() ?? Data() #if DEBUG self.debugSaveReceipt(receiptData: receiptData) #endif - + for transaction in transactionsToAssign { if let transactionIdentifier = transaction.transactionIdentifier { self.finishedSuccessfulTransactions.insert(transactionIdentifier) } } - + self.disposableSet.set( (purpose |> castError(AssignAppStoreTransactionError.self) @@ -560,7 +566,7 @@ extension InAppPurchaseManager: SKPaymentTransactionObserver { for transaction in transactions { queue.finishTransaction(transaction) } - + let _ = completion.start() }), forKey: transactionIds @@ -568,13 +574,13 @@ extension InAppPurchaseManager: SKPaymentTransactionObserver { } } } - + public func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) { Queue.mainQueue().async { if let onRestoreCompletion = self.onRestoreCompletion { Logger.shared.log("InAppPurchaseManager", "Transactions restoration finished") self.onRestoreCompletion = nil - + if let receiptData = getReceiptData() { let signal: Signal switch self.engine { @@ -606,7 +612,7 @@ extension InAppPurchaseManager: SKPaymentTransactionObserver { } } } - + public func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) { Queue.mainQueue().async { if let onRestoreCompletion = self.onRestoreCompletion { @@ -616,7 +622,7 @@ extension InAppPurchaseManager: SKPaymentTransactionObserver { } } } - + private func debugSaveReceipt(receiptData: Data) { guard case let .authorized(engine) = self.engine else { return @@ -638,12 +644,12 @@ private final class PendingInAppPurchaseState: Codable { case purpose case storeProductId } - + enum Purpose: Codable { enum DecodingError: Error { case generic } - + enum CodingKeys: String, CodingKey { case type case peer @@ -665,7 +671,7 @@ private final class PendingInAppPurchaseState: Codable { case phoneCodeHash case premiumDays } - + enum PurposeType: Int32 { case subscription case upgrade @@ -678,7 +684,7 @@ private final class PendingInAppPurchaseState: Codable { case starsGiveaway case authCode } - + case subscription case upgrade case restore @@ -689,7 +695,7 @@ private final class PendingInAppPurchaseState: Codable { case starsGift(peerId: EnginePeer.Id, count: Int64) case starsGiveaway(stars: Int64, boostPeer: EnginePeer.Id, additionalPeerIds: [EnginePeer.Id], countries: [String], onlyNewSubscribers: Bool, showWinners: Bool, prizeDescription: String?, randomId: Int64, untilDate: Int32, users: Int32) case authCode(restore: Bool, phoneNumber: String, phoneCodeHash: String, premiumDays: Int32) - + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -757,10 +763,10 @@ private final class PendingInAppPurchaseState: Codable { throw DecodingError.generic } } - + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - + switch self { case .subscription: try container.encode(PurposeType.subscription.rawValue, forKey: .type) @@ -815,7 +821,7 @@ private final class PendingInAppPurchaseState: Codable { try container.encode(premiumDays, forKey: .premiumDays) } } - + init(appStorePurpose: AppStoreTransactionPurpose) { switch appStorePurpose { case .subscription: @@ -840,7 +846,7 @@ private final class PendingInAppPurchaseState: Codable { self = .authCode(restore: restore, phoneNumber: phoneNumber, phoneCodeHash: phoneCodeHash, premiumDays: premiumDays) } } - + func appStorePurpose(product: InAppPurchaseManager.Product?) -> AppStoreTransactionPurpose { let (currency, amount) = product?.priceCurrencyAndAmount ?? ("", 0) switch self { @@ -867,22 +873,22 @@ private final class PendingInAppPurchaseState: Codable { } } } - + public let productId: String public let purpose: Purpose - + public init(productId: String, purpose: Purpose) { self.productId = productId self.purpose = purpose } - + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.productId = try container.decode(String.self, forKey: .productId) self.purpose = try container.decode(Purpose.self, forKey: .purpose) } - + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -894,7 +900,7 @@ private final class PendingInAppPurchaseState: Codable { private func pendingInAppPurchaseState(engine: SomeTelegramEngine, productId: String) -> Signal { let key = EngineDataBuffer(length: 8) key.setInt64(0, value: Int64(bitPattern: productId.persistentHashValue)) - + switch engine { case let .authorized(engine): return engine.data.get(TelegramEngine.EngineData.Item.ItemCache.Item(collectionId: ApplicationSpecificItemCacheCollectionId.pendingInAppPurchaseState, id: key)) @@ -912,8 +918,8 @@ private func pendingInAppPurchaseState(engine: SomeTelegramEngine, productId: St private func updatePendingInAppPurchaseState(engine: SomeTelegramEngine, productId: String, content: PendingInAppPurchaseState?) -> Signal { let key = EngineDataBuffer(length: 8) key.setInt64(0, value: Int64(bitPattern: productId.persistentHashValue)) - - + + switch engine { case let .authorized(engine): if let content = content { diff --git a/submodules/ItemListPeerItem/Sources/ItemListPeerItem.swift b/submodules/ItemListPeerItem/Sources/ItemListPeerItem.swift index e1f339963a..349df1af21 100644 --- a/submodules/ItemListPeerItem/Sources/ItemListPeerItem.swift +++ b/submodules/ItemListPeerItem/Sources/ItemListPeerItem.swift @@ -25,70 +25,70 @@ private final class ShimmerEffectNode: ASDisplayNode { private var currentForegroundColor: UIColor? private let imageNodeContainer: ASDisplayNode private let imageNode: ASImageNode - + private var absoluteLocation: (CGRect, CGSize)? private var isCurrentlyInHierarchy = false private var shouldBeAnimating = false - + override init() { self.imageNodeContainer = ASDisplayNode() self.imageNodeContainer.isLayerBacked = true - + self.imageNode = ASImageNode() self.imageNode.isLayerBacked = true self.imageNode.displaysAsynchronously = false self.imageNode.displayWithoutProcessing = true self.imageNode.contentMode = .scaleToFill - + super.init() - + self.isLayerBacked = true self.clipsToBounds = true - + self.imageNodeContainer.addSubnode(self.imageNode) self.addSubnode(self.imageNodeContainer) } - + override func didEnterHierarchy() { super.didEnterHierarchy() - + self.isCurrentlyInHierarchy = true self.updateAnimation() } - + override func didExitHierarchy() { super.didExitHierarchy() - + self.isCurrentlyInHierarchy = false self.updateAnimation() } - + func update(backgroundColor: UIColor, foregroundColor: UIColor) { if let currentBackgroundColor = self.currentBackgroundColor, currentBackgroundColor.isEqual(backgroundColor), let currentForegroundColor = self.currentForegroundColor, currentForegroundColor.isEqual(foregroundColor) { return } self.currentBackgroundColor = backgroundColor self.currentForegroundColor = foregroundColor - + self.imageNode.image = generateImage(CGSize(width: 4.0, height: 320.0), opaque: true, scale: 1.0, rotatedContext: { size, context in context.setFillColor(backgroundColor.cgColor) context.fill(CGRect(origin: CGPoint(), size: size)) - + context.clip(to: CGRect(origin: CGPoint(), size: size)) - + let transparentColor = foregroundColor.withAlphaComponent(0.0).cgColor let peakColor = foregroundColor.cgColor - + var locations: [CGFloat] = [0.0, 0.5, 1.0] let colors: [CGColor] = [transparentColor, peakColor, transparentColor] - + let colorSpace = CGColorSpaceCreateDeviceRGB() let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! - + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions()) }) } - + func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { if let absoluteLocation = self.absoluteLocation, absoluteLocation.0 == rect && absoluteLocation.1 == containerSize { return @@ -96,19 +96,19 @@ private final class ShimmerEffectNode: ASDisplayNode { let sizeUpdated = self.absoluteLocation?.1 != containerSize let frameUpdated = self.absoluteLocation?.0 != rect self.absoluteLocation = (rect, containerSize) - + if sizeUpdated { if self.shouldBeAnimating { self.imageNode.layer.removeAnimation(forKey: "shimmer") self.addImageAnimation() } } - + if frameUpdated { self.imageNodeContainer.frame = CGRect(origin: CGPoint(x: -rect.minX, y: -rect.minY), size: containerSize) } } - + private func updateAnimation() { let shouldBeAnimating = self.isCurrentlyInHierarchy && self.absoluteLocation != nil if shouldBeAnimating != self.shouldBeAnimating { @@ -120,7 +120,7 @@ private final class ShimmerEffectNode: ASDisplayNode { } } } - + private func addImageAnimation() { guard let containerSize = self.absoluteLocation?.1 else { return @@ -139,57 +139,57 @@ private final class LoadingShimmerNode: ASDisplayNode { case circle(CGRect) case roundedRectLine(startPoint: CGPoint, width: CGFloat, diameter: CGFloat) } - + private let backgroundNode: ASDisplayNode private let effectNode: ShimmerEffectNode private let foregroundNode: ASImageNode - + private var currentShapes: [Shape] = [] private var currentBackgroundColor: UIColor? private var currentForegroundColor: UIColor? private var currentShimmeringColor: UIColor? private var currentSize = CGSize() - + override init() { self.backgroundNode = ASDisplayNode() - + self.effectNode = ShimmerEffectNode() - + self.foregroundNode = ASImageNode() self.foregroundNode.displaysAsynchronously = false self.foregroundNode.displayWithoutProcessing = true - + super.init() - + self.addSubnode(self.backgroundNode) self.addSubnode(self.effectNode) self.addSubnode(self.foregroundNode) } - + func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { self.effectNode.updateAbsoluteRect(rect, within: containerSize) } - + func update(backgroundColor: UIColor, foregroundColor: UIColor, shimmeringColor: UIColor, shapes: [Shape], size: CGSize) { if self.currentShapes == shapes, let currentBackgroundColor = self.currentBackgroundColor, currentBackgroundColor.isEqual(backgroundColor), let currentForegroundColor = self.currentForegroundColor, currentForegroundColor.isEqual(foregroundColor), let currentShimmeringColor = self.currentShimmeringColor, currentShimmeringColor.isEqual(shimmeringColor), self.currentSize == size { return } - + self.currentBackgroundColor = backgroundColor self.currentForegroundColor = foregroundColor self.currentShimmeringColor = shimmeringColor self.currentShapes = shapes self.currentSize = size - + self.backgroundNode.backgroundColor = foregroundColor - + self.effectNode.update(backgroundColor: foregroundColor, foregroundColor: shimmeringColor) - + self.foregroundNode.image = generateImage(size, rotatedContext: { size, context in context.setFillColor(backgroundColor.cgColor) context.setBlendMode(.copy) context.fill(CGRect(origin: CGPoint(), size: size)) - + context.setFillColor(UIColor.clear.cgColor) for shape in shapes { switch shape { @@ -202,7 +202,7 @@ private final class LoadingShimmerNode: ASDisplayNode { } } }) - + self.backgroundNode.frame = CGRect(origin: CGPoint(), size: size) self.foregroundNode.frame = CGRect(origin: CGPoint(), size: size) self.effectNode.frame = CGRect(origin: CGPoint(), size: size) @@ -214,7 +214,7 @@ public struct ItemListPeerItemEditing: Equatable { public var editing: Bool public var canBeReordered: Bool public var revealed: Bool? - + public init(editable: Bool, editing: Bool, canBeReordered: Bool = false, revealed: Bool?) { self.editable = editable self.editing = editing @@ -234,7 +234,7 @@ public enum ItemListPeerItemText { case accent case constructive } - + case presence case text(String, TextColor) case none @@ -257,7 +257,7 @@ public struct ItemListPeerItemSwitch { public var value: Bool public var style: ItemListPeerItemSwitchStyle public var isEnabled: Bool - + public init(value: Bool, style: ItemListPeerItemSwitchStyle, isEnabled: Bool = true) { self.value = value self.style = style @@ -297,7 +297,7 @@ public struct ItemListPeerItemRevealOption { public var type: ItemListPeerItemRevealOptionType public var title: String public var action: () -> Void - + public init(type: ItemListPeerItemRevealOptionType, title: String, action: @escaping () -> Void) { self.type = type self.title = title @@ -307,7 +307,7 @@ public struct ItemListPeerItemRevealOption { public struct ItemListPeerItemRevealOptions { public var options: [ItemListPeerItemRevealOption] - + public init(options: [ItemListPeerItemRevealOption]) { self.options = options } @@ -315,7 +315,7 @@ public struct ItemListPeerItemRevealOptions { public struct ItemListPeerItemShimmering { public var alternationIndex: Int - + public init(alternationIndex: Int) { self.alternationIndex = alternationIndex } @@ -347,10 +347,10 @@ public final class ItemListPeerItem: ListViewItem, ItemListItem, ItemListRevealO self.resolveInlineStickers = resolveInlineStickers } } - + case account(AccountContext) case custom(Custom) - + public var accountPeerId: EnginePeer.Id { switch self { case let .account(context): @@ -359,7 +359,7 @@ public final class ItemListPeerItem: ListViewItem, ItemListItem, ItemListRevealO return custom.accountPeerId } } - + public var engine: TelegramEngine { switch self { case let .account(context): @@ -368,7 +368,7 @@ public final class ItemListPeerItem: ListViewItem, ItemListItem, ItemListRevealO return custom.engine } } - + public var animationCache: AnimationCache { switch self { case let .account(context): @@ -377,7 +377,7 @@ public final class ItemListPeerItem: ListViewItem, ItemListItem, ItemListRevealO return custom.animationCache } } - + public var animationRenderer: MultiAnimationRenderer { switch self { case let .account(context): @@ -386,7 +386,7 @@ public final class ItemListPeerItem: ListViewItem, ItemListItem, ItemListRevealO return custom.animationRenderer } } - + public var isPremiumDisabled: Bool { switch self { case let .account(context): @@ -395,7 +395,7 @@ public final class ItemListPeerItem: ListViewItem, ItemListItem, ItemListRevealO return custom.isPremiumDisabled } } - + public var resolveInlineStickers: ([Int64]) -> Signal<[Int64: TelegramMediaFile], NoError> { switch self { case let .account(context): @@ -406,7 +406,7 @@ public final class ItemListPeerItem: ListViewItem, ItemListItem, ItemListRevealO return custom.resolveInlineStickers } } - + public var energyUsageSettings: EnergyUsageSettings { switch self { case let .account(context): @@ -415,7 +415,7 @@ public final class ItemListPeerItem: ListViewItem, ItemListItem, ItemListRevealO return .default } } - + public var contentSettings: ContentSettings { switch self { case let .account(context): @@ -425,7 +425,7 @@ public final class ItemListPeerItem: ListViewItem, ItemListItem, ItemListRevealO } } } - + let presentationData: ItemListPresentationData let systemStyle: ItemListSystemStyle let dateTimeFormat: PresentationDateTimeFormat @@ -472,7 +472,7 @@ public final class ItemListPeerItem: ListViewItem, ItemListItem, ItemListRevealO public var hasActiveRevealOptions: Bool { return self.editing.revealed == true } - + public init( presentationData: ItemListPresentationData, systemStyle: ItemListSystemStyle = .legacy, @@ -560,7 +560,7 @@ public final class ItemListPeerItem: ListViewItem, ItemListItem, ItemListRevealO self.storyStats = storyStats self.openStories = openStories } - + public init( presentationData: ItemListPresentationData, systemStyle: ItemListSystemStyle = .legacy, @@ -648,15 +648,15 @@ public final class ItemListPeerItem: ListViewItem, ItemListItem, ItemListRevealO self.storyStats = storyStats self.openStories = openStories } - + public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { async { let node = ItemListPeerItemNode() let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem), self.getHeaderAtTop(top: previousItem, bottom: nextItem)) - + node.contentSize = layout.contentSize node.insets = layout.insets - + Queue.mainQueue().async { completion(node, { return (node.avatarNode.ready, { _ in apply(synchronousLoads, false) }) @@ -664,7 +664,7 @@ public final class ItemListPeerItem: ListViewItem, ItemListItem, ItemListRevealO } } } - + private func getHeaderAtTop(top: ListViewItem?, bottom: ListViewItem?) -> Bool { var headerAtTop = false if let top = top as? ItemListPeerItem, top.header != nil { @@ -674,20 +674,20 @@ public final class ItemListPeerItem: ListViewItem, ItemListItem, ItemListRevealO } else if self.header != nil { headerAtTop = true } - + return headerAtTop } - + public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { Queue.mainQueue().async { if let nodeValue = node() as? ItemListPeerItemNode { let makeLayout = nodeValue.asyncLayout() - + var animated = true if case .None = animation { animated = false } - + async { let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem), self.getHeaderAtTop(top: previousItem, bottom: nextItem)) Queue.mainQueue().async { @@ -699,7 +699,7 @@ public final class ItemListPeerItem: ListViewItem, ItemListItem, ItemListRevealO } } } - + public func selected(listView: ListView){ listView.clearHighlightAnimated(true) self.action?() @@ -715,20 +715,20 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo private let highlightedBackgroundNode: ASDisplayNode private var disabledOverlayNode: ASDisplayNode? private let maskNode: ASImageNode - + private let containerNode: ContextControllerSourceNode public override var controlsContainer: ASDisplayNode { return self.containerNode } - + fileprivate let avatarNode: AvatarNode private var avatarIconComponent: EmojiStatusComponent? private var avatarIconView: ComponentView? - + private var customAvatarIconView: UIImageView? - + private var avatarButton: HighlightTrackingButton? - + private let titleNode: TextNode private let labelNode: TextNodeWithEntities private let labelBadgeNode: ASImageNode @@ -738,19 +738,21 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo private var credibilityIconView: ComponentHostView? private var verifiedIconComponent: EmojiStatusComponent? private var verifiedIconView: ComponentHostView? + private var winterGramIconComponent: EmojiStatusComponent? + private var winterGramIconView: ComponentHostView? private var switchNode: SwitchNode? private var checkNode: ASImageNode? private var leftCheckNode: CheckNode? - + private var shimmerNode: LoadingShimmerNode? private var absoluteLocation: (CGRect, CGSize)? - + private var peerPresenceManager: PeerPresenceStatusManager? private var layoutParams: (ItemListPeerItem, ListViewItemLayoutParams, ItemListNeighbors, Bool)? - + private var editableControlNode: ItemListEditableControlNode? private var reorderControlNode: ItemListEditableReorderControlNode? - + override public var visibility: ListViewItemNodeVisibility { didSet { let wasVisible = self.visibilityStatus @@ -766,7 +768,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo } } } - + private var visibilityStatus: Bool = false { didSet { if self.visibilityStatus != oldValue { @@ -797,7 +799,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo } } } - + override public var canBeSelected: Bool { if self.editableControlNode != nil || self.disabledOverlayNode != nil { return false @@ -808,66 +810,66 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo return false } } - + public var tag: ItemListItemTag? { return self.layoutParams?.0.tag } - + public init() { self.backgroundNode = ASDisplayNode() self.backgroundNode.isLayerBacked = true - + self.topStripeNode = ASDisplayNode() self.topStripeNode.isLayerBacked = true - + self.bottomStripeNode = ASDisplayNode() self.bottomStripeNode.isLayerBacked = true - + self.maskNode = ASImageNode() self.maskNode.isUserInteractionEnabled = false - + self.containerNode = ContextControllerSourceNode() - + self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: floor(40.0 * 16.0 / 37.0))) //self.avatarNode.isLayerBacked = !smartInvertColorsEnabled() - + self.titleNode = TextNode() self.titleNode.isUserInteractionEnabled = false self.titleNode.contentMode = .left self.titleNode.contentsScale = UIScreen.main.scale - + self.statusNode = TextNode() self.statusNode.isUserInteractionEnabled = false self.statusNode.contentMode = .left self.statusNode.contentsScale = UIScreen.main.scale - + self.labelNode = TextNodeWithEntities() - + self.labelBadgeNode = ASImageNode() self.labelBadgeNode.displayWithoutProcessing = true self.labelBadgeNode.displaysAsynchronously = false self.labelBadgeNode.isLayerBacked = true - + self.highlightedBackgroundNode = ASDisplayNode() self.highlightedBackgroundNode.isLayerBacked = true - + super.init(layerBacked: false, rotated: false, seeThrough: false) - + self.isAccessibilityElement = true - + self.addSubnode(self.containerNode) self.containerNode.addSubnode(self.avatarNode) self.containerNode.addSubnode(self.titleNode) self.containerNode.addSubnode(self.statusNode) self.containerNode.addSubnode(self.labelNode.textNode) - + self.peerPresenceManager = PeerPresenceStatusManager(update: { [weak self] in if let strongSelf = self, let layoutParams = strongSelf.layoutParams { let (_, apply) = strongSelf.asyncLayout()(layoutParams.0, layoutParams.1, layoutParams.2, layoutParams.3) apply(false, true) } }) - + self.containerNode.activated = { [weak self] gesture, _ in guard let strongSelf = self, let item = strongSelf.layoutParams?.0, let contextAction = item.contextAction else { gesture.cancel() @@ -876,13 +878,13 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo contextAction(strongSelf.containerNode, gesture) } } - + override public func didLoad() { super.didLoad() - + self.updateEnableGestures() } - + private func updateEnableGestures() { if let item = self.layoutParams?.0, item.disableInteractiveTransitionIfNecessary, let revealOptions = item.revealOptions, !revealOptions.options.isEmpty { self.view.disablesInteractiveTransitionGestureRecognizer = true @@ -890,42 +892,42 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo self.view.disablesInteractiveTransitionGestureRecognizer = false } } - + public func asyncLayout() -> (_ item: ItemListPeerItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors, _ headerAtTop: Bool) -> (ListViewItemNodeLayout, (Bool, Bool) -> Void) { let makeTitleLayout = TextNode.asyncLayout(self.titleNode) let makeStatusLayout = TextNode.asyncLayout(self.statusNode) let makeLabelLayout = TextNodeWithEntities.asyncLayout(self.labelNode) let editableControlLayout = ItemListEditableControlNode.asyncLayout(self.editableControlNode) let reorderControlLayout = ItemListEditableReorderControlNode.asyncLayout(self.reorderControlNode) - + var currentDisabledOverlayNode = self.disabledOverlayNode - + var currentSwitchNode = self.switchNode var currentCheckNode = self.checkNode - + let currentLabelArrowNode = self.labelArrowNode - + let currentItem = self.layoutParams?.0 - + let currentHasBadge = self.labelBadgeNode.image != nil - + return { item, params, neighbors, headerAtTop in var updateArrowImage: UIImage? - + let statusFontSize: CGFloat = floor(item.presentationData.fontSize.itemListBaseFontSize * 14.0 / 17.0) let labelFontSize: CGFloat = floor(item.presentationData.fontSize.itemListBaseFontSize * 13.0 / 17.0) - + let titleFont = Font.regular(item.presentationData.fontSize.itemListBaseFontSize) let titleBoldFont = Font.medium(item.presentationData.fontSize.itemListBaseFontSize) let statusFont = Font.regular(statusFontSize) let labelFont = Font.regular(labelFontSize) let labelDisclosureFont = Font.regular(item.presentationData.fontSize.itemListBaseFontSize) - + var updatedLabelBadgeImage: UIImage? var credibilityIcon: EmojiStatusComponent.Content? var credibilityParticleColor: UIColor? var verifiedIcon: EmojiStatusComponent.Content? - + if case .threatSelfAsSaved = item.aliasHandling, item.peer.id == item.context.accountPeerId { } else { if item.peer.isScam { @@ -937,10 +939,10 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo if let color = emojiStatus.color { credibilityParticleColor = UIColor(rgb: UInt32(bitPattern: color)) } - } else if item.peer.isPremium && !item.context.isPremiumDisabled { + } else if item.peer.isPremium && !currentWinterGramSettings.hidePremiumStatuses && !item.context.isPremiumDisabled { credibilityIcon = .premium(color: item.presentationData.theme.list.itemAccentColor) } - + if item.peer.isVerified { credibilityIcon = .verified(fillColor: item.presentationData.theme.list.itemCheckColors.fillColor, foregroundColor: item.presentationData.theme.list.itemCheckColors.foregroundColor, sizeType: .compact) credibilityParticleColor = nil @@ -949,7 +951,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo verifiedIcon = .animation(content: .customEmoji(fileId: verificationIconFileId), size: CGSize(width: 32.0, height: 32.0), placeholderColor: item.presentationData.theme.list.mediaPlaceholderColor, themeColor: item.presentationData.theme.list.itemAccentColor, loopMode: .count(0)) } } - + var titleIconsWidth: CGFloat = 0.0 if let verifiedIcon = verifiedIcon { titleIconsWidth += 4.0 @@ -973,14 +975,18 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo titleIconsWidth += 16.0 } } - + let isWinterGramOfficial = isWinterGramOfficialPeer(item.peer) + if isWinterGramOfficial { + titleIconsWidth += 4.0 + 16.0 + } + var badgeColor: UIColor? if case .badge = item.label { badgeColor = item.presentationData.theme.list.itemAccentColor } else if case let .text(_, _, color, hasBackground) = item.label, let color, hasBackground { badgeColor = color.withMultipliedAlpha(0.1) } - + let badgeDiameter: CGFloat = 20.0 if currentItem?.presentationData.theme !== item.presentationData.theme { updateArrowImage = PresentationResourcesItemList.disclosureArrowImage(item.presentationData.theme) @@ -990,11 +996,11 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo } else if let badgeColor = badgeColor, !currentHasBadge { updatedLabelBadgeImage = generateStretchableFilledCircleImage(diameter: badgeDiameter, color: badgeColor) } - + var titleAttributedString: NSAttributedString? var statusAttributedString: NSAttributedString? var labelAttributedString: NSAttributedString? - + let peerRevealOptions: [ItemListRevealOption] if item.editing.editable && item.enabled { if let revealOptions = item.revealOptions { @@ -1027,13 +1033,13 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo } else { peerRevealOptions = [] } - + var additionalLeftInset: CGFloat = 0.0 var leftInset: CGFloat = params.leftInset var rightInset: CGFloat = params.rightInset var switchSize = CGSize(width: 51.0, height: 31.0) var checkImage: UIImage? - + if let switchValue = item.switchValue { switch switchValue.style { case .standard: @@ -1059,14 +1065,14 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo currentSwitchNode = nil currentCheckNode = nil } - + if let currentSwitchNode, let switchView = currentSwitchNode.view as? UISwitch { if currentSwitchNode.bounds.size.width.isZero { switchView.sizeToFit() } switchSize = switchView.bounds.size } - + let titleColor: UIColor switch item.nameColor { case .primary: @@ -1074,7 +1080,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo case .secret: titleColor = item.presentationData.theme.chatList.secretTitleColor } - + let currentBoldFont: UIFont switch item.nameStyle { case .distinctBold: @@ -1082,7 +1088,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo case .plain: currentBoldFont = titleFont } - + if let threadInfo = item.threadInfo { titleAttributedString = NSAttributedString(string: threadInfo.title, font: currentBoldFont, textColor: titleColor) } else if item.peer.id == item.context.accountPeerId, case .threatSelfAsSaved = item.aliasHandling { @@ -1117,7 +1123,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo } else if case let .channel(channel) = item.peer { titleAttributedString = NSAttributedString(string: channel.title, font: currentBoldFont, textColor: titleColor) } - + switch item.text { case .presence: if case let .user(user) = item.peer, let botInfo = user.botInfo { @@ -1188,17 +1194,17 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo leftInset += 65.0 avatarFontSize = floor(40.0 * 16.0 / 37.0) } - + var editableControlSizeAndApply: (CGFloat, (CGFloat) -> ItemListEditableControlNode)? var reorderControlSizeAndApply: (CGFloat, (CGFloat, Bool, ContainedViewLayoutTransition) -> ItemListEditableReorderControlNode)? - + let editingOffset: CGFloat var reorderInset: CGFloat = 0.0 if item.editing.editing { let sizeAndApply = editableControlLayout(item.presentationData.theme, false) editableControlSizeAndApply = sizeAndApply editingOffset = sizeAndApply.0 - + if item.editing.canBeReordered { let reorderSizeAndApply = reorderControlLayout(item.presentationData.theme) reorderControlSizeAndApply = reorderSizeAndApply @@ -1207,7 +1213,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo } else { editingOffset = 0.0 } - + var labelMaximumNumberOfLines = 1 var labelInset: CGFloat = 0.0 var labelAlignment: NSTextAlignment = .natural @@ -1249,17 +1255,17 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo labelAttributedString = NSAttributedString(string: text, font: badgeFont, textColor: item.presentationData.theme.list.itemCheckColors.foregroundColor) labelInset += 15.0 } - + labelInset += reorderInset - + let (labelLayout, labelApply) = makeLabelLayout(TextNodeLayoutArguments(attributedString: labelAttributedString, backgroundColor: nil, maximumNumberOfLines: labelMaximumNumberOfLines, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - 16.0 - editingOffset - rightInset, height: CGFloat.greatestFiniteMagnitude), alignment: labelAlignment, lineSpacing: labelLineSpacing, cutout: nil, insets: UIEdgeInsets())) - + let constrainedTitleSize = CGSize(width: params.width - leftInset - 12.0 - editingOffset - rightInset - labelLayout.size.width - labelInset - titleIconsWidth, height: CGFloat.greatestFiniteMagnitude) let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: titleAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: constrainedTitleSize, alignment: .natural, cutout: nil, insets: UIEdgeInsets())) - + let constrainedStatusSize = CGSize(width: params.width - leftInset - 8.0 - editingOffset - rightInset - labelLayout.size.width - labelInset, height: CGFloat.greatestFiniteMagnitude) let (statusLayout, statusApply) = makeStatusLayout(TextNodeLayoutArguments(attributedString: statusAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: constrainedStatusSize, alignment: .natural, cutout: nil, insets: UIEdgeInsets())) - + var insets = itemListNeighborsGroupedInsets(neighbors, params) if !item.hasTopGroupInset { switch neighbors.top { @@ -1276,19 +1282,19 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo if headerAtTop, let header = item.header { insets.top += header.height + 18.0 } - + let titleSpacing: CGFloat = statusLayout.size.height == 0.0 ? 0.0 : 1.0 - + let minHeight: CGFloat = titleLayout.size.height + verticalInset * 2.0 let rawHeight: CGFloat = verticalInset * 2.0 + titleLayout.size.height + titleSpacing + statusLayout.size.height - + let contentSize = CGSize(width: params.width, height: max(minHeight, rawHeight)) let separatorHeight = UIScreenPixel let separatorRightInset: CGFloat = item.systemStyle == .glass ? 16.0 : 0.0 - + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) let layoutSize = layout.size - + if !item.enabled { if currentDisabledOverlayNode == nil { currentDisabledOverlayNode = ASDisplayNode() @@ -1297,16 +1303,16 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo } else { currentDisabledOverlayNode = nil } - + return (layout, { [weak self] synchronousLoad, animated in if let strongSelf = self { strongSelf.layoutParams = (item, params, neighbors, headerAtTop) - + strongSelf.containerNode.frame = CGRect(origin: CGPoint(), size: layout.contentSize) strongSelf.containerNode.isGestureEnabled = item.contextAction != nil - + strongSelf.avatarNode.font = avatarPlaceholderFont(size: avatarFontSize) - + strongSelf.accessibilityLabel = titleAttributedString?.string var combinedValueString = "" if let statusString = statusAttributedString?.string, !statusString.isEmpty { @@ -1315,13 +1321,13 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo if let labelString = labelAttributedString?.string, !labelString.isEmpty { combinedValueString.append(", \(labelString)") } - + strongSelf.accessibilityValue = combinedValueString - + if let updateArrowImage = updateArrowImage { strongSelf.labelArrowNode?.image = updateArrowImage } - + let itemBackgroundColor: UIColor let itemSeparatorColor: UIColor switch item.style { @@ -1332,23 +1338,23 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo itemBackgroundColor = item.presentationData.theme.list.itemBlocksBackgroundColor itemSeparatorColor = item.presentationData.theme.list.itemBlocksSeparatorColor } - + strongSelf.topStripeNode.backgroundColor = itemSeparatorColor strongSelf.bottomStripeNode.backgroundColor = itemSeparatorColor strongSelf.backgroundNode.backgroundColor = itemBackgroundColor strongSelf.highlightedBackgroundNode.backgroundColor = item.presentationData.theme.list.itemHighlightedBackgroundColor - + strongSelf.backgroundNode.isHidden = !item.displayBackground - + let revealOffset = strongSelf.revealOffset - + let transition: ContainedViewLayoutTransition if animated { transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) } else { transition = .immediate } - + if let currentDisabledOverlayNode = currentDisabledOverlayNode { if currentDisabledOverlayNode != strongSelf.disabledOverlayNode { strongSelf.disabledOverlayNode = currentDisabledOverlayNode @@ -1365,7 +1371,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo }) strongSelf.disabledOverlayNode = nil } - + if let editableControlSizeAndApply = editableControlSizeAndApply { let editableControlFrame = CGRect(origin: CGPoint(x: params.leftInset + revealOffset, y: 0.0), size: CGSize(width: editableControlSizeAndApply.0, height: layout.contentSize.height)) if strongSelf.editableControlNode == nil { @@ -1395,7 +1401,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo editableControlNode?.removeFromSupernode() }) } - + if let reorderControlSizeAndApply = reorderControlSizeAndApply { if strongSelf.reorderControlNode == nil { let reorderControlNode = reorderControlSizeAndApply.1(layout.contentSize.height, false, .immediate) @@ -1412,7 +1418,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo reorderControlNode?.removeFromSupernode() }) } - + let _ = titleApply() let _ = statusApply() if case let .account(context) = item.context { @@ -1421,7 +1427,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo let _ = labelApply(nil) } strongSelf.labelNode.textNode.isHidden = labelAttributedString == nil - + if strongSelf.backgroundNode.supernode == nil { strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) } @@ -1434,7 +1440,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo if strongSelf.maskNode.supernode == nil { strongSelf.addSubnode(strongSelf.maskNode) } - + let hasCorners = itemListHasRoundedBlockLayout(params) && !item.noCorners var hasTopCorners = false var hasBottomCorners = false @@ -1461,22 +1467,22 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo bottomStripeIsHidden = hasCorners || !item.displayDecorations } strongSelf.updateRevealOptionsSeparatorNodes(top: strongSelf.topStripeNode, bottom: strongSelf.bottomStripeNode, topIsHidden: topStripeIsHidden, bottomIsHidden: bottomStripeIsHidden, topHiddenByPreviousRevealOptions: neighbors.topHasActiveRevealOptions, bottomHiddenByNextRevealOptions: neighbors.bottomHasActiveRevealOptions) - + strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: item.systemStyle == .glass) : nil - + strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0) transition.updateFrame(node: strongSelf.topStripeNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight))) transition.updateFrame(node: strongSelf.bottomStripeNode, frame: CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset - params.rightInset - separatorRightInset, height: separatorHeight))) - + var titleFrame = CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: verticalInset + verticalOffset), size: titleLayout.size) - + var titleLeftOffset: CGFloat = 0.0 var nextIconX: CGFloat = titleFrame.maxX if let verifiedIcon { let animationCache = item.context.animationCache let animationRenderer = item.context.animationRenderer - + var verifiedIconTransition = transition let verifiedIconView: ComponentHostView if let current = strongSelf.verifiedIconView { @@ -1487,7 +1493,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo strongSelf.containerNode.view.addSubview(verifiedIconView) strongSelf.verifiedIconView = verifiedIconView } - + let verifiedIconComponent = EmojiStatusComponent( postbox: item.context.engine.account.postbox, energyUsageSettings: item.context.energyUsageSettings, @@ -1500,16 +1506,16 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo emojiFileUpdated: nil ) strongSelf.verifiedIconComponent = verifiedIconComponent - + let iconSize = verifiedIconView.update( transition: .immediate, component: AnyComponent(verifiedIconComponent), environment: {}, containerSize: CGSize(width: 16.0, height: 16.0) ) - + verifiedIconTransition.updateFrame(view: verifiedIconView, frame: CGRect(origin: CGPoint(x: titleFrame.minX, y: floorToScreenPixels(titleFrame.midY - iconSize.height / 2.0)), size: iconSize)) - + titleLeftOffset += iconSize.width + 4.0 nextIconX += iconSize.width + 4.0 } else if let verifiedIconView = strongSelf.verifiedIconView { @@ -1517,14 +1523,14 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo verifiedIconView.removeFromSuperview() } titleFrame = titleFrame.offsetBy(dx: titleLeftOffset, dy: 0.0) - + transition.updateFrame(node: strongSelf.titleNode, frame: titleFrame) transition.updateFrame(node: strongSelf.statusNode, frame: CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: strongSelf.titleNode.frame.maxY + titleSpacing), size: statusLayout.size)) - + if let credibilityIcon = credibilityIcon { let animationCache = item.context.animationCache let animationRenderer = item.context.animationRenderer - + var creditibilityIconTransition = transition let credibilityIconView: ComponentHostView if let current = strongSelf.credibilityIconView { @@ -1535,7 +1541,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo strongSelf.containerNode.view.addSubview(credibilityIconView) strongSelf.credibilityIconView = credibilityIconView } - + let credibilityIconComponent = EmojiStatusComponent( postbox: item.context.engine.account.postbox, energyUsageSettings: item.context.energyUsageSettings, @@ -1555,14 +1561,59 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo environment: {}, containerSize: CGSize(width: 20.0, height: 20.0) ) - + nextIconX += 4.0 creditibilityIconTransition.updateFrame(view: credibilityIconView, frame: CGRect(origin: CGPoint(x: nextIconX, y: floorToScreenPixels(titleFrame.midY - iconSize.height / 2.0)), size: iconSize)) + // Advance past the credibility icon so a following badge (e.g. the WinterGram + // snowflake) lands to its right instead of overlapping it. + nextIconX += iconSize.width } else if let credibilityIconView = strongSelf.credibilityIconView { strongSelf.credibilityIconView = nil credibilityIconView.removeFromSuperview() } - + + if isWinterGramOfficial { + let animationCache = item.context.animationCache + let animationRenderer = item.context.animationRenderer + + var winterGramIconTransition = transition + let winterGramIconView: ComponentHostView + if let current = strongSelf.winterGramIconView { + winterGramIconView = current + } else { + winterGramIconTransition = .immediate + winterGramIconView = ComponentHostView() + strongSelf.containerNode.view.addSubview(winterGramIconView) + strongSelf.winterGramIconView = winterGramIconView + } + + let winterGramIconComponent = EmojiStatusComponent( + postbox: item.context.engine.account.postbox, + energyUsageSettings: item.context.energyUsageSettings, + resolveInlineStickers: item.context.resolveInlineStickers, + animationCache: animationCache, + animationRenderer: animationRenderer, + content: isWinterGramOfficialPeer(item.peer) ? .winterGramBadge(backplateColor: winterGramBadgeBackplateColor(theme: item.presentationData.theme)) : EmojiStatusComponent.Content.none, + isVisibleForAnimations: strongSelf.visibilityStatus, + action: nil, + emojiFileUpdated: nil + ) + strongSelf.winterGramIconComponent = winterGramIconComponent + let iconSize = winterGramIconView.update( + transition: .immediate, + component: AnyComponent(winterGramIconComponent), + environment: {}, + containerSize: CGSize(width: 16.0, height: 16.0) + ) + + nextIconX += 4.0 + winterGramIconTransition.updateFrame(view: winterGramIconView, frame: CGRect(origin: CGPoint(x: nextIconX, y: floorToScreenPixels(titleFrame.midY - iconSize.height / 2.0)), size: iconSize)) + nextIconX += iconSize.width + } else if let winterGramIconView = strongSelf.winterGramIconView { + strongSelf.winterGramIconView = nil + winterGramIconView.removeFromSuperview() + } + if let currentSwitchNode = currentSwitchNode { if currentSwitchNode !== strongSelf.switchNode { strongSelf.switchNode = currentSwitchNode @@ -1581,7 +1632,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo switchNode.removeFromSupernode() strongSelf.switchNode = nil } - + if let currentCheckNode = currentCheckNode { if currentCheckNode !== strongSelf.checkNode { strongSelf.checkNode = currentCheckNode @@ -1598,9 +1649,9 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo checkNode.removeFromSupernode() strongSelf.checkNode = nil } - + var rightLabelInset: CGFloat = 15.0 + params.rightInset - + if let updatedLabelArrowNode = updatedLabelArrowNode { strongSelf.labelArrowNode = updatedLabelArrowNode strongSelf.containerNode.addSubnode(updatedLabelArrowNode) @@ -1613,7 +1664,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo labelArrowNode.removeFromSupernode() strongSelf.labelArrowNode = nil } - + if let updateBadgeImage = updatedLabelBadgeImage { if strongSelf.labelBadgeNode.supernode == nil { strongSelf.containerNode.insertSubnode(strongSelf.labelBadgeNode, belowSubnode: strongSelf.labelNode.textNode) @@ -1624,19 +1675,19 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo strongSelf.labelBadgeNode.image = nil strongSelf.labelBadgeNode.removeFromSupernode() } - + let badgeWidth = max(badgeDiameter, labelLayout.size.width + 12.0) var labelFrame = CGRect(origin: CGPoint(x: revealOffset + params.width - labelLayout.size.width - rightLabelInset, y: floor((contentSize.height - labelLayout.size.height) / 2.0) + 1.0), size: labelLayout.size) if strongSelf.labelBadgeNode.image != nil { labelFrame.origin.x -= 6.0 } transition.updateFrame(node: strongSelf.labelNode.textNode, frame: labelFrame) - + strongSelf.labelBadgeNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels(labelFrame.midX - badgeWidth * 0.5), y: floorToScreenPixels(labelFrame.midY - badgeDiameter * 0.5)), size: CGSize(width: badgeWidth, height: badgeDiameter)) - + let avatarFrame = CGRect(origin: CGPoint(x: params.leftInset + additionalLeftInset + revealOffset + editingOffset + 15.0, y: floorToScreenPixels((layout.contentSize.height - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize)) transition.updateFrame(node: strongSelf.avatarNode, frame: avatarFrame) - + if item.storyStats != nil { let avatarButton: HighlightTrackingButton if let current = strongSelf.avatarButton { @@ -1652,7 +1703,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo strongSelf.avatarButton = nil avatarButton.removeFromSuperview() } - + if let switchValue = item.switchValue, case .leftCheck = switchValue.style { let leftCheckNode: CheckNode if let current = strongSelf.leftCheckNode { @@ -1673,13 +1724,13 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo leftCheckNode.removeFromSupernode() } } - + if let threadInfo = item.threadInfo { let threadIconSize = floor(avatarSize * 0.9) let threadIconFrame = CGRect(origin: CGPoint(x: avatarFrame.minX + floor((avatarFrame.width - threadIconSize) / 2.0), y: avatarFrame.minY + floor((avatarFrame.height - threadIconSize) / 2.0)), size: CGSize(width: threadIconSize, height: threadIconSize)) - + strongSelf.avatarNode.isHidden = true - + let avatarIconView: ComponentView if let current = strongSelf.avatarIconView { avatarIconView = current @@ -1687,14 +1738,14 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo avatarIconView = ComponentView() strongSelf.avatarIconView = avatarIconView } - + let avatarIconContent: EmojiStatusComponent.Content if let fileId = threadInfo.icon, fileId != 0 { avatarIconContent = .animation(content: .customEmoji(fileId: fileId), size: CGSize(width: 48.0, height: 48.0), placeholderColor: item.presentationData.theme.list.mediaPlaceholderColor, themeColor: item.presentationData.theme.list.itemAccentColor, loopMode: .forever) } else { avatarIconContent = .topic(title: String(threadInfo.title.prefix(1)), color: threadInfo.iconColor, size: threadIconFrame.size) } - + let avatarIconComponent = EmojiStatusComponent( postbox: item.context.engine.account.postbox, energyUsageSettings: item.context.energyUsageSettings, @@ -1713,7 +1764,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo environment: {}, containerSize: threadIconFrame.size ) - + if let avatarIconComponentView = avatarIconView.view { if avatarIconComponentView.superview == nil { strongSelf.containerNode.view.addSubview(avatarIconComponentView) @@ -1751,12 +1802,12 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo overrideImage = .deletedIcon } strongSelf.avatarNode.imageNode.animateFirstTransition = item.animateFirstAvatarTransition - + var clipStyle: AvatarNodeClipStyle = .round if case let .channel(channel) = item.peer, channel.isForumOrMonoForum { clipStyle = .roundedRect } - + strongSelf.avatarNode.setPeer( accountPeerId: item.context.accountPeerId, postbox: item.context.engine.account.postbox, @@ -1769,7 +1820,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo clipStyle: clipStyle, synchronousLoad: synchronousLoad ) - + strongSelf.avatarNode.setStoryStats(storyStats: item.storyStats.flatMap { storyStats in return AvatarNode.StoryStats( totalCount: storyStats.totalCount, @@ -1784,17 +1835,17 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo ), transition: .immediate) } } - + strongSelf.updateRevealOptionsHighlightedBackgroundFrame(strongSelf.highlightedBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: params.width, height: layout.contentSize.height + UIScreenPixel + UIScreenPixel)), transition: transition) - + if let presence = item.presence { strongSelf.peerPresenceManager?.reset(presence: presence) } - + if let shimmering = item.shimmering { strongSelf.avatarNode.isHidden = true strongSelf.titleNode.isHidden = true - + let shimmerNode: LoadingShimmerNode if let current = strongSelf.shimmerNode { shimmerNode = current @@ -1824,14 +1875,14 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo } else if let shimmerNode = strongSelf.shimmerNode { strongSelf.avatarNode.isHidden = false strongSelf.titleNode.isHidden = false - + strongSelf.shimmerNode = nil shimmerNode.removeFromSupernode() } - + if let customAvatarIcon = item.customAvatarIcon { strongSelf.avatarNode.isHidden = true - + let customAvatarIconView: UIImageView if let current = strongSelf.customAvatarIconView { customAvatarIconView = current @@ -1841,31 +1892,31 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo strongSelf.containerNode.view.addSubview(customAvatarIconView) } customAvatarIconView.image = customAvatarIcon - + transition.updateFrame(view: customAvatarIconView, frame: strongSelf.avatarNode.frame) } else if let customAvatarIconView = strongSelf.customAvatarIconView { strongSelf.customAvatarIconView = nil customAvatarIconView.removeFromSuperview() } - + strongSelf.backgroundNode.isHidden = !item.displayDecorations strongSelf.highlightedBackgroundNode.isHidden = !item.displayDecorations || !item.highlightable - + strongSelf.updateLayout(size: layout.contentSize, leftInset: params.leftInset, rightInset: params.rightInset) - + strongSelf.setRevealOptions((left: [], right: peerRevealOptions)) if let revealed = item.editing.revealed { strongSelf.setRevealOptionsOpened(revealed, animated: animated) } - + strongSelf.updateIsHighlighted(transition: transition) } }) } } - + var isHighlighted = false - + var reallyHighlighted: Bool { var reallyHighlighted = self.isHighlighted || self.isRevealOptionsActive if let (item, _, _, _) = self.layoutParams, item.highlighted { @@ -1873,38 +1924,38 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo } return reallyHighlighted } - + func updateIsHighlighted(transition: ContainedViewLayoutTransition) { self.updateRevealOptionsHighlightedBackgroundNode(self.highlightedBackgroundNode, isHighlighted: self.reallyHighlighted, transition: transition, aboveNodes: [self.bottomStripeNode, self.topStripeNode, self.backgroundNode]) } - + override public func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) { super.setHighlighted(highlighted, at: point, animated: animated) - + if let avatarButton = self.avatarButton, avatarButton.bounds.contains(self.view.convert(point, to: avatarButton)) { self.isHighlighted = false } else { self.isHighlighted = highlighted - + self.updateIsHighlighted(transition: (animated && !highlighted) ? .animated(duration: 0.3, curve: .easeInOut) : .immediate) } } - + override public func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) } - + override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) } - + override public func updateRevealOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) { super.updateRevealOffset(offset: offset, transition: transition) - + guard let item = self.layoutParams?.0, let params = self.layoutParams?.1 else { return } - + let leftInset: CGFloat switch item.height { case .generic: @@ -1912,7 +1963,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo case .peerList: leftInset = 65.0 + params.leftInset } - + let editingOffset: CGFloat if let editableControlNode = self.editableControlNode { editingOffset = editableControlNode.bounds.size.width @@ -1922,22 +1973,22 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo } else { editingOffset = 0.0 } - + var titleLeftOffset: CGFloat = 0.0 if let verifiedIconView = self.verifiedIconView { transition.updateFrame(view: verifiedIconView, frame: CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: verifiedIconView.frame.minY), size: verifiedIconView.bounds.size)) titleLeftOffset += verifiedIconView.bounds.size.width + 4.0 } - + transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset + titleLeftOffset, y: self.titleNode.frame.minY), size: self.titleNode.bounds.size)) transition.updateFrame(node: self.statusNode, frame: CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: self.statusNode.frame.minY), size: self.statusNode.bounds.size)) - + if let credibilityIconView = self.credibilityIconView { transition.updateFrame(view: credibilityIconView, frame: CGRect(origin: CGPoint(x: self.titleNode.frame.maxX + 4.0, y: credibilityIconView.frame.minY), size: credibilityIconView.bounds.size)) } - + var rightLabelInset: CGFloat = 15.0 + params.rightInset - + if let labelArrowNode = self.labelArrowNode { if let image = labelArrowNode.image { let labelArrowNodeFrame = CGRect(origin: CGPoint(x: revealOffset + params.width - rightLabelInset - image.size.width + 8.0, y: labelArrowNode.frame.minY), size: image.size) @@ -1945,35 +1996,35 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo rightLabelInset += 19.0 } } - + let badgeDiameter: CGFloat = 20.0 let labelSize = self.labelNode.textNode.frame.size - + let badgeWidth = max(badgeDiameter, labelSize.width + 12.0) var labelFrame = CGRect(origin: CGPoint(x: offset + params.width - self.labelNode.textNode.bounds.size.width - rightLabelInset, y: self.labelNode.textNode.frame.minY), size: self.labelNode.textNode.bounds.size) if self.labelBadgeNode.image != nil { labelFrame.origin.x -= 6.0 } - + transition.updateFrame(node: self.labelNode.textNode, frame: labelFrame) - + transition.updateFrame(node: self.labelBadgeNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels(labelFrame.midX - badgeWidth * 0.5), y: floorToScreenPixels(labelFrame.midY - badgeDiameter * 0.5)), size: CGSize(width: badgeWidth, height: badgeDiameter))) - + let avatarFrame = CGRect(origin: CGPoint(x: revealOffset + editingOffset + params.leftInset + 15.0, y: self.avatarNode.frame.minY), size: self.avatarNode.bounds.size) transition.updateFrame(node: self.avatarNode, frame: avatarFrame) if let avatarButton = self.avatarButton { avatarButton.frame = avatarFrame } - + if let customAvatarIconView = self.customAvatarIconView { transition.updateFrame(view: customAvatarIconView, frame: avatarFrame) } - + if let avatarIconComponentView = self.avatarIconView?.view { let avatarFrame = self.avatarNode.frame let threadIconSize = floor(avatarFrame.width * 0.9) let threadIconFrame = CGRect(origin: CGPoint(x: avatarFrame.minX + floor((avatarFrame.width - threadIconSize) / 2.0), y: avatarFrame.minY + floor((avatarFrame.height - threadIconSize) / 2.0)), size: CGSize(width: threadIconSize, height: threadIconSize)) - + transition.updateFrame(view: avatarIconComponentView, frame: threadIconFrame) } } @@ -1983,23 +2034,23 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo self.updateIsHighlighted(transition: transition) } - + override public func revealOptionsInteractivelyOpened() { if let (item, _, _, _) = self.layoutParams { item.setPeerIdWithRevealedOptions(item.peer.id, nil) } } - + override public func revealOptionsInteractivelyClosed() { if let (item, _, _, _) = self.layoutParams { item.setPeerIdWithRevealedOptions(nil, item.peer.id) } } - + override public func revealOptionSelected(_ option: ItemListRevealOption, animated: Bool) { self.setRevealOptionsOpened(false, animated: true) self.revealOptionsInteractivelyClosed() - + if let (item, _, _, _) = self.layoutParams { if let revealOptions = item.revealOptions { if option.key >= 0 && option.key < Int32(revealOptions.options.count) { @@ -2010,13 +2061,13 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo } } } - + private func toggleUpdated(_ value: Bool) { if let (item, _, _, _) = self.layoutParams { item.toggleUpdated?(value) } } - + override public func headers() -> [ListViewItemHeader]? { if let item = self.layoutParams?.0 { return item.header.flatMap { [$0] } @@ -2024,7 +2075,7 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo return nil } } - + override public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { var rect = rect rect.origin.y += self.insets.top @@ -2033,14 +2084,14 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo shimmerNode.updateAbsoluteRect(rect, within: containerSize) } } - + override public func isReorderable(at point: CGPoint) -> Bool { if let reorderControlNode = self.reorderControlNode, reorderControlNode.frame.contains(point), !self.isDisplayingRevealedOptions { return true } return false } - + @objc private func avatarButtonPressed() { guard let item = self.layoutParams?.0 else { return @@ -2061,9 +2112,9 @@ public final class ItemListPeerItemHeader: ListViewItemHeader { public let strings: PresentationStrings public let actionTitle: String? public let action: (() -> Void)? - + public let height: CGFloat = 28.0 - + public init(theme: PresentationTheme, strings: PresentationStrings, context: AccountContext, text: NSAttributedString, additionalText: String, actionTitle: String? = nil, id: Int64, action: (() -> Void)? = nil) { self.context = context self.text = text @@ -2082,11 +2133,11 @@ public final class ItemListPeerItemHeader: ListViewItemHeader { return false } } - + public func node(synchronousLoad: Bool) -> ListViewItemHeaderNode { return ItemListPeerItemHeaderNode(theme: self.theme, strings: self.strings, context: self.context, text: self.text, additionalText: self.additionalText, actionTitle: self.actionTitle, action: self.action) } - + public func updateNode(_ node: ListViewItemHeaderNode, previous: ListViewItemHeader?, next: ListViewItemHeader?) { (node as? ItemListPeerItemHeaderNode)?.update(text: self.text, additionalText: self.additionalText, actionTitle: self.actionTitle, action: self.action) } @@ -2098,9 +2149,9 @@ public final class ItemListPeerItemHeaderNode: ListViewItemHeaderNode, ItemListH private let context: AccountContext private var actionTitle: String? private var action: (() -> Void)? - + private var validLayout: (size: CGSize, leftInset: CGFloat, rightInset: CGFloat)? - + private let backgroundNode: ASDisplayNode private let snappedBackgroundNode: ASDisplayNode private let separatorNode: ASDisplayNode @@ -2108,29 +2159,29 @@ public final class ItemListPeerItemHeaderNode: ListViewItemHeaderNode, ItemListH private let additionalTextNode: ImmediateTextNode private let actionTextNode: ImmediateTextNode private let actionButton: HighlightableButtonNode - + private var stickDistanceFactor: CGFloat? - + public init(theme: PresentationTheme, strings: PresentationStrings, context: AccountContext, text: NSAttributedString, additionalText: String, actionTitle: String?, action: (() -> Void)?) { self.context = context self.theme = theme self.strings = strings self.actionTitle = actionTitle self.action = action - + self.backgroundNode = ASDisplayNode() self.backgroundNode.backgroundColor = theme.list.blocksBackgroundColor - + self.snappedBackgroundNode = ASDisplayNode() self.snappedBackgroundNode.backgroundColor = theme.rootController.navigationBar.opaqueBackgroundColor self.snappedBackgroundNode.alpha = 0.0 - + self.separatorNode = ASDisplayNode() self.separatorNode.backgroundColor = theme.list.itemBlocksSeparatorColor self.separatorNode.alpha = 0.0 - + let titleFont = Font.regular(13.0) - + self.textNode = ImmediateTextNodeWithEntities() self.textNode.displaysAsynchronously = false self.textNode.maximumNumberOfLines = 1 @@ -2143,22 +2194,22 @@ public final class ItemListPeerItemHeaderNode: ListViewItemHeaderNode, ItemListH attemptSynchronous: true ) self.textNode.visibility = true - + self.additionalTextNode = ImmediateTextNode() self.additionalTextNode.displaysAsynchronously = false self.additionalTextNode.maximumNumberOfLines = 1 self.additionalTextNode.attributedText = NSAttributedString(string: additionalText, font: titleFont, textColor: theme.list.sectionHeaderTextColor) - + self.actionTextNode = ImmediateTextNode() self.actionTextNode.displaysAsynchronously = false self.actionTextNode.maximumNumberOfLines = 1 self.actionTextNode.attributedText = NSAttributedString(string: actionTitle ?? "", font: titleFont, textColor: action == nil ? theme.list.sectionHeaderTextColor : theme.list.itemAccentColor) - + self.actionButton = HighlightableButtonNode() self.actionButton.isUserInteractionEnabled = self.action != nil - + super.init() - + self.addSubnode(self.backgroundNode) self.addSubnode(self.snappedBackgroundNode) self.addSubnode(self.separatorNode) @@ -2166,7 +2217,7 @@ public final class ItemListPeerItemHeaderNode: ListViewItemHeaderNode, ItemListH self.addSubnode(self.additionalTextNode) self.addSubnode(self.actionTextNode) self.addSubnode(self.actionButton) - + self.actionButton.addTarget(self, action: #selector(self.actionButtonPressed), forControlEvents: .touchUpInside) self.actionButton.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { @@ -2180,25 +2231,25 @@ public final class ItemListPeerItemHeaderNode: ListViewItemHeaderNode, ItemListH } } } - + @objc private func actionButtonPressed() { self.action?() } - + public func updateTheme(theme: PresentationTheme) { self.theme = theme - + self.backgroundNode.backgroundColor = theme.list.blocksBackgroundColor self.snappedBackgroundNode.backgroundColor = theme.rootController.navigationBar.opaqueBackgroundColor self.separatorNode.backgroundColor = theme.list.itemBlocksSeparatorColor - + let titleFont = Font.regular(13.0) - + self.textNode.attributedText = NSAttributedString(string: self.textNode.attributedText?.string ?? "", font: titleFont, textColor: theme.list.sectionHeaderTextColor) self.additionalTextNode.attributedText = NSAttributedString(string: self.additionalTextNode.attributedText?.string ?? "", font: titleFont, textColor: theme.list.sectionHeaderTextColor) self.actionTextNode.attributedText = NSAttributedString(string: self.actionTextNode.attributedText?.string ?? "", font: titleFont, textColor: theme.list.sectionHeaderTextColor) } - + public func update(text: NSAttributedString, additionalText: String, actionTitle: String?, action: (() -> Void)?) { self.actionTitle = actionTitle self.action = action @@ -2211,31 +2262,31 @@ public final class ItemListPeerItemHeaderNode: ListViewItemHeaderNode, ItemListH self.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, transition: .immediate) } } - + override public func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) { self.validLayout = (size, leftInset, rightInset) self.backgroundNode.frame = CGRect(origin: CGPoint(), size: size) self.snappedBackgroundNode.frame = CGRect(origin: CGPoint(), size: size) self.separatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: size.height - UIScreenPixel), size: CGSize(width: size.width, height: UIScreenPixel)) - + let sideInset: CGFloat = 15.0 + leftInset - + let actionTextSize = self.actionTextNode.updateLayout(CGSize(width: size.width - sideInset * 2.0, height: size.height)) let additionalTextSize = self.additionalTextNode.updateLayout(CGSize(width: size.width - sideInset * 2.0 - actionTextSize.width - 8.0, height: size.height)) let textSize = self.textNode.updateLayout(CGSize(width: max(1.0, size.width - sideInset * 2.0 - actionTextSize.width - 8.0 - additionalTextSize.width), height: size.height)) - + let textFrame = CGRect(origin: CGPoint(x: sideInset, y: 7.0), size: textSize) self.textNode.frame = textFrame self.additionalTextNode.frame = CGRect(origin: CGPoint(x: textFrame.maxX, y: 7.0), size: additionalTextSize) self.actionTextNode.frame = CGRect(origin: CGPoint(x: size.width - sideInset - actionTextSize.width, y: 7.0), size: actionTextSize) self.actionButton.frame = CGRect(origin: CGPoint(x: size.width - sideInset - actionTextSize.width, y: 0.0), size: CGSize(width: actionTextSize.width, height: size.height)) } - + override public func animateRemoved(duration: Double) { self.alpha = 0.0 self.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, removeOnCompletion: true) } - + override public func updateStickDistanceFactor(_ factor: CGFloat, distance: CGFloat, transition: ContainedViewLayoutTransition) { if self.stickDistanceFactor == factor { return diff --git a/submodules/ItemListUI/Sources/Items/ItemListExpandableSelectionItem.swift b/submodules/ItemListUI/Sources/Items/ItemListExpandableSelectionItem.swift new file mode 100644 index 0000000000..a90570106a --- /dev/null +++ b/submodules/ItemListUI/Sources/Items/ItemListExpandableSelectionItem.swift @@ -0,0 +1,463 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import TelegramPresentationData +import AppBundle +import CheckNode + +// An expandable row for options: single-select radio-style or multi-select checkbox-style. +// The header shows the selected value (single) or a count (multi), and the inline sub-items use +// square checkbox visuals. In multi-select mode the menu stays open after a tap. +public class ItemListExpandableSelectionItem: ListViewItem, ItemListItem { + public enum SelectionMode { + case single + case multiple + } + + public struct Option: Equatable { + public var id: AnyHashable + public var title: String + public var isSelected: Bool + public var index: Int + + public init(id: AnyHashable, title: String, isSelected: Bool, index: Int = 0) { + self.id = id + self.title = title + self.isSelected = isSelected + self.index = index + } + } + + public let presentationData: ItemListPresentationData + public let systemStyle: ItemListSystemStyle + public let title: String + public let options: [Option] + public let mode: SelectionMode + public let isExpanded: Bool + public let sectionId: ItemListSectionId + public let style: ItemListStyle + public let updated: (Option) -> Void + public let toggleExpanded: () -> Void + public let tag: ItemListItemTag? + + public var isAlwaysPlain: Bool { return false } + public let selectable: Bool = true + + public init(presentationData: ItemListPresentationData, systemStyle: ItemListSystemStyle = .legacy, title: String, options: [Option], mode: SelectionMode = .single, isExpanded: Bool, sectionId: ItemListSectionId, style: ItemListStyle, updated: @escaping (Option) -> Void, toggleExpanded: @escaping () -> Void = {}, tag: ItemListItemTag? = nil) { + self.presentationData = presentationData + self.systemStyle = systemStyle + self.title = title + self.options = options + self.mode = mode + self.isExpanded = isExpanded + self.sectionId = sectionId + self.style = style + self.updated = updated + self.toggleExpanded = toggleExpanded + self.tag = tag + } + + public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { + async { + let node = ItemListExpandableSelectionItemNode() + let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + node.contentSize = layout.contentSize + node.insets = layout.insets + Queue.mainQueue().async { + completion(node, { + return (nil, { _ in apply(ListViewItemUpdateAnimation.None) }) + }) + } + } + } + + public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { + Queue.mainQueue().async { + if let nodeValue = node() as? ItemListExpandableSelectionItemNode { + let makeLayout = nodeValue.asyncLayout() + async { + let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + Queue.mainQueue().async { + completion(layout, { _ in + apply(animation) + }) + } + } + } + } + } + + public func selected(listView: ListView) { + listView.clearHighlightAnimated(true) + // Tapping the header row toggles the inline option list open/closed. + self.toggleExpanded() + } +} + +private final class SelectionOptionNode: HighlightTrackingButtonNode { + private let textNode: ImmediateTextNode + private var checkNode: CheckNode? + private let separatorNode: ASDisplayNode + + private var theme: PresentationTheme? + private var option: ItemListExpandableSelectionItem.Option? + private var action: ((ItemListExpandableSelectionItem.Option) -> Void)? + + init() { + self.textNode = ImmediateTextNode() + self.separatorNode = ASDisplayNode() + self.separatorNode.isLayerBacked = true + super.init() + self.addSubnode(self.separatorNode) + self.addSubnode(self.textNode) + self.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside) + } + + @objc private func pressed() { + guard let option = self.option, let action = self.action else { + return + } + action(option) + } + + func update(presentationData: ItemListPresentationData, option: ItemListExpandableSelectionItem.Option, action: @escaping (ItemListExpandableSelectionItem.Option) -> Void, size: CGSize, transition: ContainedViewLayoutTransition) { + let themeUpdated = self.theme !== presentationData.theme + self.option = option + self.action = action + let leftInset: CGFloat = 60.0 + if themeUpdated { + self.separatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor + } + let checkNode: CheckNode + if let current = self.checkNode { + checkNode = current + if themeUpdated { + checkNode.theme = CheckNodeTheme(theme: presentationData.theme, style: .plain) + } + } else { + checkNode = CheckNode(theme: CheckNodeTheme(theme: presentationData.theme, style: .plain), content: .check(isRectangle: true)) + checkNode.isUserInteractionEnabled = false + self.checkNode = checkNode + self.addSubnode(checkNode) + } + let checkSize = CGSize(width: 22.0, height: 22.0) + checkNode.frame = CGRect(origin: CGPoint(x: floor((leftInset - checkSize.width) / 2.0), y: floor((size.height - checkSize.height) / 2.0)), size: checkSize) + checkNode.setSelected(option.isSelected, animated: transition.isAnimated) + transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: leftInset, y: size.height - UIScreenPixel), size: CGSize(width: size.width - leftInset - 16.0, height: UIScreenPixel))) + self.textNode.attributedText = NSAttributedString(string: option.title, font: Font.regular(17.0), textColor: presentationData.theme.list.itemPrimaryTextColor) + let titleSize = self.textNode.updateLayout(CGSize(width: size.width - leftInset, height: 100.0)) + self.textNode.frame = CGRect(origin: CGPoint(x: leftInset, y: floor((size.height - titleSize.height) / 2.0)), size: titleSize) + } +} + +public class ItemListExpandableSelectionItemNode: ListViewItemNode, ItemListItemNode { + private let backgroundNode: ASDisplayNode + private let topStripeNode: ASDisplayNode + private let bottomTopStripeNode: ASDisplayNode + private let bottomStripeNode: ASDisplayNode + private let highlightedBackgroundNode: ASDisplayNode + private let maskNode: ASImageNode + + private let titleNode: TextNode + private let titleValueNode: TextNode + private let expandArrowNode: ASImageNode + private let subItemContainer: ASDisplayNode + private var subItemNodes: [AnyHashable: SelectionOptionNode] = [:] + private let activateArea: AccessibilityAreaNode + + private var item: ItemListExpandableSelectionItem? + + public var tag: ItemListItemTag? { + return self.item?.tag + } + + public init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isLayerBacked = true + self.backgroundNode.backgroundColor = .white + self.maskNode = ASImageNode() + self.maskNode.isUserInteractionEnabled = false + self.topStripeNode = ASDisplayNode() + self.topStripeNode.isLayerBacked = true + self.bottomTopStripeNode = ASDisplayNode() + self.bottomTopStripeNode.isLayerBacked = true + self.bottomStripeNode = ASDisplayNode() + self.bottomStripeNode.isLayerBacked = true + self.titleNode = TextNode() + self.titleNode.isUserInteractionEnabled = false + self.titleValueNode = TextNode() + self.titleValueNode.isUserInteractionEnabled = false + self.expandArrowNode = ASImageNode() + self.expandArrowNode.displaysAsynchronously = false + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.isLayerBacked = true + self.activateArea = AccessibilityAreaNode() + self.subItemContainer = ASDisplayNode() + self.subItemContainer.clipsToBounds = true + super.init(layerBacked: false) + self.addSubnode(self.titleNode) + self.addSubnode(self.titleValueNode) + self.addSubnode(self.expandArrowNode) + self.addSubnode(self.activateArea) + self.addSubnode(self.subItemContainer) + self.activateArea.activate = { [weak self] in + guard let strongSelf = self, let item = strongSelf.item else { + return false + } + item.toggleExpanded() + return true + } + } + + public func asyncLayout() -> (_ item: ItemListExpandableSelectionItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + let makeTitleValueLayout = TextNode.asyncLayout(self.titleValueNode) + let currentItem = self.item + return { item, params, neighbors in + let separatorHeight = UIScreenPixel + let titleFont = Font.regular(item.presentationData.fontSize.itemListBaseFontSize) + var updatedTheme: PresentationTheme? + if currentItem?.presentationData.theme !== item.presentationData.theme { + updatedTheme = item.presentationData.theme + } + let itemBackgroundColor: UIColor + let itemSeparatorColor: UIColor + let separatorRightInset: CGFloat = item.systemStyle == .glass ? 16.0 : 0.0 + var contentSize: CGSize + var insets: UIEdgeInsets + switch item.style { + case .plain: + itemBackgroundColor = item.presentationData.theme.list.plainBackgroundColor + itemSeparatorColor = item.presentationData.theme.list.itemPlainSeparatorColor + contentSize = CGSize(width: params.width, height: item.systemStyle == .glass ? 52.0 : 44.0) + insets = itemListNeighborsPlainInsets(neighbors) + case .blocks: + itemBackgroundColor = item.presentationData.theme.list.itemBlocksBackgroundColor + itemSeparatorColor = item.presentationData.theme.list.itemBlocksSeparatorColor + contentSize = CGSize(width: params.width, height: item.systemStyle == .glass ? 52.0 : 44.0) + insets = itemListNeighborsGroupedInsets(neighbors, params) + } + let leftInset: CGFloat = 16.0 + params.leftInset + let selectedCount = item.options.filter(\.isSelected).count + let titleValue: String + switch item.mode { + case .single: + titleValue = item.options.first(where: { $0.isSelected })?.title ?? "\(selectedCount)/\(item.options.count)" + case .multiple: + titleValue = "\(selectedCount)/\(item.options.count)" + } + let arrowReservedWidth: CGFloat = 30.0 + let valueRightInset = params.rightInset + 16.0 + arrowReservedWidth + let titleConstrainedWidth = params.width - leftInset - valueRightInset - 12.0 + let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.title, font: titleFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: titleConstrainedWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) + let valueConstrainedWidth = max(40.0, params.width - leftInset - valueRightInset - titleLayout.size.width - 12.0) + let (titleValueLayout, titleValueApply) = makeTitleValueLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: titleValue, font: Font.regular(16.0), textColor: item.presentationData.theme.list.itemSecondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: valueConstrainedWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) + let verticalInset: CGFloat = item.systemStyle == .glass ? 15.0 : 11.0 + contentSize.height = max(contentSize.height, titleLayout.size.height + verticalInset * 2.0) + let mainContentHeight = contentSize.height + let optionHeight: CGFloat = item.systemStyle == .glass ? 52.0 : 44.0 + let effectiveSubItemsHeight: CGFloat = item.isExpanded ? CGFloat(item.options.count) * optionHeight : 0.0 + contentSize.height += effectiveSubItemsHeight + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) + let layoutSize = layout.size + return (layout, { [weak self] animation in + guard let strongSelf = self else { + return + } + strongSelf.item = item + let transition: ContainedViewLayoutTransition = animation.transition + strongSelf.activateArea.frame = CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: CGSize(width: params.width - params.leftInset - params.rightInset, height: mainContentHeight)) + strongSelf.activateArea.accessibilityLabel = item.title + strongSelf.activateArea.accessibilityValue = titleValue + if updatedTheme != nil { + strongSelf.topStripeNode.backgroundColor = itemSeparatorColor + strongSelf.bottomTopStripeNode.backgroundColor = itemSeparatorColor + strongSelf.bottomStripeNode.backgroundColor = itemSeparatorColor + strongSelf.backgroundNode.backgroundColor = itemBackgroundColor + strongSelf.highlightedBackgroundNode.backgroundColor = item.presentationData.theme.list.itemHighlightedBackgroundColor + strongSelf.expandArrowNode.image = generateTintedImage(image: UIImage(bundleImageName: "Item List/DisclosureArrow"), color: item.presentationData.theme.list.itemPrimaryTextColor) + } + switch item.style { + case .plain: + if strongSelf.backgroundNode.supernode != nil { + strongSelf.backgroundNode.removeFromSupernode() + } + if strongSelf.topStripeNode.supernode != nil { + strongSelf.topStripeNode.removeFromSupernode() + } + if strongSelf.bottomStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 0) + } + if strongSelf.bottomTopStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomTopStripeNode, at: 1) + } + if strongSelf.maskNode.supernode != nil { + strongSelf.maskNode.removeFromSupernode() + } + strongSelf.bottomTopStripeNode.frame = CGRect(origin: CGPoint(x: leftInset, y: mainContentHeight - separatorHeight), size: CGSize(width: params.width - leftInset, height: separatorHeight)) + strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: leftInset, y: layout.contentSize.height - separatorHeight), size: CGSize(width: params.width - leftInset, height: separatorHeight)) + case .blocks: + if strongSelf.backgroundNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) + } + if strongSelf.topStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1) + } + if strongSelf.bottomStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) + } + if strongSelf.bottomTopStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomTopStripeNode, at: 3) + } + if strongSelf.maskNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.maskNode, aboveSubnode: strongSelf.activateArea) + } + let hasCorners = itemListHasRoundedBlockLayout(params) + var hasTopCorners = false + var hasBottomCorners = false + switch neighbors.top { + case .sameSection(false): + strongSelf.topStripeNode.isHidden = true + default: + hasTopCorners = true + strongSelf.topStripeNode.isHidden = hasCorners + } + let bottomStripeInset: CGFloat + switch neighbors.bottom { + case .sameSection(false): + bottomStripeInset = leftInset + strongSelf.bottomStripeNode.isHidden = false + strongSelf.bottomTopStripeNode.isHidden = false + default: + bottomStripeInset = 0.0 + hasBottomCorners = true + strongSelf.bottomStripeNode.isHidden = hasCorners + strongSelf.bottomTopStripeNode.isHidden = false + } + strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: item.systemStyle == .glass) : nil + let backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) + animation.animator.updateFrame(layer: strongSelf.backgroundNode.layer, frame: backgroundFrame, completion: nil) + animation.animator.updateFrame(layer: strongSelf.maskNode.layer, frame: backgroundFrame.insetBy(dx: params.leftInset, dy: 0.0), completion: nil) + animation.animator.updateFrame(layer: strongSelf.topStripeNode.layer, frame: CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)), completion: nil) + animation.animator.updateFrame(layer: strongSelf.bottomTopStripeNode.layer, frame: CGRect(origin: CGPoint(x: bottomStripeInset, y: mainContentHeight - separatorHeight), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)), completion: nil) + animation.animator.updateFrame(layer: strongSelf.bottomStripeNode.layer, frame: CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height - separatorHeight), size: CGSize(width: layoutSize.width - bottomStripeInset - params.rightInset - separatorRightInset, height: separatorHeight)), completion: nil) + } + let _ = titleApply() + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: floorToScreenPixels((mainContentHeight - titleLayout.size.height) / 2.0)), size: titleLayout.size) + let _ = titleValueApply() + let valueX = params.width - valueRightInset - titleValueLayout.size.width + strongSelf.titleValueNode.frame = CGRect(origin: CGPoint(x: max(strongSelf.titleNode.frame.maxX + 9.0, valueX), y: strongSelf.titleNode.frame.minY + floor((titleLayout.size.height - titleValueLayout.size.height) / 2.0)), size: titleValueLayout.size) + if let image = strongSelf.expandArrowNode.image { + strongSelf.expandArrowNode.position = CGPoint(x: params.width - params.rightInset - 16.0 - image.size.width * 0.4, y: strongSelf.titleValueNode.frame.midY) + let scaleFactor: CGFloat = 0.8 + strongSelf.expandArrowNode.bounds = CGRect(origin: CGPoint(), size: CGSize(width: image.size.width * scaleFactor, height: image.size.height * scaleFactor)) + transition.updateTransformRotation(node: strongSelf.expandArrowNode, angle: item.isExpanded ? CGFloat.pi * -0.5 : CGFloat.pi * 0.5) + } + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: params.width, height: optionHeight + UIScreenPixel + UIScreenPixel)) + animation.animator.updateFrame(layer: strongSelf.subItemContainer.layer, frame: CGRect(origin: CGPoint(x: 0.0, y: mainContentHeight), size: CGSize(width: params.width, height: effectiveSubItemsHeight)), completion: nil) + var validIds: [AnyHashable] = [] + let subItemSize = CGSize(width: params.width - params.leftInset - params.rightInset, height: optionHeight) + var nextSubItemPosition = CGPoint(x: params.leftInset, y: 0.0) + for option in item.options { + validIds.append(option.id) + let subItemNode: SelectionOptionNode + var subItemNodeTransition = animation.transition + if let current = strongSelf.subItemNodes[option.id] { + subItemNode = current + } else { + subItemNodeTransition = .immediate + subItemNode = SelectionOptionNode() + strongSelf.subItemNodes[option.id] = subItemNode + strongSelf.subItemContainer.addSubnode(subItemNode) + } + let subItemFrame = CGRect(origin: nextSubItemPosition, size: subItemSize) + subItemNode.update(presentationData: item.presentationData, option: option, action: { option in + item.updated(option) + if item.mode == .single { + item.toggleExpanded() + } + }, size: subItemSize, transition: subItemNodeTransition) + subItemNodeTransition.updateFrame(node: subItemNode, frame: subItemFrame) + nextSubItemPosition.y += subItemSize.height + } + var removeIds: [AnyHashable] = [] + for (id, itemNode) in strongSelf.subItemNodes { + if !validIds.contains(id) { + removeIds.append(id) + itemNode.removeFromSupernode() + } + } + for id in removeIds { + strongSelf.subItemNodes.removeValue(forKey: id) + } + }) + } + } + + override public func accessibilityActivate() -> Bool { + return false + } + + override public func visibleForSelection(at point: CGPoint) -> Bool { + if !self.canBeSelected { + return false + } + if point.y > self.subItemContainer.frame.minY { + return false + } + return true + } + + override public func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) { + var highlighted = highlighted + if point.y > self.subItemContainer.frame.minY { + highlighted = false + } + super.setHighlighted(highlighted, at: point, animated: animated) + if highlighted { + self.highlightedBackgroundNode.alpha = 1.0 + if self.highlightedBackgroundNode.supernode == nil { + var anchorNode: ASDisplayNode? + if self.bottomStripeNode.supernode != nil { + anchorNode = self.bottomStripeNode + } else if self.topStripeNode.supernode != nil { + anchorNode = self.topStripeNode + } else if self.backgroundNode.supernode != nil { + anchorNode = self.backgroundNode + } + if let anchorNode = anchorNode { + self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: anchorNode) + } else { + self.addSubnode(self.highlightedBackgroundNode) + } + } + } else { + if self.highlightedBackgroundNode.supernode != nil { + if animated { + self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in + if let strongSelf = self, completed { + strongSelf.highlightedBackgroundNode.removeFromSupernode() + } + }) + self.highlightedBackgroundNode.alpha = 0.0 + } else { + self.highlightedBackgroundNode.removeFromSupernode() + } + } + } + } + + override public func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { + self.layer.allowsGroupOpacity = true + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4, completion: { [weak self] _ in + self?.layer.allowsGroupOpacity = false + }) + } + + override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.allowsGroupOpacity = true + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + } +} diff --git a/submodules/ItemListUI/Sources/Items/ItemListExpandableSwitchItem.swift b/submodules/ItemListUI/Sources/Items/ItemListExpandableSwitchItem.swift index 6f95c58d2c..3855d36af8 100644 --- a/submodules/ItemListUI/Sources/Items/ItemListExpandableSwitchItem.swift +++ b/submodules/ItemListUI/Sources/Items/ItemListExpandableSwitchItem.swift @@ -19,7 +19,7 @@ public class ItemListExpandableSwitchItem: ListViewItem, ItemListItem { public var title: String public var isSelected: Bool public var isEnabled: Bool - + public init( id: AnyHashable, title: String, @@ -32,7 +32,7 @@ public class ItemListExpandableSwitchItem: ListViewItem, ItemListItem { self.isEnabled = isEnabled } } - + let presentationData: ItemListPresentationData let systemStyle: ItemListSystemStyle let icon: UIImage? @@ -54,9 +54,9 @@ public class ItemListExpandableSwitchItem: ListViewItem, ItemListItem { let selectAction: () -> Void let subAction: (SubItem) -> Void public let tag: ItemListItemTag? - + public let selectable: Bool = true - + public init(presentationData: ItemListPresentationData, systemStyle: ItemListSystemStyle = .legacy, icon: UIImage? = nil, title: String, value: Bool, isExpanded: Bool, subItems: [SubItem], type: ItemListExpandableSwitchItemNodeType = .regular, enableInteractiveChanges: Bool = true, enabled: Bool = true, displayLocked: Bool = false, disableLeadingInset: Bool = false, maximumNumberOfLines: Int = 1, noCorners: Bool = false, sectionId: ItemListSectionId, style: ItemListStyle, updated: @escaping (Bool) -> Void, activatedWhileDisabled: @escaping () -> Void = {}, selectAction: @escaping () -> Void, subAction: @escaping (SubItem) -> Void, tag: ItemListItemTag? = nil) { self.presentationData = presentationData self.systemStyle = systemStyle @@ -80,15 +80,15 @@ public class ItemListExpandableSwitchItem: ListViewItem, ItemListItem { self.subAction = subAction self.tag = tag } - + public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { async { let node = ItemListExpandableSwitchItemNode(type: self.type) let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) - + node.contentSize = layout.contentSize node.insets = layout.insets - + Queue.mainQueue().async { completion(node, { return (nil, { _ in apply(ListViewItemUpdateAnimation.None) }) @@ -96,18 +96,18 @@ public class ItemListExpandableSwitchItem: ListViewItem, ItemListItem { } } } - + public func selected(listView: ListView) { listView.clearHighlightAnimated(true) - + self.selectAction() } - + public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { Queue.mainQueue().async { if let nodeValue = node() as? ItemListExpandableSwitchItemNode { let makeLayout = nodeValue.asyncLayout() - + async { let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) Queue.mainQueue().async { @@ -125,45 +125,45 @@ private final class SubItemNode: HighlightTrackingButtonNode { private let textNode: ImmediateTextNode private var checkNode: CheckNode? private let separatorNode: ASDisplayNode - + private var theme: PresentationTheme? private var item: ItemListExpandableSwitchItem.SubItem? private var action: ((ItemListExpandableSwitchItem.SubItem) -> Void)? - + init() { self.textNode = ImmediateTextNode() - + self.separatorNode = ASDisplayNode() self.separatorNode.isLayerBacked = true - + super.init() - + self.addSubnode(self.separatorNode) self.addSubnode(self.textNode) - + self.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside) } - + @objc private func pressed() { guard let item = self.item, item.isEnabled, let action = self.action else { return } action(item) } - + func update(presentationData: ItemListPresentationData, item: ItemListExpandableSwitchItem.SubItem, action: @escaping (ItemListExpandableSwitchItem.SubItem) -> Void, size: CGSize, transition: ContainedViewLayoutTransition) { let themeUpdated = self.theme !== presentationData.theme - + self.item = item self.action = action - + let leftInset: CGFloat = 60.0 let separatorRightInset: CGFloat = 16.0 - + if themeUpdated { self.separatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor } - + let checkNode: CheckNode if let current = self.checkNode { checkNode = current @@ -171,23 +171,23 @@ private final class SubItemNode: HighlightTrackingButtonNode { checkNode.theme = CheckNodeTheme(theme: presentationData.theme, style: .plain) } } else { - checkNode = CheckNode(theme: CheckNodeTheme(theme: presentationData.theme, style: .plain)) + checkNode = CheckNode(theme: CheckNodeTheme(theme: presentationData.theme, style: .plain), content: .check(isRectangle: true)) checkNode.isUserInteractionEnabled = false self.checkNode = checkNode self.addSubnode(checkNode) } - + let checkSize = CGSize(width: 22.0, height: 22.0) checkNode.frame = CGRect(origin: CGPoint(x: floor((leftInset - checkSize.width) / 2.0), y: floor((size.height - checkSize.height) / 2.0)), size: checkSize) - + checkNode.setSelected(item.isSelected, animated: transition.isAnimated) - + transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: leftInset, y: size.height - UIScreenPixel), size: CGSize(width: size.width - leftInset - separatorRightInset, height: UIScreenPixel))) - + self.textNode.attributedText = NSAttributedString(string: item.title, font: Font.regular(17.0), textColor: presentationData.theme.list.itemPrimaryTextColor) let titleSize = self.textNode.updateLayout(CGSize(width: size.width - leftInset, height: 100.0)) self.textNode.frame = CGRect(origin: CGPoint(x: leftInset, y: floor((size.height - titleSize.height) / 2.0)), size: titleSize) - + self.alpha = item.isEnabled ? 1.0 : 0.5 } } @@ -199,7 +199,7 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod private let bottomStripeNode: ASDisplayNode private let highlightedBackgroundNode: ASDisplayNode private let maskNode: ASImageNode - + private let iconNode: ASImageNode private let titleNode: TextNode private let titleValueNode: TextNode @@ -207,41 +207,41 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod private var switchNode: ASDisplayNode & ItemListSwitchNodeImpl private let switchGestureNode: ASDisplayNode private var disabledOverlayNode: ASDisplayNode? - + private var lockedIconNode: ASImageNode? - + private let subItemContainer: ASDisplayNode private var subItemNodes: [AnyHashable: SubItemNode] = [:] - + private let activateArea: AccessibilityAreaNode - + private var item: ItemListExpandableSwitchItem? - + public var tag: ItemListItemTag? { return self.item?.tag } - + public init(type: ItemListExpandableSwitchItemNodeType) { self.backgroundNode = ASDisplayNode() self.backgroundNode.isLayerBacked = true self.backgroundNode.backgroundColor = .white - + self.maskNode = ASImageNode() self.maskNode.isUserInteractionEnabled = false - + self.topStripeNode = ASDisplayNode() self.topStripeNode.isLayerBacked = true - + self.bottomTopStripeNode = ASDisplayNode() self.bottomTopStripeNode.isLayerBacked = true - + self.bottomStripeNode = ASDisplayNode() self.bottomStripeNode.isLayerBacked = true - + self.iconNode = ASImageNode() self.iconNode.isLayerBacked = true self.iconNode.displaysAsynchronously = false - + self.titleNode = TextNode() self.titleNode.isUserInteractionEnabled = false switch type { @@ -250,25 +250,25 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod case .icon: self.switchNode = IconSwitchNode() } - + self.titleValueNode = TextNode() self.titleValueNode.isUserInteractionEnabled = false - + self.expandArrowNode = ASImageNode() self.expandArrowNode.displaysAsynchronously = false - + self.highlightedBackgroundNode = ASDisplayNode() self.highlightedBackgroundNode.isLayerBacked = true - + self.switchGestureNode = ASDisplayNode() - + self.activateArea = AccessibilityAreaNode() - + self.subItemContainer = ASDisplayNode() self.subItemContainer.clipsToBounds = true - + super.init(layerBacked: false) - + self.addSubnode(self.titleNode) self.addSubnode(self.titleValueNode) self.addSubnode(self.expandArrowNode) @@ -276,7 +276,7 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod self.addSubnode(self.switchGestureNode) self.addSubnode(self.activateArea) self.addSubnode(self.subItemContainer) - + self.activateArea.activate = { [weak self] in guard let strongSelf = self, let item = strongSelf.item, item.enabled else { return false @@ -289,42 +289,42 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod return true } } - + override public func didLoad() { super.didLoad() - + (self.switchNode.view as? UISwitch)?.addTarget(self, action: #selector(self.switchValueChanged(_:)), for: .valueChanged) self.switchGestureNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) } - + func asyncLayout() -> (_ item: ItemListExpandableSwitchItem, _ params: ListViewItemLayoutParams, _ insets: ItemListNeighbors) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { let makeTitleLayout = TextNode.asyncLayout(self.titleNode) let makeTitleValueLayout = TextNode.asyncLayout(self.titleValueNode) - + let currentItem = self.item var currentDisabledOverlayNode = self.disabledOverlayNode - + return { item, params, neighbors in var contentSize: CGSize var insets: UIEdgeInsets let separatorHeight = UIScreenPixel let separatorRightInset: CGFloat = item.systemStyle == .glass ? 16.0 : 0.0 - + let itemBackgroundColor: UIColor let itemSeparatorColor: UIColor - + let titleFont = Font.regular(item.presentationData.fontSize.itemListBaseFontSize) - + var updatedTheme: PresentationTheme? if currentItem?.presentationData.theme !== item.presentationData.theme { updatedTheme = item.presentationData.theme } - + var updateIcon = false if currentItem?.icon != item.icon { updateIcon = true } - + switch item.style { case .plain: itemBackgroundColor = item.presentationData.theme.list.plainBackgroundColor @@ -337,22 +337,22 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod contentSize = CGSize(width: params.width, height: item.systemStyle == .glass ? 52.0 : 44.0) insets = itemListNeighborsGroupedInsets(neighbors, params) } - + var leftInset = 16.0 + params.leftInset if let _ = item.icon { leftInset += 43.0 } - + if item.disableLeadingInset { insets.top = 0.0 insets.bottom = 0.0 } - + let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.title, font: titleFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor), backgroundColor: nil, maximumNumberOfLines: item.maximumNumberOfLines, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - params.rightInset - 64.0 - 32.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) - + let titleValue = "\(item.subItems.filter(\.isSelected).count)/\(item.subItems.count)" let (titleValueLayout, titleValueApply) = makeTitleValueLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: titleValue, font: Font.bold(14.0), textColor: item.presentationData.theme.list.itemPrimaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - params.rightInset - 64.0 - titleLayout.size.width, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) - + let verticalInset: CGFloat switch item.systemStyle { case .glass: @@ -360,16 +360,16 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod case .legacy: verticalInset = 11.0 } - + contentSize.height = max(contentSize.height, titleLayout.size.height + verticalInset * 2.0) - + let mainContentHeight = contentSize.height var effectiveSubItemsHeight: CGFloat = 0.0 if item.isExpanded { effectiveSubItemsHeight = CGFloat(item.subItems.count) * (item.systemStyle == .glass ? 52.0 : 44.0) } contentSize.height += effectiveSubItemsHeight - + if !item.enabled { if currentDisabledOverlayNode == nil { currentDisabledOverlayNode = ASDisplayNode() @@ -377,16 +377,16 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod } else { currentDisabledOverlayNode = nil } - + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) let layoutSize = layout.size - + return (ListViewItemNodeLayout(contentSize: contentSize, insets: insets), { [weak self] animation in if let strongSelf = self { strongSelf.item = item - + strongSelf.activateArea.frame = CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: CGSize(width: params.width - params.leftInset - params.rightInset, height: mainContentHeight)) - + strongSelf.activateArea.accessibilityLabel = item.title strongSelf.activateArea.accessibilityValue = item.value ? item.presentationData.strings.VoiceOver_Common_On : item.presentationData.strings.VoiceOver_Common_Off strongSelf.activateArea.accessibilityHint = item.presentationData.strings.VoiceOver_Common_SwitchHint @@ -396,7 +396,7 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod accessibilityTraits.insert(.notEnabled) } strongSelf.activateArea.accessibilityTraits = accessibilityTraits - + if let icon = item.icon { if strongSelf.iconNode.supernode == nil { strongSelf.addSubnode(strongSelf.iconNode) @@ -410,9 +410,9 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod strongSelf.iconNode.image = nil strongSelf.iconNode.removeFromSupernode() } - + let transition: ContainedViewLayoutTransition = animation.transition - + if let currentDisabledOverlayNode = currentDisabledOverlayNode { if currentDisabledOverlayNode != strongSelf.disabledOverlayNode { strongSelf.disabledOverlayNode = currentDisabledOverlayNode @@ -430,24 +430,24 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod }) strongSelf.disabledOverlayNode = nil } - + if let _ = updatedTheme { strongSelf.topStripeNode.backgroundColor = itemSeparatorColor strongSelf.bottomTopStripeNode.backgroundColor = itemSeparatorColor strongSelf.bottomStripeNode.backgroundColor = itemSeparatorColor strongSelf.backgroundNode.backgroundColor = itemBackgroundColor - + strongSelf.switchNode.frameColor = item.presentationData.theme.list.itemSwitchColors.frameColor strongSelf.switchNode.contentColor = item.presentationData.theme.list.itemSwitchColors.contentColor strongSelf.switchNode.handleColor = item.presentationData.theme.list.itemSwitchColors.handleColor strongSelf.switchNode.positiveContentColor = item.presentationData.theme.list.itemSwitchColors.positiveColor strongSelf.switchNode.negativeContentColor = item.presentationData.theme.list.itemSwitchColors.negativeColor - + strongSelf.highlightedBackgroundNode.backgroundColor = item.presentationData.theme.list.itemHighlightedBackgroundColor } - + let _ = titleApply() - + switch item.style { case .plain: if strongSelf.backgroundNode.supernode != nil { @@ -483,7 +483,7 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod if strongSelf.maskNode.supernode == nil { strongSelf.insertSubnode(strongSelf.maskNode, aboveSubnode: strongSelf.switchGestureNode) } - + let hasCorners = itemListHasRoundedBlockLayout(params) && !item.noCorners var hasTopCorners = false var hasBottomCorners = false @@ -506,9 +506,9 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod strongSelf.bottomStripeNode.isHidden = hasCorners strongSelf.bottomTopStripeNode.isHidden = false } - + strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: item.systemStyle == .glass) : nil - + let backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) animation.animator.updateFrame(layer: strongSelf.backgroundNode.layer, frame: backgroundFrame, completion: nil) animation.animator.updateFrame(layer: strongSelf.maskNode.layer, frame: backgroundFrame.insetBy(dx: params.leftInset, dy: 0.0), completion: nil) @@ -516,12 +516,12 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod animation.animator.updateFrame(layer: strongSelf.bottomTopStripeNode.layer, frame: CGRect(origin: CGPoint(x: bottomStripeInset, y: mainContentHeight - separatorHeight), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)), completion: nil) animation.animator.updateFrame(layer: strongSelf.bottomStripeNode.layer, frame: CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height - separatorHeight), size: CGSize(width: layoutSize.width - bottomStripeInset - params.rightInset - separatorRightInset, height: separatorHeight)), completion: nil) } - + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: floorToScreenPixels((mainContentHeight - titleLayout.size.height) / 2.0)), size: titleLayout.size) - + let _ = titleValueApply() strongSelf.titleValueNode.frame = CGRect(origin: CGPoint(x: strongSelf.titleNode.frame.maxX + 9.0, y: strongSelf.titleNode.frame.minY + floor((titleLayout.size.height - titleValueLayout.size.height) / 2.0)), size: titleValueLayout.size) - + if let updatedTheme { strongSelf.expandArrowNode.image = generateTintedImage(image: UIImage(bundleImageName: "Item List/DisclosureArrow"), color: updatedTheme.list.itemPrimaryTextColor) } @@ -531,13 +531,13 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod strongSelf.expandArrowNode.bounds = CGRect(origin: CGPoint(), size: CGSize(width: image.size.width * scaleFactor, height: image.size.height * scaleFactor)) transition.updateTransformRotation(node: strongSelf.expandArrowNode, angle: item.isExpanded ? CGFloat.pi * -0.5 : CGFloat.pi * 0.5) } - + if let switchView = strongSelf.switchNode.view as? UISwitch { if strongSelf.switchNode.bounds.size.width.isZero { switchView.sizeToFit() } let switchSize = switchView.bounds.size - + strongSelf.switchNode.frame = CGRect(origin: CGPoint(x: params.width - params.rightInset - switchSize.width - 15.0, y: floor((mainContentHeight - switchSize.height) / 2.0)), size: switchSize) strongSelf.switchGestureNode.frame = strongSelf.switchNode.frame if switchView.isOn != item.value { @@ -546,13 +546,13 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod switchView.isUserInteractionEnabled = item.enableInteractiveChanges } strongSelf.switchGestureNode.isHidden = item.enableInteractiveChanges && item.enabled - + if item.displayLocked { var updateLockedIconImage = false if let _ = updatedTheme { updateLockedIconImage = true } - + let lockedIconNode: ASImageNode if let current = strongSelf.lockedIconNode { lockedIconNode = current @@ -562,13 +562,13 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod strongSelf.lockedIconNode = lockedIconNode strongSelf.insertSubnode(lockedIconNode, aboveSubnode: strongSelf.switchNode) } - + if updateLockedIconImage, let image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Accessory Panels/TextLockIcon"), color: item.presentationData.theme.list.itemSecondaryTextColor) { lockedIconNode.image = image } - + let switchFrame = strongSelf.switchNode.frame - + if let icon = lockedIconNode.image { lockedIconNode.frame = CGRect(origin: CGPoint(x: switchFrame.minX + 10.0 + UIScreenPixel, y: switchFrame.minY + 9.0), size: icon.size) } @@ -576,17 +576,17 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod strongSelf.lockedIconNode = nil lockedIconNode.removeFromSupernode() } - + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: params.width, height: (item.systemStyle == .glass ? 52.0 : 44.0) + UIScreenPixel + UIScreenPixel)) - + animation.animator.updateFrame(layer: strongSelf.subItemContainer.layer, frame: CGRect(origin: CGPoint(x: 0.0, y: mainContentHeight), size: CGSize(width: params.width, height: effectiveSubItemsHeight)), completion: nil) - + var validIds: [AnyHashable] = [] let subItemSize = CGSize(width: params.width - params.leftInset - params.rightInset, height: item.systemStyle == .glass ? 52.0 : 44.0) var nextSubItemPosition = CGPoint(x: params.leftInset, y: 0.0) for subItem in item.subItems { validIds.append(subItem.id) - + let subItemNode: SubItemNode var subItemNodeTransition = transition if let current = strongSelf.subItemNodes[subItem.id] { @@ -600,7 +600,7 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod let subItemFrame = CGRect(origin: nextSubItemPosition, size: subItemSize) subItemNode.update(presentationData: item.presentationData, item: subItem, action: item.subAction, size: subItemSize, transition: subItemNodeTransition) subItemNodeTransition.updateFrame(node: subItemNode, frame: subItemFrame) - + nextSubItemPosition.y += subItemSize.height } var removeIds: [AnyHashable] = [] @@ -617,7 +617,7 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod }) } } - + override public func accessibilityActivate() -> Bool { guard let item = self.item else { return false @@ -634,7 +634,7 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod } return true } - + override public func visibleForSelection(at point: CGPoint) -> Bool { if !self.canBeSelected { return false @@ -642,18 +642,18 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod if point.y > self.subItemContainer.frame.minY { return false } - + return true } - + override public func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) { var highlighted = highlighted if point.y > self.subItemContainer.frame.minY { highlighted = false } - + super.setHighlighted(highlighted, at: point, animated: animated) - + if highlighted { self.highlightedBackgroundNode.alpha = 1.0 if self.highlightedBackgroundNode.supernode == nil { @@ -688,26 +688,26 @@ public class ItemListExpandableSwitchItemNode: ListViewItemNode, ItemListItemNod } } } - + override public func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { self.layer.allowsGroupOpacity = true self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4, completion: { [weak self] _ in self?.layer.allowsGroupOpacity = false }) } - + override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.layer.allowsGroupOpacity = true self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) } - + @objc private func switchValueChanged(_ switchView: UISwitch) { if let item = self.item { let value = switchView.isOn item.updated(value) } } - + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if let item = self.item, let switchView = self.switchNode.view as? UISwitch, case .ended = recognizer.state { if item.enabled { diff --git a/submodules/ItemListUI/Sources/Items/ItemListSwitchItem.swift b/submodules/ItemListUI/Sources/Items/ItemListSwitchItem.swift index 3a05467b15..ec7af3f073 100644 --- a/submodules/ItemListUI/Sources/Items/ItemListSwitchItem.swift +++ b/submodules/ItemListUI/Sources/Items/ItemListSwitchItem.swift @@ -18,7 +18,7 @@ public class ItemListSwitchItem: ListViewItem, ItemListItem { case primary case accent } - + let presentationData: ItemListPresentationData let systemStyle: ItemListSystemStyle let icon: UIImage? @@ -40,7 +40,7 @@ public class ItemListSwitchItem: ListViewItem, ItemListItem { let activatedWhileDisabled: () -> Void let action: (() -> Void)? public let tag: ItemListItemTag? - + public init(presentationData: ItemListPresentationData, systemStyle: ItemListSystemStyle = .legacy, icon: UIImage? = nil, title: String, text: String? = nil, textColor: TextColor = .primary, titleBadgeComponent: AnyComponent? = nil, value: Bool, type: ItemListSwitchItemNodeType = .regular, enableInteractiveChanges: Bool = true, enabled: Bool = true, displayLocked: Bool = false, disableLeadingInset: Bool = false, maximumNumberOfLines: Int = 1, noCorners: Bool = false, sectionId: ItemListSectionId, style: ItemListStyle, updated: @escaping (Bool) -> Void, activatedWhileDisabled: @escaping () -> Void = {}, action: (() -> Void)? = nil, tag: ItemListItemTag? = nil) { self.presentationData = presentationData self.systemStyle = systemStyle @@ -64,15 +64,15 @@ public class ItemListSwitchItem: ListViewItem, ItemListItem { self.action = action self.tag = tag } - + public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { async { let node = ItemListSwitchItemNode(type: self.type) let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) - + node.contentSize = layout.contentSize node.insets = layout.insets - + Queue.mainQueue().async { completion(node, { return (nil, { _ in apply(false) }) @@ -80,12 +80,12 @@ public class ItemListSwitchItem: ListViewItem, ItemListItem { } } } - + public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { Queue.mainQueue().async { if let nodeValue = node() as? ItemListSwitchItemNode { let makeLayout = nodeValue.asyncLayout() - + async { let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) Queue.mainQueue().async { @@ -101,11 +101,11 @@ public class ItemListSwitchItem: ListViewItem, ItemListItem { } } } - + public var selectable: Bool { return self.action != nil && self.enabled } - + public func selected(listView: ListView){ listView.clearHighlightAnimated(true) if self.enabled { @@ -120,7 +120,7 @@ protocol ItemListSwitchNodeImpl { var handleColor: UIColor { get set } var positiveContentColor: UIColor { get set } var negativeContentColor: UIColor { get set } - + var isOn: Bool { get } func setOn(_ value: Bool, animated: Bool) } @@ -130,14 +130,14 @@ extension SwitchNode: ItemListSwitchNodeImpl { get { return .white } set(value) { - + } } var negativeContentColor: UIColor { get { return .white } set(value) { - + } } } @@ -152,22 +152,22 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { private let bottomStripeNode: ASDisplayNode private let highlightedBackgroundNode: ASDisplayNode private let maskNode: ASImageNode - + private let iconNode: ASImageNode private let titleNode: TextNode private var textNode: TextNode? private var switchNode: ASDisplayNode & ItemListSwitchNodeImpl private let switchGestureNode: ASDisplayNode private var disabledOverlayNode: ASDisplayNode? - + private var titleBadgeComponentView: ComponentView? - + private var lockedIconNode: ASImageNode? - + private let activateArea: AccessibilityAreaNode - + private var item: ItemListSwitchItem? - + public var tag: ItemListItemTag? { return self.item?.tag } @@ -175,7 +175,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { public var contextSourceView: UIView { return self.textNode?.view ?? self.switchNode.view } - + public init(type: ItemListSwitchItemNodeType) { self.backgroundNode = ASDisplayNode() self.backgroundNode.isLayerBacked = true @@ -183,45 +183,45 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { self.highlightNode = ASDisplayNode() self.highlightNode.isLayerBacked = true - + self.maskNode = ASImageNode() self.maskNode.isUserInteractionEnabled = false - + self.topStripeNode = ASDisplayNode() self.topStripeNode.isLayerBacked = true - + self.bottomStripeNode = ASDisplayNode() self.bottomStripeNode.isLayerBacked = true - + self.iconNode = ASImageNode() self.iconNode.isLayerBacked = true self.iconNode.displaysAsynchronously = false - + self.titleNode = TextNode() self.titleNode.anchorPoint = CGPoint() self.titleNode.isUserInteractionEnabled = false - + switch type { case .regular: self.switchNode = SwitchNode() case .icon: self.switchNode = IconSwitchNode() } - + self.highlightedBackgroundNode = ASDisplayNode() self.highlightedBackgroundNode.isLayerBacked = true - + self.switchGestureNode = ASDisplayNode() - + self.activateArea = AccessibilityAreaNode() - + super.init(layerBacked: false) - + self.addSubnode(self.titleNode) self.addSubnode(self.switchNode) self.addSubnode(self.switchGestureNode) self.addSubnode(self.activateArea) - + self.activateArea.activate = { [weak self] in guard let strongSelf = self, let item = strongSelf.item, item.enabled else { return false @@ -234,21 +234,21 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { return true } } - + override public func didLoad() { super.didLoad() - + (self.switchNode.view as? UISwitch)?.addTarget(self, action: #selector(self.switchValueChanged(_:)), for: .valueChanged) self.switchGestureNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) } - + public func displayHighlight() { if self.backgroundNode.supernode != nil { self.insertSubnode(self.highlightNode, aboveSubnode: self.backgroundNode) } else { self.insertSubnode(self.highlightNode, at: 0) } - + Queue.mainQueue().after(1.2, { self.highlightNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in self.highlightNode.removeFromSupernode() @@ -269,41 +269,41 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { transition.updateAlpha(node: self.switchNode, alpha: hasContextMenu ? 0.5 : 1.0) } } - + func asyncLayout() -> (_ item: ItemListSwitchItem, _ params: ListViewItemLayoutParams, _ insets: ItemListNeighbors) -> (ListViewItemNodeLayout, (Bool) -> Void) { let makeTitleLayout = TextNode.asyncLayout(self.titleNode) let makeTextLayout = TextNode.asyncLayout(self.textNode) - + let currentItem = self.item var currentDisabledOverlayNode = self.disabledOverlayNode - + return { item, params, neighbors in var contentSize: CGSize var insets: UIEdgeInsets let separatorHeight = UIScreenPixel let separatorRightInset: CGFloat = item.systemStyle == .glass ? 16.0 : 0.0 - + let itemBackgroundColor: UIColor let itemSeparatorColor: UIColor - + let titleFont = Font.regular(item.presentationData.fontSize.itemListBaseFontSize) let textFont = Font.regular(item.presentationData.fontSize.itemListBaseFontSize * 14.0 / 17.0) - + var updatedTheme: PresentationTheme? if currentItem?.presentationData.theme !== item.presentationData.theme { updatedTheme = item.presentationData.theme } - + var updatedValue = false if currentItem?.value != item.value { updatedValue = true } - + var updateIcon = false if currentItem?.icon != item.icon { updateIcon = true } - + switch item.style { case .plain: itemBackgroundColor = item.presentationData.theme.list.plainBackgroundColor @@ -316,9 +316,9 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { contentSize = CGSize(width: params.width, height: 44.0) insets = itemListNeighborsGroupedInsets(neighbors, params) } - - - + + + var topInset: CGFloat if item.text != nil { topInset = 9.0 @@ -329,23 +329,23 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { contentSize.height = 52.0 topInset += 4.0 } - + var leftInset = 16.0 + params.leftInset if let _ = item.icon { leftInset += 43.0 } - + if item.disableLeadingInset { insets.top = 0.0 insets.bottom = 0.0 } - + let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.title, font: titleFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor), backgroundColor: nil, maximumNumberOfLines: item.maximumNumberOfLines, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - params.rightInset - 64.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) - + contentSize.height = max(contentSize.height, titleLayout.size.height + topInset * 2.0) - + var textLayoutAndApply: (TextNodeLayout, () -> TextNode)? - + if let text = item.text { let textColor: UIColor switch item.textColor { @@ -358,7 +358,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { contentSize.height += -1.0 + textLayout.size.height textLayoutAndApply = (textLayout, textApply) } - + if !item.enabled { if currentDisabledOverlayNode == nil { currentDisabledOverlayNode = ASDisplayNode() @@ -366,10 +366,10 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { } else { currentDisabledOverlayNode = nil } - + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) let layoutSize = layout.size - + return (ListViewItemNodeLayout(contentSize: contentSize, insets: insets), { [weak self] animated in if let strongSelf = self { let transition: ContainedViewLayoutTransition @@ -378,11 +378,11 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { } else { transition = .immediate } - + strongSelf.item = item - + strongSelf.activateArea.frame = CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: CGSize(width: params.width - params.leftInset - params.rightInset, height: layout.contentSize.height)) - + strongSelf.activateArea.accessibilityLabel = item.title strongSelf.activateArea.accessibilityValue = item.value ? item.presentationData.strings.VoiceOver_Common_On : item.presentationData.strings.VoiceOver_Common_Off strongSelf.activateArea.accessibilityHint = item.presentationData.strings.VoiceOver_Common_SwitchHint @@ -392,7 +392,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { accessibilityTraits.insert(.notEnabled) } strongSelf.activateArea.accessibilityTraits = accessibilityTraits - + if let icon = item.icon { var iconTransition = transition if strongSelf.iconNode.supernode == nil { @@ -413,7 +413,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { strongSelf.iconNode.image = nil strongSelf.iconNode.removeFromSupernode() } - + if let currentDisabledOverlayNode = currentDisabledOverlayNode { if currentDisabledOverlayNode != strongSelf.disabledOverlayNode { strongSelf.disabledOverlayNode = currentDisabledOverlayNode @@ -431,7 +431,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { }) strongSelf.disabledOverlayNode = nil } - + if let _ = updatedTheme { strongSelf.topStripeNode.backgroundColor = itemSeparatorColor strongSelf.bottomStripeNode.backgroundColor = itemSeparatorColor @@ -444,9 +444,9 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { strongSelf.highlightedBackgroundNode.backgroundColor = item.presentationData.theme.list.itemHighlightedBackgroundColor strongSelf.highlightNode.backgroundColor = item.presentationData.theme.list.itemSearchHighlightColor } - + let _ = titleApply() - + switch item.style { case .plain: if strongSelf.backgroundNode.supernode != nil { @@ -475,7 +475,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { if strongSelf.maskNode.supernode == nil { strongSelf.insertSubnode(strongSelf.maskNode, aboveSubnode: strongSelf.switchGestureNode) } - + let hasCorners = itemListHasRoundedBlockLayout(params) && !item.noCorners var hasTopCorners = false var hasBottomCorners = false @@ -496,16 +496,16 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { hasBottomCorners = true strongSelf.bottomStripeNode.isHidden = hasCorners } - + strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: item.systemStyle == .glass) : nil - + transition.updateFrame(node: strongSelf.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))) transition.updateFrame(node: strongSelf.highlightNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))) transition.updateFrame(node: strongSelf.maskNode, frame: strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0)) transition.updateFrame(node: strongSelf.topStripeNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight))) transition.updateFrame(node: strongSelf.bottomStripeNode, frame: CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height - separatorHeight), size: CGSize(width: layoutSize.width - params.rightInset - bottomStripeInset - separatorRightInset, height: separatorHeight))) } - + var titleOriginY = floorToScreenPixels((contentSize.height - titleLayout.size.height) / 2.0) + 1.0 if textLayoutAndApply != nil { titleOriginY = topInset + 1.0 @@ -513,7 +513,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: titleOriginY), size: titleLayout.size) transition.updatePosition(node: strongSelf.titleNode, position: titleFrame.origin) strongSelf.titleNode.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) - + if let (textLayout, textApply) = textLayoutAndApply { let textNode = textApply() if strongSelf.textNode !== textNode { @@ -521,7 +521,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { strongSelf.textNode = textNode textNode.isUserInteractionEnabled = false strongSelf.addSubnode(textNode) - + if transition.isAnimated { textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } @@ -537,22 +537,29 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { textNode.removeFromSupernode() } } - + if let switchView = strongSelf.switchNode.view as? UISwitch { if strongSelf.switchNode.bounds.size.width.isZero { switchView.sizeToFit() } let switchSize = switchView.bounds.size - - transition.updateFrame(node: strongSelf.switchNode, frame: CGRect(origin: CGPoint(x: params.width - params.rightInset - switchSize.width - 15.0, y: floor((contentSize.height - switchSize.height) / 2.0)), size: switchSize)) - strongSelf.switchGestureNode.frame = strongSelf.switchNode.frame + let switchScale: CGFloat = 1.15 + let scaledSwitchSize = CGSize(width: switchSize.width * switchScale, height: switchSize.height * switchScale) + let switchOrigin = CGPoint( + x: params.width - params.rightInset - scaledSwitchSize.width - 15.0 + (scaledSwitchSize.width - switchSize.width) / 2.0, + y: floor((contentSize.height - scaledSwitchSize.height) / 2.0 + (scaledSwitchSize.height - switchSize.height) / 2.0) + ) + + transition.updateFrame(node: strongSelf.switchNode, frame: CGRect(origin: switchOrigin, size: switchSize)) + strongSelf.switchNode.transform = CATransform3DMakeScale(switchScale, switchScale, 1.0) + strongSelf.switchGestureNode.frame = CGRect(origin: CGPoint(x: params.width - params.rightInset - scaledSwitchSize.width - 15.0, y: floor((contentSize.height - scaledSwitchSize.height) / 2.0)), size: scaledSwitchSize) if switchView.isOn != item.value { switchView.setOn(item.value, animated: animated) } switchView.isUserInteractionEnabled = item.enableInteractiveChanges } strongSelf.switchGestureNode.isHidden = item.enableInteractiveChanges && item.enabled - + if item.displayLocked { var lockedIconTransition = transition var updateLockedIconImage = false @@ -562,7 +569,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { if updatedValue { updateLockedIconImage = true } - + let lockedIconNode: ASImageNode if let current = strongSelf.lockedIconNode { lockedIconNode = current @@ -573,13 +580,13 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { strongSelf.lockedIconNode = lockedIconNode strongSelf.insertSubnode(lockedIconNode, aboveSubnode: strongSelf.switchNode) } - + if updateLockedIconImage, let image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Accessory Panels/TextLockIcon"), color: item.value ? item.presentationData.theme.list.itemSwitchColors.positiveColor : item.presentationData.theme.list.itemSecondaryTextColor) { lockedIconNode.image = image } - + let switchFrame = strongSelf.switchNode.frame - + if let icon = lockedIconNode.image { let iconOrigin: CGPoint switch item.systemStyle { @@ -590,7 +597,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { } else { offset = CGPoint(x: 5.0, y: 1.0) } - + iconOrigin = CGPoint(x: item.value ? switchFrame.maxX - icon.size.width - 16.0 + UIScreenPixel + offset.x : switchFrame.minX + 16.0 - UIScreenPixel - offset.x, y: switchFrame.minY + 8.0 + offset.y) case .legacy: iconOrigin = CGPoint(x: item.value ? switchFrame.maxX - icon.size.width - 11.0 : switchFrame.minX + 11.0, y: switchFrame.minY + 9.0) @@ -601,7 +608,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { strongSelf.lockedIconNode = nil lockedIconNode.removeFromSupernode() } - + if let component = item.titleBadgeComponent { let componentView: ComponentView if let current = strongSelf.titleBadgeComponentView { @@ -610,7 +617,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { componentView = ComponentView() strongSelf.titleBadgeComponentView = componentView } - + let badgeSize = componentView.update( transition: .immediate, component: component, @@ -629,13 +636,13 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { strongSelf.titleBadgeComponentView = nil componentView.view?.removeFromSuperview() } - + transition.updateFrame(node: strongSelf.highlightedBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: params.width, height: layoutSize.height + UIScreenPixel + UIScreenPixel))) } }) } } - + override public func accessibilityActivate() -> Bool { guard let item = self.item else { return false @@ -652,10 +659,10 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { } return true } - + override public func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) { super.setHighlighted(highlighted, at: point, animated: animated) - + if highlighted { self.highlightedBackgroundNode.alpha = 1.0 if self.highlightedBackgroundNode.supernode == nil { @@ -690,26 +697,26 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { } } } - + override public func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { self.layer.allowsGroupOpacity = true self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4, completion: { [weak self] _ in self?.layer.allowsGroupOpacity = false }) } - + override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.layer.allowsGroupOpacity = true self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) } - + @objc private func switchValueChanged(_ switchView: UISwitch) { if let item = self.item { let value = switchView.isOn item.updated(value) } } - + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if let item = self.item, let switchView = self.switchNode.view as? UISwitch, case .ended = recognizer.state { if item.enabled && !item.displayLocked { diff --git a/submodules/MtProtoKit/Sources/MTApiEnvironment.m b/submodules/MtProtoKit/Sources/MTApiEnvironment.m index 36da88228b..82ba8ed37d 100644 --- a/submodules/MtProtoKit/Sources/MTApiEnvironment.m +++ b/submodules/MtProtoKit/Sources/MTApiEnvironment.m @@ -27,7 +27,7 @@ static NSData * _Nullable parseHexString(NSString * _Nonnull hex) { return nil; } } - + return [NSData dataWithBytesNoCopy:bytes length:[hex length]/2 freeWhenDone:YES]; } @@ -36,14 +36,14 @@ static NSString * _Nonnull dataToHexString(NSData * _Nonnull data) { if (dataBuffer == NULL) { return @""; } - + NSUInteger dataLength = [data length]; NSMutableString *hexString = [NSMutableString stringWithCapacity:(dataLength * 2)]; - + for (int i = 0; i < (int)dataLength; ++i) { [hexString appendString:[NSString stringWithFormat:@"%02lx", (unsigned long)dataBuffer[i]]]; } - + return hexString; } @@ -91,7 +91,7 @@ static NSData *base64_decode(NSString *str) { while (finalString.length % 4 != 0) { finalString = [finalString stringByAppendingString:@"="]; } - + hexData = base64_decode(finalString); } if (hexData != nil) { @@ -105,10 +105,10 @@ static NSData *base64_decode(NSString *str) { if (data == nil || data.length < 16) { return nil; } - + uint8_t firstByte = 0; [data getBytes:&firstByte length:1]; - + if (data.length == 16) { return [[MTProxySecretType0 alloc] initWithSecret:data]; } else if (data.length == 17) { @@ -359,7 +359,7 @@ static NSData *base64_decode(NSString *str) { self = [self initWithDeviceModelName:nil]; if (self != nil) { - + } return self; } @@ -380,7 +380,7 @@ static NSData *base64_decode(NSString *str) { NSProcessInfo *pInfo = [NSProcessInfo processInfo]; _systemVersion = [[[pInfo operatingSystemVersionString] componentsSeparatedByString:@" "] objectAtIndex:1]; #endif - + NSString *suffix = @""; #if TARGET_OS_OSX NSString *value = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"SOURCE"]; @@ -388,11 +388,16 @@ NSString *suffix = @""; suffix = [NSString stringWithFormat:@"%@", value]; } #endif - + //SOURCE NSString *versionString = [[NSString alloc] initWithFormat:@"%@ (%@) %@", [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"], [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleVersion"], suffix]; + // WinterGram: session app-version spoof (bridged from settings via UserDefaults, restart-applied). + NSString *wntSpoofAppVersion = [[NSUserDefaults standardUserDefaults] stringForKey:@"wnt_spoofAppVersion"]; + if (wntSpoofAppVersion != nil && wntSpoofAppVersion.length != 0) { + versionString = wntSpoofAppVersion; + } _appVersion = versionString; - + _systemLangCode = [[NSLocale preferredLanguages] objectAtIndex:0]; #if TARGET_OS_OSX _langPack = @"macos"; @@ -400,7 +405,7 @@ NSString *suffix = @""; _langPack = @"ios"; #endif _langPackCode = @""; - + [self _updateApiInitializationHash]; } return self; @@ -412,25 +417,25 @@ NSString *suffix = @""; - (void)setLayer:(NSNumber *)layer { _layer = layer; - + [self _updateApiInitializationHash]; } - (void)setAppVersion:(NSString *)appVersion { _appVersion = appVersion; - + [self _updateApiInitializationHash]; } - (void)setLangPack:(NSString *)langPack { _langPack = langPack; - + [self _updateApiInitializationHash]; } - (void)setLangPackCode:(NSString *)langPackCode { _langPackCode = langPackCode; - + [self _updateApiInitializationHash]; } @@ -438,7 +443,7 @@ NSString *suffix = @""; { #if TARGET_OS_IPHONE NSString *platform = [self platform]; - + if ([platform isEqualToString:@"iPhone1,1"]) return @"iPhone"; if ([platform isEqualToString:@"iPhone1,2"]) @@ -549,7 +554,7 @@ NSString *suffix = @""; return @"iPhone 17 Pro Max"; if ([platform isEqualToString:@"iPhone18,4"]) return @"iPhone Air"; - + if ([platform hasPrefix:@"iPod1"]) return @"iPod touch 1G"; if ([platform hasPrefix:@"iPod2"]) @@ -564,204 +569,204 @@ NSString *suffix = @""; return @"iPod touch 6G"; if ([platform hasPrefix:@"iPod9"]) return @"iPod touch 7G"; - + if ([platform isEqualToString:@"iPad2,5"] || [platform isEqualToString:@"iPad2,6"] || [platform isEqualToString:@"iPad2,7"]) return @"iPad mini"; - + if ([platform hasPrefix:@"iPad2"]) return @"iPad 2G"; - + if ([platform isEqualToString:@"iPad3,1"] || [platform isEqualToString:@"iPad3,2"] || [platform isEqualToString:@"iPad3,3"]) return @"iPad 3G"; - + if ([platform isEqualToString:@"iPad3,4"] || [platform isEqualToString:@"iPad3,5"] || [platform isEqualToString:@"iPad3,6"]) return @"iPad 3G"; - + if ([platform isEqualToString:@"iPad4,1"] || [platform isEqualToString:@"iPad4,2"]) return @"iPad Air"; - + if ([platform isEqualToString:@"iPad4,4"] || [platform isEqualToString:@"iPad4,5"] || [platform isEqualToString:@"iPad4,6"]) return @"iPad mini Retina"; - + if ([platform isEqualToString:@"iPad4,7"] || [platform isEqualToString:@"iPad4,8"] || [platform isEqualToString:@"iPad4,9"]) return @"iPad mini 3"; - + if ([platform isEqualToString:@"iPad5,1"] || [platform isEqualToString:@"iPad5,2"]) return @"iPad mini 4"; - + if ([platform isEqualToString:@"iPad5,3"] || [platform isEqualToString:@"iPad5,4"]) return @"iPad Air 2"; - + if ([platform isEqualToString:@"iPad6,3"] || [platform isEqualToString:@"iPad6,4"]) return @"iPad Pro 9.7 inch"; - + if ([platform isEqualToString:@"iPad6,7"] || [platform isEqualToString:@"iPad6,8"]) return @"iPad Pro 12.9 inch"; - + if ([platform isEqualToString:@"iPad6,11"] || [platform isEqualToString:@"iPad6,12"]) return @"iPad (2017)"; - + if ([platform isEqualToString:@"iPad7,1"] || [platform isEqualToString:@"iPad7,2"]) return @"iPad Pro (2nd gen)"; - + if ([platform isEqualToString:@"iPad7,3"] || [platform isEqualToString:@"iPad7,4"]) return @"iPad Pro 10.5 inch"; - + if ([platform isEqualToString:@"iPad7,5"] || [platform isEqualToString:@"iPad7,6"]) return @"iPad (6th gen)"; - + if ([platform isEqualToString:@"iPad7,11"] || [platform isEqualToString:@"iPad7,12"]) return @"iPad 10.2 inch (7th gen)"; - + if ([platform isEqualToString:@"iPad8,1"] || [platform isEqualToString:@"iPad8,2"] || [platform isEqualToString:@"iPad8,3"] || [platform isEqualToString:@"iPad8,4"]) return @"iPad Pro 11 inch"; - + if ([platform isEqualToString:@"iPad8,5"] || [platform isEqualToString:@"iPad8,6"] || [platform isEqualToString:@"iPad8,7"] || [platform isEqualToString:@"iPad8,8"]) return @"iPad Pro 12.9 inch (3rd gen)"; - + if ([platform isEqualToString:@"iPad8,9"] || [platform isEqualToString:@"iPad8,10"]) return @"iPad Pro 11 inch (2th gen)"; - + if ([platform isEqualToString:@"iPad8,11"] || [platform isEqualToString:@"iPad8,12"]) return @"iPad Pro 12.9 inch (4th gen)"; - + if ([platform isEqualToString:@"iPad11,1"] || [platform isEqualToString:@"iPad11,2"]) return @"iPad mini (5th gen)"; - + if ([platform isEqualToString:@"iPad11,3"] || [platform isEqualToString:@"iPad11,4"]) return @"iPad Air (3rd gen)"; - + if ([platform isEqualToString:@"iPad11,6"] || [platform isEqualToString:@"iPad11,7"]) return @"iPad (8th gen)"; - + if ([platform isEqualToString:@"iPad12,1"] || [platform isEqualToString:@"iPad12,2"]) return @"iPad (9th gen)"; - + if ([platform isEqualToString:@"iPad13,1"] || [platform isEqualToString:@"iPad13,2"]) return @"iPad Air (4th gen)"; - + if ([platform isEqualToString:@"iPad13,4"] || [platform isEqualToString:@"iPad13,5"] || [platform isEqualToString:@"iPad13,6"] || [platform isEqualToString:@"iPad13,7"]) return @"iPad Pro 11 inch (3th gen)"; - + if ([platform isEqualToString:@"iPad13,8"] || [platform isEqualToString:@"iPad13,9"] || [platform isEqualToString:@"iPad13,10"] || [platform isEqualToString:@"iPad13,11"]) return @"iPad Pro 12.9 inch (5th gen)"; - + if ([platform isEqualToString:@"iPad13,16"] || [platform isEqualToString:@"iPad13,17"]) return @"iPad Air (5th gen)"; - + if ([platform isEqualToString:@"iPad13,18"] || [platform isEqualToString:@"iPad13,19"]) return @"iPad (10th gen)"; - + if ([platform isEqualToString:@"iPad14,1"] || [platform isEqualToString:@"iPad14,2"]) return @"iPad mini (6th gen)"; - + if ([platform isEqualToString:@"iPad14,3"] || [platform isEqualToString:@"iPad14,4"]) return @"iPad Pro 11 inch (4th gen)"; - + if ([platform isEqualToString:@"iPad14,5"] || [platform isEqualToString:@"iPad14,6"]) return @"iPad Pro 12.9 inch (6th gen)"; - + if ([platform isEqualToString:@"iPad14,8"] || [platform isEqualToString:@"iPad14,9"]) return @"iPad Air (6th gen)"; - + if ([platform isEqualToString:@"iPad14,10"] || [platform isEqualToString:@"iPad14,11"]) return @"iPad Air (7th gen)"; - + if ([platform isEqualToString:@"iPad15,3"] || [platform isEqualToString:@"iPad15,4"]) return @"iPad Air 11 inch (7th gen)"; - + if ([platform isEqualToString:@"iPad15,5"] || [platform isEqualToString:@"iPad15,6"]) return @"iPad Air 13 inch (7th gen)"; - + if ([platform isEqualToString:@"iPad15,7"] || [platform isEqualToString:@"iPad15,8"]) return @"iPad (11th gen)"; - + if ([platform isEqualToString:@"iPad16,1"] || [platform isEqualToString:@"iPad16,2"]) return @"iPad mini (7th gen)"; - + if ([platform isEqualToString:@"iPad16,3"] || [platform isEqualToString:@"iPad16,4"]) return @"iPad Pro 11 inch (5th gen)"; - + if ([platform isEqualToString:@"iPad16,5"] || [platform isEqualToString:@"iPad16,6"]) return @"iPad Pro 12.9 inch (7th gen)"; - + if ([platform isEqualToString:@"iPad16,8"] || [platform isEqualToString:@"iPad16,9"]) return @"iPad Air 11 inch (8th gen)"; - + if ([platform isEqualToString:@"iPad16,10"] || [platform isEqualToString:@"iPad16,11"]) return @"iPad Air 13 inch (8th gen)"; - + if ([platform hasPrefix:@"iPhone"]) return @"Unknown iPhone"; if ([platform hasPrefix:@"iPod"]) return @"Unknown iPod"; if ([platform hasPrefix:@"iPad"]) return @"Unknown iPad"; - + if ([platform hasSuffix:@"86"] || [platform isEqual:@"x86_64"] || [platform isEqual:@"arm64"]) { return @"iPhone Simulator"; } #else return [self macHWName]; #endif - + return @"Unknown iOS device"; } - + - (NSString *)macHWName { size_t len = 0; sysctlbyname("hw.model", NULL, &len, NULL, 0); @@ -779,12 +784,12 @@ NSString *suffix = @""; { size_t size; sysctlbyname(typeSpecifier, NULL, &size, NULL, 0); - + char *answer = malloc(size); sysctlbyname(typeSpecifier, answer, &size, NULL, 0); - + NSString *results = [NSString stringWithCString:answer encoding: NSUTF8StringEncoding]; - + free(answer); return results; } @@ -796,15 +801,15 @@ NSString *suffix = @""; - (MTApiEnvironment *)withUpdatedLangPackCode:(NSString *)langPackCode { MTApiEnvironment *result = [[MTApiEnvironment alloc] initWithDeviceModelName:_deviceModelName]; - + result.apiId = self.apiId; result.appVersion = self.appVersion; result.layer = self.layer; - + result.langPack = self.langPack; - + result->_langPackCode = langPackCode; - + result.disableUpdates = self.disableUpdates; result.tcpPayloadPrefix = self.tcpPayloadPrefix; result.datacenterAddressOverrides = self.datacenterAddressOverrides; @@ -812,105 +817,105 @@ NSString *suffix = @""; result->_socksProxySettings = self.socksProxySettings; result->_networkSettings = self.networkSettings; result->_systemCode = self.systemCode; - + [result _updateApiInitializationHash]; - + return result; } - (instancetype)copyWithZone:(NSZone *)__unused zone { MTApiEnvironment *result = [[MTApiEnvironment alloc] initWithDeviceModelName:_deviceModelName]; - + result.apiId = self.apiId; result.appVersion = self.appVersion; result.layer = self.layer; - + result.langPack = self.langPack; - + result->_langPackCode = self.langPackCode; result->_socksProxySettings = self.socksProxySettings; result->_networkSettings = self.networkSettings; result->_systemCode = self.systemCode; - + result.disableUpdates = self.disableUpdates; result.tcpPayloadPrefix = self.tcpPayloadPrefix; result.datacenterAddressOverrides = self.datacenterAddressOverrides; result.accessHostOverride = self.accessHostOverride; - + [result _updateApiInitializationHash]; - + return result; } - (MTApiEnvironment *)withUpdatedSocksProxySettings:(MTSocksProxySettings *)socksProxySettings { MTApiEnvironment *result = [[MTApiEnvironment alloc] initWithDeviceModelName:_deviceModelName]; - + result.apiId = self.apiId; result.appVersion = self.appVersion; result.layer = self.layer; - + result.langPack = self.langPack; - + result->_langPackCode = self.langPackCode; result->_socksProxySettings = socksProxySettings; result->_networkSettings = self.networkSettings; result->_systemCode = self.systemCode; - + result.disableUpdates = self.disableUpdates; result.tcpPayloadPrefix = self.tcpPayloadPrefix; result.datacenterAddressOverrides = self.datacenterAddressOverrides; result.accessHostOverride = self.accessHostOverride; - + [result _updateApiInitializationHash]; - + return result; } - (MTApiEnvironment *)withUpdatedNetworkSettings:(MTNetworkSettings *)networkSettings { MTApiEnvironment *result = [[MTApiEnvironment alloc] initWithDeviceModelName:_deviceModelName]; - + result.apiId = self.apiId; result.appVersion = self.appVersion; result.layer = self.layer; - + result.langPack = self.langPack; - + result->_langPackCode = self.langPackCode; result->_socksProxySettings = self.socksProxySettings; result->_networkSettings = networkSettings; result->_systemCode = self.systemCode; - + result.disableUpdates = self.disableUpdates; result.tcpPayloadPrefix = self.tcpPayloadPrefix; result.datacenterAddressOverrides = self.datacenterAddressOverrides; result.accessHostOverride = self.accessHostOverride; - + [result _updateApiInitializationHash]; - + return result; } - (MTApiEnvironment *)withUpdatedSystemCode:(NSData *)systemCode { MTApiEnvironment *result = [[MTApiEnvironment alloc] initWithDeviceModelName:_deviceModelName]; - + result.apiId = self.apiId; result.appVersion = self.appVersion; result.layer = self.layer; - + result.langPack = self.langPack; - + result->_langPackCode = self.langPackCode; result->_socksProxySettings = self.socksProxySettings; result->_networkSettings = self.networkSettings; result->_systemCode = systemCode; - + result.disableUpdates = self.disableUpdates; result.tcpPayloadPrefix = self.tcpPayloadPrefix; result.datacenterAddressOverrides = self.datacenterAddressOverrides; result.accessHostOverride = self.accessHostOverride; - + [result _updateApiInitializationHash]; - + return result; } diff --git a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift index e825015ca7..999fa056f5 100644 --- a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift @@ -342,7 +342,7 @@ public enum PremiumSource: Equatable { } } } - + case settings case stickers case reactions @@ -392,7 +392,7 @@ public enum PremiumSource: Equatable { case aiTools case auth(String, Int32) case premiumGift(TelegramMediaFile) - + var identifier: String? { switch self { case .settings: @@ -526,7 +526,7 @@ public enum PremiumPerk: CaseIterable { case todo case copyProtection case aiTools - + case businessLocation case businessHours case businessGreetingMessage @@ -535,7 +535,7 @@ public enum PremiumPerk: CaseIterable { case businessChatBots case businessIntro case businessLinks - + public static var allCases: [PremiumPerk] { return [ .doubleLimits, @@ -566,7 +566,7 @@ public enum PremiumPerk: CaseIterable { .aiTools ] } - + public static var allBusinessCases: [PremiumPerk] { return [ .businessLocation, @@ -579,8 +579,8 @@ public enum PremiumPerk: CaseIterable { .businessChatBots ] } - - + + init?(identifier: String, business: Bool) { for perk in business ? PremiumPerk.allBusinessCases : PremiumPerk.allCases { if perk.identifier == identifier { @@ -590,7 +590,7 @@ public enum PremiumPerk: CaseIterable { } return nil } - + var identifier: String { switch self { case .doubleLimits: @@ -663,7 +663,7 @@ public enum PremiumPerk: CaseIterable { return "business_links" } } - + func title(strings: PresentationStrings) -> String { switch self { case .doubleLimits: @@ -736,7 +736,7 @@ public enum PremiumPerk: CaseIterable { return strings.Business_Links } } - + func subtitle(strings: PresentationStrings) -> String { switch self { case .doubleLimits: @@ -809,7 +809,7 @@ public enum PremiumPerk: CaseIterable { return strings.Business_LinksInfo } } - + var iconName: String { switch self { case .doubleLimits: @@ -923,15 +923,15 @@ struct PremiumIntroConfiguration { .businessChatBots ]) } - + let perks: [PremiumPerk] let businessPerks: [PremiumPerk] - + fileprivate init(perks: [PremiumPerk], businessPerks: [PremiumPerk]) { self.perks = perks self.businessPerks = businessPerks } - + public static func with(appConfiguration: AppConfiguration) -> PremiumIntroConfiguration { if let data = appConfiguration.data, let values = data["premium_promo_order"] as? [String] { var perks: [PremiumPerk] = [] @@ -951,13 +951,13 @@ struct PremiumIntroConfiguration { if perks.count < 4 { perks = PremiumIntroConfiguration.defaultValue.perks } - + #if DEBUG if !perks.contains(.aiTools) { perks.insert(.aiTools, at: 1) } #endif - + var businessPerks: [PremiumPerk] = [] if let values = data["business_promo_order"] as? [String] { for value in values { @@ -974,7 +974,7 @@ struct PremiumIntroConfiguration { if businessPerks.count < 4 { businessPerks = PremiumIntroConfiguration.defaultValue.businessPerks } - + return PremiumIntroConfiguration(perks: perks, businessPerks: businessPerks) } else { return .defaultValue @@ -985,15 +985,15 @@ struct PremiumIntroConfiguration { private struct PremiumProduct: Equatable { let option: PremiumPromoConfiguration.PremiumProductOption let storeProduct: InAppPurchaseManager.Product? - + var id: String { return self.storeProduct?.id ?? self.option.botUrl } - + var months: Int32 { return self.option.months } - + var price: String { if let storeProduct = self.storeProduct { return storeProduct.price @@ -1001,7 +1001,7 @@ private struct PremiumProduct: Equatable { return formatCurrencyAmount(self.option.amount, currency: self.option.currency) } } - + var pricePerMonth: String { if let storeProduct = self.storeProduct { return storeProduct.pricePerMonth(Int(self.months)) @@ -1009,7 +1009,7 @@ private struct PremiumProduct: Equatable { return formatCurrencyAmount(self.option.amount / Int64(self.months), currency: self.option.currency) } } - + var priceCurrencyAndAmount: (currency: String, amount: Int64) { if let priceCurrencyAndAmount = self.storeProduct?.priceCurrencyAndAmount { return priceCurrencyAndAmount @@ -1017,7 +1017,7 @@ private struct PremiumProduct: Equatable { return (self.option.currency, self.option.amount) } } - + var priceValue: NSDecimalNumber { if let priceValue = self.storeProduct?.priceValue { return priceValue @@ -1025,15 +1025,15 @@ private struct PremiumProduct: Equatable { return self.optionPriceValue } } - + var optionPriceValue: NSDecimalNumber { return currencyToFractionalAmount(value: self.option.amount, currency: self.option.currency).flatMap { NSDecimalNumber(floatLiteral: $0) } ?? 0.0 } - + var isCurrent: Bool { return self.option.isCurrent } - + var transactionId: String? { return self.option.transactionId } @@ -1042,7 +1042,7 @@ private struct PremiumProduct: Equatable { final class PerkIconComponent: CombinedComponent { let backgroundColor: UIColor let iconName: String - + init( backgroundColor: UIColor, iconName: String @@ -1050,7 +1050,7 @@ final class PerkIconComponent: CombinedComponent { self.backgroundColor = backgroundColor self.iconName = iconName } - + static func ==(lhs: PerkIconComponent, rhs: PerkIconComponent) -> Bool { if lhs.backgroundColor != rhs.backgroundColor { return false @@ -1060,7 +1060,7 @@ final class PerkIconComponent: CombinedComponent { } return true } - + static var body: Body { let image = Child(Image.self) return { context in @@ -1087,14 +1087,14 @@ final class SectionGroupComponent: Component { public let accessibilityLabel: String public let isEnabled: Bool public let action: () -> Void - + public init(_ content: AnyComponentWithIdentity, accessibilityLabel: String, isEnabled: Bool = true, action: @escaping () -> Void) { self.content = content self.accessibilityLabel = accessibilityLabel self.isEnabled = isEnabled self.action = action } - + public static func ==(lhs: Item, rhs: Item) -> Bool { if lhs.content != rhs.content { return false @@ -1108,12 +1108,12 @@ final class SectionGroupComponent: Component { return true } } - + public let items: [Item] public let backgroundColor: UIColor public let selectionColor: UIColor public let separatorColor: UIColor - + public init( items: [Item], backgroundColor: UIColor, @@ -1125,7 +1125,7 @@ final class SectionGroupComponent: Component { self.selectionColor = selectionColor self.separatorColor = separatorColor } - + public static func ==(lhs: SectionGroupComponent, rhs: SectionGroupComponent) -> Bool { if lhs.items != rhs.items { return false @@ -1141,49 +1141,49 @@ final class SectionGroupComponent: Component { } return true } - + public final class View: UIView { private var buttonViews: [AnyHashable: HighlightTrackingButton] = [:] private var itemViews: [AnyHashable: ComponentHostView] = [:] private var separatorViews: [UIView] = [] - + private var component: SectionGroupComponent? - + override init(frame: CGRect) { super.init(frame: frame) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + @objc private func buttonPressed(_ sender: HighlightTrackingButton) { guard let component = self.component else { return } - + if let (id, _) = self.buttonViews.first(where: { $0.value === sender }), let item = component.items.first(where: { $0.content.id == id }) { item.action() } } - + func update(component: SectionGroupComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let sideInset: CGFloat = 16.0 - + self.backgroundColor = component.backgroundColor - + var size = CGSize(width: availableSize.width, height: 0.0) - + var validIds: [AnyHashable] = [] - + var i = 0 for item in component.items { validIds.append(item.content.id) - + let buttonView: HighlightTrackingButton let itemView: ComponentHostView var itemTransition = transition - + if let current = self.buttonViews[item.content.id] { buttonView = current } else { @@ -1195,7 +1195,7 @@ final class SectionGroupComponent: Component { self.addSubview(buttonView) } buttonView.accessibilityLabel = item.accessibilityLabel - + if let current = self.itemViews[item.content.id] { itemView = current } else { @@ -1212,12 +1212,12 @@ final class SectionGroupComponent: Component { ) buttonView.isEnabled = item.isEnabled itemView.alpha = item.isEnabled ? 1.0 : 0.3 - + let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height), size: itemSize) buttonView.frame = CGRect(origin: itemFrame.origin, size: CGSize(width: availableSize.width, height: itemSize.height + UIScreenPixel)) itemView.frame = CGRect(origin: CGPoint(x: itemFrame.minX + sideInset, y: itemFrame.minY + floor((itemFrame.height - itemSize.height) / 2.0)), size: itemSize) itemView.isUserInteractionEnabled = false - + buttonView.highligthedChanged = { [weak buttonView] highlighted in if highlighted { buttonView?.backgroundColor = component.selectionColor @@ -1227,9 +1227,9 @@ final class SectionGroupComponent: Component { }) } } - + size.height += itemSize.height - + if i != component.items.count - 1 { let separatorView: UIView if self.separatorViews.count > i { @@ -1240,12 +1240,12 @@ final class SectionGroupComponent: Component { self.addSubview(separatorView) } separatorView.backgroundColor = component.separatorColor - + separatorView.frame = CGRect(origin: CGPoint(x: itemFrame.minX + sideInset * 2.0 + 30.0, y: itemFrame.maxY), size: CGSize(width: size.width - sideInset * 2.0 - 30.0, height: UIScreenPixel)) } i += 1 } - + var removeIds: [AnyHashable] = [] for (id, itemView) in self.itemViews { if !validIds.contains(id) { @@ -1256,24 +1256,24 @@ final class SectionGroupComponent: Component { for id in removeIds { self.itemViews.removeValue(forKey: id) } - + if !self.separatorViews.isEmpty, self.separatorViews.count > component.items.count - 1 { for i in (component.items.count - 1) ..< self.separatorViews.count { self.separatorViews[i].removeFromSuperview() } self.separatorViews.removeSubrange((component.items.count - 1) ..< self.separatorViews.count) } - + self.component = component - + return size } } - + public func makeView() -> View { return View(frame: CGRect()) } - + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } @@ -1290,7 +1290,7 @@ final class PerkComponent: CombinedComponent { let accentColor: UIColor let displayArrow: Bool let badge: String? - + init( iconName: String, iconBackgroundColors: [UIColor], @@ -1314,7 +1314,7 @@ final class PerkComponent: CombinedComponent { self.displayArrow = displayArrow self.badge = badge } - + static func ==(lhs: PerkComponent, rhs: PerkComponent) -> Bool { if lhs.iconName != rhs.iconName { return false @@ -1348,7 +1348,7 @@ final class PerkComponent: CombinedComponent { } return true } - + static var body: Body { let iconBackground = Child(RoundedRectangle.self) let icon = Child(BundleIconComponent.self) @@ -1360,14 +1360,14 @@ final class PerkComponent: CombinedComponent { return { context in let component = context.component - + let sideInset: CGFloat = 16.0 let iconTopInset: CGFloat = 15.0 let textTopInset: CGFloat = 9.0 let textBottomInset: CGFloat = 9.0 let spacing: CGFloat = 2.0 let iconSize = CGSize(width: 30.0, height: 30.0) - + let iconBackground = iconBackground.update( component: RoundedRectangle( colors: component.iconBackgroundColors, @@ -1376,7 +1376,7 @@ final class PerkComponent: CombinedComponent { availableSize: iconSize, transition: context.transition ) - + let icon = icon.update( component: BundleIconComponent( name: component.iconName, @@ -1385,7 +1385,7 @@ final class PerkComponent: CombinedComponent { availableSize: iconSize, transition: context.transition ) - + let title = title.update( component: MultilineTextComponent( text: .plain( @@ -1401,7 +1401,7 @@ final class PerkComponent: CombinedComponent { availableSize: CGSize(width: context.availableSize.width - iconBackground.size.width - sideInset * 2.83, height: context.availableSize.height), transition: context.transition ) - + let subtitle = subtitle.update( component: MultilineTextComponent( text: .plain( @@ -1417,27 +1417,27 @@ final class PerkComponent: CombinedComponent { availableSize: CGSize(width: context.availableSize.width - iconBackground.size.width - sideInset * 2.83, height: context.availableSize.height), transition: context.transition ) - + let iconPosition = CGPoint(x: iconBackground.size.width / 2.0, y: iconTopInset + iconBackground.size.height / 2.0) context.add(iconBackground .position(iconPosition) ) - + context.add(icon .position(iconPosition) ) - + context.add(title .position(CGPoint(x: iconBackground.size.width + sideInset + title.size.width / 2.0, y: textTopInset + title.size.height / 2.0)) ) - + if let badge = component.badge { let badgeText = badgeText.update( component: MultilineTextComponent(text: .plain(NSAttributedString(string: badge, font: Font.semibold(11.0), textColor: .white))), availableSize: context.availableSize, transition: context.transition ) - + let badgeWidth = badgeText.size.width + 7.0 let badgeBackground = badgeBackground.update( component: RoundedRectangle( @@ -1447,22 +1447,22 @@ final class PerkComponent: CombinedComponent { availableSize: CGSize(width: badgeWidth, height: 16.0), transition: context.transition ) - + context.add(badgeBackground .position(CGPoint(x: iconBackground.size.width + sideInset + title.size.width + badgeWidth / 2.0 + 8.0, y: textTopInset + title.size.height / 2.0 - 1.0)) ) - + context.add(badgeText .position(CGPoint(x: iconBackground.size.width + sideInset + title.size.width + badgeWidth / 2.0 + 8.0, y: textTopInset + title.size.height / 2.0 - 1.0)) ) } - + context.add(subtitle .position(CGPoint(x: iconBackground.size.width + sideInset + subtitle.size.width / 2.0, y: textTopInset + title.size.height + spacing + subtitle.size.height / 2.0)) ) - + let size = CGSize(width: context.availableSize.width, height: textTopInset + title.size.height + spacing + subtitle.size.height + textBottomInset) - + if component.displayArrow { let arrow = arrow.update( component: BundleIconComponent( @@ -1476,7 +1476,7 @@ final class PerkComponent: CombinedComponent { .position(CGPoint(x: context.availableSize.width - 7.0 - arrow.size.width / 2.0, y: size.height / 2.0)) ) } - + return size } } @@ -1485,7 +1485,7 @@ final class PerkComponent: CombinedComponent { private final class PremiumIntroScreenContentComponent: CombinedComponent { typealias EnvironmentType = (ViewControllerComponentContainer.Environment, ScrollChildEnvironment) - + let screenContext: PremiumIntroScreen.ScreenContext let mode: PremiumIntroScreen.Mode let source: PremiumSource @@ -1504,7 +1504,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { let updateIsFocused: (Bool) -> Void let copyLink: (String) -> Void let shareLink: (String) -> Void - + init( screenContext: PremiumIntroScreen.ScreenContext, mode: PremiumIntroScreen.Mode, @@ -1544,7 +1544,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { self.copyLink = copyLink self.shareLink = shareLink } - + static func ==(lhs: PremiumIntroScreenContentComponent, rhs: PremiumIntroScreenContentComponent) -> Bool { if lhs.source != rhs.source { return false @@ -1573,44 +1573,44 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { if lhs.promoConfiguration != rhs.promoConfiguration { return false } - + return true } - + final class State: ComponentState { private let screenContext: PremiumIntroScreen.ScreenContext private let present: (ViewController) -> Void - + var products: [PremiumProduct]? var selectedProductId: String? var validPurchases: [InAppPurchaseManager.ReceiptPurchase] = [] - + var newPerks: [String] = [] - + var isPremium: Bool? var peer: EnginePeer? var adsEnabled = false - + private var disposable: Disposable? private(set) var configuration = PremiumIntroConfiguration.defaultValue - + private var stickersDisposable: Disposable? private var newPerksDisposable: Disposable? private var preloadDisposableSet = DisposableSet() private var adsEnabledDisposable: Disposable? - + var price: String? { return self.products?.first(where: { $0.id == self.selectedProductId })?.price } - + var isAnnual: Bool { return self.products?.first(where: { $0.id == self.selectedProductId })?.id.hasSuffix(".annual") ?? false } - + var isBiannual: Bool { return self.products?.first(where: { $0.id == self.selectedProductId })?.months == 24 } - + var canUpgrade: Bool { if let products = self.products, let current = products.first(where: { $0.isCurrent }), let transactionId = current.transactionId { if self.validPurchases.contains(where: { $0.transactionId == transactionId }) { @@ -1622,9 +1622,9 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { return false } } - + var cachedChevronImage: (UIImage, PresentationTheme)? - + init( screenContext: PremiumIntroScreen.ScreenContext, source: PremiumSource, @@ -1632,11 +1632,11 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { ) { self.screenContext = screenContext self.present = present - + super.init() - + self.newPerks = [PremiumPerk.aiTools.identifier, PremiumPerk.copyProtection.identifier] - + let premiumIntroConfiguration: Signal let accountPeer: Signal switch screenContext { @@ -1650,7 +1650,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { premiumIntroConfiguration = .single(PremiumIntroConfiguration.defaultValue) accountPeer = .single(nil) } - + self.disposable = combineLatest( queue: Queue.mainQueue(), premiumIntroConfiguration, @@ -1660,15 +1660,15 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { return } let isFirstTime = self.peer == nil - + self.configuration = premiumIntroConfiguration self.peer = accountPeer self.updated(transition: .immediate) - + if let identifier = source.identifier, isFirstTime { var jsonString: String = "{" jsonString += "\"source\": \"\(identifier)\"," - + jsonString += "\"data\": {\"premium_promo_order\":[" var isFirst = true for perk in premiumIntroConfiguration.perks { @@ -1679,16 +1679,16 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { jsonString += "\"\(perk.identifier)\"" } jsonString += "]}}" - + if let context = screenContext.context, let data = jsonString.data(using: .utf8), let json = JSON(data: data) { context.engine.accountData.addAppLogEvent(type: "premium.promo_screen_show", data: json) } } }) - + if let context = screenContext.context { let _ = updatePremiumPromoConfigurationOnce(account: context.account).start() - + let stickersKey: PostboxViewKey = .orderedItemList(id: Namespaces.OrderedItemList.CloudPremiumStickers) self.stickersDisposable = (context.account.postbox.combinedView(keys: [stickersKey]) |> deliverOnMainQueue).start(next: { [weak self] views in @@ -1707,7 +1707,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { } } }) - + self.adsEnabledDisposable = (context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.AdsEnabled(id: context.account.peerId)) |> deliverOnMainQueue).start(next: { [weak self] adsEnabled in guard let self else { @@ -1718,7 +1718,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { }) } } - + deinit { self.disposable?.dispose() self.preloadDisposableSet.dispose() @@ -1726,9 +1726,9 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { self.newPerksDisposable?.dispose() self.adsEnabledDisposable?.dispose() } - + private var updatedPeerStatus: PeerEmojiStatus? - + private weak var emojiStatusSelectionController: ViewController? private var previousEmojiSetupTimestamp: Double? func openEmojiSetup(sourceView: UIView, currentFileId: Int64?, color: UIColor?) { @@ -1740,13 +1740,13 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { return } self.previousEmojiSetupTimestamp = currentTimestamp - + self.emojiStatusSelectionController?.dismiss() var selectedItems = Set() if let currentFileId { selectedItems.insert(MediaId(namespace: Namespaces.Media.CloudFile, id: currentFileId)) } - + let controller = EmojiStatusSelectionController( context: context, mode: .statusSelection, @@ -1779,11 +1779,11 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { self.present(controller) } } - + func makeState() -> State { return State(screenContext: self.screenContext, source: self.source, present: self.present) } - + static var body: Body { let overscroll = Child(Rectangle.self) let fade = Child(RoundedRectangle.self) @@ -1799,10 +1799,10 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { let infoTitle = Child(MultilineTextComponent.self) let infoText = Child(MultilineTextComponent.self) let termsText = Child(MultilineTextComponent.self) - + return { context in let sideInset: CGFloat = 16.0 - + let scrollEnvironment = context.environment[ScrollChildEnvironment.self].value let environment = context.environment[ViewControllerComponentContainer.Environment.self].value let state = context.state @@ -1810,21 +1810,21 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { state.selectedProductId = context.component.selectedProductId state.validPurchases = context.component.validPurchases state.isPremium = context.component.isPremium - + let theme = environment.theme let strings = environment.strings let presentationData = context.component.screenContext.presentationData - + let availableWidth = context.availableSize.width let sideInsets = sideInset * 2.0 + environment.safeInsets.left + environment.safeInsets.right var size = CGSize(width: context.availableSize.width, height: 0.0) - + var topBackgroundColor = theme.list.plainBackgroundColor let bottomBackgroundColor = theme.list.blocksBackgroundColor if theme.overallDarkAppearance { topBackgroundColor = bottomBackgroundColor } - + let overscroll = overscroll.update( component: Rectangle(color: topBackgroundColor), availableSize: CGSize(width: context.availableSize.width, height: 1000), @@ -1833,7 +1833,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { context.add(overscroll .position(CGPoint(x: overscroll.size.width / 2.0, y: -overscroll.size.height / 2.0)) ) - + let fade = fade.update( component: RoundedRectangle( colors: [ @@ -1849,16 +1849,16 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { context.add(fade .position(CGPoint(x: fade.size.width / 2.0, y: fade.size.height / 2.0)) ) - + size.height += 183.0 + 10.0 + environment.navigationHeight - 56.0 - + let textColor = theme.list.itemPrimaryTextColor let accentColor = theme.list.itemAccentColor let subtitleColor = theme.list.itemSecondaryTextColor - + let textFont = Font.regular(15.0) let boldTextFont = Font.semibold(15.0) - + var link = "" let textString: String if case .premiumGift = context.component.source { @@ -1903,11 +1903,11 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { textString = strings.Premium_Description } } - + let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: accentColor), linkAttribute: { contents in return (TelegramTextAttributes.URL, contents) }) - + let shareLink = context.component.shareLink let textComponent: _ConcreteChildComponent if context.component.justBought { @@ -1949,7 +1949,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { ) size.height += text.size.height size.height += 21.0 - + let gradientColors: [UIColor] = [ UIColor(rgb: 0xef6922), UIColor(rgb: 0xe95a2c), @@ -1977,27 +1977,27 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { UIColor(rgb: 0x3eb26d), UIColor(rgb: 0x3dbd4a) ] - + let accountContext = context.component.screenContext.context let present = context.component.present let push = context.component.push let selectProduct = context.component.selectProduct let buy = context.component.buy let updateIsFocused = context.component.updateIsFocused - + let layoutOptions = { if let products = state.products, products.count > 1, state.isPremium == false || (!context.component.justBought && state.canUpgrade) { var optionsItems: [SectionGroupComponent.Item] = [] - + let shortestProductPrice: (Int64, NSDecimalNumber) if let product = products.first(where: { $0.id.hasSuffix(".monthly") }) { shortestProductPrice = (Int64(Float(product.priceCurrencyAndAmount.amount)), product.priceValue) } else { shortestProductPrice = (1, NSDecimalNumber(decimal: 1)) } - + let currentProductMonths = state.products?.first(where: { $0.isCurrent })?.months ?? 0 - + var referenceProduct: InAppPurchaseManager.Product? for product in products { if let storeProduct = product.storeProduct { @@ -2005,7 +2005,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { break } } - + var i = 0 for product in products { let giftTitle: String @@ -2018,7 +2018,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { } else { giftTitle = strings.Premium_Annual } - + let fraction = Float(product.priceCurrencyAndAmount.amount) / Float(product.months) / Float(shortestProductPrice.0) let discountValue = Int(round((1.0 - fraction) * 20.0) * 5.0) let discount: String @@ -2027,18 +2027,18 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { } else { discount = "" } - + var defaultPrice: String = "" if let referenceProduct { defaultPrice = referenceProduct.defaultPrice(shortestProductPrice.1, monthsCount: Int(product.months)) } - + var subtitle = "" var accessibilitySubtitle = "" var pricePerMonth = product.price if product.months > 1 { pricePerMonth = product.pricePerMonth - + if discountValue > 0 { subtitle = "**\(defaultPrice)** \(product.price)" accessibilitySubtitle = product.price @@ -2060,7 +2060,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { accessibilitySubtitle = subtitle } pricePerMonth = environment.strings.Premium_PricePerMonth(pricePerMonth).string - + optionsItems.append( SectionGroupComponent.Item( AnyComponentWithIdentity( @@ -2089,7 +2089,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { ) i += 1 } - + let optionsSection = optionsSection.update( component: SectionGroupComponent( items: optionsItems, @@ -2107,7 +2107,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { .cornerRadius(26.0) ) size.height += optionsSection.size.height - + if case .emojiStatus = context.component.source { size.height -= 18.0 } else { @@ -2115,20 +2115,20 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { } } } - + let textSideInset: CGFloat = 16.0 - + let forceDark = context.component.forceDark let layoutPerks = { size.height += 8.0 - + var i = 0 var perksItems: [AnyComponentWithIdentity] = [] for perk in state.configuration.perks { if case .business = context.component.mode, case .business = perk { continue } - + let isNew = state.newPerks.contains(perk.identifier) let titleComponent = AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( @@ -2138,7 +2138,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { )), maximumNumberOfLines: 0 )) - + let titleCombinedComponent: AnyComponent if isNew { titleCombinedComponent = AnyComponent(HStack([ @@ -2148,7 +2148,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { } else { titleCombinedComponent = AnyComponent(HStack([AnyComponentWithIdentity(id: AnyHashable(0), component: titleComponent)], spacing: 0.0)) } - + perksItems.append(AnyComponentWithIdentity(id: perksItems.count, component: AnyComponent(ListActionItemComponent( theme: environment.theme, title: AnyComponent(VStack([ @@ -2241,7 +2241,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { buttonText = strings.Premium_SubscribeFor(state?.price ?? "–").string } } - + var dismissImpl: (() -> Void)? let controller = PremiumLimitsListScreen(context: accountContext, subject: demoSubject, source: .intro(state?.price), order: state?.configuration.perks, buttonText: buttonText, isPremium: isPremium, forceDark: forceDark) controller.action = { [weak state] in @@ -2265,7 +2265,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { )))) i += 1 } - + let perksSection = perksSection.update( component: ListSectionComponent( theme: environment.theme, @@ -2290,7 +2290,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { .disappear(.default(alpha: true)) ) size.height += perksSection.size.height - + if case .emojiStatus = context.component.source { if state.isPremium == true { size.height -= 23.0 @@ -2301,10 +2301,10 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { size.height += 23.0 } } - + let layoutBusinessPerks = { size.height += 8.0 - + let gradientColors: [UIColor] = [ UIColor(rgb: 0xef6922), UIColor(rgb: 0xe54937), @@ -2315,7 +2315,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { UIColor(rgb: 0x676bff), UIColor(rgb: 0x0088ff) ] - + var i = 0 var perksItems: [AnyComponentWithIdentity] = [] for perk in state.configuration.businessPerks { @@ -2328,7 +2328,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { )), maximumNumberOfLines: 0 )) - + let titleCombinedComponent: AnyComponent if isNew { titleCombinedComponent = AnyComponent(HStack([ @@ -2338,7 +2338,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { } else { titleCombinedComponent = AnyComponent(HStack([AnyComponentWithIdentity(id: AnyHashable(0), component: titleComponent)], spacing: 0.0)) } - + perksItems.append(AnyComponentWithIdentity(id: perksItems.count, component: AnyComponent(ListActionItemComponent( theme: environment.theme, title: AnyComponent(VStack([ @@ -2361,7 +2361,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { guard let accountContext else { return } - + let isPremium = state?.isPremium == true if isPremium { switch perk { @@ -2464,8 +2464,8 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { default: fatalError() } - - + + let buttonText: String if isPremium { buttonText = strings.Common_OK @@ -2478,7 +2478,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { buttonText = strings.Premium_SubscribeFor(state?.price ?? "–").string } } - + var dismissImpl: (() -> Void)? let controller = PremiumLimitsListScreen(context: accountContext, subject: demoSubject, source: .intro(state?.price), order: state?.configuration.businessPerks, buttonText: buttonText, isPremium: isPremium, forceDark: forceDark) controller.action = { [weak state] in @@ -2500,7 +2500,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { )))) i += 1 } - + let businessSection = businessSection.update( component: ListSectionComponent( theme: environment.theme, @@ -2519,12 +2519,12 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { size.height += businessSection.size.height size.height += 23.0 } - + let layoutMoreBusinessPerks = { size.height += 8.0 - + let status = state.peer?.emojiStatus - + let accentColor = environment.theme.list.itemAccentColor var perksItems: [AnyComponentWithIdentity] = [] if let accountContext = context.component.screenContext.context { @@ -2569,7 +2569,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { } )))) } - + perksItems.append(AnyComponentWithIdentity(id: perksItems.count, component: AnyComponent(ListActionItemComponent( theme: environment.theme, style: .glass, @@ -2603,7 +2603,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { push(accountContext.sharedContext.makeFilterSettingsController(context: accountContext, modal: false, scrollToTags: true, dismissed: nil)) } )))) - + perksItems.append(AnyComponentWithIdentity(id: perksItems.count, component: AnyComponent(ListActionItemComponent( theme: environment.theme, style: .glass, @@ -2637,7 +2637,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { push(accountContext.sharedContext.makeMyStoriesController(context: accountContext, isArchive: false)) } )))) - + let moreBusinessSection = moreBusinessSection.update( component: ListSectionComponent( theme: environment.theme, @@ -2670,7 +2670,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { size.height += moreBusinessSection.size.height size.height += 23.0 } - + let termsFont = Font.regular(13.0) let boldTermsFont = Font.semibold(13.0) let italicTermsFont = Font.italic(13.0) @@ -2680,10 +2680,10 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { let termsMarkdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: termsFont, textColor: termsTextColor), bold: MarkdownAttributeSet(font: termsFont, textColor: termsTextColor), link: MarkdownAttributeSet(font: termsFont, textColor: environment.theme.list.itemAccentColor), linkAttribute: { contents in return (TelegramTextAttributes.URL, contents) }) - + let layoutAdsSettings = { size.height += 8.0 - + var adsSettingsItems: [AnyComponentWithIdentity] = [] adsSettingsItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( theme: environment.theme, @@ -2708,7 +2708,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { action: nil, tag: doNotHideAdsTag )))) - + let adsInfoString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString(environment.strings.Business_AdsInfo, attributes: termsMarkdownAttributes, textAlignment: .natural )) if state.cachedChevronImage == nil || state.cachedChevronImage?.1 !== theme { @@ -2763,7 +2763,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { size.height += adsSettingsSection.size.height size.height += 23.0 } - + let copyLink = context.component.copyLink if case .emojiStatus = context.component.source { layoutPerks() @@ -2790,11 +2790,11 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { size.height += linkButton.size.height size.height += 17.0 } - + layoutPerks() } else { layoutOptions() - + if case .business = context.component.mode { layoutBusinessPerks() if context.component.isPremium == true { @@ -2803,9 +2803,9 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { } } else { layoutPerks() - + let textPadding: CGFloat = 17.0 - + let infoTitle = infoTitle.update( component: MultilineTextComponent( text: .plain( @@ -2824,7 +2824,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { ) size.height += infoTitle.size.height size.height += 3.0 - + let infoText = infoText.update( component: MultilineTextComponent( text: .markdown( @@ -2839,7 +2839,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { availableSize: CGSize(width: availableWidth - sideInsets - textSideInset * 2.0, height: .greatestFiniteMagnitude), transition: context.transition ) - + let infoBackground = infoBackground.update( component: RoundedRectangle( color: environment.theme.list.itemBlocksBackgroundColor, @@ -2857,14 +2857,14 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { ) size.height += infoBackground.size.height size.height += 6.0 - + var isGiftView = false if case let .gift(fromId, _, _, _) = context.component.source { if let accountContext = context.component.screenContext.context, fromId == accountContext.account.peerId { isGiftView = true } } - + let termsString: MultilineTextComponent.TextContent if isGiftView { termsString = .plain(NSAttributedString()) @@ -2877,7 +2877,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { attributes: termsMarkdownAttributes ) } - + let controller = environment.controller let termsTapActionImpl: ([NSAttributedString.Key: Any]) -> Void = { attributes in if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String, let controller = controller() as? PremiumIntroScreen, let context = controller.context, let navigationController = controller.navigationController as? NavigationController { @@ -2907,7 +2907,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { } } } - + let termsText = termsText.update( component: MultilineTextComponent( text: termsString, @@ -2937,16 +2937,16 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { size.height += 10.0 } } - + size.height += scrollEnvironment.insets.bottom if case .business = context.component.mode, state.isPremium == false { size.height += 123.0 } - + if context.component.source != .settings { size.height += 44.0 } - + return size } } @@ -2954,7 +2954,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { private final class PremiumIntroScreenComponent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment - + let overNavigationContainer: UIView let screenContext: PremiumIntroScreen.ScreenContext let mode: PremiumIntroScreen.Mode @@ -2967,7 +2967,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { let completion: () -> Void let copyLink: (String) -> Void let shareLink: (String) -> Void - + init(overNavigationContainer: UIView, screenContext: PremiumIntroScreen.ScreenContext, mode: PremiumIntroScreen.Mode, source: PremiumSource, forceDark: Bool, forceHasPremium: Bool, updateInProgress: @escaping (Bool) -> Void, present: @escaping (ViewController) -> Void, push: @escaping (ViewController) -> Void, completion: @escaping () -> Void, copyLink: @escaping (String) -> Void, shareLink: @escaping (String) -> Void) { self.overNavigationContainer = overNavigationContainer self.screenContext = screenContext @@ -2982,7 +2982,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { self.copyLink = copyLink self.shareLink = shareLink } - + static func ==(lhs: PremiumIntroScreenComponent, rhs: PremiumIntroScreenComponent) -> Bool { if lhs.mode != rhs.mode { return false @@ -2998,7 +2998,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { } return true } - + final class State: ComponentState { private let screenContext: PremiumIntroScreen.ScreenContext private let source: PremiumSource @@ -3006,45 +3006,45 @@ private final class PremiumIntroScreenComponent: CombinedComponent { private let present: (ViewController) -> Void var navigationController: (() -> NavigationController?)? private let completion: () -> Void - + var topContentOffset: CGFloat? var bottomContentOffset: CGFloat? - + var hasIdleAnimations = true - + var inProgress = false - + private(set) var promoConfiguration: PremiumPromoConfiguration? - + private(set) var products: [PremiumProduct]? private(set) var selectedProductId: String? fileprivate var validPurchases: [InAppPurchaseManager.ReceiptPurchase] = [] - + var isPremium: Bool? var otherPeerName: String? var justBought = false - + var emojiFile: TelegramMediaFile? var emojiPackTitle: String? private var emojiFileDisposable: Disposable? - + private var disposable: Disposable? private var paymentDisposable = MetaDisposable() private var activationDisposable = MetaDisposable() private var preloadDisposableSet = DisposableSet() - + var price: String? { return self.products?.first(where: { $0.id == self.selectedProductId })?.price } - + var isAnnual: Bool { return self.products?.first(where: { $0.id == self.selectedProductId })?.id.hasSuffix(".annual") ?? false } - + var isBiannual: Bool { return self.products?.first(where: { $0.id == self.selectedProductId })?.months == 24 } - + var canUpgrade: Bool { if let products = self.products, let current = products.first(where: { $0.isCurrent }), let transactionId = current.transactionId { if self.validPurchases.contains(where: { $0.transactionId == transactionId }) { @@ -3056,7 +3056,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { return false } } - + init( screenContext: PremiumIntroScreen.ScreenContext, source: PremiumSource, @@ -3070,18 +3070,18 @@ private final class PremiumIntroScreenComponent: CombinedComponent { self.updateInProgress = updateInProgress self.present = present self.completion = completion - + super.init() - + self.validPurchases = screenContext.inAppPurchaseManager?.getReceiptPurchases() ?? [] - + let availableProducts: Signal<[InAppPurchaseManager.Product], NoError> if let inAppPurchaseManager = screenContext.inAppPurchaseManager { availableProducts = inAppPurchaseManager.availableProducts } else { availableProducts = .single([]) } - + let otherPeerName: Signal if let context = screenContext.context { if case let .gift(fromPeerId, toPeerId, _, _) = source { @@ -3106,11 +3106,11 @@ private final class PremiumIntroScreenComponent: CombinedComponent { } else { otherPeerName = .single(nil) } - + if forceHasPremium { self.isPremium = true } - + let isPremium: Signal let promoConfiguration: Signal switch screenContext { @@ -3124,7 +3124,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { isPremium = .single(false) promoConfiguration = .single(PremiumPromoConfiguration.defaultValue) } - + self.disposable = combineLatest( queue: Queue.mainQueue(), availableProducts, @@ -3134,7 +3134,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { ).start(next: { [weak self] availableProducts, promoConfiguration, isPremium, otherPeerName in if let strongSelf = self { strongSelf.promoConfiguration = promoConfiguration - + let hadProducts = strongSelf.products != nil var products: [PremiumProduct] = [] for option in promoConfiguration.premiumProductOptions { @@ -3144,25 +3144,25 @@ private final class PremiumIntroScreenComponent: CombinedComponent { products.append(PremiumProduct(option: option, storeProduct: nil)) } } - + strongSelf.products = products strongSelf.isPremium = forceHasPremium || isPremium strongSelf.otherPeerName = otherPeerName - + if !hadProducts { strongSelf.selectedProductId = strongSelf.products?.first?.id - + if let context = screenContext.context { for (_, video) in promoConfiguration.videos { strongSelf.preloadDisposableSet.add(preloadVideoResource(postbox: context.account.postbox, userLocation: .other, userContentType: .video, resourceReference: .standalone(resource: video.resource), duration: 3.0).start()) } } } - + strongSelf.updated(transition: .immediate) } }) - + if case let .emojiStatus(_, emojiFileId, emojiFile, maybeEmojiPack) = source, let emojiPack = maybeEmojiPack, case let .result(info, _, _) = emojiPack { if let emojiFile = emojiFile { self.emojiFile = emojiFile @@ -3182,7 +3182,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { } } } - + deinit { self.disposable?.dispose() self.paymentDisposable.dispose() @@ -3190,14 +3190,21 @@ private final class PremiumIntroScreenComponent: CombinedComponent { self.emojiFileDisposable?.dispose() self.preloadDisposableSet.dispose() } - + func buy() { guard !self.inProgress else { return } - + let presentationData = self.screenContext.presentationData - + + if case .gift = self.source { + } else { + let alertController = textAlertController(sharedContext: self.screenContext.sharedContext, title: "WinterGram", text: "In-app purchases are not available in WinterGram. To subscribe to Telegram Premium, use the official Telegram app, then sign in here — your subscription will work in WinterGram too.", actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]) + self.present(alertController) + return + } + if case let .gift(_, _, _, giftCode) = self.source, let giftCode, giftCode.usedDate == nil { guard let context = self.screenContext.context else { return @@ -3205,17 +3212,17 @@ private final class PremiumIntroScreenComponent: CombinedComponent { self.inProgress = true self.updateInProgress(true) self.updated(transition: .immediate) - + self.paymentDisposable.set((context.engine.payments.applyPremiumGiftCode(slug: giftCode.slug) |> deliverOnMainQueue).start(error: { [weak self] error in guard let self else { return } - + self.inProgress = false self.updateInProgress(false) self.updated(transition: .immediate) - + if case let .waitForExpiration(date) = error { let dateText = stringForMediumDate(timestamp: date, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat) self.present(UndoOverlayController(presentationData: presentationData, content: .info(title: presentationData.strings.Premium_Gift_ApplyLink_AlreadyHasPremium_Title, text: presentationData.strings.Premium_Gift_ApplyLink_AlreadyHasPremium_Text(dateText).string, timeout: nil, customUndoText: nil), elevatedLayout: true, position: .bottom, action: { _ in return true })) @@ -3224,7 +3231,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { guard let self else { return } - + self.inProgress = false self.justBought = true self.updateInProgress(false) @@ -3233,17 +3240,17 @@ private final class PremiumIntroScreenComponent: CombinedComponent { })) return } - + guard let inAppPurchaseManager = self.screenContext.inAppPurchaseManager, let premiumProduct = self.products?.first(where: { $0.id == self.selectedProductId }) else { return } - + let isUpgrade = self.products?.first(where: { $0.isCurrent }) != nil - + var hasActiveSubsciption = false if let context = self.screenContext.context, let data = context.currentAppConfiguration.with({ $0 }).data, let _ = data["ios_killswitch_disable_receipt_check"] { - + } else if !self.validPurchases.isEmpty && !isUpgrade { let now = Date() for purchase in self.validPurchases.reversed() { @@ -3252,25 +3259,25 @@ private final class PremiumIntroScreenComponent: CombinedComponent { } } } - + if hasActiveSubsciption { let errorText = presentationData.strings.Premium_Purchase_OnlyOneSubscriptionAllowed let alertController = textAlertController(sharedContext: self.screenContext.sharedContext, title: nil, text: errorText, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]) self.present(alertController) return } - + if let context = self.screenContext.context { context.engine.accountData.addAppLogEvent(type: "premium.promo_screen_accept") } - + self.inProgress = true self.updateInProgress(true) self.updated(transition: .immediate) - + if let storeProduct = premiumProduct.storeProduct { let purpose: AppStoreTransactionPurpose = isUpgrade ? .upgrade : .subscription - + let canPurchasePremium: Signal switch self.screenContext { case let .accountContext(context): @@ -3305,19 +3312,19 @@ private final class PremiumIntroScreenComponent: CombinedComponent { } else { activation = .complete() } - + self.activationDisposable.set((activation |> deliverOnMainQueue).start(error: { [weak self] _ in if let self { self.inProgress = false self.updateInProgress(false) - + self.updated(transition: .immediate) - + if let context = self.screenContext.context { context.engine.accountData.addAppLogEvent(type: "premium.promo_screen_fail") } - + let errorText = presentationData.strings.Premium_Purchase_ErrorUnknown let alertController = textAlertController(sharedContext: self.screenContext.sharedContext, title: nil, text: errorText, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]) self.present(alertController) @@ -3331,10 +3338,10 @@ private final class PremiumIntroScreenComponent: CombinedComponent { } self.inProgress = false self.updateInProgress(false) - + self.isPremium = true self.justBought = true - + self.updated(transition: .easeInOut(duration: 0.25)) self.completion() })) @@ -3346,7 +3353,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { self.inProgress = false self.updateInProgress(false) self.updated(transition: .immediate) - + var errorText: String? switch error { case .generic: @@ -3364,12 +3371,12 @@ private final class PremiumIntroScreenComponent: CombinedComponent { case .cancelled: break } - + if let errorText = errorText { if let context = self.screenContext.context { context.engine.accountData.addAppLogEvent(type: "premium.promo_screen_fail") } - + let alertController = textAlertController(sharedContext: self.screenContext.sharedContext, title: nil, text: errorText, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]) self.present(alertController) } @@ -3382,7 +3389,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { }) } else if case let .accountContext(context) = self.screenContext, let navigationController = self.navigationController?() { context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: premiumProduct.option.botUrl, forceExternal: false, presentationData: presentationData, navigationController: navigationController, dismissInput: {}) - + Queue.mainQueue().after(3.0) { self.inProgress = false self.updateInProgress(false) @@ -3390,22 +3397,22 @@ private final class PremiumIntroScreenComponent: CombinedComponent { } } } - + func updateIsFocused(_ isFocused: Bool) { self.hasIdleAnimations = !isFocused self.updated(transition: .immediate) } - + func selectProduct(_ productId: String) { self.selectedProductId = productId self.updated(transition: .immediate) } } - + func makeState() -> State { return State(screenContext: self.screenContext, source: self.source, forceHasPremium: self.forceHasPremium, updateInProgress: self.updateInProgress, present: self.present, completion: self.completion) } - + static var body: Body { let background = Child(Rectangle.self) let scrollContent = Child(ScrollComponent.self) @@ -3416,18 +3423,18 @@ private final class PremiumIntroScreenComponent: CombinedComponent { let secondaryTitle = Child(MultilineTextWithEntitiesComponent.self) let bottomEdgeEffect = Child(EdgeEffectComponent.self) let button = Child(ButtonComponent.self) - + var updatedInstalled: Bool? - + return { context in let environment = context.environment[EnvironmentType.self].value let state = context.state state.navigationController = { [weak environment] in return environment?.controller()?.navigationController as? NavigationController } - + let background = background.update(component: Rectangle(color: environment.theme.list.blocksBackgroundColor), environment: {}, availableSize: context.availableSize, transition: context.transition) - + var starIsVisible = true if let topContentOffset = state.topContentOffset, topContentOffset >= 123.0 { starIsVisible = false @@ -3437,7 +3444,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { if case .profile = context.component.source { isIntro = false } - + let header: _UpdatedChildComponent if case .business = context.component.mode { header = coin.update( @@ -3498,7 +3505,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { transition: context.transition ) } - + let titleString: String if case .premiumGift = context.component.source { titleString = environment.strings.Premium_PremiumGift_Title @@ -3519,7 +3526,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { } else { titleString = environment.strings.Premium_Title } - + let title = title.update( component: MultilineTextComponent( text: .plain(NSAttributedString(string: titleString, font: Font.bold(28.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)), @@ -3539,11 +3546,11 @@ private final class PremiumIntroScreenComponent: CombinedComponent { if case let .emojiStatus(peerId, _, file, maybeEmojiPack) = context.component.source, let emojiPack = maybeEmojiPack, case let .result(info, _, _) = emojiPack { loadedEmojiPack = maybeEmojiPack highlightableLinks = true - + if peerId.isGroupOrChannel, otherPeerName.count > 20 { otherPeerName = otherPeerName.prefix(20).trimmingCharacters(in: .whitespacesAndNewlines) + "\u{2026}" } - + var packReference: StickerPackReference? if let file = file { for attribute in file.attributes { @@ -3591,7 +3598,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { } else { secondaryTitleText = "" } - + let textColor = environment.theme.list.itemPrimaryTextColor let accentColor: UIColor if case .emojiStatus = context.component.source { @@ -3599,13 +3606,13 @@ private final class PremiumIntroScreenComponent: CombinedComponent { } else { accentColor = UIColor(rgb: 0x597cf5) } - + let textFont = Font.bold(18.0) let boldTextFont = Font.bold(18.0) let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: isAnonymous ? textColor : accentColor), linkAttribute: { _ in return nil }) - + let secondaryAttributedText = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString(secondaryTitleText, attributes: markdownAttributes)) if let emojiFile = state.emojiFile { let range = (secondaryAttributedText.string as NSString).range(of: "#") @@ -3641,7 +3648,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { if let loadedEmojiPack, case let .result(info, items, installed) = loadedEmojiPack { loadedPack = .result(info: info, items: items, installed: updatedInstalled ?? installed) } - + let controller = context.sharedContext.makeStickerPackScreen(context: context, updatedPresentationData: nil, mainStickerPack: packReference, stickerPacks: [packReference], loadedStickerPacks: loadedPack.flatMap { [$0] } ?? [], actionTitle: nil, isEditing: false, expandIfNeeded: false, parentNavigationController: navigationController, sendSticker: { _, _, _ in return false }, actionPerformed: { actions in @@ -3665,12 +3672,12 @@ private final class PremiumIntroScreenComponent: CombinedComponent { availableSize: CGSize(width: context.availableSize.width - 32.0, height: context.availableSize.width), transition: context.transition ) - + let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: environment.safeInsets.bottom, innerDiameter: 52.0, sideInset: 30.0) let bottomPanelPadding: CGFloat = 12.0 let bottomInset: CGFloat = environment.safeInsets.bottom > 0.0 ? environment.safeInsets.bottom + 5.0 : bottomPanelPadding let bottomPanelHeight: CGFloat = state.isPremium == true && !state.canUpgrade ? bottomInset : bottomPanelPadding + 52.0 + bottomInset - + let scrollContent = scrollContent.update( component: ScrollComponent( content: AnyComponent(PremiumIntroScreenContentComponent( @@ -3719,28 +3726,28 @@ private final class PremiumIntroScreenComponent: CombinedComponent { availableSize: context.availableSize, transition: context.transition ) - + let topInset: CGFloat = environment.navigationHeight - 56.0 - + context.add(background .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) ) - + context.add(scrollContent .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) ) - + let titleOffset: CGFloat let titleScale: CGFloat let titleOffsetDelta = (topInset + 160.0) - (environment.statusBarHeight + (environment.navigationHeight - environment.statusBarHeight) / 2.0) let titleAlpha: CGFloat - + if let topContentOffset = state.topContentOffset { let topContentOffset = topContentOffset + max(0.0, min(1.0, topContentOffset / titleOffsetDelta)) * 10.0 titleOffset = topContentOffset let fraction = max(0.0, min(1.0, titleOffset / titleOffsetDelta)) titleScale = 1.0 - fraction * 0.36 - + if state.otherPeerName != nil { titleAlpha = min(1.0, fraction * 1.1) } else { @@ -3751,34 +3758,34 @@ private final class PremiumIntroScreenComponent: CombinedComponent { titleOffset = 0.0 titleAlpha = state.otherPeerName != nil ? 0.0 : 1.0 } - + context.addWithExternalContainer(header .position(CGPoint(x: context.availableSize.width / 2.0, y: topInset + header.size.height / 2.0 - 30.0 - titleOffset * titleScale)) .scale(titleScale), container: context.component.overNavigationContainer ) - + context.addWithExternalContainer(title .position(CGPoint(x: context.availableSize.width / 2.0, y: max(topInset + 160.0 - titleOffset, environment.statusBarHeight + (environment.navigationHeight - environment.statusBarHeight) / 2.0))) .scale(titleScale) .opacity(titleAlpha), container: context.component.overNavigationContainer ) - + context.addWithExternalContainer(secondaryTitle .position(CGPoint(x: context.availableSize.width / 2.0, y: max(topInset + 160.0 - titleOffset, environment.statusBarHeight + (environment.navigationHeight - environment.statusBarHeight) / 2.0))) .scale(titleScale) .opacity(max(0.0, 1.0 - titleAlpha * 1.8)), container: context.component.overNavigationContainer ) - + var isUnusedGift = false if case let .gift(fromId, _, _, giftCode) = context.component.source, let accountContext = context.component.screenContext.context { if let giftCode, giftCode.usedDate == nil, fromId != accountContext.account.peerId { isUnusedGift = true } } - + var buttonIsHidden = true if !state.justBought { if isUnusedGift { @@ -3789,7 +3796,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { buttonIsHidden = false } } - + if !buttonIsHidden { let buttonTitle: String var buttonSubtitle: String? @@ -3822,7 +3829,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { buttonTitle = environment.strings.Premium_SubscribeFor(state.price ?? "–").string } } - + let controller = environment.controller let buttonGradientColors = [ UIColor(rgb: 0x0077ff), @@ -3880,7 +3887,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { ), availableSize: CGSize(width: context.availableSize.width - buttonInsets.left - buttonInsets.right - environment.safeInsets.left - environment.safeInsets.right, height: 52.0), transition: context.transition) - + let bottomEdgeEffectHeight = 13.0 + buttonInsets.bottom + button.size.height let bottomEdgeEffect = bottomEdgeEffect.update( component: EdgeEffectComponent( @@ -3919,7 +3926,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { }) ) } - + return context.availableSize } } @@ -3929,7 +3936,7 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { public enum ScreenContext { case accountContext(AccountContext) case sharedContext(SharedAccountContext, TelegramEngineUnauthorized, InAppPurchaseManager) - + var context: AccountContext? { switch self { case let .accountContext(context): @@ -3938,7 +3945,7 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { return nil } } - + var sharedContext: SharedAccountContext { switch self { case let .accountContext(context): @@ -3947,7 +3954,7 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { return sharedContext } } - + var inAppPurchaseManager: InAppPurchaseManager? { switch self { case let .accountContext(context): @@ -3956,7 +3963,7 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { return inAppPurchaseManager } } - + var presentationData: PresentationData { switch self { case let .accountContext(context): @@ -3965,7 +3972,7 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { return sharedContext.currentPresentationData.with { $0 } } } - + var updatedPresentationData: (initial: PresentationData, signal: Signal) { switch self { case let .accountContext(context): @@ -3975,12 +3982,12 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { } } } - + public enum Mode { case premium case business } - + fileprivate var context: AccountContext? { switch self.screenContext { case let .accountContext(context): @@ -3992,45 +3999,45 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { private let screenContext: ScreenContext fileprivate let mode: Mode private let focusOnItemTag: PremiumIntroEntryTag? - + private var didSetReady = false private let _ready = Promise() public override var ready: Promise { return self._ready } - + public weak var sourceView: UIView? public var sourceRect: CGRect? public weak var containerView: UIView? public var animationColor: UIColor? - + private let overNavigationContainer: UIView - + public convenience init(context: AccountContext, mode: Mode = .premium, source: PremiumSource, modal: Bool = true, forceDark: Bool = false, forceHasPremium: Bool = false, focusOnItemTag: PremiumIntroEntryTag? = nil) { self.init(screenContext: .accountContext(context), mode: mode, source: source, modal: modal, forceDark: forceDark, forceHasPremium: forceHasPremium, focusOnItemTag: focusOnItemTag) } - + public init(screenContext: ScreenContext, mode: Mode = .premium, source: PremiumSource, modal: Bool = true, forceDark: Bool = false, forceHasPremium: Bool = false, focusOnItemTag: PremiumIntroEntryTag? = nil) { self.screenContext = screenContext self.mode = mode self.focusOnItemTag = focusOnItemTag - + let presentationData = screenContext.presentationData - + var updateInProgressImpl: ((Bool) -> Void)? var pushImpl: ((ViewController) -> Void)? var presentImpl: ((ViewController) -> Void)? var completionImpl: (() -> Void)? var copyLinkImpl: ((String) -> Void)? var shareLinkImpl: ((String) -> Void)? - + self.overNavigationContainer = SparseContainerView() - + var baseNavigationColors: BaseNavigationColors = .plain if case .emojiStatus = source { baseNavigationColors = .blocks } - + super.init(component: PremiumIntroScreenComponent( overNavigationContainer: self.overNavigationContainer, screenContext: screenContext, @@ -4057,15 +4064,15 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { shareLinkImpl?(link) } ), navigationBarAppearance: .default, presentationMode: modal ? .modal : .default, theme: forceDark ? .dark : .default, updatedPresentationData: screenContext.updatedPresentationData, baseNavigationColors: baseNavigationColors) - + self._hasGlassStyle = true - + if modal { self.navigationPresentation = .modal } else { self.navigationPresentation = .modalInLargeLayout } - + updateInProgressImpl = { [weak self] inProgress in guard let self else { return @@ -4074,7 +4081,7 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { self.view.disablesInteractiveTransitionGestureRecognizer = inProgress self.view.disablesInteractiveModalDismiss = inProgress } - + presentImpl = { [weak self] c in if c is UndoOverlayController { self?.present(c, in: .current) @@ -4082,44 +4089,44 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { self?.present(c, in: .window(.root)) } } - + pushImpl = { [weak self] c in self?.push(c) } - + completionImpl = { [weak self] in if let self { self.animateSuccess() } } - + copyLinkImpl = { [weak self] link in UIPasteboard.general.string = link - + guard let self else { return } self.dismissAllTooltips() - + self.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, position: .top, action: { _ in return true }), in: .current) } - + shareLinkImpl = { [weak self] link in guard let self, case let .accountContext(context) = screenContext, let navigationController = self.navigationController as? NavigationController else { return } - + let messages: [EnqueueMessage] = [.message(text: link, attributes: [], inlineStickers: [:], mediaReference: nil, threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])] - + let peerSelectionController = context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(context: context, filter: [.onlyWriteable, .excludeDisabled], multipleSelection: false, selectForumThreads: true)) peerSelectionController.peerSelected = { [weak peerSelectionController, weak navigationController] peer, threadId in if let _ = peerSelectionController { Queue.mainQueue().after(0.88) { HapticFeedback().success() } - + (navigationController?.topViewController as? ViewController)?.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: true, text: peer.id == context.account.peerId ? presentationData.strings.GiftLink_LinkSharedToSavedMessages : presentationData.strings.GiftLink_LinkSharedToChat(peer.compactDisplayTitle).string), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .window(.root)) - + let _ = (enqueueMessages(account: context.account, peerId: peer.id, messages: messages) |> deliverOnMainQueue).startStandalone() if let peerSelectionController = peerSelectionController { @@ -4129,21 +4136,21 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { } navigationController.pushViewController(peerSelectionController) } - + if case .business = mode, case let .accountContext(context) = screenContext { context.account.viewTracker.keepQuickRepliesApproximatelyUpdated() context.account.viewTracker.keepBusinessLinksApproximatelyUpdated() } - + if let navigationBar = self.navigationBar { navigationBar.customOverBackgroundContentView.addSubview(self.overNavigationContainer) } } - + required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override public func viewDidLoad() { super.viewDidLoad() @@ -4161,12 +4168,12 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { }) } } - + public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) self.dismissAllTooltips() } - + fileprivate func dismissAllTooltips() { self.window?.forEachController({ controller in if let controller = controller as? UndoOverlayController { @@ -4180,19 +4187,19 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { return true }) } - + @objc private func cancelPressed() { self.dismiss() self.wasDismissed?() } - + public func animateSuccess() { self.view.addSubview(ConfettiView(frame: self.view.bounds)) } - + public override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) - + if !self.didSetReady { if let view = findTaggedComponentViewImpl(view: self.view, tag: PremiumCoinComponent.View.Tag()) as? PremiumCoinComponent.View { self.didSetReady = true @@ -4200,12 +4207,12 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { } else if let view = findTaggedComponentViewImpl(view: self.view, tag: PremiumStarComponent.View.Tag()) as? PremiumStarComponent.View { self.didSetReady = true self._ready.set(view.ready) - + if let sourceView = self.sourceView { view.animateFrom = sourceView view.containerView = self.containerView view.animationColor = self.animationColor - + self.sourceView = nil self.containerView = nil self.animationColor = nil @@ -4213,14 +4220,14 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { } else if let view = findTaggedComponentViewImpl(view: self.view, tag: EmojiHeaderComponent.View.Tag()) as? EmojiHeaderComponent.View { self.didSetReady = true self._ready.set(view.ready) - + if let sourceView = self.sourceView { view.animateFrom = sourceView view.sourceRect = self.sourceRect view.containerView = self.containerView - + view.animateIn() - + self.sourceView = nil self.containerView = nil self.animationColor = nil @@ -4233,7 +4240,7 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { private final class BadgeComponent: CombinedComponent { let color: UIColor let text: String - + init( color: UIColor, text: String @@ -4241,7 +4248,7 @@ private final class BadgeComponent: CombinedComponent { self.color = color self.text = text } - + static func ==(lhs: BadgeComponent, rhs: BadgeComponent) -> Bool { if lhs.color != rhs.color { return false @@ -4251,20 +4258,20 @@ private final class BadgeComponent: CombinedComponent { } return true } - + static var body: Body { let badgeBackground = Child(RoundedRectangle.self) let badgeText = Child(MultilineTextComponent.self) return { context in let component = context.component - + let badgeText = badgeText.update( component: MultilineTextComponent(text: .plain(NSAttributedString(string: component.text, font: Font.semibold(11.0), textColor: .white))), availableSize: context.availableSize, transition: context.transition ) - + let badgeSize = CGSize(width: badgeText.size.width + 7.0, height: 16.0) let badgeBackground = badgeBackground.update( component: RoundedRectangle( @@ -4274,15 +4281,15 @@ private final class BadgeComponent: CombinedComponent { availableSize: badgeSize, transition: context.transition ) - + context.add(badgeBackground .position(CGPoint(x: badgeSize.width / 2.0, y: badgeSize.height / 2.0)) ) - + context.add(badgeText .position(CGPoint(x: badgeSize.width / 2.0, y: badgeSize.height / 2.0)) ) - + return badgeSize } } diff --git a/submodules/RMIntro/Sources/platform/ios/RMIntroViewController.m b/submodules/RMIntro/Sources/platform/ios/RMIntroViewController.m index 2b2977e44d..9a8550ad84 100644 --- a/submodules/RMIntro/Sources/platform/ios/RMIntroViewController.m +++ b/submodules/RMIntro/Sources/platform/ios/RMIntroViewController.m @@ -76,7 +76,7 @@ typedef enum { - (void)layoutSubviews { [super layoutSubviews]; - + if (_onLayout) { _onLayout(); } @@ -88,25 +88,25 @@ typedef enum { { id _didEnterBackgroundObserver; id _willEnterBackgroundObserver; - + UIColor *_backgroundColor; UIColor *_primaryColor; UIColor *_buttonColor; UIColor *_accentColor; UIColor *_regularDotColor; UIColor *_highlightedDotColor; - + TGModernButton *_alternativeLanguageButton; - + SMetaDisposable *_localizationsDisposable; TGSuggestedLocalization *_alternativeLocalizationInfo; - + SVariable *_alternativeLocalization; NSDictionary *_englishStrings; - + UIView *_wrapperView; UIView *_startButton; - + bool _loadedView; } @end @@ -120,14 +120,14 @@ typedef enum { if (self != nil) { _isEnabled = true; - + _backgroundColor = backgroundColor; _primaryColor = primaryColor; _buttonColor = buttonColor; _accentColor = accentColor; _regularDotColor = regularDotColor; _highlightedDotColor = highlightedDotColor; - + NSArray *stringKeys = @[ @"Tour.Title1", @"Tour.Title2", @@ -143,7 +143,7 @@ typedef enum { @"Tour.Text6", @"Tour.StartButton" ]; - + NSMutableDictionary *englishStrings = [[NSMutableDictionary alloc] init]; NSBundle *bundle = [NSBundle bundleWithPath:[[NSBundle mainBundle] pathForResource:@"en" ofType:@"lproj"]]; for (NSString *key in stringKeys) { @@ -159,44 +159,48 @@ typedef enum { } } _englishStrings = englishStrings; - - _headlines = @[ _englishStrings[@"Tour.Title1"], _englishStrings[@"Tour.Title2"], _englishStrings[@"Tour.Title6"], _englishStrings[@"Tour.Title3"], _englishStrings[@"Tour.Title4"], _englishStrings[@"Tour.Title5"]]; + + NSString *brandTitle = _englishStrings[@"Tour.Title1"]; + if ([[NSUserDefaults standardUserDefaults] boolForKey:@"wnt_useDefaultBranding"]) { + brandTitle = @"Telegram"; + } + _headlines = @[ brandTitle, _englishStrings[@"Tour.Title2"], _englishStrings[@"Tour.Title6"], _englishStrings[@"Tour.Title3"], _englishStrings[@"Tour.Title4"], _englishStrings[@"Tour.Title5"]]; _descriptions = @[_englishStrings[@"Tour.Text1"], _englishStrings[@"Tour.Text2"], _englishStrings[@"Tour.Text6"], _englishStrings[@"Tour.Text3"], _englishStrings[@"Tour.Text4"], _englishStrings[@"Tour.Text5"]]; - + __weak RMIntroViewController *weakSelf = self; _didEnterBackgroundObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidEnterBackgroundNotification object:nil queue:nil usingBlock:^(__unused NSNotification *notification) { __strong RMIntroViewController *strongSelf = weakSelf; [strongSelf stopTimer]; }]; - + _willEnterBackgroundObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationWillEnterForegroundNotification object:nil queue:nil usingBlock:^(__unused NSNotification *notification) { __strong RMIntroViewController *strongSelf = weakSelf; [strongSelf loadGL]; [strongSelf startTimer]; }]; - + _alternativeLanguageButton = [[TGModernButton alloc] init]; _alternativeLanguageButton.modernHighlight = true; [_alternativeLanguageButton setTitleColor:accentColor]; - + _alternativeLanguageButton.titleLabel.font = [UIFont systemFontOfSize:18.0]; _alternativeLanguageButton.hidden = true; [_alternativeLanguageButton addTarget:self action:@selector(alternativeLanguageButtonPressed) forControlEvents:UIControlEventTouchUpInside]; - + _alternativeLocalization = [[SVariable alloc] init]; - + _localizationsDisposable = [[suggestedLocalizationSignal deliverOn:[SQueue mainQueue]] startStrictWithNext:^(TGSuggestedLocalization *next) { __strong RMIntroViewController *strongSelf = weakSelf; if (strongSelf != nil && next != nil) { if (strongSelf->_alternativeLocalizationInfo == nil) { strongSelf->_alternativeLocalizationInfo = next; - + [strongSelf->_alternativeLanguageButton setTitle:next.continueWithLanguageString forState:UIControlStateNormal]; strongSelf->_alternativeLanguageButton.hidden = false; [strongSelf->_alternativeLanguageButton sizeToFit]; - + if ([strongSelf isViewLoaded]) { strongSelf->_alternativeLanguageButton.alpha = 0.0; [UIView animateWithDuration:0.3 animations:^{ @@ -232,22 +236,22 @@ typedef enum { - (void)animateIn { CGPoint logoTargetPosition = _glkView.center; _glkView.center = CGPointMake(self.view.bounds.size.width / 2.0, self.view.bounds.size.height / 2.0); - + RMIntroPageView *firstPage = (RMIntroPageView *)[_pageViews firstObject]; CGPoint headerTargetPosition = firstPage.headerLabel.center; firstPage.headerLabel.center = CGPointMake(headerTargetPosition.x, headerTargetPosition.y + 140.0); - + CGPoint descriptionTargetPosition = firstPage.descriptionLabel.center; firstPage.descriptionLabel.center = CGPointMake(descriptionTargetPosition.x, descriptionTargetPosition.y + 160.0); - + CGPoint pageControlTargetPosition = _pageControl.center; _pageControl.center = CGPointMake(pageControlTargetPosition.x, pageControlTargetPosition.y + 200.0); - + CGPoint buttonTargetPosition = _startButton.center; _startButton.center = CGPointMake(buttonTargetPosition.x, buttonTargetPosition.y + 220.0); - + _glkView.transform = CGAffineTransformMakeScale(0.66, 0.66); - + [UIView animateWithDuration:0.65 delay:0.15 usingSpringWithDamping:1.2f initialSpringVelocity:0.0 options:kNilOptions animations:^{ _glkView.center = logoTargetPosition; firstPage.headerLabel.center = headerTargetPosition; @@ -256,12 +260,12 @@ typedef enum { _startButton.center = buttonTargetPosition; _glkView.transform = CGAffineTransformIdentity; } completion:nil]; - + _glkView.alpha = 0.0; _pageScrollView.alpha = 0.0; _pageControl.alpha = 0.0; _startButton.alpha = 0.0; - + [UIView animateWithDuration:0.3 delay:0.15 options:kNilOptions animations:^{ _glkView.alpha = 1.0; _pageScrollView.alpha = 1.0; @@ -275,23 +279,23 @@ typedef enum { #if TARGET_OS_SIMULATOR && defined(__aarch64__) return; #endif - + if (/*[[UIApplication sharedApplication] applicationState] != UIApplicationStateBackground*/true && !_isOpenGLLoaded) { _context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2]; if (!_context) NSLog(@"Failed to create ES context"); - + bool isIpad = ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad); - + CGFloat size = 200; if (isIpad) size *= 1.2; - + int height = 50; if (isIpad) height += 138 / 2; - + _glkView = [[GLKView alloc] initWithFrame:CGRectMake(self.view.bounds.size.width / 2 - size / 2, height, size, size) context:_context]; _glkView.backgroundColor = _backgroundColor; _glkView.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin; @@ -300,10 +304,10 @@ typedef enum { _glkView.enableSetNeedsDisplay = false; _glkView.userInteractionEnabled = false; _glkView.delegate = self; - + [self setupGL]; [self.view addSubview:_glkView]; - + [self startTimer]; _isOpenGLLoaded = true; } @@ -315,7 +319,7 @@ typedef enum { return; [self stopTimer]; - + if ([EAGLContext currentContext] == _glkView.context) [EAGLContext setCurrentContext:nil]; @@ -334,26 +338,26 @@ typedef enum { [strongSelf updateLayout]; } }; - + [self viewDidLoad]; } - (void)viewDidLoad { [super viewDidLoad]; - + if (_loadedView) { return; } _loadedView = true; - + self.view.backgroundColor = _backgroundColor; - + [self loadGL]; - + _wrapperView = [[UIScrollView alloc]initWithFrame:self.view.bounds]; [self.view addSubview:_wrapperView]; - + _pageScrollView = [[UIScrollView alloc]initWithFrame:self.view.bounds]; _pageScrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; _pageScrollView.clipsToBounds = true; @@ -365,9 +369,9 @@ typedef enum { _pageScrollView.contentSize = CGSizeMake(_headlines.count * self.view.bounds.size.width, self.view.bounds.size.height); _pageScrollView.delegate = self; [_wrapperView addSubview:_pageScrollView]; - + _pageViews = [NSMutableArray array]; - + for (NSUInteger i = 0; i < _headlines.count; i++) { RMIntroPageView *p = [[RMIntroPageView alloc]initWithFrame:CGRectMake(i * self.view.bounds.size.width, 0, self.view.bounds.size.width, 0) headline:[_headlines objectAtIndex:i] description:[_descriptions objectAtIndex:i] color:_primaryColor]; @@ -377,9 +381,9 @@ typedef enum { [_pageScrollView addSubview:p]; } [_pageScrollView setPage:0]; - + [self.view addSubview:_alternativeLanguageButton]; - + _pageControl = [[UIPageControl alloc] init]; _pageControl.autoresizingMask = UIViewAutoresizingFlexibleBottomMargin; _pageControl.userInteractionEnabled = false; @@ -406,7 +410,7 @@ typedef enum { { if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad) return true; - + return false; } @@ -414,7 +418,7 @@ typedef enum { { if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad) return UIInterfaceOrientationMaskAll; - + return UIInterfaceOrientationMaskPortrait; } @@ -422,9 +426,9 @@ typedef enum { { CGSize viewSize = self.view.frame.size; int max = (int)MAX(viewSize.width, viewSize.height); - + DeviceScreen deviceScreen = Inch55; - + if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad) { switch (max) @@ -432,7 +436,7 @@ typedef enum { case 1366: deviceScreen = iPadPro; break; - + default: deviceScreen = iPad; break; @@ -459,24 +463,24 @@ typedef enum { break; } } - + return deviceScreen; } - (void)updateLayout { UIInterfaceOrientation isVertical = (self.view.bounds.size.height / self.view.bounds.size.width > 1.0f); - + CGFloat statusBarHeight = 0; - + CGFloat pageControlY = 0; CGFloat glViewY = 0; CGFloat startButtonY = 0; CGFloat pageY = 0; - + CGFloat languageButtonSpread = 60.0f; CGFloat languageButtonOffset = 26.0f; - + DeviceScreen deviceScreen = [self deviceScreen]; switch (deviceScreen) { @@ -486,14 +490,14 @@ typedef enum { pageY = isVertical ? 485 : 335; pageControlY = pageY + 200.0f; break; - + case iPadPro: glViewY = isVertical ? 221 + 110 : 221; startButtonY = 120; pageY = isVertical ? 605 : 435; pageControlY = pageY + 200.0f; break; - + case Inch35: pageControlY = 162 / 2; glViewY = 62 - 20; @@ -509,7 +513,7 @@ typedef enum { languageButtonSpread = 65.0f; languageButtonOffset = 15.0f; break; - + case Inch4: glViewY = 62; startButtonY = 75; @@ -518,7 +522,7 @@ typedef enum { languageButtonSpread = 50.0f; languageButtonOffset = 20.0f; break; - + case Inch47: pageControlY = 162 / 2 + 10; glViewY = 62 + 25; @@ -526,32 +530,32 @@ typedef enum { pageY = 245 + 50; pageControlY = pageY + 160.0f; break; - + case Inch55: glViewY = 62 + 45; startButtonY = 75 + 20; pageY = 245 + 85; pageControlY = pageY + 160.0f; break; - + case Inch65: glViewY = 62 + 85; startButtonY = 75 + 30; pageY = 245 + 125; pageControlY = pageY + 160.0f; break; - + default: break; } - + if (!_alternativeLanguageButton.isHidden) { startButtonY += languageButtonSpread; } - + _pageControl.frame = CGRectMake(0, pageControlY, self.view.bounds.size.width, 7); _glkView.frame = CGRectChangedOriginY(_glkView.frame, glViewY - statusBarHeight); - + CGFloat startButtonWidth = MIN(430.0 - 48.0, self.view.bounds.size.width - 48.0f); UIView *startButton = self.createStartButton(startButtonWidth); if (startButton.superview == nil) { @@ -559,14 +563,14 @@ typedef enum { [self.view addSubview:startButton]; } startButton.frame = CGRectMake(floor((self.view.bounds.size.width - startButtonWidth) / 2.0f), self.view.bounds.size.height - startButtonY - statusBarHeight, startButtonWidth, 50.0f); - + _alternativeLanguageButton.frame = CGRectMake(floor((self.view.bounds.size.width - _alternativeLanguageButton.frame.size.width) / 2.0f), CGRectGetMaxY(startButton.frame) + languageButtonOffset, _alternativeLanguageButton.frame.size.width, _alternativeLanguageButton.frame.size.height); - + _wrapperView.frame = CGRectMake(0, 0, self.view.bounds.size.width, self.view.bounds.size.height); _pageScrollView.frame=CGRectMake(0, 20, self.view.bounds.size.width, self.view.bounds.size.height - 20); _pageScrollView.contentSize=CGSizeMake(_headlines.count * self.view.bounds.size.width, 150); _pageScrollView.contentOffset = CGPointMake(_currentPage * self.view.bounds.size.width, 0); - + [_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); }]; @@ -575,14 +579,14 @@ typedef enum { - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; - + [self loadGL]; } - (void)viewDidDisappear:(BOOL)animated { [super viewDidDisappear:animated]; - + [self freeGL]; } @@ -602,18 +606,18 @@ typedef enum { { [[NSNotificationCenter defaultCenter] removeObserver:_didEnterBackgroundObserver]; [[NSNotificationCenter defaultCenter] removeObserver:_willEnterBackgroundObserver]; - + [_localizationsDisposable dispose]; - + [self freeGL]; } - (void)setupGL { [EAGLContext setCurrentContext:_glkView.context]; - + UIColor *color = _backgroundColor; - + CGFloat red = 0.0f; CGFloat green = 0.0f; CGFloat blue = 0.0f; @@ -623,19 +627,19 @@ typedef enum { blue = red; } set_intro_background_color(red, green, blue); - + set_telegram_textures(setup_texture(@"telegram_sphere.png", color), setup_texture(@"telegram_plane1.png", color)); - + set_ic_textures(setup_texture(@"ic_bubble_dot.png", color), setup_texture(@"ic_bubble.png", color), setup_texture(@"ic_cam_lens.png", color), setup_texture(@"ic_cam.png", color), setup_texture(@"ic_pencil.png", color), setup_texture(@"ic_pin.png", color), setup_texture(@"ic_smile_eye.png", color), setup_texture(@"ic_smile.png", color), setup_texture(@"ic_videocam.png", color)); - + set_fast_textures(setup_texture(@"fast_body.png", color), setup_texture(@"fast_spiral.png", color), setup_texture(@"fast_arrow.png", color), setup_texture(@"fast_arrow_shadow.png", color)); - + set_free_textures(setup_texture(@"knot_up1.png", color), setup_texture(@"knot_down.png", color)); - + set_powerful_textures(setup_texture(@"powerful_mask.png", color), setup_texture(@"powerful_star.png", color), setup_texture(@"powerful_infinity.png", color), setup_texture(@"powerful_infinity_white.png", color)); - + set_private_textures(setup_texture(@"private_door.png", color), setup_texture(@"private_screw.png", color)); - + on_surface_created(); on_surface_changed(200, 200, 1, 0,0,0,0,0); } @@ -645,10 +649,10 @@ typedef enum { - (void)glkView:(GLKView *)__unused view drawInRect:(CGRect)__unused rect { double time = CFAbsoluteTimeGetCurrent(); - + set_page((int)_currentPage); set_date(time); - + on_draw_frame(); } @@ -666,28 +670,28 @@ NSInteger _current_page_end; - (void)scrollViewDidScroll:(UIScrollView *)scrollView { CGFloat offset = (scrollView.contentOffset.x - _currentPage * scrollView.frame.size.width) / self.view.frame.size.width; - + set_scroll_offset((float)offset); - + if (justEndDragging) { justEndDragging = false; - + CGFloat page = scrollView.contentOffset.x / scrollView.frame.size.width; CGFloat sign = scrollView.contentOffset.x - x; - + if (sign > 0) { if (page > _currentPage) _currentPage++; } - + if (sign < 0) { if (page < _currentPage) _currentPage--; } - + _currentPage = MAX(0, MIN(5, _currentPage)); _current_page_end = _currentPage; } @@ -709,7 +713,7 @@ NSInteger _current_page_end; } } } - + [_pageControl setCurrentPage:_currentPage]; } diff --git a/submodules/SettingsUI/BUILD b/submodules/SettingsUI/BUILD index b8a0540a12..ade087b2b1 100644 --- a/submodules/SettingsUI/BUILD +++ b/submodules/SettingsUI/BUILD @@ -20,6 +20,7 @@ swift_library( "//submodules/AccountContext:AccountContext", "//submodules/ActivityIndicator:ActivityIndicator", "//submodules/AlertUI:AlertUI", + "//submodules/PromptUI:PromptUI", "//submodules/PresentationDataUtils:PresentationDataUtils", "//submodules/AvatarNode:AvatarNode", "//submodules/CallListUI:CallListUI", diff --git a/submodules/SettingsUI/Sources/Data and Storage/DataAndStorageSettingsController.swift b/submodules/SettingsUI/Sources/Data and Storage/DataAndStorageSettingsController.swift index 699e3c24de..e2a537d128 100644 --- a/submodules/SettingsUI/Sources/Data and Storage/DataAndStorageSettingsController.swift +++ b/submodules/SettingsUI/Sources/Data and Storage/DataAndStorageSettingsController.swift @@ -23,6 +23,7 @@ public enum AutomaticSaveIncomingPeerType { private final class DataAndStorageControllerArguments { let openStorageUsage: () -> Void let openNetworkUsage: () -> Void + let clearDeletedMessages: () -> Void let openProxy: () -> Void let openAutomaticDownloadConnectionType: (AutomaticDownloadConnectionType) -> Void let resetAutomaticDownload: () -> Void @@ -38,6 +39,7 @@ private final class DataAndStorageControllerArguments { init( openStorageUsage: @escaping () -> Void, openNetworkUsage: @escaping () -> Void, + clearDeletedMessages: @escaping () -> Void, openProxy: @escaping () -> Void, openAutomaticDownloadConnectionType: @escaping (AutomaticDownloadConnectionType) -> Void, resetAutomaticDownload: @escaping () -> Void, @@ -52,6 +54,7 @@ private final class DataAndStorageControllerArguments { ) { self.openStorageUsage = openStorageUsage self.openNetworkUsage = openNetworkUsage + self.clearDeletedMessages = clearDeletedMessages self.openProxy = openProxy self.openAutomaticDownloadConnectionType = openAutomaticDownloadConnectionType self.resetAutomaticDownload = resetAutomaticDownload @@ -86,7 +89,7 @@ public enum DataAndStorageEntryTag: ItemListItemTag, Equatable { case autoSave(AutomaticSaveIncomingPeerType) case sensitiveContent case useLessVoiceData - + public func isEqual(to other: ItemListItemTag) -> Bool { if let other = other as? DataAndStorageEntryTag, self == other { return true @@ -99,18 +102,19 @@ public enum DataAndStorageEntryTag: ItemListItemTag, Equatable { private enum DataAndStorageEntry: ItemListNodeEntry { case storageUsage(PresentationTheme, String, String) case networkUsage(PresentationTheme, String, String) + case deletedMessages(PresentationTheme, String, String, Bool) case automaticDownloadHeader(PresentationTheme, String) case automaticDownloadCellular(PresentationTheme, String, String) case automaticDownloadWifi(PresentationTheme, String, String) case automaticDownloadReset(PresentationTheme, String, Bool) - + case autoSaveHeader(String) case autoSaveItem(index: Int, type: AutomaticSaveIncomingPeerType, title: String, label: String, value: String) case autoSaveInfo(String) - + case downloadInBackground(PresentationTheme, String, Bool) case downloadInBackgroundInfo(PresentationTheme, String) - + case useLessVoiceData(PresentationTheme, String, Bool) case useLessVoiceDataInfo(PresentationTheme, String) case otherHeader(PresentationTheme, String) @@ -119,16 +123,16 @@ private enum DataAndStorageEntry: ItemListNodeEntry { case pauseMusicOnRecording(PresentationTheme, String, Bool) case raiseToListen(PresentationTheme, String, Bool) case raiseToListenInfo(PresentationTheme, String) - + case sensitiveContent(String, Bool) case sensitiveContentInfo(String) - + case connectionHeader(PresentationTheme, String) case connectionProxy(PresentationTheme, String, String) - + var section: ItemListSectionId { switch self { - case .storageUsage, .networkUsage: + case .storageUsage, .networkUsage, .deletedMessages: return DataAndStorageSection.usage.rawValue case .automaticDownloadHeader, .automaticDownloadCellular, .automaticDownloadWifi, .automaticDownloadReset: return DataAndStorageSection.autoDownload.rawValue @@ -146,25 +150,27 @@ private enum DataAndStorageEntry: ItemListNodeEntry { return DataAndStorageSection.connection.rawValue } } - + var stableId: Int32 { switch self { case .storageUsage: return 0 case .networkUsage: return 1 - case .automaticDownloadHeader: + case .deletedMessages: return 2 - case .automaticDownloadCellular: + case .automaticDownloadHeader: return 3 - case .automaticDownloadWifi: + case .automaticDownloadCellular: return 4 - case .automaticDownloadReset: + case .automaticDownloadWifi: return 5 - case .autoSaveHeader: + case .automaticDownloadReset: return 6 + case .autoSaveHeader: + return 7 case let .autoSaveItem(index, _, _, _, _): - return 7 + Int32(index) + return 8 + Int32(index) case .autoSaveInfo: return 20 case .downloadInBackground: @@ -197,7 +203,7 @@ private enum DataAndStorageEntry: ItemListNodeEntry { return 39 } } - + static func ==(lhs: DataAndStorageEntry, rhs: DataAndStorageEntry) -> Bool { switch lhs { case let .storageUsage(lhsTheme, lhsText, lhsValue): @@ -212,6 +218,12 @@ private enum DataAndStorageEntry: ItemListNodeEntry { } else { return false } + case let .deletedMessages(lhsTheme, lhsText, lhsValue, lhsEnabled): + if case let .deletedMessages(rhsTheme, rhsText, rhsValue, rhsEnabled) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue, lhsEnabled == rhsEnabled { + return true + } else { + return false + } case let .automaticDownloadHeader(lhsTheme, lhsText): if case let .automaticDownloadHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { return true @@ -340,11 +352,11 @@ private enum DataAndStorageEntry: ItemListNodeEntry { } } } - + static func <(lhs: DataAndStorageEntry, rhs: DataAndStorageEntry) -> Bool { return lhs.stableId < rhs.stableId } - + func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { let arguments = arguments as! DataAndStorageControllerArguments switch self { @@ -356,6 +368,10 @@ private enum DataAndStorageEntry: ItemListNodeEntry { return ItemListDisclosureItem(presentationData: presentationData, systemStyle: .glass, icon: PresentationResourcesSettings.dataUsage, title: text, label: value, sectionId: self.section, style: .blocks, action: { arguments.openNetworkUsage() }) + case let .deletedMessages(_, text, value, _): + return ItemListDisclosureItem(presentationData: presentationData, systemStyle: .glass, icon: PresentationResourcesItemList.deleteIconImage(presentationData.theme), title: text, label: value, sectionId: self.section, style: .blocks, action: { + arguments.clearDeletedMessages() + }) case let .automaticDownloadHeader(_, text): return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) case let .automaticDownloadCellular(_, text, value): @@ -454,7 +470,7 @@ private struct DataAndStorageData: Equatable { let mediaInputSettings: MediaInputSettings let voiceCallSettings: VoiceCallSettings let proxySettings: ProxySettings? - + init(automaticMediaDownloadSettings: MediaAutoDownloadSettings, autodownloadSettings: AutodownloadSettings, generatedMediaStoreSettings: GeneratedMediaStoreSettings, mediaInputSettings: MediaInputSettings, voiceCallSettings: VoiceCallSettings, proxySettings: ProxySettings?) { self.automaticMediaDownloadSettings = automaticMediaDownloadSettings self.autodownloadSettings = autodownloadSettings @@ -463,7 +479,7 @@ private struct DataAndStorageData: Equatable { self.voiceCallSettings = voiceCallSettings self.proxySettings = proxySettings } - + static func ==(lhs: DataAndStorageData, rhs: DataAndStorageData) -> Bool { return lhs.automaticMediaDownloadSettings == rhs.automaticMediaDownloadSettings && lhs.generatedMediaStoreSettings == rhs.generatedMediaStoreSettings && lhs.mediaInputSettings == rhs.mediaInputSettings && lhs.voiceCallSettings == rhs.voiceCallSettings && lhs.proxySettings == rhs.proxySettings } @@ -501,7 +517,7 @@ private func stringForAutoDownloadTypes(strings: PresentationStrings, decimalSep if types.isEmpty { return strings.ChatSettings_AutoDownloadSettings_OffForAll } - + var string: String = "" for i in 0 ..< types.count { if !string.isEmpty { @@ -524,11 +540,11 @@ private func stringForAutoDownloadSetting(strings: PresentationStrings, decimalS return strings.ChatSettings_AutoDownloadSettings_OffForAll } else { let categories = effectiveAutodownloadCategories(settings: settings, networkType: connectionType.automaticDownloadNetworkType) - + let photo = isAutodownloadEnabledForAnyPeerType(category: categories.photo) let video = isAutodownloadEnabledForAnyPeerType(category: categories.video) let file = isAutodownloadEnabledForAnyPeerType(category: categories.file) - + return stringForAutoDownloadTypes(strings: strings, decimalSeparator: decimalSeparator, photo: photo, videoSize: video ? categories.video.sizeLimit : nil, fileSize: file ? categories.file.sizeLimit : nil) } } @@ -544,7 +560,7 @@ private func autosaveLabelAndValue(presentationData: PresentationData, settings: case .channels: configuration = settings.configurations[.channels] ?? .default } - + for exception in settings.exceptions { if let maybePeer = exceptionPeers[exception.id], let peer = maybePeer { let peerTypeValue: AutomaticSaveIncomingPeerType @@ -560,20 +576,20 @@ private func autosaveLabelAndValue(presentationData: PresentationData, settings: peerTypeValue = .groups } } - + if peerTypeValue == peerType { exceptionCount += 1 } } } - + let value: String if configuration.photo || configuration.video { value = presentationData.strings.Settings_AutosaveMediaOn } else { value = presentationData.strings.Settings_AutosaveMediaOff } - + var label = "" if configuration.photo && configuration.video { label.append(presentationData.strings.Settings_AutosaveMediaAllMedia(dataSizeString(Int(configuration.maximumVideoSize), formatting: DataSizeStringFormatting(presentationData: presentationData))).string) @@ -590,46 +606,48 @@ private func autosaveLabelAndValue(presentationData: PresentationData, settings: label.append(presentationData.strings.Settings_AutosaveMediaVideo(dataSizeString(Int(configuration.maximumVideoSize), formatting: DataSizeStringFormatting(presentationData: presentationData))).string) } } - + if exceptionCount != 0 { if !label.isEmpty { label.append(", ") } label.append(presentationData.strings.Notifications_CategoryExceptions(Int32(exceptionCount))) } - + return (label, value) } -private func dataAndStorageControllerEntries(context: AccountContext, state: DataAndStorageControllerState, data: DataAndStorageData, presentationData: PresentationData, contentSettingsConfiguration: ContentSettingsConfiguration?, networkUsage: Int64, storageUsage: Int64, mediaAutoSaveSettings: MediaAutoSaveSettings, autosaveExceptionPeers: [EnginePeer.Id: EnginePeer?], mediaSettings: MediaDisplaySettings, showSensitiveContentSetting: Bool) -> [DataAndStorageEntry] { +private func dataAndStorageControllerEntries(context: AccountContext, state: DataAndStorageControllerState, data: DataAndStorageData, presentationData: PresentationData, contentSettingsConfiguration: ContentSettingsConfiguration?, networkUsage: Int64, storageUsage: Int64, deletedMessagesSize: Int64, mediaAutoSaveSettings: MediaAutoSaveSettings, autosaveExceptionPeers: [EnginePeer.Id: EnginePeer?], mediaSettings: MediaDisplaySettings, showSensitiveContentSetting: Bool) -> [DataAndStorageEntry] { var entries: [DataAndStorageEntry] = [] - + entries.append(.storageUsage(presentationData.theme, presentationData.strings.ChatSettings_Cache, dataSizeString(storageUsage, formatting: DataSizeStringFormatting(presentationData: presentationData)))) entries.append(.networkUsage(presentationData.theme, presentationData.strings.NetworkUsageSettings_Title, dataSizeString(networkUsage, formatting: DataSizeStringFormatting(presentationData: presentationData)))) - + // Deleted-messages row sits below Storage/Network Usage (stableId 2). + entries.append(.deletedMessages(presentationData.theme, "Deleted Messages", dataSizeString(deletedMessagesSize, formatting: DataSizeStringFormatting(presentationData: presentationData)), deletedMessagesSize > 0)) + entries.append(.automaticDownloadHeader(presentationData.theme, presentationData.strings.ChatSettings_AutoDownloadTitle.uppercased())) entries.append(.automaticDownloadCellular(presentationData.theme, presentationData.strings.ChatSettings_AutoDownloadUsingCellular, stringForAutoDownloadSetting(strings: presentationData.strings, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator, settings: data.automaticMediaDownloadSettings, connectionType: .cellular))) entries.append(.automaticDownloadWifi(presentationData.theme, presentationData.strings.ChatSettings_AutoDownloadUsingWiFi, stringForAutoDownloadSetting(strings: presentationData.strings, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator, settings: data.automaticMediaDownloadSettings, connectionType: .wifi))) - + let defaultSettings = MediaAutoDownloadSettings.defaultSettings entries.append(.automaticDownloadReset(presentationData.theme, presentationData.strings.ChatSettings_AutoDownloadReset, data.automaticMediaDownloadSettings.cellular != defaultSettings.cellular || data.automaticMediaDownloadSettings.wifi != defaultSettings.wifi)) - + entries.append(.autoSaveHeader(presentationData.strings.Settings_SaveToCameraRollSection)) - + let privateLabelAndValue = autosaveLabelAndValue(presentationData: presentationData, settings: mediaAutoSaveSettings, peerType: .privateChats, exceptionPeers: autosaveExceptionPeers) let groupsLabelAndValue = autosaveLabelAndValue(presentationData: presentationData, settings: mediaAutoSaveSettings, peerType: .groups, exceptionPeers: autosaveExceptionPeers) let channelsLabelAndValue = autosaveLabelAndValue(presentationData: presentationData, settings: mediaAutoSaveSettings, peerType: .channels, exceptionPeers: autosaveExceptionPeers) - + entries.append(.autoSaveItem(index: 0, type: .privateChats, title: presentationData.strings.Notifications_PrivateChats, label: privateLabelAndValue.label, value: privateLabelAndValue.value)) entries.append(.autoSaveItem(index: 1, type: .groups, title: presentationData.strings.Notifications_GroupChats, label: groupsLabelAndValue.label, value: groupsLabelAndValue.value)) entries.append(.autoSaveItem(index: 2, type: .channels, title: presentationData.strings.Notifications_Channels, label: channelsLabelAndValue.label, value: channelsLabelAndValue.value)) entries.append(.autoSaveInfo(presentationData.strings.Settings_SaveToCameraRollInfo)) - - + + let dataSaving = effectiveDataSaving(for: data.voiceCallSettings, autodownloadSettings: data.autodownloadSettings) entries.append(.useLessVoiceData(presentationData.theme, presentationData.strings.ChatSettings_UseLessDataForCalls, dataSaving != .never)) entries.append(.useLessVoiceDataInfo(presentationData.theme, presentationData.strings.CallSettings_UseLessDataLongDescription)) - + entries.append(.otherHeader(presentationData.theme, presentationData.strings.ChatSettings_Other)) if #available(iOSApplicationExtension 13.2, iOS 13.2, *) { entries.append(.shareSheet(presentationData.theme, presentationData.strings.ChatSettings_IntentsSettings)) @@ -643,7 +661,7 @@ private func dataAndStorageControllerEntries(context: AccountContext, state: Dat entries.append(.sensitiveContent(presentationData.strings.Settings_SensitiveContent, contentSettingsConfiguration.sensitiveContentEnabled)) entries.append(.sensitiveContentInfo(presentationData.strings.Settings_SensitiveContentInfo)) } - + let proxyValue: String if let proxySettings = data.proxySettings, let activeServer = proxySettings.activeServer, proxySettings.enabled { switch activeServer.connection { @@ -657,32 +675,32 @@ private func dataAndStorageControllerEntries(context: AccountContext, state: Dat } entries.append(.connectionHeader(presentationData.theme, presentationData.strings.ChatSettings_ConnectionType_Title.uppercased())) entries.append(.connectionProxy(presentationData.theme, presentationData.strings.SocksProxySetup_Title, proxyValue)) - + return entries } public func dataAndStorageController(context: AccountContext, focusOnItemTag: DataAndStorageEntryTag? = nil) -> ViewController { let initialState = DataAndStorageControllerState() let statePromise = ValuePromise(initialState, ignoreRepeated: true) - + var pushControllerImpl: ((ViewController) -> Void)? var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)? var presentAgeVerificationImpl: ((@escaping () -> Void) -> Void)? - + let actionsDisposable = DisposableSet() - + //let cacheUsagePromise = Promise() //cacheUsagePromise.set(cacheUsageStats(context: context)) - + let updateSensitiveContentDisposable = MetaDisposable() actionsDisposable.add(updateSensitiveContentDisposable) - + let updatedContentSettingsConfiguration = contentSettingsConfiguration(network: context.account.network) |> map(Optional.init) let contentSettingsConfiguration = Promise() contentSettingsConfiguration.set(.single(nil) |> then(updatedContentSettingsConfiguration)) - + struct UsageData: Equatable { var network: Int64 var storage: Int64 @@ -694,56 +712,56 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da ) |> map { disk1, disk2, networkStats -> UsageData in var network: Int64 = 0 - + var keys: [KeyPath] = [] - + keys.append(\.generic.cellular.outgoing) keys.append(\.generic.cellular.incoming) keys.append(\.generic.wifi.incoming) keys.append(\.generic.wifi.outgoing) - + keys.append(\.image.cellular.outgoing) keys.append(\.image.cellular.incoming) keys.append(\.image.wifi.incoming) keys.append(\.image.wifi.outgoing) - + keys.append(\.video.cellular.outgoing) keys.append(\.video.cellular.incoming) keys.append(\.video.wifi.incoming) keys.append(\.video.wifi.outgoing) - + keys.append(\.audio.cellular.outgoing) keys.append(\.audio.cellular.incoming) keys.append(\.audio.wifi.incoming) keys.append(\.audio.wifi.outgoing) - + keys.append(\.file.cellular.outgoing) keys.append(\.file.cellular.incoming) keys.append(\.file.wifi.incoming) keys.append(\.file.wifi.outgoing) - + keys.append(\.call.cellular.outgoing) keys.append(\.call.cellular.incoming) keys.append(\.call.wifi.incoming) keys.append(\.call.wifi.outgoing) - + keys.append(\.sticker.cellular.outgoing) keys.append(\.sticker.cellular.incoming) keys.append(\.sticker.wifi.incoming) keys.append(\.sticker.wifi.outgoing) - + keys.append(\.voiceMessage.cellular.outgoing) keys.append(\.voiceMessage.cellular.incoming) keys.append(\.voiceMessage.wifi.incoming) keys.append(\.voiceMessage.wifi.outgoing) - + for key in keys { network += networkStats[keyPath: key] } - + return UsageData(network: network, storage: disk1 + disk2) } - + let dataAndStorageDataPromise = Promise() dataAndStorageDataPromise.set(context.sharedContext.accountManager.sharedData(keys: [SharedDataKeys.autodownloadSettings, ApplicationSpecificSharedDataKeys.automaticMediaDownloadSettings, ApplicationSpecificSharedDataKeys.generatedMediaStoreSettings, ApplicationSpecificSharedDataKeys.voiceCallSettings, ApplicationSpecificSharedDataKeys.mediaInputSettings, SharedDataKeys.proxySettings]) |> map { sharedData -> DataAndStorageData in @@ -753,7 +771,7 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da } else { automaticMediaDownloadSettings = .defaultSettings } - + var autodownloadSettings: AutodownloadSettings if let value = sharedData.entries[SharedDataKeys.autodownloadSettings]?.get(AutodownloadSettings.self) { autodownloadSettings = value @@ -761,36 +779,36 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da } else { autodownloadSettings = .defaultSettings } - + let generatedMediaStoreSettings: GeneratedMediaStoreSettings if let value = sharedData.entries[ApplicationSpecificSharedDataKeys.generatedMediaStoreSettings]?.get(GeneratedMediaStoreSettings.self) { generatedMediaStoreSettings = value } else { generatedMediaStoreSettings = GeneratedMediaStoreSettings.defaultSettings } - + let mediaInputSettings: MediaInputSettings if let value = sharedData.entries[ApplicationSpecificSharedDataKeys.mediaInputSettings]?.get(MediaInputSettings.self) { mediaInputSettings = value } else { mediaInputSettings = MediaInputSettings.defaultSettings } - + let voiceCallSettings: VoiceCallSettings if let value = sharedData.entries[ApplicationSpecificSharedDataKeys.voiceCallSettings]?.get(VoiceCallSettings.self) { voiceCallSettings = value } else { voiceCallSettings = VoiceCallSettings.defaultSettings } - + var proxySettings: ProxySettings? if let value = sharedData.entries[SharedDataKeys.proxySettings]?.get(ProxySettings.self) { proxySettings = value } - + return DataAndStorageData(automaticMediaDownloadSettings: automaticMediaDownloadSettings, autodownloadSettings: autodownloadSettings, generatedMediaStoreSettings: generatedMediaStoreSettings, mediaInputSettings: mediaInputSettings, voiceCallSettings: voiceCallSettings, proxySettings: proxySettings) }) - + let arguments = DataAndStorageControllerArguments(openStorageUsage: { pushControllerImpl?(StorageUsageScreen(context: context, makeStorageUsageExceptionsScreen: { category in return storageUsageExceptionsScreen(context: context, category: category) @@ -806,7 +824,7 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da } return automaticMediaDownloadSettings } - + let _ = (combineLatest( accountNetworkUsageStats(account: context.account, reset: []), mediaAutoDownloadSettings @@ -814,18 +832,20 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da |> take(1) |> deliverOnMainQueue).start(next: { stats, mediaAutoDownloadSettings in var stats = stats - + if stats.resetWifiTimestamp == 0 { var value = stat() if stat(context.account.basePath, &value) == 0 { stats.resetWifiTimestamp = Int32(value.st_ctimespec.tv_sec) } } - + pushControllerImpl?(DataUsageScreen(context: context, stats: stats, mediaAutoDownloadSettings: mediaAutoDownloadSettings, makeAutodownloadSettingsController: { isCellular in return autodownloadMediaConnectionTypeController(context: context, connectionType: isCellular ? .cellular : .wifi) })) }) + }, clearDeletedMessages: { + pushControllerImpl?(winterGramDeletedMessagesController(context: context)) }, openProxy: { pushControllerImpl?(proxySettingsController(context: context)) }, openAutomaticDownloadConnectionType: { connectionType in @@ -837,7 +857,7 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da ActionSheetTextItem(title: presentationData.strings.AutoDownloadSettings_ResetHelp), ActionSheetButtonItem(title: presentationData.strings.AutoDownloadSettings_Reset, color: .destructive, action: { [weak actionSheet] in actionSheet?.dismissAnimated() - + let _ = updateMediaDownloadSettingsInteractively(accountManager: context.sharedContext.accountManager, { settings in var settings = settings let defaultSettings = MediaAutoDownloadSettings.defaultSettings @@ -910,12 +930,12 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da update() } }) - + let preferences = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key: ApplicationSpecificPreferencesKeys.mediaAutoSaveSettings)) |> map { entry -> MediaAutoSaveSettings in return entry?.get(MediaAutoSaveSettings.self) ?? MediaAutoSaveSettings.default } - + let autosaveExceptionPeers: Signal<[EnginePeer.Id: EnginePeer?], NoError> = preferences |> mapToSignal { mediaAutoSaveSettings -> Signal<[EnginePeer.Id: EnginePeer?], NoError> in let peerIds = mediaAutoSaveSettings.exceptions.map(\.id) @@ -926,7 +946,9 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da let sensitiveContent = Atomic(value: nil) let canAdjustSensitiveContent = Atomic(value: nil) - + + let deletedMessagesSizeSignal = winterGramDeletedMessagesSize(postbox: context.account.postbox) + let signal = combineLatest(queue: .mainQueue(), context.sharedContext.presentationData, statePromise.get(), @@ -935,11 +957,12 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da contentSettingsConfiguration.get(), preferences, usageSignal, - autosaveExceptionPeers + autosaveExceptionPeers, + deletedMessagesSizeSignal ) - |> map { presentationData, state, dataAndStorageData, sharedData, contentSettingsConfiguration, mediaAutoSaveSettings, usageSignal, autosaveExceptionPeers -> (ItemListControllerState, (ItemListNodeState, Any)) in + |> map { presentationData, state, dataAndStorageData, sharedData, contentSettingsConfiguration, mediaAutoSaveSettings, usageSignal, autosaveExceptionPeers, deletedMessagesSize -> (ItemListControllerState, (ItemListNodeState, Any)) in let mediaSettings = sharedData.entries[ApplicationSpecificSharedDataKeys.mediaDisplaySettings]?.get(MediaDisplaySettings.self) ?? MediaDisplaySettings.defaultSettings - + let previousSensitiveContent = sensitiveContent.swap(contentSettingsConfiguration?.sensitiveContentEnabled) var animateChanges = false if previousSensitiveContent != contentSettingsConfiguration?.sensitiveContentEnabled { @@ -950,15 +973,15 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da let _ = canAdjustSensitiveContent.swap(contentSettingsConfiguration?.sensitiveContentEnabled) } let showSensitiveContentSetting = canAdjustSensitiveContent.with { $0 } ?? false - + let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(presentationData.strings.ChatSettings_Title), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: false) - let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: dataAndStorageControllerEntries(context: context, state: state, data: dataAndStorageData, presentationData: presentationData, contentSettingsConfiguration: contentSettingsConfiguration, networkUsage: usageSignal.network, storageUsage: usageSignal.storage, mediaAutoSaveSettings: mediaAutoSaveSettings, autosaveExceptionPeers: autosaveExceptionPeers, mediaSettings: mediaSettings, showSensitiveContentSetting: showSensitiveContentSetting), style: .blocks, ensureVisibleItemTag: focusOnItemTag, emptyStateItem: nil, animateChanges: animateChanges) - + let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: dataAndStorageControllerEntries(context: context, state: state, data: dataAndStorageData, presentationData: presentationData, contentSettingsConfiguration: contentSettingsConfiguration, networkUsage: usageSignal.network, storageUsage: usageSignal.storage, deletedMessagesSize: deletedMessagesSize, mediaAutoSaveSettings: mediaAutoSaveSettings, autosaveExceptionPeers: autosaveExceptionPeers, mediaSettings: mediaSettings, showSensitiveContentSetting: showSensitiveContentSetting), style: .blocks, ensureVisibleItemTag: focusOnItemTag, emptyStateItem: nil, animateChanges: animateChanges) + return (controllerState, (listState, arguments)) } |> afterDisposed { actionsDisposable.dispose() } - + let controller = ItemListController(context: context, state: signal) pushControllerImpl = { [weak controller] c in if let controller = controller { @@ -976,7 +999,7 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da update() }) } - + if let focusOnItemTag { var didFocusOnItem = false controller.afterTransactionCompleted = { [weak controller] in @@ -990,6 +1013,6 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da } } } - + return controller } diff --git a/submodules/SettingsUI/Sources/Data and Storage/WinterGramDeletedMessagesCategoryItem.swift b/submodules/SettingsUI/Sources/Data and Storage/WinterGramDeletedMessagesCategoryItem.swift new file mode 100644 index 0000000000..b2dd4d9e03 --- /dev/null +++ b/submodules/SettingsUI/Sources/Data and Storage/WinterGramDeletedMessagesCategoryItem.swift @@ -0,0 +1,280 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData +import ItemListUI +import CheckNode + +// WinterGram: a Storage-Usage-style category row for the deleted-messages screen. +// Shows a colored check-circle, title + percentage, and the size on the right. +final class WinterGramDeletedMessagesCategoryItem: ListViewItem, ItemListItem { + let presentationData: ItemListPresentationData + let category: WinterGramDeletedMessageCategory + let color: UIColor + let title: String + let size: Int64 + let fraction: Double + let checked: Bool + let isLast: Bool + let sectionId: ItemListSectionId + let toggle: () -> Void + + init(presentationData: ItemListPresentationData, category: WinterGramDeletedMessageCategory, color: UIColor, title: String, size: Int64, fraction: Double, checked: Bool, isLast: Bool, sectionId: ItemListSectionId, toggle: @escaping () -> Void) { + self.presentationData = presentationData + self.category = category + self.color = color + self.title = title + self.size = size + self.fraction = fraction + self.checked = checked + self.isLast = isLast + self.sectionId = sectionId + self.toggle = toggle + } + + func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { + async { + let node = WinterGramDeletedMessagesCategoryItemNode() + let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + node.contentSize = layout.contentSize + node.insets = layout.insets + Queue.mainQueue().async { + completion(node, { + return (nil, { _ in apply() }) + }) + } + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { + Queue.mainQueue().async { + if let nodeValue = node() as? WinterGramDeletedMessagesCategoryItemNode { + let makeLayout = nodeValue.asyncLayout() + async { + let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + Queue.mainQueue().async { + completion(layout, { _ in + apply() + }) + } + } + } + } + } +} + +final class WinterGramDeletedMessagesCategoryItemNode: ListViewItemNode { + private let backgroundNode: ASDisplayNode + private let topStripeNode: ASDisplayNode + private let bottomStripeNode: ASDisplayNode + private let maskNode: ASImageNode + private let checkNode: CheckNode + private let titleNode: ImmediateTextNode + private let percentNode: ImmediateTextNode + private let sizeNode: ImmediateTextNode + private var separatorNode: ASDisplayNode? + private var tapGestureRecognizer: UITapGestureRecognizer? + private var tapAction: (() -> Void)? + + init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isLayerBacked = true + self.topStripeNode = ASDisplayNode() + self.topStripeNode.isLayerBacked = true + self.bottomStripeNode = ASDisplayNode() + self.bottomStripeNode.isLayerBacked = true + self.maskNode = ASImageNode() + + self.checkNode = CheckNode(theme: CheckNodeTheme(backgroundColor: .gray, strokeColor: .white, borderColor: .gray, overlayBorder: false, hasInset: false, hasShadow: false), content: .check(isRectangle: false)) + self.titleNode = ImmediateTextNode() + self.titleNode.displaysAsynchronously = false + self.titleNode.isUserInteractionEnabled = false + self.percentNode = ImmediateTextNode() + self.percentNode.displaysAsynchronously = false + self.percentNode.isUserInteractionEnabled = false + self.sizeNode = ImmediateTextNode() + self.sizeNode.displaysAsynchronously = false + self.sizeNode.isUserInteractionEnabled = false + + super.init(layerBacked: false) + + self.addSubnode(self.checkNode) + self.addSubnode(self.titleNode) + self.addSubnode(self.percentNode) + self.addSubnode(self.sizeNode) + } + + override func didLoad() { + super.didLoad() + let tap = UITapGestureRecognizer(target: self, action: #selector(self.tapPressed)) + self.tapGestureRecognizer = tap + self.view.addGestureRecognizer(tap) + } + + @objc private func tapPressed() { + self.tapAction?() + } + + func asyncLayout() -> (_ item: WinterGramDeletedMessagesCategoryItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { + return { item, params, neighbors in + let height: CGFloat = 52.0 + let contentSize = CGSize(width: params.width, height: height) + let insets = itemListNeighborsGroupedInsets(neighbors, params) + let separatorHeight = UIScreenPixel + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) + let layoutSize = layout.size + + let theme = item.presentationData.theme + + return (layout, { [weak self] in + guard let strongSelf = self else { + return + } + + strongSelf.tapAction = item.toggle + + strongSelf.backgroundNode.backgroundColor = theme.list.itemBlocksBackgroundColor + strongSelf.topStripeNode.backgroundColor = theme.list.itemBlocksSeparatorColor + strongSelf.bottomStripeNode.backgroundColor = theme.list.itemBlocksSeparatorColor + + if strongSelf.backgroundNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) + } + if strongSelf.topStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1) + } + if strongSelf.bottomStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) + } + if strongSelf.maskNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.maskNode, at: 3) + } + + let hasCorners = itemListHasRoundedBlockLayout(params) + switch neighbors.top { + case .sameSection(false): + strongSelf.topStripeNode.isHidden = true + default: + strongSelf.topStripeNode.isHidden = hasCorners + } + let bottomStripeInset: CGFloat + let bottomStripeOffset: CGFloat + switch neighbors.bottom { + case .sameSection(false): + bottomStripeInset = params.leftInset + 16.0 + bottomStripeOffset = 0.0 + default: + bottomStripeInset = 0.0 + bottomStripeOffset = separatorHeight + } + + strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: layoutSize.width, height: contentSize.height)) + strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)) + strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) + + strongSelf.maskNode.image = nil + if hasCorners { + let maskingCornerRadius: CGFloat = 10.0 + if let cornersImage = generateStretchableFilledCircleImage(radius: maskingCornerRadius, color: .black) { + strongSelf.maskNode.image = cornersImage + } + } + + let checkDiameter: CGFloat = 22.0 + let checkFrame = CGRect(origin: CGPoint(x: 20.0, y: floor((height - checkDiameter) / 2.0)), size: CGSize(width: checkDiameter, height: checkDiameter)) + strongSelf.checkNode.frame = checkFrame + strongSelf.checkNode.theme = CheckNodeTheme( + backgroundColor: item.color, + strokeColor: theme.list.itemCheckColors.foregroundColor, + borderColor: theme.list.itemCheckColors.strokeColor, + overlayBorder: false, + hasInset: false, + hasShadow: false + ) + strongSelf.checkNode.setSelected(item.checked, animated: false) + + let sizeFormatting = DataSizeStringFormatting(strings: item.presentationData.strings, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator) + let sizeString = dataSizeString(item.size, formatting: sizeFormatting) + strongSelf.sizeNode.attributedText = NSAttributedString(string: sizeString, font: Font.regular(17.0), textColor: theme.list.itemSecondaryTextColor) + let sizeSize = strongSelf.sizeNode.updateLayout(CGSize(width: params.width - 100.0, height: 44.0)) + + let percentString: String + if item.fraction > 0.0 { + let value = floor(item.fraction * 100.0 * 10.0) / 10.0 + if value < 0.1 { + percentString = "<0.1%" + } else if abs(Double(Int(value)) - value) < 0.001 { + percentString = "\(Int(value))%" + } else { + percentString = "\(value)%" + } + } else { + percentString = "" + } + strongSelf.percentNode.attributedText = NSAttributedString(string: percentString, font: Font.regular(17.0), textColor: theme.list.itemSecondaryTextColor) + let percentSize = strongSelf.percentNode.updateLayout(CGSize(width: params.width - 100.0, height: 44.0)) + + strongSelf.titleNode.attributedText = NSAttributedString(string: item.title, font: Font.regular(17.0), textColor: theme.list.itemPrimaryTextColor) + + let sizeFrame = CGRect(origin: CGPoint(x: params.width - 16.0 - sizeSize.width, y: floor((height - sizeSize.height) / 2.0)), size: sizeSize) + let percentFrame = CGRect(origin: CGPoint(x: sizeFrame.minX - 8.0 - percentSize.width, y: floor((height - percentSize.height) / 2.0)), size: percentSize) + let titleMaxWidth = max(0.0, percentFrame.minX - 8.0 - 62.0) + let titleSize = strongSelf.titleNode.updateLayout(CGSize(width: titleMaxWidth, height: 44.0)) + let titleFrame = CGRect(origin: CGPoint(x: 62.0, y: floor((height - titleSize.height) / 2.0)), size: titleSize) + + strongSelf.titleNode.frame = titleFrame + strongSelf.percentNode.frame = percentFrame + strongSelf.sizeNode.frame = sizeFrame + + if item.isLast { + if let separatorNode = strongSelf.separatorNode { + separatorNode.isHidden = true + } + } else { + let separatorNode: ASDisplayNode + if let current = strongSelf.separatorNode { + separatorNode = current + } else { + separatorNode = ASDisplayNode() + separatorNode.isLayerBacked = true + strongSelf.separatorNode = separatorNode + strongSelf.insertSubnode(separatorNode, aboveSubnode: strongSelf.backgroundNode) + } + separatorNode.isHidden = false + separatorNode.backgroundColor = theme.list.itemBlocksSeparatorColor + separatorNode.frame = CGRect(origin: CGPoint(x: 62.0, y: height), size: CGSize(width: params.width - 62.0, height: UIScreenPixel)) + } + }) + } + } +} + +// Expose a factory so the controller can use the custom row without knowing the private types. +func winterGramDeletedMessagesCategoryItem( + presentationData: ItemListPresentationData, + category: WinterGramDeletedMessageCategory, + color: UIColor, + title: String, + size: Int64, + fraction: Double, + checked: Bool, + isLast: Bool, + sectionId: ItemListSectionId, + toggle: @escaping () -> Void +) -> ListViewItem { + return WinterGramDeletedMessagesCategoryItem( + presentationData: presentationData, + category: category, + color: color, + title: title, + size: size, + fraction: fraction, + checked: checked, + isLast: isLast, + sectionId: sectionId, + toggle: toggle + ) +} diff --git a/submodules/SettingsUI/Sources/Data and Storage/WinterGramDeletedMessagesController.swift b/submodules/SettingsUI/Sources/Data and Storage/WinterGramDeletedMessagesController.swift new file mode 100644 index 0000000000..e01cd40e4e --- /dev/null +++ b/submodules/SettingsUI/Sources/Data and Storage/WinterGramDeletedMessagesController.swift @@ -0,0 +1,558 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData +import TelegramUIPreferences +import ItemListUI +import ItemListPeerActionItem +import ItemListPeerItem +import AccountContext +import TelegramStringFormatting +import UndoUI +import ComponentFlow +import StorageUsageScreen + +private func generatePieChartImage(size: CGSize, values: [(color: UIColor, fraction: CGFloat)], theme: PresentationTheme) -> UIImage? { + return generateImage(size, contextGenerator: { targetSize, context in + context.clear(CGRect(origin: .zero, size: targetSize)) + let center = CGPoint(x: targetSize.width / 2.0, y: targetSize.height / 2.0) + let outerRadius = min(targetSize.width, targetSize.height) / 2.0 - 2.0 + let innerRadius = outerRadius * 0.52 + let separatorAngle: CGFloat = 0.03 + var startAngle: CGFloat = -CGFloat.pi / 2.0 + let total = values.reduce(0.0) { $0 + max(0.0, $1.fraction) } + guard total > 0.0 else { + let emptyColor = theme.list.itemAccentColor.withAlphaComponent(0.25) + context.setFillColor(emptyColor.cgColor) + context.fillEllipse(in: CGRect(x: center.x - outerRadius, y: center.y - outerRadius, width: outerRadius * 2.0, height: outerRadius * 2.0)) + context.setFillColor(theme.list.blocksBackgroundColor.cgColor) + context.fillEllipse(in: CGRect(x: center.x - innerRadius, y: center.y - innerRadius, width: innerRadius * 2.0, height: innerRadius * 2.0)) + return + } + for (color, fraction) in values { + let rawSweep = (fraction / total) * CGFloat.pi * 2.0 + let sweep = max(0.0, rawSweep - separatorAngle) + let endAngle = startAngle + sweep + let path = CGMutablePath() + path.addArc(center: center, radius: outerRadius, startAngle: startAngle, endAngle: endAngle, clockwise: false) + path.addArc(center: center, radius: innerRadius, startAngle: endAngle, endAngle: startAngle, clockwise: true) + path.closeSubpath() + context.addPath(path) + context.setFillColor(color.cgColor) + context.fillPath() + startAngle += rawSweep + } + }, opaque: false) +} + +private let categoryColors: [WinterGramDeletedMessageCategory: UIColor] = [ + .text: UIColor(rgb: 0x34C759), + .photo: UIColor(rgb: 0x007AFF), + .video: UIColor(rgb: 0xFF2D55), + .voice: UIColor(rgb: 0xFF9500), + .videoMessage: UIColor(rgb: 0xAF52DE), + .music: UIColor(rgb: 0x5856D6), + .sticker: UIColor(rgb: 0xFFCC00), + .other: UIColor(rgb: 0x8E8E93) +] + +private func categoryTitle(_ category: WinterGramDeletedMessageCategory, _ strings: PresentationStrings) -> String { + switch category { + case .text: + return strings.WinterGram_DeletedMessages_Text + case .photo: + return strings.WinterGram_DeletedMessages_Photos + case .video: + return strings.WinterGram_DeletedMessages_Videos + case .voice: + return strings.WinterGram_DeletedMessages_Voice + case .videoMessage: + return strings.WinterGram_DeletedMessages_VideoMessages + case .music: + return strings.WinterGram_DeletedMessages_Music + case .sticker: + return strings.WinterGram_DeletedMessages_Stickers + case .other: + return strings.WinterGram_DeletedMessages_Other + } +} + +private final class WinterGramDeletedMessagesChartItem: ListViewItem, ItemListItem { + let presentationData: ItemListPresentationData + let chartData: PieChartComponent.ChartData + let totalText: String + let sectionId: ItemListSectionId + + init(presentationData: ItemListPresentationData, chartData: PieChartComponent.ChartData, totalText: String, sectionId: ItemListSectionId) { + self.presentationData = presentationData + self.chartData = chartData + self.totalText = totalText + self.sectionId = sectionId + } + + func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { + async { + let node = WinterGramDeletedMessagesChartItemNode() + let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + node.contentSize = layout.contentSize + node.insets = layout.insets + Queue.mainQueue().async { + completion(node, { + return (nil, { _ in apply() }) + }) + } + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { + Queue.mainQueue().async { + if let nodeValue = node() as? WinterGramDeletedMessagesChartItemNode { + let makeLayout = nodeValue.asyncLayout() + async { + let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + Queue.mainQueue().async { + completion(layout, { _ in + apply() + }) + } + } + } + } + } +} + +private final class WinterGramDeletedMessagesChartItemNode: ListViewItemNode { + private let backgroundNode: ASDisplayNode + private let topStripeNode: ASDisplayNode + private let bottomStripeNode: ASDisplayNode + private let maskNode: ASImageNode + private let chartHost: ComponentHostView + private let totalLabelNode: ImmediateTextNode + + init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isLayerBacked = true + self.topStripeNode = ASDisplayNode() + self.topStripeNode.isLayerBacked = true + self.bottomStripeNode = ASDisplayNode() + self.bottomStripeNode.isLayerBacked = true + self.maskNode = ASImageNode() + self.chartHost = ComponentHostView() + self.totalLabelNode = ImmediateTextNode() + self.totalLabelNode.displaysAsynchronously = false + self.totalLabelNode.isUserInteractionEnabled = false + + super.init(layerBacked: false) + + self.addSubnode(self.totalLabelNode) + } + + func asyncLayout() -> (_ item: WinterGramDeletedMessagesChartItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { + return { item, params, neighbors in + let imageSize = CGSize(width: 240.0, height: 240.0) + let contentSize = CGSize(width: params.width, height: imageSize.height + 24.0) + let insets = itemListNeighborsGroupedInsets(neighbors, params) + let separatorHeight = UIScreenPixel + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) + let layoutSize = layout.size + + let theme = item.presentationData.theme + + return (layout, { [weak self] in + guard let strongSelf = self else { + return + } + + strongSelf.backgroundNode.backgroundColor = theme.list.itemBlocksBackgroundColor + strongSelf.topStripeNode.backgroundColor = theme.list.itemBlocksSeparatorColor + strongSelf.bottomStripeNode.backgroundColor = theme.list.itemBlocksSeparatorColor + + if strongSelf.backgroundNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) + } + if strongSelf.topStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1) + } + if strongSelf.bottomStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) + } + if strongSelf.maskNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.maskNode, at: 3) + } + + let hasCorners = itemListHasRoundedBlockLayout(params) + var hasTopCorners = false + var hasBottomCorners = false + switch neighbors.top { + case .sameSection(false): + strongSelf.topStripeNode.isHidden = true + default: + hasTopCorners = true + strongSelf.topStripeNode.isHidden = hasCorners + } + let bottomStripeInset: CGFloat + let bottomStripeOffset: CGFloat + switch neighbors.bottom { + case .sameSection(false): + bottomStripeInset = params.leftInset + 16.0 + bottomStripeOffset = -separatorHeight + strongSelf.bottomStripeNode.isHidden = false + default: + bottomStripeInset = 0.0 + bottomStripeOffset = 0.0 + hasBottomCorners = true + strongSelf.bottomStripeNode.isHidden = hasCorners + } + + strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(theme, top: hasTopCorners, bottom: hasBottomCorners) : nil + + strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) + strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0) + strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)) + strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) + + if strongSelf.chartHost.superview == nil { + strongSelf.view.addSubview(strongSelf.chartHost) + } + // Keep the centre total label above the pie. + strongSelf.view.bringSubviewToFront(strongSelf.totalLabelNode.view) + let chartSize = strongSelf.chartHost.update( + transition: .immediate, + component: AnyComponent(PieChartComponent( + theme: theme, + strings: item.presentationData.strings, + emptyColor: theme.list.itemAccentColor.withAlphaComponent(0.25), + chartData: item.chartData + )), + environment: {}, + containerSize: imageSize + ) + let chartFrame = CGRect(origin: CGPoint(x: floor((params.width - chartSize.width) / 2.0), y: 12.0), size: chartSize) + strongSelf.chartHost.frame = chartFrame + + // Total size in the donut centre, matching the native Storage Usage screen. + strongSelf.totalLabelNode.attributedText = NSAttributedString(string: item.totalText, font: Font.semibold(20.0), textColor: theme.list.itemPrimaryTextColor) + let totalLabelSize = strongSelf.totalLabelNode.updateLayout(CGSize(width: chartFrame.width, height: 44.0)) + strongSelf.totalLabelNode.frame = CGRect(origin: CGPoint(x: chartFrame.minX + floor((chartFrame.width - totalLabelSize.width) / 2.0), y: chartFrame.minY + floor((chartFrame.height - totalLabelSize.height) / 2.0)), size: totalLabelSize) + }) + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + } +} + +private enum WinterGramDeletedMessagesControllerSection: Int32 { + case overview + case chart + case categories + case topChats + case delete +} + +private enum WinterGramDeletedMessagesControllerEntry: ItemListNodeEntry { + case overviewHeader(PresentationTheme, String) + case overviewInfo(PresentationTheme, String) + case chart(PresentationTheme, PieChartComponent.ChartData, String) + case categoryHeader(PresentationTheme, String) + case category(PresentationTheme, WinterGramDeletedMessageCategory, Int64, Int, Bool, Bool, Double) + case topChatsHeader(PresentationTheme, String) + case topChat(PresentationTheme, EnginePeer, Int, Int64, Int32) + case delete(PresentationTheme, String, Bool) + + var section: ItemListSectionId { + switch self { + case .overviewHeader, .overviewInfo: + return WinterGramDeletedMessagesControllerSection.overview.rawValue + case .chart: + return WinterGramDeletedMessagesControllerSection.chart.rawValue + case .categoryHeader, .category: + return WinterGramDeletedMessagesControllerSection.categories.rawValue + case .topChatsHeader, .topChat: + return WinterGramDeletedMessagesControllerSection.topChats.rawValue + case .delete: + return WinterGramDeletedMessagesControllerSection.delete.rawValue + } + } + + var stableId: Int32 { + switch self { + case .overviewHeader: + return 0 + case .overviewInfo: + return 1 + case .chart: + return 2 + case .categoryHeader: + return 3 + case let .category(_, category, _, _, _, _, _): + return 10 + category.rawValue + case .topChatsHeader: + return 200 + case let .topChat(_, _, _, _, index): + return 210 + index + case .delete: + return 1000 + } + } + + static func ==(lhs: WinterGramDeletedMessagesControllerEntry, rhs: WinterGramDeletedMessagesControllerEntry) -> Bool { + switch lhs { + case let .overviewHeader(lhsTheme, lhsText): + if case let .overviewHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } else { + return false + } + case let .overviewInfo(lhsTheme, lhsText): + if case let .overviewInfo(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } else { + return false + } + case let .chart(lhsTheme, lhsData, lhsTotal): + if case let .chart(rhsTheme, rhsData, rhsTotal) = rhs, lhsTheme === rhsTheme, lhsData == rhsData, lhsTotal == rhsTotal { + return true + } else { + return false + } + case let .categoryHeader(lhsTheme, lhsText): + if case let .categoryHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } else { + return false + } + case let .category(lhsTheme, lhsCategory, lhsSize, lhsCount, lhsChecked, lhsIsLast, lhsFraction): + if case let .category(rhsTheme, rhsCategory, rhsSize, rhsCount, rhsChecked, rhsIsLast, rhsFraction) = rhs, lhsTheme === rhsTheme, lhsCategory == rhsCategory, lhsSize == rhsSize, lhsCount == rhsCount, lhsChecked == rhsChecked, lhsIsLast == rhsIsLast, lhsFraction == rhsFraction { + return true + } else { + return false + } + case let .topChatsHeader(lhsTheme, lhsText): + if case let .topChatsHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } else { + return false + } + case let .topChat(lhsTheme, lhsPeer, lhsCount, lhsSize, lhsIndex): + if case let .topChat(rhsTheme, rhsPeer, rhsCount, rhsSize, rhsIndex) = rhs, lhsTheme === rhsTheme, lhsPeer == rhsPeer, lhsCount == rhsCount, lhsSize == rhsSize, lhsIndex == rhsIndex { + return true + } else { + return false + } + case let .delete(lhsTheme, lhsText, lhsEnabled): + if case let .delete(rhsTheme, rhsText, rhsEnabled) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsEnabled == rhsEnabled { + return true + } else { + return false + } + } + } + + static func <(lhs: WinterGramDeletedMessagesControllerEntry, rhs: WinterGramDeletedMessagesControllerEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { + let arguments = arguments as! WinterGramDeletedMessagesControllerArguments + switch self { + case let .overviewHeader(_, text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + case let .overviewInfo(_, text): + return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section, style: .blocks) + case let .chart(_, chartData, totalText): + return WinterGramDeletedMessagesChartItem(presentationData: presentationData, chartData: chartData, totalText: totalText, sectionId: self.section) + case let .categoryHeader(_, text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + case let .category(_, category, size, _, checked, isLast, fraction): + let title = categoryTitle(category, presentationData.strings) + return winterGramDeletedMessagesCategoryItem( + presentationData: presentationData, + category: category, + color: categoryColors[category] ?? presentationData.theme.list.itemAccentColor, + title: title, + size: size, + fraction: fraction, + checked: checked, + isLast: isLast, + sectionId: self.section, + toggle: { + arguments.toggleCategory(category) + } + ) + case let .topChatsHeader(_, text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + 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 }) + 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() + } : nil) + } + } +} + +private func generateFilledCircleImage(diameter: CGFloat, color: UIColor) -> UIImage? { + return generateImage(CGSize(width: diameter, height: diameter), contextGenerator: { size, context in + context.clear(CGRect(origin: .zero, size: size)) + context.setFillColor(color.cgColor) + context.fillEllipse(in: CGRect(origin: .zero, size: size)) + }, opaque: false) +} + +private final class WinterGramDeletedMessagesControllerArguments { + let context: AccountContext + let toggleCategory: (WinterGramDeletedMessageCategory) -> Void + let deleteSelected: () -> Void + + init(context: AccountContext, toggleCategory: @escaping (WinterGramDeletedMessageCategory) -> Void, deleteSelected: @escaping () -> Void) { + self.context = context + self.toggleCategory = toggleCategory + self.deleteSelected = deleteSelected + } +} + +private func winterGramDeletedMessagesControllerEntries(stats: WinterGramDeletedMessagesStats, topPeers: [EnginePeer], selectedCategories: Set, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, theme: PresentationTheme) -> [WinterGramDeletedMessagesControllerEntry] { + let sizeFormatting = DataSizeStringFormatting(strings: strings, decimalSeparator: dateTimeFormat.decimalSeparator) + + var entries: [WinterGramDeletedMessagesControllerEntry] = [] + + entries.append(.overviewHeader(theme, strings.WinterGram_DeletedMessages_Title.uppercased())) + 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 { + let color = categoryColors[stat.category] ?? theme.list.itemAccentColor + let fraction = chartTotal > 0 ? Double(stat.size) / Double(chartTotal) : 0.0 + chartItems.append(PieChartComponent.ChartData.Item( + id: AnyHashable(stat.category), + displayValue: fraction, + displaySize: stat.size, + value: fraction, + color: color, + particle: nil, + title: categoryTitle(stat.category, strings), + mergeable: false, + mergeFactor: 1.0 + )) + } + let chartTotalText = dataSizeString(chartTotal, formatting: sizeFormatting) + entries.append(.chart(theme, PieChartComponent.ChartData(items: chartItems), chartTotalText)) + + entries.append(.categoryHeader(theme, strings.WinterGram_DeletedMessages_SelectTypes.uppercased())) + let visibleCategories = stats.categories.filter { $0.count > 0 } + for (index, stat) in visibleCategories.enumerated() { + let fraction = chartTotal > 0 ? Double(stat.size) / Double(chartTotal) : 0.0 + entries.append(.category(theme, stat.category, stat.size, stat.count, selectedCategories.contains(stat.category), index == visibleCategories.count - 1, fraction)) + } + + if !stats.topChats.isEmpty { + entries.append(.topChatsHeader(theme, strings.WinterGram_DeletedMessages_TopChats.uppercased())) + var topChatIndex: Int32 = 0 + for stat in stats.topChats { + if let peer = topPeers.first(where: { $0.id == EnginePeer.Id(stat.peerId) }) { + entries.append(.topChat(theme, peer, stat.count, stat.size, topChatIndex)) + topChatIndex += 1 + } + } + } + + let canDelete = !selectedCategories.isEmpty + entries.append(.delete(theme, strings.WinterGram_DeletedMessages_DeleteSelected, canDelete)) + + return entries +} + +public func winterGramDeletedMessagesController(context: AccountContext) -> ViewController { + var presentControllerImpl: ((ViewController, Any?) -> Void)? + + let selectedCategories = Atomic>(value: Set(WinterGramDeletedMessageCategory.allCases)) + let selectedCategoriesPromise = ValuePromise>(Set(WinterGramDeletedMessageCategory.allCases), ignoreRepeated: true) + + let arguments = WinterGramDeletedMessagesControllerArguments(context: context, toggleCategory: { category in + let selected = selectedCategories.modify { selected in + var selected = selected + if selected.contains(category) { + selected.remove(category) + } else { + selected.insert(category) + } + return selected + } + selectedCategoriesPromise.set(selected) + }, deleteSelected: { + let selected = selectedCategories.with { $0 } + guard !selected.isEmpty else { + return + } + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let actionSheet = ActionSheetController(presentationData: presentationData) + actionSheet.setItemGroups([ActionSheetItemGroup(items: [ + ActionSheetTextItem(title: presentationData.strings.WinterGram_DeletedMessages_ConfirmDelete), + ActionSheetButtonItem(title: presentationData.strings.Common_Delete, color: .destructive, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + let _ = (winterGramClearDeletedMessages(postbox: context.account.postbox, categories: selected) + |> deliverOnMainQueue).start(next: { freedSize in + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let sizeFormatting = DataSizeStringFormatting(strings: presentationData.strings, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator) + presentControllerImpl?(UndoOverlayController(presentationData: presentationData, content: .succeed(text: presentationData.strings.WinterGram_DeletedMessages_Deleted(dataSizeString(freedSize, formatting: sizeFormatting)).string, timeout: nil, customUndoText: nil), elevatedLayout: false, action: { _ in return false }), nil) + selectedCategoriesPromise.set(Set(WinterGramDeletedMessageCategory.allCases)) + let _ = selectedCategories.swap(Set(WinterGramDeletedMessageCategory.allCases)) + }) + }) + ]), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ])]) + presentControllerImpl?(actionSheet, nil) + }) + + let statsSignal = winterGramDeletedMessagesStats(postbox: context.account.postbox) + let topPeersSignal = statsSignal + |> map { stats -> [EnginePeer.Id] in + return stats.topChats.map { EnginePeer.Id($0.peerId) } + } + |> distinctUntilChanged + |> mapToSignal { peerIds -> Signal<[EnginePeer], NoError> in + guard !peerIds.isEmpty else { + return .single([]) + } + return context.engine.data.subscribe( + EngineDataMap(peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:))) + ) + |> map { peerMap -> [EnginePeer] in + return peerMap.values.compactMap { $0 } + } + } + + let signal = combineLatest(queue: .mainQueue(), + context.sharedContext.presentationData, + statsSignal, + selectedCategoriesPromise.get(), + topPeersSignal + ) + |> 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) + return (controllerState, (listState, arguments)) + } + + let controller = ItemListController(context: context, state: signal) + presentControllerImpl = { [weak controller] c, a in + controller?.present(c, in: .window(.root), with: a) + } + + return controller +} diff --git a/submodules/SettingsUI/Sources/Themes/ThemeSettingsAppIconItem.swift b/submodules/SettingsUI/Sources/Themes/ThemeSettingsAppIconItem.swift index 3db095dc8c..470861bb96 100644 --- a/submodules/SettingsUI/Sources/Themes/ThemeSettingsAppIconItem.swift +++ b/submodules/SettingsUI/Sources/Themes/ThemeSettingsAppIconItem.swift @@ -13,7 +13,7 @@ private func generateBorderImage(theme: PresentationTheme, bordered: Bool, selec return generateImage(CGSize(width: 30.0, height: 30.0), rotatedContext: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) - + let lineWidth: CGFloat if selected { var accentColor = theme.list.itemAccentColor @@ -26,7 +26,7 @@ private func generateBorderImage(theme: PresentationTheme, bordered: Bool, selec context.setStrokeColor(theme.list.disclosureArrowColor.withAlphaComponent(0.4).cgColor) lineWidth = 1.0 - UIScreenPixel } - + if bordered || selected { context.setLineWidth(lineWidth) context.strokeEllipse(in: bounds.insetBy(dx: lineWidth / 2.0, dy: lineWidth / 2.0)) @@ -36,7 +36,7 @@ private func generateBorderImage(theme: PresentationTheme, bordered: Bool, selec class ThemeSettingsAppIconItem: ListViewItem, ItemListItem { var sectionId: ItemListSectionId - + let theme: PresentationTheme let strings: PresentationStrings let systemStyle: ItemListSystemStyle @@ -45,7 +45,7 @@ class ThemeSettingsAppIconItem: ListViewItem, ItemListItem { let currentIconName: String? let updated: (PresentationAppIcon) -> Void let tag: ItemListItemTag? - + init(theme: PresentationTheme, strings: PresentationStrings, systemStyle: ItemListSystemStyle, sectionId: ItemListSectionId, icons: [PresentationAppIcon], isPremium: Bool, currentIconName: String?, updated: @escaping (PresentationAppIcon) -> Void, tag: ItemListItemTag? = nil) { self.theme = theme self.strings = strings @@ -57,15 +57,15 @@ class ThemeSettingsAppIconItem: ListViewItem, ItemListItem { self.tag = tag self.sectionId = sectionId } - + func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { async { let node = ThemeSettingsAppIconItemNode() let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) - + node.contentSize = layout.contentSize node.insets = layout.insets - + Queue.mainQueue().async { completion(node, { return (nil, { _ in apply() }) @@ -73,12 +73,12 @@ class ThemeSettingsAppIconItem: ListViewItem, ItemListItem { } } } - + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { Queue.mainQueue().async { if let nodeValue = node() as? ThemeSettingsAppIconItemNode { let makeLayout = nodeValue.asyncLayout() - + async { let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) Queue.mainQueue().async { @@ -95,49 +95,52 @@ class ThemeSettingsAppIconItem: ListViewItem, ItemListItem { private let badgeSize = CGSize(width: 24.0, height: 24.0) private let badgeStrokeSize: CGFloat = 2.0 +private let appIconGridIconSize = CGSize(width: 70.0, height: 70.0) +private let appIconGridNodeSize = CGSize(width: 82.0, height: 112.0) + private final class ThemeSettingsAppIconNode : ASDisplayNode { private let iconNode: ASImageNode private let overlayNode: ASImageNode fileprivate let lockNode: ASImageNode private let textNode: ImmediateTextNode private var action: (() -> Void)? - + private let activateAreaNode: AccessibilityAreaNode - + private var locked = false - + override init() { self.iconNode = ASImageNode() self.iconNode.clipsToBounds = true - self.iconNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 63.0, height: 63.0)) + self.iconNode.frame = CGRect(origin: CGPoint(), size: appIconGridIconSize) self.iconNode.isLayerBacked = true - self.iconNode.cornerRadius = 15.0 - + self.iconNode.cornerRadius = 17.0 + self.overlayNode = ASImageNode() - self.overlayNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 63.0, height: 63.0)) + self.overlayNode.frame = CGRect(origin: CGPoint(), size: appIconGridIconSize) self.overlayNode.isLayerBacked = true - + self.lockNode = ASImageNode() self.lockNode.contentMode = .scaleAspectFit self.lockNode.displaysAsynchronously = false self.lockNode.isUserInteractionEnabled = false - + self.textNode = ImmediateTextNode() self.textNode.isUserInteractionEnabled = false self.textNode.displaysAsynchronously = false - + self.activateAreaNode = AccessibilityAreaNode() self.activateAreaNode.accessibilityTraits = [.button] - + super.init() - + self.addSubnode(self.iconNode) self.addSubnode(self.overlayNode) self.addSubnode(self.textNode) self.addSubnode(self.lockNode) self.addSubnode(self.activateAreaNode) } - + func setup(theme: PresentationTheme, icon: UIImage, title: NSAttributedString, locked: Bool, color: UIColor, bordered: Bool, selected: Bool, action: @escaping () -> Void) { self.locked = locked self.iconNode.image = icon @@ -147,52 +150,90 @@ private final class ThemeSettingsAppIconNode : ASDisplayNode { self.action = { action() } - + self.activateAreaNode.accessibilityLabel = title.string if locked { self.activateAreaNode.accessibilityTraits = [.button, .notEnabled] } else { self.activateAreaNode.accessibilityTraits = [.button] } - + self.setNeedsLayout() } - + override func didLoad() { super.didLoad() - + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) } - + @objc func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.action?() } } - + override func layout() { super.layout() - + let bounds = self.bounds - let iconSize = CGSize(width: 63.0, height: 63.0) - - self.iconNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((bounds.width - iconSize.width) / 2.0), y: 13.0), size: iconSize) + let iconSize = appIconGridIconSize + + self.iconNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((bounds.width - iconSize.width) / 2.0), y: 14.0), size: iconSize) self.overlayNode.frame = self.iconNode.frame - + let textSize = self.textNode.updateLayout(CGSize(width: bounds.size.width + 8.0, height: bounds.size.height)) - let textFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((bounds.width - textSize.width) / 2.0), y: 81.0), size: textSize) + let textFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((bounds.width - textSize.width) / 2.0), y: 91.0), size: textSize) self.textNode.frame = textFrame - + let badgeFinalSize = CGSize(width: badgeSize.width + badgeStrokeSize * 2.0, height: badgeSize.height + badgeStrokeSize * 2.0) self.lockNode.frame = CGRect(x: bounds.width - 24.0, y: 4.0, width: badgeFinalSize.width, height: badgeFinalSize.height) - + self.activateAreaNode.frame = bounds } } -private let textFont = Font.regular(12.0) -private let selectedTextFont = Font.medium(12.0) +private let textFont = Font.regular(13.0) +private let selectedTextFont = Font.medium(13.0) + +private func winterGramAssetDisplayTitle(_ rawName: String, prefixes: [String]) -> String? { + var name = rawName.components(separatedBy: "/").last ?? rawName + if let dotIndex = name.lastIndex(of: ".") { + name = String(name[.. String in + let lowercased = word.lowercased() + if lowercased == "wintergram" { + return "WinterGram" + } + if lowercased == "ios" { + return "iOS" + } + return lowercased.prefix(1).uppercased() + String(lowercased.dropFirst()) + } + + return words.isEmpty ? nil : words.joined(separator: " ") +} + +private func winterGramIconTitle(_ name: String) -> String? { + return winterGramAssetDisplayTitle(name, prefixes: [ + "icon-app-", + "app-icon-", + "WinterGram" + ]) +} class ThemeSettingsAppIconItemNode: ListViewItemNode, ItemListItemNode { private let backgroundNode: ASDisplayNode @@ -200,103 +241,103 @@ class ThemeSettingsAppIconItemNode: ListViewItemNode, ItemListItemNode { private let topStripeNode: ASDisplayNode private let bottomStripeNode: ASDisplayNode private let maskNode: ASImageNode - + private let containerNode: ASDisplayNode private var nodes: [ThemeSettingsAppIconNode] = [] - + private var item: ThemeSettingsAppIconItem? private var layoutParams: ListViewItemLayoutParams? - + var tag: ItemListItemTag? { return self.item?.tag } - + private var lockImage: UIImage? - + init() { self.backgroundNode = ASDisplayNode() self.backgroundNode.isLayerBacked = true - + self.highlightNode = ASDisplayNode() self.highlightNode.isLayerBacked = true - + self.topStripeNode = ASDisplayNode() self.topStripeNode.isLayerBacked = true - + self.bottomStripeNode = ASDisplayNode() self.bottomStripeNode.isLayerBacked = true - + self.maskNode = ASImageNode() - + self.containerNode = ASDisplayNode() - + super.init(layerBacked: false) - + self.addSubnode(self.containerNode) } - + public func displayHighlight() { if self.backgroundNode.supernode != nil { self.insertSubnode(self.highlightNode, aboveSubnode: self.backgroundNode) } else { self.insertSubnode(self.highlightNode, at: 0) } - + Queue.mainQueue().after(1.2, { self.highlightNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in self.highlightNode.removeFromSupernode() }) }) } - + func asyncLayout() -> (_ item: ThemeSettingsAppIconItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { return { item, params, neighbors in let contentSize: CGSize let insets: UIEdgeInsets let separatorHeight = UIScreenPixel - - let nodeSize = CGSize(width: 74.0, height: 102.0) - let height: CGFloat = nodeSize.height * ceil(CGFloat(item.icons.count) / 4.0) + 12.0 - + + let nodeSize = appIconGridNodeSize + let height: CGFloat = nodeSize.height * ceil(CGFloat(item.icons.count) / 4.0) + 14.0 + contentSize = CGSize(width: params.width, height: height) insets = itemListNeighborsGroupedInsets(neighbors, params) - + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) let layoutSize = layout.size - + return (layout, { [weak self] in if let strongSelf = self { let previousItem = strongSelf.item strongSelf.item = item strongSelf.layoutParams = params - + if previousItem?.theme !== item.theme { strongSelf.lockImage = generateImage(CGSize(width: badgeSize.width + badgeStrokeSize, height: badgeSize.height + badgeStrokeSize), contextGenerator: { size, context in context.clear(CGRect(origin: .zero, size: size)) - + context.setFillColor(item.theme.list.itemBlocksBackgroundColor.cgColor) context.fillEllipse(in: CGRect(origin: .zero, size: size)) - + context.addEllipse(in: CGRect(origin: .zero, size: size).insetBy(dx: badgeStrokeSize, dy: badgeStrokeSize)) context.clip() - + var locations: [CGFloat] = [0.0, 1.0] let colors: [CGColor] = [UIColor(rgb: 0x9076FF).cgColor, UIColor(rgb: 0xB86DEA).cgColor] let colorSpace = CGColorSpaceCreateDeviceRGB() let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions()) - + if let icon = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Accessory Panels/TextLockIcon"), color: .white) { context.draw(icon.cgImage!, in: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - icon.size.width) / 2.0), y: floorToScreenPixels((size.height - icon.size.height) / 2.0)), size: icon.size), byTiling: false) } }) } - + strongSelf.backgroundNode.backgroundColor = item.theme.list.itemBlocksBackgroundColor strongSelf.topStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor strongSelf.bottomStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor strongSelf.highlightNode.backgroundColor = item.theme.list.itemSearchHighlightColor - + if strongSelf.backgroundNode.supernode == nil { strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) } @@ -309,7 +350,7 @@ class ThemeSettingsAppIconItemNode: ListViewItemNode, ItemListItemNode { if strongSelf.maskNode.supernode == nil { strongSelf.insertSubnode(strongSelf.maskNode, at: 3) } - + let hasCorners = itemListHasRoundedBlockLayout(params) var hasTopCorners = false var hasBottomCorners = false @@ -333,24 +374,24 @@ class ThemeSettingsAppIconItemNode: ListViewItemNode, ItemListItemNode { hasBottomCorners = true strongSelf.bottomStripeNode.isHidden = hasCorners } - + strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: item.systemStyle == .glass) : nil - + strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) strongSelf.highlightNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0) strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)) strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) - + strongSelf.containerNode.frame = CGRect(origin: CGPoint(x: params.leftInset, y: 2.0), size: CGSize(width: layoutSize.width - params.leftInset - params.rightInset, height: layoutSize.height)) - + let sideInset: CGFloat = 8.0 let spacing: CGFloat = floorToScreenPixels((params.width - sideInset * 2.0 - params.leftInset - params.rightInset - nodeSize.width * 4.0) / 3.0) let verticalSpacing: CGFloat = 0.0 - + var x: CGFloat = sideInset var y: CGFloat = 0.0 - + var i = 0 for icon in item.icons { if i > 0 && i % 4 == 0 { @@ -359,7 +400,7 @@ class ThemeSettingsAppIconItemNode: ListViewItemNode, ItemListItemNode { } let nodeFrame = CGRect(x: x, y: y, width: nodeSize.width, height: nodeSize.height) x += nodeSize.width + spacing - + let imageNode: ThemeSettingsAppIconNode if strongSelf.nodes.count > i { imageNode = strongSelf.nodes[i] @@ -369,7 +410,7 @@ class ThemeSettingsAppIconItemNode: ListViewItemNode, ItemListItemNode { strongSelf.containerNode.addSubnode(imageNode) } imageNode.lockNode.image = strongSelf.lockImage - + if let image = UIImage(named: icon.imageName, in: getAppBundle(), compatibleWith: nil) { let selected = icon.name == item.currentIconName @@ -403,29 +444,33 @@ class ThemeSettingsAppIconItemNode: ListViewItemNode, ItemListItemNode { case "PremiumTurbo": name = item.strings.Appearance_AppIconTurbo default: - name = icon.name + name = winterGramIconTitle(icon.name) ?? icon.name } - + imageNode.setup(theme: item.theme, icon: image, title: NSAttributedString(string: name, font: selected ? selectedTextFont : textFont, textColor: selected ? item.theme.list.itemAccentColor : item.theme.list.itemPrimaryTextColor, paragraphAlignment: .center), locked: !item.isPremium && icon.isPremium, color: item.theme.list.itemPrimaryTextColor, bordered: bordered, selected: selected, action: { item.updated(icon) }) } - + imageNode.frame = nodeFrame - + i += 1 } + + while strongSelf.nodes.count > i { + let node = strongSelf.nodes.removeLast() + node.removeFromSupernode() + } } }) } } - + override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) } - + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) } } - diff --git a/submodules/SettingsUI/Sources/Themes/ThemeSettingsChatPreviewItem.swift b/submodules/SettingsUI/Sources/Themes/ThemeSettingsChatPreviewItem.swift index 58e3acebfc..64ac5ee99d 100644 --- a/submodules/SettingsUI/Sources/Themes/ThemeSettingsChatPreviewItem.swift +++ b/submodules/SettingsUI/Sources/Themes/ThemeSettingsChatPreviewItem.swift @@ -10,6 +10,7 @@ import ItemListUI import PresentationDataUtils import AccountContext import WallpaperBackgroundNode +import AvatarNode struct ChatPreviewMessageItem: Equatable { static func == (lhs: ChatPreviewMessageItem, rhs: ChatPreviewMessageItem) -> Bool { @@ -27,16 +28,20 @@ struct ChatPreviewMessageItem: Equatable { if lhs.nameColor != rhs.nameColor { return false } + if lhs.photo != rhs.photo { + return false + } if lhs.backgroundEmojiId != rhs.backgroundEmojiId { return false } return true } - + let outgoing: Bool let reply: (String, String)? let text: String let nameColor: PeerColor + var photo: [TelegramMediaImageRepresentation] = [] let backgroundEmojiId: Int64? } @@ -53,8 +58,10 @@ class ThemeSettingsChatPreviewItem: ListViewItem, ItemListItem { let dateTimeFormat: PresentationDateTimeFormat let nameDisplayOrder: PresentationPersonNameOrder let messageItems: [ChatPreviewMessageItem] - - init(context: AccountContext, systemStyle: ItemListSystemStyle = .legacy, theme: PresentationTheme, componentTheme: PresentationTheme, strings: PresentationStrings, sectionId: ItemListSectionId, fontSize: PresentationFontSize, chatBubbleCorners: PresentationChatBubbleCorners, wallpaper: TelegramWallpaper, dateTimeFormat: PresentationDateTimeFormat, nameDisplayOrder: PresentationPersonNameOrder, messageItems: [ChatPreviewMessageItem]) { + let avatarCornerRadius: Int32 + let avatarPeer: EnginePeer? + + init(context: AccountContext, systemStyle: ItemListSystemStyle = .legacy, theme: PresentationTheme, componentTheme: PresentationTheme, strings: PresentationStrings, sectionId: ItemListSectionId, fontSize: PresentationFontSize, chatBubbleCorners: PresentationChatBubbleCorners, wallpaper: TelegramWallpaper, dateTimeFormat: PresentationDateTimeFormat, nameDisplayOrder: PresentationPersonNameOrder, messageItems: [ChatPreviewMessageItem], avatarCornerRadius: Int32 = currentWinterGramSettings.avatarCornerRadius, avatarPeer: EnginePeer? = nil) { self.context = context self.systemStyle = systemStyle self.theme = theme @@ -67,16 +74,18 @@ class ThemeSettingsChatPreviewItem: ListViewItem, ItemListItem { self.dateTimeFormat = dateTimeFormat self.nameDisplayOrder = nameDisplayOrder self.messageItems = messageItems + self.avatarCornerRadius = avatarCornerRadius + self.avatarPeer = avatarPeer } - + func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { async { let node = ThemeSettingsChatPreviewItemNode() let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) - + node.contentSize = layout.contentSize node.insets = layout.insets - + Queue.mainQueue().async { completion(node, { return (nil, { _ in apply() }) @@ -84,12 +93,12 @@ class ThemeSettingsChatPreviewItem: ListViewItem, ItemListItem { } } } - + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { Queue.mainQueue().async { if let nodeValue = node() as? ThemeSettingsChatPreviewItemNode { let makeLayout = nodeValue.asyncLayout() - + async { let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) Queue.mainQueue().async { @@ -108,72 +117,107 @@ class ThemeSettingsChatPreviewItemNode: ListViewItemNode { private let topStripeNode: ASDisplayNode private let bottomStripeNode: ASDisplayNode private let maskNode: ASImageNode - + private let avatarNode: AvatarNode + private let containerNode: ASDisplayNode private var messageNodes: [ListViewItemNode]? - + private var item: ThemeSettingsChatPreviewItem? private var finalImage = true - + private let disposable = MetaDisposable() - + init() { self.topStripeNode = ASDisplayNode() self.topStripeNode.isLayerBacked = true - + self.bottomStripeNode = ASDisplayNode() self.bottomStripeNode.isLayerBacked = true - + self.maskNode = ASImageNode() - + self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 15.0)) + self.avatarNode.isUserInteractionEnabled = false + self.containerNode = ASDisplayNode() self.containerNode.subnodeTransform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0) - + super.init(layerBacked: false) - + self.clipsToBounds = true - + self.addSubnode(self.containerNode) + self.addSubnode(self.avatarNode) } - + deinit { self.disposable.dispose() } - + func asyncLayout() -> (_ item: ThemeSettingsChatPreviewItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { let currentNodes = self.messageNodes + let currentItem = self.item var currentBackgroundNode = self.backgroundNode - - return { item, params, neighbors in - if currentBackgroundNode == nil { - currentBackgroundNode = createWallpaperBackgroundNode(context: item.context, forChatDisplay: false) - } - currentBackgroundNode?.update(wallpaper: item.wallpaper, animated: false) - currentBackgroundNode?.updateBubbleTheme(bubbleTheme: item.componentTheme, bubbleCorners: item.chatBubbleCorners) + return { item, params, neighbors in + let canReuseCurrentNodes = currentItem?.avatarCornerRadius == item.avatarCornerRadius + + if currentBackgroundNode == nil { + // WallpaperBackgroundNodeImpl.init touches `self.view` (portal source views), which asserts + // off the main thread. asyncLayout runs on a background queue, so create it on main. + if Thread.isMainThread { + currentBackgroundNode = createWallpaperBackgroundNode(context: item.context, forChatDisplay: false) + } else { + let context = item.context + let semaphore = DispatchSemaphore(value: 0) + var createdNode: WallpaperBackgroundNode? + Queue.mainQueue().async { + createdNode = createWallpaperBackgroundNode(context: context, forChatDisplay: false) + semaphore.signal() + } + semaphore.wait() + currentBackgroundNode = createdNode + } + } let insets: UIEdgeInsets let separatorHeight = UIScreenPixel - - let peerId = EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(1)) - let otherPeerId = EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(2)) + + // Use a fake group chat so incoming preview messages render author avatars + // (private-chat previews intentionally hide avatars; group/channel previews show them). + let chatPeerId = EnginePeer.Id(namespace: Namespaces.Peer.CloudGroup, id: EnginePeer.Id.Id._internalFromInt64Value(1)) + let incomingAuthorId = EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(2)) + let outgoingAuthorId = EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(3)) + + let chatPeer = TelegramGroup(id: chatPeerId, title: "Preview", photo: [], participantCount: 2, role: .member, membership: .Member, flags: TelegramGroupFlags(), defaultBannedRights: nil, migrationReference: nil, creationDate: 0, version: 0) + let outgoingAuthor = TelegramUser(id: outgoingAuthorId, accessHash: nil, firstName: "", lastName: "", username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil) + var items: [ListViewItem] = [] for messageItem in item.messageItems.reversed() { var peers = EngineSimpleDictionary() + peers[chatPeerId] = chatPeer var messages = EngineSimpleDictionary() - - let replyMessageId = EngineMessage.Id(peerId: peerId, namespace: 0, id: 3) + + let replyMessageId = EngineMessage.Id(peerId: chatPeerId, namespace: 0, id: 3) if let (author, text) = messageItem.reply { - peers[peerId] = TelegramUser(id: peerId, accessHash: nil, firstName: author, lastName: "", username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: messageItem.nameColor, backgroundEmojiId: messageItem.backgroundEmojiId, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil) - messages[replyMessageId] = EngineRawMessage(stableId: 3, stableVersion: 0, id: replyMessageId, globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 66000, flags: [.Incoming], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: peers[peerId], text: text, attributes: [], media: [], peers: peers, associatedMessages: EngineSimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) + let replyAuthor = TelegramUser(id: incomingAuthorId, accessHash: nil, firstName: author, lastName: "", username: nil, phone: nil, photo: messageItem.photo, botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: messageItem.nameColor, backgroundEmojiId: messageItem.backgroundEmojiId, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil) + peers[incomingAuthorId] = replyAuthor + messages[replyMessageId] = EngineRawMessage(stableId: 3, stableVersion: 0, id: replyMessageId, globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 66000, flags: [.Incoming], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: replyAuthor, text: text, attributes: [], media: [], peers: peers, associatedMessages: EngineSimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) } - - let message = EngineRawMessage(stableId: 1, stableVersion: 0, id: EngineMessage.Id(peerId: messageItem.outgoing ? otherPeerId : peerId, namespace: 0, id: 1), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 66000, flags: messageItem.outgoing ? [] : [.Incoming], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: messageItem.outgoing ? TelegramUser(id: otherPeerId, accessHash: nil, firstName: "", lastName: "", username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil) : nil, text: messageItem.text, attributes: messageItem.reply != nil ? [ReplyMessageAttribute(messageId: replyMessageId, threadMessageId: nil, quote: nil, isQuote: false, innerSubject: nil)] : [], media: [], peers: peers, associatedMessages: messages, associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) - items.append(item.context.sharedContext.makeChatMessagePreviewItem(context: item.context, messages: [message], theme: item.componentTheme, strings: item.strings, wallpaper: item.wallpaper, fontSize: item.fontSize, chatBubbleCorners: item.chatBubbleCorners, dateTimeFormat: item.dateTimeFormat, nameOrder: item.nameDisplayOrder, forcedResourceStatus: nil, tapMessage: nil, clickThroughMessage: nil, backgroundNode: currentBackgroundNode, availableReactions: nil, accountPeer: nil, isCentered: false, isPreview: true, isStandalone: false, rank: nil, rankRole: nil)) + + let author: EngineRawPeer + if messageItem.outgoing { + author = outgoingAuthor + } else { + author = TelegramUser(id: incomingAuthorId, accessHash: nil, firstName: "Winter", lastName: "Gram", username: nil, phone: nil, photo: messageItem.photo, botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: messageItem.nameColor, backgroundEmojiId: messageItem.backgroundEmojiId, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil) + } + peers[author.id] = author + + let message = EngineRawMessage(stableId: 1, stableVersion: 0, id: EngineMessage.Id(peerId: chatPeerId, namespace: 0, id: 1), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 66000, flags: messageItem.outgoing ? [] : [.Incoming], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: author, text: messageItem.text, attributes: messageItem.reply != nil ? [ReplyMessageAttribute(messageId: replyMessageId, threadMessageId: nil, quote: nil, isQuote: false, innerSubject: nil)] : [], media: [], peers: peers, associatedMessages: messages, associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) + items.append(item.context.sharedContext.makeChatMessagePreviewItem(context: item.context, messages: [message], theme: item.componentTheme, strings: item.strings, wallpaper: item.wallpaper, fontSize: item.fontSize, chatBubbleCorners: item.chatBubbleCorners, dateTimeFormat: item.dateTimeFormat, nameOrder: item.nameDisplayOrder, forcedResourceStatus: nil, tapMessage: nil, clickThroughMessage: nil, backgroundNode: currentBackgroundNode, availableReactions: nil, accountPeer: outgoingAuthor, isCentered: false, isPreview: true, isStandalone: false, rank: nil, rankRole: nil)) } - + var nodes: [ListViewItemNode] = [] - if let messageNodes = currentNodes { + if let messageNodes = currentNodes, canReuseCurrentNodes { nodes = messageNodes for i in 0 ..< items.count { let itemNode = messageNodes[i] @@ -181,12 +225,12 @@ class ThemeSettingsChatPreviewItemNode: ListViewItemNode { return itemNode }, params: params, previousItem: i == 0 ? nil : items[i - 1], nextItem: i == (items.count - 1) ? nil : items[i + 1], animation: .None, completion: { (layout, apply) in let nodeFrame = CGRect(origin: itemNode.frame.origin, size: CGSize(width: layout.size.width, height: layout.size.height)) - + itemNode.contentSize = layout.contentSize itemNode.insets = layout.insets itemNode.frame = nodeFrame itemNode.isUserInteractionEnabled = false - + apply(ListViewItemApply(isOnScreen: true)) }) } @@ -203,37 +247,64 @@ class ThemeSettingsChatPreviewItemNode: ListViewItemNode { } nodes = messageNodes } - + var contentSize = CGSize(width: params.width, height: 4.0 + 4.0) for node in nodes { contentSize.height += node.frame.size.height } insets = itemListNeighborsGroupedInsets(neighbors, params) - + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) let layoutSize = layout.size - + return (layout, { [weak self] in if let strongSelf = self { strongSelf.item = item - + strongSelf.containerNode.frame = CGRect(origin: CGPoint(), size: contentSize) - + + if !canReuseCurrentNodes { + currentNodes?.forEach { $0.removeFromSupernode() } + } strongSelf.messageNodes = nodes var topOffset: CGFloat = 4.0 - for node in nodes { + var avatarFrame: CGRect? + let displayedMessageItems = Array(item.messageItems.reversed()) + for (nodeIndex, node) in nodes.enumerated() { if node.supernode == nil { strongSelf.containerNode.addSubnode(node) } 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 + avatarFrame = CGRect( + x: params.leftInset + 9.0, + y: topOffset + max(6.0, node.frame.height - avatarSize - 8.0), + width: avatarSize, + height: avatarSize + ) + } topOffset += node.frame.size.height } + if let avatarPeer = item.avatarPeer, let avatarFrame { + strongSelf.avatarNode.isHidden = false + strongSelf.avatarNode.frame = avatarFrame + 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) + strongSelf.avatarNode.updateSize(size: avatarFrame.size) + } else { + strongSelf.avatarNode.isHidden = true + } if let currentBackgroundNode = currentBackgroundNode, strongSelf.backgroundNode !== currentBackgroundNode { strongSelf.backgroundNode = currentBackgroundNode strongSelf.insertSubnode(currentBackgroundNode, at: 0) } - + currentBackgroundNode?.update(wallpaper: item.wallpaper, animated: false) + currentBackgroundNode?.updateBubbleTheme(bubbleTheme: item.componentTheme, bubbleCorners: item.chatBubbleCorners) + strongSelf.topStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor strongSelf.bottomStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor @@ -246,7 +317,7 @@ class ThemeSettingsChatPreviewItemNode: ListViewItemNode { if strongSelf.maskNode.supernode == nil { strongSelf.insertSubnode(strongSelf.maskNode, at: 3) } - + let hasCorners = itemListHasRoundedBlockLayout(params) var hasTopCorners = false var hasBottomCorners = false @@ -270,11 +341,11 @@ class ThemeSettingsChatPreviewItemNode: ListViewItemNode { hasBottomCorners = true strongSelf.bottomStripeNode.isHidden = hasCorners } - + strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.componentTheme, top: hasTopCorners, bottom: hasBottomCorners, glass: item.systemStyle == .glass) : nil - + let backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) - + let displayMode: WallpaperDisplayMode if abs(params.availableHeight - params.width) < 100.0, params.availableHeight > 700.0 { displayMode = .halfAspectFill @@ -289,7 +360,7 @@ class ThemeSettingsChatPreviewItemNode: ListViewItemNode { displayMode = .aspectFill } } - + if let backgroundNode = strongSelf.backgroundNode { backgroundNode.frame = backgroundFrame.insetBy(dx: 0.0, dy: -100.0) backgroundNode.updateLayout(size: backgroundNode.bounds.size, displayMode: displayMode, transition: .immediate) @@ -301,11 +372,11 @@ class ThemeSettingsChatPreviewItemNode: ListViewItemNode { }) } } - + override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) } - + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) } diff --git a/submodules/SettingsUI/Sources/Themes/ThemeSettingsController.swift b/submodules/SettingsUI/Sources/Themes/ThemeSettingsController.swift index 965bca3666..cf51a3b67a 100644 --- a/submodules/SettingsUI/Sources/Themes/ThemeSettingsController.swift +++ b/submodules/SettingsUI/Sources/Themes/ThemeSettingsController.swift @@ -24,6 +24,89 @@ import WallpaperGridScreen import PeerNameColorItem import DeviceModel +private func avatarRadiusLabel(_ value: Int32) -> String { + switch value { + case 50: return "Round" + case 30: return "Squircle" + case 15: return "Rounded" + case 0: return "Square" + default: return "\(value)" + } +} + +private func winterGramAssetDisplayTitle(_ rawName: String, prefixes: [String]) -> String? { + var name = rawName.components(separatedBy: "/").last ?? rawName + if let dotIndex = name.lastIndex(of: ".") { + name = String(name[.. String in + let lowercased = word.lowercased() + if lowercased == "wintergram" { + return "WinterGram" + } + if lowercased == "ios" { + return "iOS" + } + return lowercased.prefix(1).uppercased() + String(lowercased.dropFirst()) + } + + return words.isEmpty ? nil : words.joined(separator: " ") +} + +private func winterGramBannerTitle(_ name: String) -> String { + if name == "WntGramBanner" { + return "WinterGram" + } + return winterGramAssetDisplayTitle(name, prefixes: [ + "banner-", + "wntgram", + "WinterGram" + ]) ?? name +} + +private func winterGramAvailableBannerNames(selectedBanner: String) -> [String] { + var names: [String] = ["WntGramBanner"] + if !selectedBanner.isEmpty && !names.contains(selectedBanner) { + names.append(selectedBanner) + } + return names.filter { UIImage(bundleImageName: $0) != nil } +} + +private func winterGramTopBannerPreviewImage(theme: PresentationTheme, bannerName: String) -> UIImage { + let size = CGSize(width: 144.0, height: 36.0) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { _ in + let bounds = CGRect(origin: .zero, size: size) + let plateRect = bounds.insetBy(dx: 1.0, dy: 1.0) + let platePath = UIBezierPath(roundedRect: plateRect, cornerRadius: 12.0) + theme.list.itemBlocksBackgroundColor.setFill() + platePath.fill() + theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.45).setStroke() + platePath.lineWidth = UIScreenPixel + platePath.stroke() + + UIColor.black.setFill() + UIBezierPath(roundedRect: CGRect(x: floor((size.width - 44.0) / 2.0), y: 6.0, width: 44.0, height: 12.0), cornerRadius: 6.0).fill() + + if let banner = UIImage(bundleImageName: bannerName) ?? UIImage(bundleImageName: "WntGramBanner") { + let bannerHeight: CGFloat = 13.0 + let bannerWidth = min(size.width - 20.0, floor(bannerHeight * banner.size.width / max(1.0, banner.size.height))) + banner.draw(in: CGRect(x: floor((size.width - bannerWidth) / 2.0), y: 20.0, width: bannerWidth, height: bannerHeight)) + } + } +} + private final class ThemeSettingsControllerArguments { let context: AccountContext let selectTheme: (PresentationThemeReference) -> Void @@ -44,7 +127,11 @@ private final class ThemeSettingsControllerArguments { let editTheme: (PresentationCloudTheme) -> Void let themeContextAction: (Bool, PresentationThemeReference, ASDisplayNode, ContextGesture?) -> Void let colorContextAction: (Bool, PresentationThemeReference, ThemeSettingsColorOption?, ASDisplayNode, ContextGesture?) -> Void - + let updateWinterGramSettingsPreview: (@escaping (WinterGramSettings) -> WinterGramSettings) -> Void + let updateWinterGramSettings: (@escaping (WinterGramSettings) -> WinterGramSettings) -> Void + let toggleIconPackExpanded: () -> Void + let toggleBannerExpanded: () -> Void + init( context: AccountContext, selectTheme: @escaping (PresentationThemeReference) -> Void, @@ -64,7 +151,11 @@ private final class ThemeSettingsControllerArguments { selectAppIcon: @escaping (PresentationAppIcon) -> Void, editTheme: @escaping (PresentationCloudTheme) -> Void, themeContextAction: @escaping (Bool, PresentationThemeReference, ASDisplayNode, ContextGesture?) -> Void, - colorContextAction: @escaping (Bool, PresentationThemeReference, ThemeSettingsColorOption?, ASDisplayNode, ContextGesture?) -> Void + colorContextAction: @escaping (Bool, PresentationThemeReference, ThemeSettingsColorOption?, ASDisplayNode, ContextGesture?) -> Void, + updateWinterGramSettingsPreview: @escaping (@escaping (WinterGramSettings) -> WinterGramSettings) -> Void, + updateWinterGramSettings: @escaping (@escaping (WinterGramSettings) -> WinterGramSettings) -> Void, + toggleIconPackExpanded: @escaping () -> Void, + toggleBannerExpanded: @escaping () -> Void ) { self.context = context self.selectTheme = selectTheme @@ -85,16 +176,22 @@ private final class ThemeSettingsControllerArguments { self.editTheme = editTheme self.themeContextAction = themeContextAction self.colorContextAction = colorContextAction + self.updateWinterGramSettingsPreview = updateWinterGramSettingsPreview + self.updateWinterGramSettings = updateWinterGramSettings + self.toggleIconPackExpanded = toggleIconPackExpanded + self.toggleBannerExpanded = toggleBannerExpanded } } private enum ThemeSettingsControllerSection: Int32 { case chatPreview + case rounding case nightMode case message case icon case powerSaving case other + case winterGram } public enum ThemeSettingsEntryTag: ItemListItemTag { @@ -110,7 +207,7 @@ public enum ThemeSettingsEntryTag: ItemListItemTag { case tapForNextMedia case nightMode case edit - + public func isEqual(to other: ItemListItemTag) -> Bool { if let other = other as? ThemeSettingsEntryTag, self == other { return true @@ -122,16 +219,20 @@ public enum ThemeSettingsEntryTag: ItemListItemTag { private enum ThemeSettingsControllerEntry: ItemListNodeEntry { case themeListHeader(PresentationTheme, String) - case chatPreview(PresentationTheme, TelegramWallpaper, PresentationFontSize, PresentationChatBubbleCorners, PresentationStrings, PresentationDateTimeFormat, PresentationPersonNameOrder, [ChatPreviewMessageItem]) + case chatPreview(PresentationTheme, TelegramWallpaper, PresentationFontSize, PresentationChatBubbleCorners, PresentationStrings, PresentationDateTimeFormat, PresentationPersonNameOrder, [ChatPreviewMessageItem], Int32, EnginePeer?, Bool) case themes(PresentationTheme, PresentationStrings, [PresentationThemeReference], PresentationThemeReference, Bool, [String: [StickerPackItem]], [Int64: PresentationThemeAccentColor], [Int64: TelegramWallpaper]) case chatTheme(PresentationTheme, String) case wallpaper(PresentationTheme, String) case nameColor(PresentationTheme, String, String, PeerNameColors.Colors?, PeerNameColors.Colors?) + case winterGramRoundingHeader(PresentationTheme, String) case autoNight(PresentationTheme, String, Bool, Bool) case autoNightTheme(PresentationTheme, String, String) case textSize(PresentationTheme, String, String) case bubbleSettings(PresentationTheme, String, String) case iconHeader(PresentationTheme, String) + case winterGramIconPack(PresentationTheme, WinterGramIconPack, Bool) + case winterGramBannerSelection(PresentationTheme, String, Bool) + case winterGramBannerPreview(PresentationTheme, String) case iconItem(PresentationTheme, PresentationStrings, [PresentationAppIcon], Bool, String?) case powerSaving case stickersAndEmoji @@ -139,69 +240,118 @@ private enum ThemeSettingsControllerEntry: ItemListNodeEntry { case sendWithCmdEnter(PresentationTheme, String, Bool) case showNextMediaOnTap(PresentationTheme, String, Bool) case showNextMediaOnTapInfo(PresentationTheme, String) - + case winterGramShowSeconds(PresentationTheme, String, Bool) + case winterGramHeader(PresentationTheme, String) + case winterGramMaterialDesign(PresentationTheme, String, Bool) + case winterGramSingleCornerRadius(PresentationTheme, String, Bool) + case winterGramAvatarRadius(PresentationTheme, String, Int32, Signal?) + case winterGramBubbleRadius(PresentationTheme, String, Int32) + case winterGramLiquidGlass(PresentationTheme, String, Bool) + case winterGramLiquidGlassVibrancy(PresentationTheme, String, Bool) + case winterGramLiquidGlassChatList(PresentationTheme, String, Bool) + case winterGramLiquidGlassNavBars(PresentationTheme, String, Bool) + case winterGramLiquidGlassTabBar(PresentationTheme, String, Bool) + case winterGramLiquidGlassBubbles(PresentationTheme, String, Bool) + var section: ItemListSectionId { switch self { case .themeListHeader, .chatPreview, .themes, .chatTheme, .wallpaper, .nameColor: return ThemeSettingsControllerSection.chatPreview.rawValue + case .winterGramRoundingHeader, .winterGramShowSeconds, .winterGramAvatarRadius, .winterGramBubbleRadius: + return ThemeSettingsControllerSection.rounding.rawValue case .autoNight, .autoNightTheme: return ThemeSettingsControllerSection.nightMode.rawValue case .textSize, .bubbleSettings: return ThemeSettingsControllerSection.message.rawValue - case .iconHeader, .iconItem: + case .iconHeader, .winterGramIconPack, .winterGramBannerSelection, .winterGramBannerPreview, .iconItem: return ThemeSettingsControllerSection.icon.rawValue case .powerSaving, .stickersAndEmoji: return ThemeSettingsControllerSection.message.rawValue case .otherHeader, .sendWithCmdEnter, .showNextMediaOnTap, .showNextMediaOnTapInfo: return ThemeSettingsControllerSection.other.rawValue + case .winterGramHeader, .winterGramMaterialDesign, .winterGramSingleCornerRadius, .winterGramLiquidGlass, .winterGramLiquidGlassVibrancy, .winterGramLiquidGlassChatList, .winterGramLiquidGlassNavBars, .winterGramLiquidGlassTabBar, .winterGramLiquidGlassBubbles: + return ThemeSettingsControllerSection.winterGram.rawValue } } - + var stableId: Int32 { switch self { case .themeListHeader: return 0 case .chatPreview: - return 1 - case .themes: - return 2 - case .chatTheme: - return 3 - case .wallpaper: - return 4 - case .nameColor: - return 5 - case .autoNight: - return 6 - case .autoNightTheme: - return 7 - case .textSize: - return 8 - case .bubbleSettings: - return 9 - case .powerSaving: return 10 + case .themes: + return 40 + case .chatTheme: + return 50 + case .wallpaper: + return 60 + case .nameColor: + return 70 + // Rounding sliders sit right after Personal Colors, before Night Mode. + case .winterGramRoundingHeader: + return 72 + case .winterGramShowSeconds: + return 73 + case .winterGramAvatarRadius: + return 74 + case .winterGramBubbleRadius: + return 75 + case .autoNight: + return 80 + case .autoNightTheme: + return 90 + case .textSize: + return 100 + case .bubbleSettings: + return 110 + case .powerSaving: + return 120 case .stickersAndEmoji: - return 11 + return 130 case .iconHeader: - return 12 + return 140 + case .winterGramBannerSelection: + return 145 + case .winterGramBannerPreview: + return 146 + case .winterGramIconPack: + return 147 case .iconItem: - return 13 + return 150 case .otherHeader: - return 14 + return 160 case .sendWithCmdEnter: - return 15 + return 170 case .showNextMediaOnTap: - return 16 + return 180 case .showNextMediaOnTapInfo: - return 17 + return 190 + case .winterGramHeader: + return 200 + case .winterGramMaterialDesign: + return 210 + case .winterGramSingleCornerRadius: + return 220 + case .winterGramLiquidGlass: + return 230 + case .winterGramLiquidGlassVibrancy: + return 240 + case .winterGramLiquidGlassChatList: + return 250 + case .winterGramLiquidGlassNavBars: + return 260 + case .winterGramLiquidGlassTabBar: + return 270 + case .winterGramLiquidGlassBubbles: + return 280 } } - + static func ==(lhs: ThemeSettingsControllerEntry, rhs: ThemeSettingsControllerEntry) -> Bool { switch lhs { - case let .chatPreview(lhsTheme, lhsWallpaper, lhsFontSize, lhsChatBubbleCorners, lhsStrings, lhsTimeFormat, lhsNameOrder, lhsItems): - if case let .chatPreview(rhsTheme, rhsWallpaper, rhsFontSize, rhsChatBubbleCorners, rhsStrings, rhsTimeFormat, rhsNameOrder, rhsItems) = rhs, lhsTheme === rhsTheme, lhsWallpaper == rhsWallpaper, lhsFontSize == rhsFontSize, lhsChatBubbleCorners == rhsChatBubbleCorners, lhsStrings === rhsStrings, lhsTimeFormat == rhsTimeFormat, lhsNameOrder == rhsNameOrder, lhsItems == rhsItems { + case let .chatPreview(lhsTheme, lhsWallpaper, lhsFontSize, lhsChatBubbleCorners, lhsStrings, lhsTimeFormat, lhsNameOrder, lhsItems, lhsAvatarCornerRadius, lhsAvatarPeer, lhsShowSeconds): + if case let .chatPreview(rhsTheme, rhsWallpaper, rhsFontSize, rhsChatBubbleCorners, rhsStrings, rhsTimeFormat, rhsNameOrder, rhsItems, rhsAvatarCornerRadius, rhsAvatarPeer, rhsShowSeconds) = rhs, lhsTheme === rhsTheme, lhsWallpaper == rhsWallpaper, lhsFontSize == rhsFontSize, lhsChatBubbleCorners == rhsChatBubbleCorners, lhsStrings === rhsStrings, lhsTimeFormat == rhsTimeFormat, lhsNameOrder == rhsNameOrder, lhsItems == rhsItems, lhsAvatarCornerRadius == rhsAvatarCornerRadius, lhsAvatarPeer == rhsAvatarPeer, lhsShowSeconds == rhsShowSeconds { return true } else { return false @@ -230,6 +380,12 @@ private enum ThemeSettingsControllerEntry: ItemListNodeEntry { } else { return false } + case let .winterGramRoundingHeader(lhsTheme, lhsText): + if case let .winterGramRoundingHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } else { + return false + } case let .autoNight(lhsTheme, lhsText, lhsValue, lhsEnabled): if case let .autoNight(rhsTheme, rhsText, rhsValue, rhsEnabled) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue, lhsEnabled == rhsEnabled { return true @@ -266,6 +422,24 @@ private enum ThemeSettingsControllerEntry: ItemListNodeEntry { } else { return false } + case let .winterGramIconPack(lhsTheme, lhsValue, lhsExpanded): + if case let .winterGramIconPack(rhsTheme, rhsValue, rhsExpanded) = rhs, lhsTheme === rhsTheme, lhsValue == rhsValue, lhsExpanded == rhsExpanded { + return true + } else { + return false + } + case let .winterGramBannerSelection(lhsTheme, lhsValue, lhsExpanded): + if case let .winterGramBannerSelection(rhsTheme, rhsValue, rhsExpanded) = rhs, lhsTheme === rhsTheme, lhsValue == rhsValue, lhsExpanded == rhsExpanded { + return true + } else { + return false + } + case let .winterGramBannerPreview(lhsTheme, lhsValue): + if case let .winterGramBannerPreview(rhsTheme, rhsValue) = rhs, lhsTheme === rhsTheme, lhsValue == rhsValue { + return true + } else { + return false + } case let .iconItem(lhsTheme, lhsStrings, lhsIcons, lhsIsPremium, lhsValue): if case let .iconItem(rhsTheme, rhsStrings, rhsIcons, rhsIsPremium, rhsValue) = rhs, lhsTheme === rhsTheme, lhsStrings === rhsStrings, lhsIcons == rhsIcons, lhsIsPremium == rhsIsPremium, lhsValue == rhsValue { return true @@ -308,18 +482,90 @@ private enum ThemeSettingsControllerEntry: ItemListNodeEntry { } else { return false } + case let .winterGramShowSeconds(lhsTheme, lhsText, lhsValue): + if case let .winterGramShowSeconds(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } else { + return false + } + case let .winterGramHeader(lhsTheme, lhsText): + if case let .winterGramHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } else { + return false + } + case let .winterGramMaterialDesign(lhsTheme, lhsText, lhsValue): + if case let .winterGramMaterialDesign(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } else { + return false + } + case let .winterGramSingleCornerRadius(lhsTheme, lhsText, lhsValue): + if case let .winterGramSingleCornerRadius(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } else { + return false + } + case let .winterGramAvatarRadius(lhsTheme, lhsText, lhsValue, _): + if case let .winterGramAvatarRadius(rhsTheme, rhsText, rhsValue, _) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } else { + return false + } + case let .winterGramBubbleRadius(lhsTheme, lhsText, lhsValue): + if case let .winterGramBubbleRadius(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } else { + return false + } + case let .winterGramLiquidGlass(lhsTheme, lhsText, lhsValue): + if case let .winterGramLiquidGlass(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } else { + return false + } + case let .winterGramLiquidGlassVibrancy(lhsTheme, lhsText, lhsValue): + if case let .winterGramLiquidGlassVibrancy(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } else { + return false + } + case let .winterGramLiquidGlassChatList(lhsTheme, lhsText, lhsValue): + if case let .winterGramLiquidGlassChatList(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } else { + return false + } + case let .winterGramLiquidGlassNavBars(lhsTheme, lhsText, lhsValue): + if case let .winterGramLiquidGlassNavBars(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } else { + return false + } + case let .winterGramLiquidGlassTabBar(lhsTheme, lhsText, lhsValue): + if case let .winterGramLiquidGlassTabBar(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } else { + return false + } + case let .winterGramLiquidGlassBubbles(lhsTheme, lhsText, lhsValue): + if case let .winterGramLiquidGlassBubbles(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } else { + return false + } } } - + static func <(lhs: ThemeSettingsControllerEntry, rhs: ThemeSettingsControllerEntry) -> Bool { return lhs.stableId < rhs.stableId } - + func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { let arguments = arguments as! ThemeSettingsControllerArguments switch self { - case let .chatPreview(theme, wallpaper, fontSize, chatBubbleCorners, strings, dateTimeFormat, nameDisplayOrder, items): - return ThemeSettingsChatPreviewItem(context: arguments.context, systemStyle: .glass, theme: theme, componentTheme: theme, strings: strings, sectionId: self.section, fontSize: fontSize, chatBubbleCorners: chatBubbleCorners, wallpaper: wallpaper, dateTimeFormat: dateTimeFormat, nameDisplayOrder: nameDisplayOrder, messageItems: items) + case let .chatPreview(theme, wallpaper, fontSize, chatBubbleCorners, strings, dateTimeFormat, nameDisplayOrder, items, avatarCornerRadius, avatarPeer, _): + return ThemeSettingsChatPreviewItem(context: arguments.context, systemStyle: .glass, theme: theme, componentTheme: theme, strings: strings, sectionId: self.section, fontSize: fontSize, chatBubbleCorners: chatBubbleCorners, wallpaper: wallpaper, dateTimeFormat: dateTimeFormat, nameDisplayOrder: nameDisplayOrder, messageItems: items, avatarCornerRadius: avatarCornerRadius, avatarPeer: avatarPeer) case let .themes(theme, strings, chatThemes, currentTheme, nightMode, animatedEmojiStickers, themeSpecificAccentColors, themeSpecificChatWallpapers): return ThemeCarouselThemeItem(context: arguments.context, theme: theme, strings: strings, sectionId: self.section, themes: chatThemes, hasNoTheme: false, animatedEmojiStickers: animatedEmojiStickers, themeSpecificAccentColors: themeSpecificAccentColors, themeSpecificChatWallpapers: themeSpecificChatWallpapers, nightMode: nightMode, currentTheme: currentTheme, updatedTheme: { theme in if let theme { @@ -344,12 +590,14 @@ private enum ThemeSettingsControllerEntry: ItemListNodeEntry { if let profileColor { colors.append(profileColor) } - + let colorImage = generateSettingsMenuPeerColorsLabelIcon(colors: colors) - + return ItemListDisclosureItem(presentationData: presentationData, systemStyle: .glass, title: text, label: "", labelStyle: .image(image: colorImage, size: colorImage.size), sectionId: self.section, style: .blocks, action: { arguments.openNameColorSettings() }) + case let .winterGramRoundingHeader(_, text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) case let .autoNight(_, title, value, enabled): return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: title, value: value, enabled: enabled, sectionId: self.section, style: .blocks, updated: { value in arguments.toggleNightTheme(value) @@ -370,6 +618,32 @@ private enum ThemeSettingsControllerEntry: ItemListNodeEntry { return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) case let .iconHeader(_, text): return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + case let .winterGramIconPack(_, value, expanded): + let options: [ItemListExpandableSelectionItem.Option] = [ + ItemListExpandableSelectionItem.Option(id: WinterGramIconPack.wintergram.rawValue, title: wntOption("WinterGram", presentationData.strings), isSelected: value == .wintergram), + ItemListExpandableSelectionItem.Option(id: WinterGramIconPack.telegram.rawValue, title: wntOption("Telegram", presentationData.strings), isSelected: value == .telegram) + ] + return ItemListExpandableSelectionItem(presentationData: presentationData, systemStyle: .glass, title: presentationData.strings.WinterGram_IconPack, options: options, isExpanded: expanded, sectionId: self.section, style: .blocks, updated: { option in + if let rawValue = option.id.base as? Int32, let pack = WinterGramIconPack(rawValue: rawValue) { + arguments.updateWinterGramSettings { var s = $0; s.iconPack = pack; return s } + } + }, toggleExpanded: { + arguments.toggleIconPackExpanded() + }) + case let .winterGramBannerSelection(_, selectedBanner, expanded): + let options = winterGramAvailableBannerNames(selectedBanner: selectedBanner).map { bannerName in + ItemListExpandableSelectionItem.Option(id: bannerName, title: winterGramBannerTitle(bannerName), isSelected: selectedBanner == bannerName) + } + return ItemListExpandableSelectionItem(presentationData: presentationData, systemStyle: .glass, title: presentationData.strings.WinterGram_TopBanner, options: options, isExpanded: expanded, sectionId: self.section, style: .blocks, updated: { option in + if let bannerName = option.id.base as? String { + arguments.updateWinterGramSettings { var s = $0; s.topBannerName = bannerName; s.topBannerStyle = .solid; return s } + } + }, toggleExpanded: { + arguments.toggleBannerExpanded() + }) + case let .winterGramBannerPreview(theme, selectedBanner): + let previewImage = winterGramTopBannerPreviewImage(theme: theme, bannerName: selectedBanner) + return ItemListDisclosureItem(presentationData: presentationData, systemStyle: .glass, icon: nil, title: "Preview", label: "", labelStyle: .image(image: previewImage, size: previewImage.size), sectionId: self.section, style: .blocks, disclosureStyle: .none, action: nil) case let .iconItem(theme, strings, icons, isPremium, value): return ThemeSettingsAppIconItem(theme: theme, strings: strings, systemStyle: .glass, sectionId: self.section, icons: icons, isPremium: isPremium, currentIconName: value, updated: { icon in arguments.selectAppIcon(icon) @@ -394,19 +668,74 @@ private enum ThemeSettingsControllerEntry: ItemListNodeEntry { }, tag: ThemeSettingsEntryTag.tapForNextMedia) case let .showNextMediaOnTapInfo(_, text): return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section) + case let .winterGramShowSeconds(_, title, value): + return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: title, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateWinterGramSettingsPreview { var s = $0; s.showMessageSeconds = value; return s } + arguments.updateWinterGramSettings { var s = $0; s.showMessageSeconds = value; return s } + }) + case let .winterGramHeader(_, text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + case let .winterGramMaterialDesign(_, title, value): + return ItemListSwitchItem(presentationData: presentationData, title: title, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateWinterGramSettings { var s = $0; s.materialDesign = value; return s } + }) + case let .winterGramSingleCornerRadius(_, title, value): + return ItemListSwitchItem(presentationData: presentationData, title: title, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateWinterGramSettings { var s = $0; s.singleCornerRadius = value; return s } + }) + case let .winterGramAvatarRadius(_, title, value, avatarSignal): + return WinterGramRadiusItem(presentationData: presentationData, title: title, value: value, minValue: 0, maxValue: 50, previewKind: .avatar, avatarSignal: avatarSignal, displayValue: { wntOption(avatarRadiusLabel($0), presentationData.strings) }, sectionId: self.section, valueChanged: { newValue in + arguments.updateWinterGramSettingsPreview { var s = $0; s.avatarCornerRadius = newValue; return s } + }, updated: { newValue in + arguments.updateWinterGramSettings { var s = $0; s.avatarCornerRadius = newValue; return s } + }) + case let .winterGramBubbleRadius(_, title, value): + return WinterGramRadiusItem(presentationData: presentationData, title: title, value: value, minValue: 0, maxValue: 20, previewKind: .bubble, displayValue: { "\($0)" }, sectionId: self.section, valueChanged: { newValue in + arguments.updateWinterGramSettingsPreview { var s = $0; s.messageBubbleRadius = newValue; return s } + }, updated: { newValue in + arguments.updateWinterGramSettings { var s = $0; s.messageBubbleRadius = newValue; return s } + }) + case let .winterGramLiquidGlass(_, title, value): + return ItemListSwitchItem(presentationData: presentationData, title: title, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateWinterGramSettings { var s = $0; s.liquidGlass.enabled = value; return s } + }) + case let .winterGramLiquidGlassVibrancy(_, title, value): + return ItemListSwitchItem(presentationData: presentationData, title: title, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateWinterGramSettings { var s = $0; s.liquidGlass.vibrancy = value; return s } + }) + case let .winterGramLiquidGlassChatList(_, title, value): + return ItemListSwitchItem(presentationData: presentationData, title: title, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateWinterGramSettings { var s = $0; s.liquidGlass.applyToChatList = value; return s } + }) + case let .winterGramLiquidGlassNavBars(_, title, value): + return ItemListSwitchItem(presentationData: presentationData, title: title, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateWinterGramSettings { var s = $0; s.liquidGlass.applyToNavigationBars = value; return s } + }) + case let .winterGramLiquidGlassTabBar(_, title, value): + return ItemListSwitchItem(presentationData: presentationData, title: title, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateWinterGramSettings { var s = $0; s.liquidGlass.applyToTabBar = value; return s } + }) + case let .winterGramLiquidGlassBubbles(_, title, value): + return ItemListSwitchItem(presentationData: presentationData, title: title, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateWinterGramSettings { var s = $0; s.liquidGlass.applyToBubbles = value; return s } + }) } } } private func themeSettingsControllerEntries( + context: AccountContext, presentationData: PresentationData, presentationThemeSettings: PresentationThemeSettings, chatSettings: ChatSettings, mediaSettings: MediaDisplaySettings, + winterGramSettings: WinterGramSettings, themeReference: PresentationThemeReference, availableThemes: [PresentationThemeReference], availableAppIcons: [PresentationAppIcon], currentAppIconName: String?, + iconPackExpanded: Bool, + bannerExpanded: Bool, isPremium: Bool, chatThemes: [PresentationThemeReference], animatedEmojiStickers: [String: [StickerPackItem]], @@ -414,13 +743,14 @@ private func themeSettingsControllerEntries( nameColors: PeerNameColors ) -> [ThemeSettingsControllerEntry] { var entries: [ThemeSettingsControllerEntry] = [] - + let strings = presentationData.strings let title = presentationData.autoNightModeTriggered ? strings.Appearance_ColorThemeNight.uppercased() : strings.Appearance_ColorTheme.uppercased() entries.append(.themeListHeader(presentationData.theme, title)) - + let nameColor: PeerColor let profileColor: PeerNameColor? + let previewPhoto: [TelegramMediaImageRepresentation] var authorName = presentationData.strings.Appearance_PreviewReplyAuthor if let accountPeer { nameColor = accountPeer.nameColor ?? .preset(.blue) @@ -428,17 +758,21 @@ private func themeSettingsControllerEntries( authorName = accountPeer.displayTitle(strings: strings, displayOrder: presentationData.nameDisplayOrder) } profileColor = accountPeer.effectiveProfileColor + previewPhoto = accountPeer.profileImageRepresentations } else { nameColor = .preset(.blue) profileColor = nil + previewPhoto = [] } - - entries.append(.chatPreview(presentationData.theme, presentationData.chatWallpaper, presentationData.chatFontSize, presentationData.chatBubbleCorners, presentationData.strings, presentationData.dateTimeFormat, presentationData.nameDisplayOrder, [ChatPreviewMessageItem(outgoing: false, reply: (authorName, presentationData.strings.Appearance_PreviewReplyText), text: presentationData.strings.Appearance_PreviewIncomingText, nameColor: nameColor, backgroundEmojiId: accountPeer?.backgroundEmojiId), ChatPreviewMessageItem(outgoing: true, reply: nil, text: presentationData.strings.Appearance_PreviewOutgoingText, nameColor: .preset(.blue), backgroundEmojiId: nil)])) - + + var previewChatBubbleCorners = presentationData.chatBubbleCorners + previewChatBubbleCorners.mainRadius = CGFloat(max(0, min(20, winterGramSettings.messageBubbleRadius))) + entries.append(.chatPreview(presentationData.theme, presentationData.chatWallpaper, presentationData.chatFontSize, previewChatBubbleCorners, presentationData.strings, presentationData.dateTimeFormat, presentationData.nameDisplayOrder, [ChatPreviewMessageItem(outgoing: false, reply: (authorName, presentationData.strings.Appearance_PreviewReplyText), text: presentationData.strings.Appearance_PreviewIncomingText, nameColor: nameColor, photo: previewPhoto, backgroundEmojiId: accountPeer?.backgroundEmojiId), ChatPreviewMessageItem(outgoing: true, reply: nil, text: presentationData.strings.Appearance_PreviewOutgoingText, nameColor: .preset(.blue), backgroundEmojiId: nil)], winterGramSettings.avatarCornerRadius, accountPeer, winterGramSettings.showMessageSeconds)) + entries.append(.themes(presentationData.theme, presentationData.strings, chatThemes, themeReference, presentationThemeSettings.automaticThemeSwitchSetting.force || presentationData.autoNightModeTriggered, animatedEmojiStickers, presentationThemeSettings.themeSpecificAccentColors, presentationThemeSettings.themeSpecificChatWallpapers)) entries.append(.chatTheme(presentationData.theme, strings.Settings_ChatThemes)) entries.append(.wallpaper(presentationData.theme, strings.Settings_ChatBackground)) - + let colors: PeerNameColors.Colors switch nameColor { case let .preset(nameColor): @@ -448,7 +782,14 @@ private func themeSettingsControllerEntries( } let profileColors = profileColor.flatMap { nameColors.getProfile($0, dark: presentationData.theme.overallDarkAppearance, subject: .palette) } entries.append(.nameColor(presentationData.theme, presentationData.strings.Settings_YourColor, accountPeer?.compactDisplayTitle ?? "", colors, profileColors)) - + + // Rounding sliders, placed right after Personal Colors. The single theme preview at the top + // already reflects both avatar and message rounding, so there is no separate rounding preview. + entries.append(.winterGramRoundingHeader(presentationData.theme, "WINTERGRAM")) + entries.append(.winterGramShowSeconds(presentationData.theme, strings.WinterGram_ShowMessageSeconds, winterGramSettings.showMessageSeconds)) + entries.append(.winterGramAvatarRadius(presentationData.theme, strings.WinterGram_AvatarShape, winterGramSettings.avatarCornerRadius, nil)) + entries.append(.winterGramBubbleRadius(presentationData.theme, strings.WinterGram_BubbleRadius, winterGramSettings.messageBubbleRadius)) + entries.append(.autoNight(presentationData.theme, strings.Appearance_NightTheme, presentationThemeSettings.automaticThemeSwitchSetting.force, !presentationData.autoNightModeTriggered || presentationThemeSettings.automaticThemeSwitchSetting.force)) let autoNightMode: String switch presentationThemeSettings.automaticThemeSwitchSetting.trigger { @@ -466,7 +807,7 @@ private func themeSettingsControllerEntries( autoNightMode = strings.AutoNightTheme_Automatic } entries.append(.autoNightTheme(presentationData.theme, strings.Appearance_AutoNightTheme, autoNightMode)) - + let textSizeValue: String if presentationThemeSettings.useSystemFont { textSizeValue = strings.Appearance_TextSize_Automatic @@ -481,24 +822,38 @@ private func themeSettingsControllerEntries( entries.append(.bubbleSettings(presentationData.theme, strings.Appearance_BubbleCornersSetting, "")) entries.append(.powerSaving) entries.append(.stickersAndEmoji) - + if !availableAppIcons.isEmpty { entries.append(.iconHeader(presentationData.theme, strings.Appearance_AppIcon.uppercased())) - entries.append(.iconItem(presentationData.theme, presentationData.strings, availableAppIcons, isPremium, currentAppIconName)) + entries.append(.winterGramBannerSelection(presentationData.theme, winterGramSettings.topBannerName, bannerExpanded)) + entries.append(.winterGramBannerPreview(presentationData.theme, winterGramSettings.topBannerName)) + entries.append(.winterGramIconPack(presentationData.theme, winterGramSettings.iconPack, iconPackExpanded)) + let filteredAppIcons = availableAppIcons.filter { icon in + let isWinterGramIcon = icon.name.hasPrefix("WinterGram") || icon.name.hasPrefix("icon-app-") + switch winterGramSettings.iconPack { + case .wintergram: + return isWinterGramIcon + case .telegram: + return !isWinterGramIcon + default: + return true + } + } + entries.append(.iconItem(presentationData.theme, presentationData.strings, filteredAppIcons.isEmpty ? availableAppIcons : filteredAppIcons, isPremium, currentAppIconName)) } - + entries.append(.otherHeader(presentationData.theme, strings.Appearance_Other.uppercased())) if DeviceModel.current.isIpad { entries.append(.sendWithCmdEnter(presentationData.theme, strings.Appearance_SendWithCmdEnter, chatSettings.sendWithCmdEnter)) } entries.append(.showNextMediaOnTap(presentationData.theme, strings.Appearance_ShowNextMediaOnTap, mediaSettings.showNextMediaOnTap)) entries.append(.showNextMediaOnTapInfo(presentationData.theme, strings.Appearance_ShowNextMediaOnTapInfo)) - + return entries } public protocol ThemeSettingsController { - + } private final class ThemeSettingsControllerImpl: ItemListController, ThemeSettingsController { @@ -515,13 +870,13 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The var presentInGlobalOverlayImpl: ((ViewController, Any?) -> Void)? var getNavigationControllerImpl: (() -> NavigationController?)? var presentCrossfadeControllerImpl: ((Bool) -> Void)? - + var selectThemeImpl: ((PresentationThemeReference) -> Void)? var selectAccentColorImpl: ((PresentationThemeAccentColor?) -> Void)? var openAccentColorPickerImpl: ((PresentationThemeReference, Bool) -> Void)? - + let _ = context.engine.themes.wallpapers().start() - + let currentAppIcon: PresentationAppIcon? var appIcons = context.sharedContext.applicationBindings.getAvailableAlternateIcons() if let alternateIconName = context.sharedContext.applicationBindings.getAlternateIconName() { @@ -529,26 +884,26 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The } else { currentAppIcon = appIcons.filter { $0.isDefault }.first } - + let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) if premiumConfiguration.isPremiumDisabled || context.account.testingEnvironment { - appIcons = appIcons.filter { !$0.isPremium } + appIcons = appIcons.filter { !$0.isPremium } } - + let availableAppIcons: Signal<[PresentationAppIcon], NoError> = .single(appIcons) let currentAppIconName = ValuePromise() currentAppIconName.set(currentAppIcon?.name ?? "Blue") - + let cloudThemes = Promise<[TelegramTheme]>() let updatedCloudThemes = context.engine.themes.themes(accountManager: context.sharedContext.accountManager) cloudThemes.set(updatedCloudThemes) - + let removedThemeIndexesPromise = Promise>(Set()) let removedThemeIndexes = Atomic>(value: Set()) - + let archivedPacks = Promise<[ArchivedStickerPackItem]?>() archivedPacks.set(.single(nil) |> then(context.engine.stickers.archivedStickerPacks() |> map(Optional.init))) - + let animatedEmojiStickers = context.engine.stickers.loadedStickerPack(reference: .animatedEmoji, forceActualized: false) |> map { animatedEmoji -> [String: [StickerPackItem]] in var animatedEmojiStickers: [String: [StickerPackItem]] = [:] @@ -568,7 +923,14 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The } return animatedEmojiStickers } - + + let winterGramPreviewSettingsValue = Atomic(value: nil) + let winterGramPreviewSettingsPromise = ValuePromise(nil, ignoreRepeated: false) + let iconPackExpandedValue = Atomic(value: true) + let iconPackExpandedPromise = ValuePromise(true, ignoreRepeated: true) + let bannerExpandedValue = Atomic(value: false) + let bannerExpandedPromise = ValuePromise(false, ignoreRepeated: true) + let arguments = ThemeSettingsControllerArguments(context: context, selectTheme: { theme in selectThemeImpl?(theme) }, openThemeSettings: { @@ -635,6 +997,11 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The pushControllerImpl?(controller) } else { currentAppIconName.set(icon.name) + let _ = updateWinterGramSettingsInteractively(accountManager: context.sharedContext.accountManager, { current in + var updated = current + updated.appIcon = icon.name + return updated + }).startStandalone() context.sharedContext.applicationBindings.requestSetAlternateIconName(icon.isDefault ? nil : icon.name, { _ in }) } @@ -703,7 +1070,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The let strings = presentationData.strings let themeController = ThemePreviewController(context: context, previewTheme: theme, source: .settings(reference, wallpaper, true)) var items: [ContextMenuItem] = [] - + if case let .cloud(theme) = reference { if theme.theme.isCreator { items.append(.action(ContextMenuActionItem(text: presentationData.strings.Appearance_EditTheme, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ApplyTheme"), color: theme.contextMenu.primaryColor) }, action: { c, f in @@ -718,7 +1085,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The } }) }) - + c?.dismiss(completion: { pushControllerImpl?(controller) }) @@ -728,7 +1095,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The guard let theme = makePresentationTheme(mediaBox: context.sharedContext.accountManager.mediaBox, themeReference: reference, preview: false) else { return } - + let resolvedWallpaper: Signal if case let .file(file) = theme.chat.defaultWallpaper, file.id == 0 { resolvedWallpaper = cachedWallpaper(engine: context.engine, network: context.account.network, slug: file.slug, settings: file.settings) @@ -738,7 +1105,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The } else { resolvedWallpaper = .single(theme.chat.defaultWallpaper) } - + let _ = (resolvedWallpaper |> deliverOnMainQueue).start(next: { wallpaper in let controller = ThemeAccentColorController(context: context, mode: .edit(settings: nil, theme: theme, wallpaper: wallpaper, generalThemeReference: reference.generalThemeReference, defaultThemeReference: nil, create: true, completion: { result, settings in @@ -766,7 +1133,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The return controllers }) })) - + c?.dismiss(completion: { pushControllerImpl?(controller) }) @@ -796,7 +1163,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The updated.insert(theme.theme.id) return updated }))) - + if isCurrent, let currentThemeIndex = themes.firstIndex(where: { $0.id == theme.theme.id }) { if let settings = theme.theme.settings?.first { if settings.baseTheme == .night { @@ -816,7 +1183,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The selectThemeImpl?(newTheme) } } - + let _ = deleteThemeInteractively(account: context.account, accountManager: context.sharedContext.accountManager, theme: theme.theme).start() }) })) @@ -837,7 +1204,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The }) }))) } - + let contextController = makeContextController(presentationData: presentationData, source: .controller(ContextControllerContentSourceImpl(controller: themeController, sourceNode: node)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture) presentInGlobalOverlayImpl?(contextController, nil) }) @@ -866,7 +1233,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The } else { generalThemeReference = reference } - + let effectiveWallpaper: TelegramWallpaper let effectiveThemeReference: PresentationThemeReference if let accentColor = accentColor, case let .theme(themeReference) = accentColor { @@ -874,7 +1241,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The } else { effectiveThemeReference = reference } - + if let wallpaper = wallpaper { effectiveWallpaper = wallpaper } else { @@ -893,7 +1260,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The } effectiveWallpaper = theme?.chat.defaultWallpaper ?? .builtin(WallpaperSettings()) } - + let wallpaperSignal: Signal if case let .file(file) = effectiveWallpaper, file.id == 0 { wallpaperSignal = cachedWallpaper(engine: context.engine, network: context.account.network, slug: file.slug, settings: file.settings) @@ -903,7 +1270,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The } else { wallpaperSignal = .single(effectiveWallpaper) } - + return wallpaperSignal |> mapToSignal { wallpaper in return chatServiceBackgroundColor(wallpaper: wallpaper, mediaBox: context.sharedContext.accountManager.mediaBox) @@ -946,7 +1313,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The let strings = presentationData.strings let themeController = ThemePreviewController(context: context, previewTheme: theme, source: .settings(effectiveThemeReference, wallpaper, true)) var items: [ContextMenuItem] = [] - + if let accentColor = accentColor { if case let .accentColor(color) = accentColor, color.baseColor != .custom { } else if case let .theme(theme) = accentColor, case let .cloud(cloudTheme) = theme { @@ -963,7 +1330,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The } }) }) - + c?.dismiss(completion: { pushControllerImpl?(controller) }) @@ -973,7 +1340,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The guard let theme = makePresentationTheme(mediaBox: context.sharedContext.accountManager.mediaBox, themeReference: effectiveThemeReference, preview: false) else { return } - + let resolvedWallpaper: Signal if case let .file(file) = theme.chat.defaultWallpaper, file.id == 0 { resolvedWallpaper = cachedWallpaper(engine: context.engine, network: context.account.network, slug: file.slug, settings: file.settings) @@ -983,7 +1350,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The } else { resolvedWallpaper = .single(theme.chat.defaultWallpaper) } - + let _ = (resolvedWallpaper |> deliverOnMainQueue).start(next: { wallpaper in var hasSettings = false @@ -1016,7 +1383,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The return controllers }) })) - + c?.dismiss(completion: { pushControllerImpl?(controller) }) @@ -1047,7 +1414,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The updated.insert(cloudTheme.theme.id) return updated }))) - + if isCurrent, let settings = cloudTheme.theme.settings?.first { let colorThemes = themes.filter { theme in if let _ = theme.settings { @@ -1056,7 +1423,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The return false } } - + if let currentThemeIndex = colorThemes.firstIndex(where: { $0.id == cloudTheme.theme.id }) { let previousThemeIndex = themes.prefix(upTo: currentThemeIndex).reversed().firstIndex(where: { $0.file != nil }) if let previousThemeIndex = previousThemeIndex { @@ -1071,7 +1438,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The } } } - + let _ = deleteThemeInteractively(account: context.account, accountManager: context.sharedContext.accountManager, theme: cloudTheme.theme).start() }) })) @@ -1089,32 +1456,63 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The let contextController = makeContextController(presentationData: presentationData, source: .controller(ContextControllerContentSourceImpl(controller: themeController, sourceNode: node)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture) presentInGlobalOverlayImpl?(contextController, nil) }) + }, updateWinterGramSettingsPreview: { f in + let updatedSettings = f(winterGramPreviewSettingsValue.with { $0 } ?? currentWinterGramSettings) + let _ = winterGramPreviewSettingsValue.swap(updatedSettings) + setCurrentWinterGramSettings(updatedSettings) + winterGramPreviewSettingsPromise.set(updatedSettings) + }, updateWinterGramSettings: { f in + let _ = (updateWinterGramSettingsInteractively(accountManager: context.sharedContext.accountManager, f) + |> deliverOnMainQueue).startStandalone(next: { _ in + let _ = winterGramPreviewSettingsValue.swap(nil) + winterGramPreviewSettingsPromise.set(nil) + }) + }, toggleIconPackExpanded: { + let nextValue = !iconPackExpandedValue.with { $0 } + let _ = iconPackExpandedValue.swap(nextValue) + iconPackExpandedPromise.set(nextValue) + }, toggleBannerExpanded: { + let nextValue = !bannerExpandedValue.with { $0 } + let _ = bannerExpandedValue.swap(nextValue) + bannerExpandedPromise.set(nextValue) }) - let signal = combineLatest( + let sharedDataAndWinterGramPreview = combineLatest( queue: .mainQueue(), - context.sharedContext.presentationData, context.sharedContext.accountManager.sharedData(keys: [ ApplicationSpecificSharedDataKeys.presentationThemeSettings, ApplicationSpecificSharedDataKeys.chatSettings, ApplicationSpecificSharedDataKeys.mediaDisplaySettings, + ApplicationSpecificSharedDataKeys.winterGramSettings, SharedDataKeys.chatThemes ]), + winterGramPreviewSettingsPromise.get() + ) + + let signal = combineLatest( + queue: .mainQueue(), + context.sharedContext.presentationData, + sharedDataAndWinterGramPreview, cloudThemes.get(), availableAppIcons, currentAppIconName.get(), + iconPackExpandedPromise.get(), + bannerExpandedPromise.get(), removedThemeIndexesPromise.get(), animatedEmojiStickers, context.account.postbox.peerView(id: context.account.peerId), context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) ) - |> map { presentationData, sharedData, cloudThemes, availableAppIcons, currentAppIconName, removedThemeIndexes, animatedEmojiStickers, peerView, accountPeer -> (ItemListControllerState, (ItemListNodeState, Any)) in + |> map { presentationData, sharedDataAndWinterGramPreview, cloudThemes, availableAppIcons, currentAppIconName, iconPackExpanded, bannerExpanded, removedThemeIndexes, animatedEmojiStickers, peerView, accountPeer -> (ItemListControllerState, (ItemListNodeState, Any)) in + let (sharedData, winterGramPreviewSettings) = sharedDataAndWinterGramPreview let settings = sharedData.entries[ApplicationSpecificSharedDataKeys.presentationThemeSettings]?.get(PresentationThemeSettings.self) ?? PresentationThemeSettings.defaultSettings let chatSettings = sharedData.entries[ApplicationSpecificSharedDataKeys.chatSettings]?.get(ChatSettings.self) ?? ChatSettings.defaultSettings let mediaSettings = sharedData.entries[ApplicationSpecificSharedDataKeys.mediaDisplaySettings]?.get(MediaDisplaySettings.self) ?? MediaDisplaySettings.defaultSettings - + let storedWinterGramSettings = sharedData.entries[ApplicationSpecificSharedDataKeys.winterGramSettings]?.get(WinterGramSettings.self) ?? WinterGramSettings.defaultSettings + let winterGramSettings = winterGramPreviewSettings ?? storedWinterGramSettings + let isPremium = peerView.peers[peerView.peerId]?.isPremium ?? false - + let themeReference: PresentationThemeReference if presentationData.autoNightModeTriggered { if let _ = settings.theme.emoticon { @@ -1125,7 +1523,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The } else { themeReference = settings.theme } - + var defaultThemes: [PresentationThemeReference] = [] if presentationData.autoNightModeTriggered { defaultThemes.append(contentsOf: [.builtin(.nightAccent), .builtin(.night)]) @@ -1137,24 +1535,24 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The .builtin(.night) ]) } - + let cloudThemes: [PresentationThemeReference] = cloudThemes.map { .cloud(PresentationCloudTheme(theme: $0, resolvedWallpaper: nil, creatorAccountId: $0.isCreator ? context.account.id : nil)) }.filter { !removedThemeIndexes.contains($0.index) } - + var availableThemes = defaultThemes if defaultThemes.first(where: { $0.index == themeReference.index }) == nil && cloudThemes.first(where: { $0.index == themeReference.index }) == nil { availableThemes.append(themeReference) } availableThemes.append(contentsOf: cloudThemes) - + var chatThemes = cloudThemes.filter { $0.emoticon != nil } chatThemes.insert(.builtin(.dayClassic), at: 0) - + let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(presentationData.strings.Appearance_Title), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)) - let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: themeSettingsControllerEntries(presentationData: presentationData, presentationThemeSettings: settings, chatSettings: chatSettings, mediaSettings: mediaSettings, themeReference: themeReference, availableThemes: availableThemes, availableAppIcons: availableAppIcons, currentAppIconName: currentAppIconName, isPremium: isPremium, chatThemes: chatThemes, animatedEmojiStickers: animatedEmojiStickers, accountPeer: accountPeer, nameColors: context.peerNameColors), style: .blocks, ensureVisibleItemTag: focusOnItemTag, animateChanges: false) - + let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: themeSettingsControllerEntries(context: context, presentationData: presentationData, presentationThemeSettings: settings, chatSettings: chatSettings, mediaSettings: mediaSettings, winterGramSettings: winterGramSettings, themeReference: themeReference, availableThemes: availableThemes, availableAppIcons: availableAppIcons, currentAppIconName: currentAppIconName, iconPackExpanded: iconPackExpanded, bannerExpanded: bannerExpanded, isPremium: isPremium, chatThemes: chatThemes, animatedEmojiStickers: animatedEmojiStickers, accountPeer: accountPeer, nameColors: context.peerNameColors), style: .blocks, ensureVisibleItemTag: focusOnItemTag, animateChanges: false) + return (controllerState, (listState, arguments)) } - + let controller = ThemeSettingsControllerImpl(context: context, state: signal) controller.alwaysSynchronous = true pushControllerImpl = { [weak controller] c in @@ -1180,12 +1578,12 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The var bottomOffset: CGFloat? var leftOffset: CGFloat? var themeItemNode: ThemeCarouselThemeItemNode? - + var view: UIView? if #available(iOS 11.0, *) { view = controller.navigationController?.view } - + let controllerFrame = controller.view.convert(controller.view.bounds, to: controller.navigationController?.view) if controllerFrame.minX > 0.0 { leftOffset = controllerFrame.minX @@ -1193,7 +1591,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The if controllerFrame.minY > 100.0 { view = nil } - + controller.forEachItemNode { node in if let itemNode = node as? ItemListItemNode { if let itemTag = itemNode.tag { @@ -1208,7 +1606,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The } } } - + if let navigationBar = controller.navigationBar { if let offset = topOffset { topOffset = max(offset, navigationBar.frame.maxY) @@ -1216,20 +1614,20 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The topOffset = navigationBar.frame.maxY } } - + if view != nil { themeItemNode?.prepareCrossfadeTransition() } - + let sectionInset = max(16.0, floor((controller.displayNode.frame.width - 674.0) / 2.0)) - + let crossfadeController = ThemeSettingsCrossfadeController(view: view, topOffset: topOffset, bottomOffset: bottomOffset, leftOffset: leftOffset, sideInset: sectionInset) crossfadeController.didAppear = { [weak themeItemNode] in if view != nil { themeItemNode?.animateCrossfadeTransition() } } - + context.sharedContext.presentGlobalController(crossfadeController, nil) } } @@ -1237,9 +1635,9 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The guard let presentationTheme = makePresentationTheme(mediaBox: context.sharedContext.accountManager.mediaBox, themeReference: theme) else { return } - + let autoNightModeTriggered = context.sharedContext.currentPresentationData.with { $0 }.autoNightModeTriggered - + let resolvedWallpaper: Signal if case let .file(file) = presentationTheme.chat.defaultWallpaper, file.id == 0 { resolvedWallpaper = cachedWallpaper(engine: context.engine, network: context.account.network, slug: file.slug, settings: file.settings) @@ -1249,13 +1647,13 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The } else { resolvedWallpaper = .single(nil) } - + var cloudTheme: TelegramTheme? if case let .cloud(theme) = theme { cloudTheme = theme.theme } let _ = applyTheme(accountManager: context.sharedContext.accountManager, account: context.account, theme: cloudTheme).start() - + let currentTheme = context.sharedContext.accountManager.transaction { transaction -> (PresentationThemeReference) in let settings = transaction.getSharedData(ApplicationSpecificSharedDataKeys.presentationThemeSettings)?.get(PresentationThemeSettings.self) ?? PresentationThemeSettings.defaultSettings if autoNightModeTriggered { @@ -1264,7 +1662,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The return settings.theme } } - + let _ = (combineLatest(resolvedWallpaper, currentTheme) |> map { resolvedWallpaper, currentTheme -> Bool in var updatedTheme = theme @@ -1274,7 +1672,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The } else { currentThemeBaseIndex = currentTheme.index } - + var baseThemeIndex: Int64? var updatedThemeBaseIndex: Int64? if case let .cloud(info) = theme { @@ -1303,7 +1701,7 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The return current.withUpdatedTheme(updatedTheme).withUpdatedAutomaticThemeSwitchSetting(updatedAutomaticThemeSwitchSetting) }).start() - + return currentThemeBaseIndex != updatedThemeBaseIndex } |> deliverOnMainQueue).start(next: { crossfadeAccentColors in presentCrossfadeControllerImpl?((cloudTheme == nil || cloudTheme?.settings != nil) && !crossfadeAccentColors) @@ -1325,13 +1723,13 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The let _ = context.engine.resources.fetch(reference: .wallpaper(wallpaper: .slug(file.slug), resource: file.file.resource), userLocation: .other, userContentType: .other).start() return .single(wallpaper) - + } else { return .single(nil) } } } - + let _ = (wallpaperSignal |> deliverOnMainQueue).start(next: { presetWallpaper in let _ = updatePresentationThemeSettingsInteractively(accountManager: context.sharedContext.accountManager, { current in @@ -1340,33 +1738,33 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The if autoNightModeTriggered { currentTheme = current.automaticThemeSwitchSetting.theme } - + let generalThemeReference: PresentationThemeReference if case let .cloud(theme) = currentTheme, let settings = theme.theme.settings?.first { generalThemeReference = .builtin(PresentationBuiltinThemeReference(baseTheme: settings.baseTheme)) } else { generalThemeReference = currentTheme } - + currentTheme = generalThemeReference var updatedTheme = current.theme var updatedAutomaticThemeSwitchSetting = current.automaticThemeSwitchSetting - + if autoNightModeTriggered { updatedAutomaticThemeSwitchSetting.theme = generalThemeReference } else { updatedTheme = generalThemeReference } - + guard let _ = makePresentationTheme(mediaBox: context.sharedContext.accountManager.mediaBox, themeReference: generalThemeReference, accentColor: accentColor?.color, wallpaper: presetWallpaper, baseColor: accentColor?.baseColor) else { return current } - + let themePreferredBaseTheme = current.themePreferredBaseTheme var themeSpecificChatWallpapers = current.themeSpecificChatWallpapers var themeSpecificAccentColors = current.themeSpecificAccentColors themeSpecificAccentColors[generalThemeReference.index] = accentColor?.withUpdatedWallpaper(presetWallpaper) - + if case .builtin = generalThemeReference { let index = coloredThemeIndex(reference: currentTheme, accentColor: accentColor) if let wallpaper = current.themeSpecificChatWallpapers[index] { @@ -1377,14 +1775,14 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The themeSpecificChatWallpapers[index] = presetWallpaper } } - + return PresentationThemeSettings(theme: updatedTheme, themePreferredBaseTheme: themePreferredBaseTheme, themeSpecificAccentColors: themeSpecificAccentColors, themeSpecificChatWallpapers: themeSpecificChatWallpapers, useSystemFont: current.useSystemFont, fontSize: current.fontSize, listsFontSize: current.listsFontSize, chatBubbleSettings: current.chatBubbleSettings, automaticThemeSwitchSetting: updatedAutomaticThemeSwitchSetting, largeEmoji: current.largeEmoji, reduceMotion: current.reduceMotion) }).start() - + presentCrossfadeControllerImpl?(true) }) } - + if let focusOnItemTag { var didFocusOnItem = false controller.afterTransactionCompleted = { [weak controller] in @@ -1398,22 +1796,22 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The } } } - + return controller } public final class ThemeSettingsCrossfadeController: ViewController { private var snapshotView: UIView? - + private var topSnapshotView: UIView? private var bottomSnapshotView: UIView? private var sideSnapshotView: UIView? - + private var leftSnapshotView: UIView? private var rightSnapshotView: UIView? - + var didAppear: (() -> Void)? - + public init(view: UIView? = nil, topOffset: CGFloat? = nil, bottomOffset: CGFloat? = nil, leftOffset: CGFloat? = nil, sideInset: CGFloat = 0.0) { if let view = view { if let leftOffset = leftOffset { @@ -1421,61 +1819,61 @@ public final class ThemeSettingsCrossfadeController: ViewController { let clipView = UIView() clipView.clipsToBounds = true clipView.addSubview(view) - + view.clipsToBounds = true view.contentMode = .topLeft - + if let topOffset = topOffset, let bottomOffset = bottomOffset { var frame = view.frame frame.origin.y = topOffset frame.size.width = leftOffset + sideInset frame.size.height = bottomOffset - topOffset clipView.frame = frame - + frame = view.frame frame.origin.y = -topOffset frame.size.width = leftOffset + sideInset frame.size.height = bottomOffset view.frame = frame } - + self.sideSnapshotView = clipView } } - + if sideInset > 0.0 { if let view = view.snapshotView(afterScreenUpdates: false), leftOffset == nil { let clipView = UIView() clipView.clipsToBounds = true clipView.addSubview(view) - + view.clipsToBounds = true view.contentMode = .topLeft - + if let topOffset = topOffset, let bottomOffset = bottomOffset { var frame = view.frame frame.origin.y = topOffset frame.size.width = sideInset frame.size.height = bottomOffset - topOffset clipView.frame = frame - + frame = view.frame frame.origin.y = -topOffset frame.size.width = sideInset frame.size.height = bottomOffset view.frame = frame } - + self.leftSnapshotView = clipView } if let view = view.snapshotView(afterScreenUpdates: false) { let clipView = UIView() clipView.clipsToBounds = true clipView.addSubview(view) - + view.clipsToBounds = true view.contentMode = .topRight - + if let topOffset = topOffset, let bottomOffset = bottomOffset { var frame = view.frame frame.origin.x = frame.width - sideInset @@ -1483,18 +1881,18 @@ public final class ThemeSettingsCrossfadeController: ViewController { frame.size.width = sideInset frame.size.height = bottomOffset - topOffset clipView.frame = frame - + frame = view.frame frame.origin.y = -topOffset frame.size.width = sideInset frame.size.height = bottomOffset view.frame = frame } - + self.rightSnapshotView = clipView } } - + if let view = view.snapshotView(afterScreenUpdates: false) { view.clipsToBounds = true view.contentMode = .top @@ -1505,7 +1903,7 @@ public final class ThemeSettingsCrossfadeController: ViewController { } self.topSnapshotView = view } - + if let view = view.snapshotView(afterScreenUpdates: false) { view.clipsToBounds = true view.contentMode = .bottom @@ -1521,17 +1919,17 @@ public final class ThemeSettingsCrossfadeController: ViewController { self.snapshotView = UIScreen.main.snapshotView(afterScreenUpdates: false) } super.init(navigationBarPresentationData: nil) - + self.statusBar.statusBarStyle = .Ignore } - + required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override public func loadDisplayNode() { self.displayNode = ViewControllerTracingNode() - + self.displayNode.backgroundColor = nil self.displayNode.isOpaque = false self.displayNode.isUserInteractionEnabled = false @@ -1554,14 +1952,14 @@ public final class ThemeSettingsCrossfadeController: ViewController { self.displayNode.view.addSubview(rightSnapshotView) } } - + override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - + self.displayNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak self] _ in self?.presentingViewController?.dismiss(animated: false, completion: nil) }) - + self.didAppear?() } } @@ -1569,16 +1967,16 @@ public final class ThemeSettingsCrossfadeController: ViewController { private final class ContextControllerContentSourceImpl: ContextControllerContentSource { let controller: ViewController weak var sourceNode: ASDisplayNode? - + let navigationController: NavigationController? = nil - + let passthroughTouches: Bool = false - + init(controller: ViewController, sourceNode: ASDisplayNode?) { self.controller = controller self.sourceNode = sourceNode } - + func transitionInfo() -> ContextControllerTakeControllerInfo? { let sourceNode = self.sourceNode return ContextControllerTakeControllerInfo(contentAreaInScreenSpace: CGRect(origin: CGPoint(), size: CGSize(width: 10.0, height: 10.0)), sourceNode: { [weak sourceNode] in @@ -1589,7 +1987,7 @@ private final class ContextControllerContentSourceImpl: ContextControllerContent } }) } - + func animatedIn() { } } diff --git a/submodules/SettingsUI/Sources/WinterGramBannerItem.swift b/submodules/SettingsUI/Sources/WinterGramBannerItem.swift new file mode 100644 index 0000000000..755d47714d --- /dev/null +++ b/submodules/SettingsUI/Sources/WinterGramBannerItem.swift @@ -0,0 +1,167 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +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 + let context = rendererContext.cgContext + let bounds = CGRect(origin: .zero, size: size) + context.saveGState() + UIBezierPath(roundedRect: bounds, cornerRadius: size.height * 0.225).addClip() + let colors = [UIColor(rgb: 0x5CC0F5).cgColor, UIColor(rgb: 0x2D7FD6).cgColor] as CFArray + if let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors, locations: [0.0, 1.0]) { + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: size.height), options: []) + } + context.restoreGState() + if let glyph = UIImage(systemName: "snowflake", withConfiguration: UIImage.SymbolConfiguration(pointSize: size.height * 0.56, weight: .semibold))?.withTintColor(.white, renderingMode: .alwaysOriginal) { + let glyphSize = glyph.size + glyph.draw(in: CGRect(x: floor((size.width - glyphSize.width) / 2.0), y: floor((size.height - glyphSize.height) / 2.0), width: glyphSize.width, height: glyphSize.height)) + } + } +} + +// 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 + let bounds = CGRect(origin: .zero, size: size) + UIBezierPath(roundedRect: bounds, cornerRadius: size.height * 0.225).addClip() + image.draw(in: bounds) + } +} + +class WinterGramBannerItem: ListViewItem, ItemListItem { + let theme: PresentationTheme + let title: String + let subtitle: String + let iconImage: UIImage? + let sectionId: ItemListSectionId + let isAlwaysPlain: Bool = true + + init(theme: PresentationTheme, title: String, subtitle: String, iconImage: UIImage? = nil, sectionId: ItemListSectionId) { + self.theme = theme + self.title = title + self.subtitle = subtitle + self.iconImage = iconImage + self.sectionId = sectionId + } + + func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { + async { + Queue.mainQueue().async { + let node = WinterGramBannerItemNode() + let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + node.contentSize = layout.contentSize + node.insets = layout.insets + completion(node, { + return (nil, { _ in apply() }) + }) + } + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { + Queue.mainQueue().async { + if let nodeValue = node() as? WinterGramBannerItemNode { + let makeLayout = nodeValue.asyncLayout() + async { + let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + Queue.mainQueue().async { + completion(layout, { _ in + apply() + }) + } + } + } + } + } +} + +class WinterGramBannerItemNode: ListViewItemNode { + private let backgroundNode: ASDisplayNode + private let iconNode: ASImageNode + private let titleNode: ImmediateTextNode + private let subtitleNode: ImmediateTextNode + + private var item: WinterGramBannerItem? + + init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isLayerBacked = true + self.iconNode = ASImageNode() + self.iconNode.displaysAsynchronously = false + self.iconNode.isUserInteractionEnabled = false + self.titleNode = ImmediateTextNode() + self.titleNode.displaysAsynchronously = false + self.titleNode.textAlignment = .center + self.subtitleNode = ImmediateTextNode() + self.subtitleNode.displaysAsynchronously = false + self.subtitleNode.textAlignment = .center + self.subtitleNode.maximumNumberOfLines = 2 + + super.init(layerBacked: false) + + self.insertSubnode(self.backgroundNode, at: 0) + self.addSubnode(self.iconNode) + self.addSubnode(self.titleNode) + self.addSubnode(self.subtitleNode) + } + + func asyncLayout() -> (_ item: WinterGramBannerItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { + return { item, params, _ in + let hasSubtitle = !item.subtitle.isEmpty + let iconSize = CGSize(width: hasSubtitle ? 96.0 : 80.0, height: hasSubtitle ? 96.0 : 80.0) + let topInset: CGFloat = hasSubtitle ? 14.0 : 10.0 + let iconTitleSpacing: CGFloat = hasSubtitle ? 10.0 : 8.0 + let titleSubtitleSpacing: CGFloat = 4.0 + + let titleFont = Font.semibold(hasSubtitle ? 26.0 : 22.0) + let subtitleFont = Font.regular(14.0) + let constrainedWidth = params.width - params.leftInset - params.rightInset - 40.0 + + let contentHeight: CGFloat = hasSubtitle ? 196.0 : 142.0 + let layout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: contentHeight), insets: UIEdgeInsets()) + + return (layout, { [weak self] in + guard let strongSelf = self else { + return + } + 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)) + + if let iconImage = item.iconImage { + strongSelf.iconNode.image = winterGramBannerRoundedIcon(image: iconImage, size: iconSize) + } else { + strongSelf.iconNode.image = winterGramBannerIcon(size: iconSize) + } + + strongSelf.titleNode.attributedText = NSAttributedString(string: item.title, font: titleFont, textColor: item.theme.list.itemPrimaryTextColor) + let titleSize = strongSelf.titleNode.updateLayout(CGSize(width: constrainedWidth, height: 30.0)) + + var subtitleSize = CGSize() + if hasSubtitle { + strongSelf.subtitleNode.attributedText = NSAttributedString(string: item.subtitle, font: subtitleFont, textColor: item.theme.list.itemSecondaryTextColor) + subtitleSize = strongSelf.subtitleNode.updateLayout(CGSize(width: constrainedWidth, height: 40.0)) + } else { + strongSelf.subtitleNode.attributedText = nil + } + + strongSelf.iconNode.frame = CGRect(origin: CGPoint(x: floor((width - iconSize.width) / 2.0), y: topInset), size: iconSize) + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: floor((width - titleSize.width) / 2.0), y: topInset + iconSize.height + iconTitleSpacing), size: titleSize) + strongSelf.subtitleNode.frame = CGRect(origin: CGPoint(x: floor((width - subtitleSize.width) / 2.0), y: topInset + iconSize.height + iconTitleSpacing + titleSize.height + titleSubtitleSpacing), size: subtitleSize) + }) + } + } +} diff --git a/submodules/SettingsUI/Sources/WinterGramMainSettingsController.swift b/submodules/SettingsUI/Sources/WinterGramMainSettingsController.swift new file mode 100644 index 0000000000..2388216ac6 --- /dev/null +++ b/submodules/SettingsUI/Sources/WinterGramMainSettingsController.swift @@ -0,0 +1,304 @@ +import Foundation +import UIKit +import Display +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData +import TelegramUIPreferences +import ItemListUI +import PresentationDataUtils +import AccountContext + +private final class WinterGramMainSettingsArguments { + let openCategory: (WinterGramSettingsSection) -> Void + let openUrl: (String) -> Void + let updateSettings: (@escaping (WinterGramSettings) -> WinterGramSettings) -> Void + let appVersionString: String + + init( + openCategory: @escaping (WinterGramSettingsSection) -> Void, + openUrl: @escaping (String) -> Void, + updateSettings: @escaping (@escaping (WinterGramSettings) -> WinterGramSettings) -> Void, + appVersionString: String + ) { + self.openCategory = openCategory + self.openUrl = openUrl + self.updateSettings = updateSettings + self.appVersionString = appVersionString + } +} + +private enum WinterGramMainSettingsSection: Int32 { + case banner + case categories + case information + 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 + + init(symbol: String, imageName: String? = nil, title: String, value: String, url: String) { + self.symbol = symbol + self.imageName = imageName + self.title = title + self.value = value + self.url = url + } +} + +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: "link", imageName: "Item List/Icons/GitHub", title: "GitHub", value: "reekeer/WinterGram", url: "https://github.com/reekeer/WinterGram") +] + +private enum WinterGramMainSettingsEntry: ItemListNodeEntry { + case banner + case categoriesHeader + case ayugram + case features + case other + case spoofing + case hiddenArchive + case informationHeader + case infoAbout + case infoUseDefaultBranding(Bool) + case infoVersion + case infoFooter + case linksHeader + case link(Int, WinterGramLink) + case linksFooter + + var section: ItemListSectionId { + switch self { + case .banner: + return WinterGramMainSettingsSection.banner.rawValue + case .linksHeader, .link, .linksFooter: + return WinterGramMainSettingsSection.links.rawValue + case .informationHeader, .infoAbout, .infoUseDefaultBranding, .infoVersion, .infoFooter: + return WinterGramMainSettingsSection.information.rawValue + default: + return WinterGramMainSettingsSection.categories.rawValue + } + } + + var stableId: Int32 { + switch self { + case .banner: return -1 + case .categoriesHeader: return 10 + case .ayugram: return 11 + case .features: return 12 + case .other: return 14 + case .spoofing: return 15 + case .hiddenArchive: return 16 + case .informationHeader: return 20 + case .infoAbout: return 21 + case .infoUseDefaultBranding: return 22 + case .infoVersion: return 23 + case .infoFooter: return 25 + case .linksHeader: return 30 + case let .link(index, _): return 31 + Int32(index) + case .linksFooter: return 100 + } + } + + static func ==(lhs: WinterGramMainSettingsEntry, rhs: WinterGramMainSettingsEntry) -> Bool { + return lhs.stableId == rhs.stableId + } + + static func <(lhs: WinterGramMainSettingsEntry, rhs: WinterGramMainSettingsEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { + let arguments = arguments as! WinterGramMainSettingsArguments + let accent = presentationData.theme.list.itemAccentColor + let lang = presentationData.strings + let category: WinterGramSettingsSection + let title: String + let iconName: String + let iconColor: UIColor + switch self { + case .banner: + return WinterGramBannerItem(theme: presentationData.theme, title: "WinterGram", subtitle: "", iconImage: UIImage(bundleImageName: "WinterGramDark"), sectionId: self.section) + case .categoriesHeader: + return ItemListSectionHeaderItem(presentationData: presentationData, text: presentationData.strings.WinterGram_Categories, sectionId: self.section) + case .informationHeader: + return ItemListSectionHeaderItem(presentationData: presentationData, text: lang.WinterGram_INFORMATION, sectionId: self.section) + case .infoAbout: + return ItemListTextItem(presentationData: presentationData, text: .plain(lang.WinterGram_WinterGramWntIsAPrivacyFocusedMessagingClientForIPhoneANativePortOfTheAyuGramExperienceItAddsGhostModeSavedDeletedMessagesAndEditHistoryAHiddenArchiveLocalPremiumAdRemovalDeepCustomizationAndLiquidGlass), sectionId: self.section) + case let .infoUseDefaultBranding(value): + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_UseDefaultTelegramBranding, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateSettings { var s = $0; s.useDefaultBranding = value; return s } + }) + case .infoVersion: + return ItemListDisclosureItem(presentationData: presentationData, title: lang.WinterGram_Version, label: arguments.appVersionString, sectionId: self.section, style: .blocks, disclosureStyle: .none, action: nil) + case .infoFooter: + return ItemListTextItem(presentationData: presentationData, text: .plain(lang.WinterGram_InfoFooter), sectionId: self.section) + case .linksHeader: + return ItemListSectionHeaderItem(presentationData: presentationData, text: presentationData.strings.WinterGram_Links, sectionId: self.section) + case .linksFooter: + return ItemListTextItem(presentationData: presentationData, text: .plain(lang.WinterGram_LinksFooter), sectionId: self.section) + case let .link(_, link): + return ItemListDisclosureItem( + presentationData: presentationData, + icon: winterGramCategoryIcon(symbolName: link.symbol, imageName: link.imageName, backgroundColor: UIColor(rgb: 0x8E8E93)), + title: wntOption(link.title, presentationData.strings), + label: link.value, + labelStyle: .coloredText(accent), + sectionId: self.section, + style: .blocks, + disclosureStyle: .none, + action: { + arguments.openUrl(link.url) + } + ) + case .ayugram: + category = .ayugram; title = "Core"; iconName = "shield.fill"; iconColor = UIColor(rgb: 0x5856D6) + case .features: + category = .antiFeatures; title = "Features"; iconName = "sparkles"; iconColor = UIColor(rgb: 0xFF9500) + 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) + case .hiddenArchive: + category = .stash; title = "Hidden Archive"; iconName = "tray.full.fill"; iconColor = UIColor(rgb: 0x34C759) + } + return ItemListDisclosureItem( + presentationData: presentationData, + icon: winterGramCategoryIcon(iconName, iconColor), + title: wntOption(title, presentationData.strings), + label: "", + sectionId: self.section, + style: .blocks, + disclosureStyle: .arrow, + action: { + arguments.openCategory(category) + } + ) + } +} + +/// 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) + 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) + 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) + } + guard let symbol = icon?.withTintColor(.white, renderingMode: .alwaysOriginal) else { + return + } + let maxIconSide: CGFloat = 30.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), + width: symbolSize.width, + height: symbolSize.height + )) + } +} + +private func winterGramMainSettingsEntries(settings: WinterGramSettings) -> [WinterGramMainSettingsEntry] { + var entries: [WinterGramMainSettingsEntry] = [ + .banner, + .categoriesHeader, + .ayugram, + .features, + .other, + .spoofing, + .hiddenArchive, + .linksHeader + ] + for (index, link) in winterGramLinks.enumerated() { + entries.append(.link(index, link)) + } + entries.append(.linksFooter) + return entries +} + +public func winterGramMainSettingsController(context: AccountContext) -> ViewController { + var pushControllerImpl: ((ViewController) -> Void)? + var getNavigationControllerImpl: (() -> NavigationController?)? + + let accountManager = context.sharedContext.accountManager + let updateSettings: (@escaping (WinterGramSettings) -> WinterGramSettings) -> Void = { f in + let _ = updateWinterGramSettingsInteractively(accountManager: accountManager, f).startStandalone() + } + + let appVersionString: String = { + let version = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "" + let build = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "" + return build.isEmpty ? version : "\(version) (\(build))" + }() + + let arguments = WinterGramMainSettingsArguments( + openCategory: { category in + 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: {}) + }, + updateSettings: updateSettings, + appVersionString: appVersionString + ) + + let signal = combineLatest(queue: .mainQueue(), + context.sharedContext.presentationData, + winterGramSettings(accountManager: context.sharedContext.accountManager) + ) + |> deliverOnMainQueue + |> map { presentationData, settings -> (ItemListControllerState, (ItemListNodeState, Any)) in + let controllerState = ItemListControllerState( + presentationData: ItemListPresentationData(presentationData), + title: .text("WinterGram"), + leftNavigationButton: nil, + rightNavigationButton: nil, + backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back) + ) + let listState = ItemListNodeState( + presentationData: ItemListPresentationData(presentationData), + entries: winterGramMainSettingsEntries(settings: settings), + style: .blocks, + animateChanges: true + ) + return (controllerState, (listState, arguments)) + } + + let controller = ItemListController(context: context, state: signal) + pushControllerImpl = { [weak controller] c in + controller?.push(c) + } + getNavigationControllerImpl = { [weak controller] in + return controller?.navigationController as? NavigationController + } + return controller +} diff --git a/submodules/SettingsUI/Sources/WinterGramOnlineTrackerController.swift b/submodules/SettingsUI/Sources/WinterGramOnlineTrackerController.swift new file mode 100644 index 0000000000..bd9178437e --- /dev/null +++ b/submodules/SettingsUI/Sources/WinterGramOnlineTrackerController.swift @@ -0,0 +1,134 @@ +import Foundation +import UIKit +import Display +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData +import TelegramUIPreferences +import ItemListUI +import PresentationDataUtils +import AccountContext + +private final class WinterGramOnlineTrackerArguments { + let openHistory: (EnginePeer.Id) -> Void + let clearAll: () -> Void + init(openHistory: @escaping (EnginePeer.Id) -> Void, clearAll: @escaping () -> Void) { + self.openHistory = openHistory + self.clearAll = clearAll + } +} + +private enum WinterGramOnlineTrackerSection: Int32 { + case peers + case actions +} + +private enum WinterGramOnlineTrackerEntry: ItemListNodeEntry { + case peer(index: Int, peerId: EnginePeer.Id, name: String, summary: String) + case clear + case footer + + var section: ItemListSectionId { + switch self { + case .peer: return WinterGramOnlineTrackerSection.peers.rawValue + case .clear, .footer: return WinterGramOnlineTrackerSection.actions.rawValue + } + } + + var stableId: Int32 { + switch self { + case let .peer(index, _, _, _): return Int32(index) + case .clear: return 100000 + case .footer: return 100001 + } + } + + static func <(lhs: WinterGramOnlineTrackerEntry, rhs: WinterGramOnlineTrackerEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { + let arguments = arguments as! WinterGramOnlineTrackerArguments + switch self { + case let .peer(_, peerId, name, summary): + return ItemListDisclosureItem(presentationData: presentationData, title: name, label: summary, sectionId: self.section, style: .blocks, action: { + arguments.openHistory(peerId) + }) + case .clear: + return ItemListActionItem(presentationData: presentationData, title: "Clear Tracking Log", kind: .destructive, alignment: .natural, sectionId: self.section, style: .blocks, action: { + arguments.clearAll() + }) + case .footer: + return ItemListTextItem(presentationData: presentationData, text: .plain("Online transitions are recorded locally while a chat is open. Tap a name to see its history."), sectionId: self.section) + } + } +} + +private func formatEntry(_ entry: WinterGramPresenceEntry) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(entry.timestamp)) + let formatter = DateFormatter() + formatter.dateFormat = "dd.MM HH:mm" + return "\(formatter.string(from: date)) — \(entry.isOnline ? "online" : "offline")" +} + +public func winterGramOnlineTrackerController(context: AccountContext) -> ViewController { + var presentControllerImpl: ((ViewController, Any?) -> Void)? + let refreshPromise = ValuePromise(true, ignoreRepeated: false) + + let arguments = WinterGramOnlineTrackerArguments( + openHistory: { peerId in + let entries = winterGramPresenceLog(peerId: peerId.toInt64()) + let text = entries.isEmpty ? "No records yet." : entries.reversed().prefix(40).map(formatEntry).joined(separator: "\n") + let controller = textAlertController(context: context, title: nil, text: text, actions: [ + TextAlertAction(type: .defaultAction, title: context.sharedContext.currentPresentationData.with { $0 }.strings.Common_OK, action: {}) + ]) + presentControllerImpl?(controller, nil) + }, + clearAll: { + winterGramClearPresenceLog() + refreshPromise.set(true) + } + ) + + let signal = combineLatest(queue: .mainQueue(), context.sharedContext.presentationData, refreshPromise.get()) + |> mapToSignal { presentationData, _ -> Signal<(ItemListControllerState, (ItemListNodeState, Any)), NoError> in + let peerIds = winterGramTrackedPeerIds() + let enginePeerIds = peerIds.map { EnginePeer.Id($0) } + return context.engine.data.get(EngineDataMap(enginePeerIds.map { TelegramEngine.EngineData.Item.Peer.Peer(id: $0) })) + |> map { peerMap -> (ItemListControllerState, (ItemListNodeState, Any)) in + var entries: [WinterGramOnlineTrackerEntry] = [] + var index = 0 + for pid in enginePeerIds { + let name: String + if let maybePeer = peerMap[pid], let peer = maybePeer { + name = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + } else { + name = "\(pid.toInt64())" + } + let log = winterGramPresenceLog(peerId: pid.toInt64()) + let summary: String + if let last = log.last { + summary = last.isOnline ? "online" : "offline" + } else { + summary = "" + } + entries.append(.peer(index: index, peerId: pid, name: name, summary: summary)) + index += 1 + } + if !entries.isEmpty { + entries.append(.clear) + } + entries.append(.footer) + + let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text("Online Tracker"), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)) + let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks) + return (controllerState, (listState, arguments)) + } + } + + let controller = ItemListController(context: context, state: signal) + presentControllerImpl = { [weak controller] c, a in + controller?.present(c, in: .window(.root), with: a) + } + return controller +} diff --git a/submodules/SettingsUI/Sources/WinterGramRadiusItem.swift b/submodules/SettingsUI/Sources/WinterGramRadiusItem.swift new file mode 100644 index 0000000000..42f86f7ece --- /dev/null +++ b/submodules/SettingsUI/Sources/WinterGramRadiusItem.swift @@ -0,0 +1,332 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import TelegramPresentationData +import LegacyComponents +import ItemListUI +import PresentationDataUtils + +enum WinterGramRadiusPreviewKind { + case avatar + 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 + let context = rendererContext.cgContext + let bounds = CGRect(origin: .zero, size: size) + switch kind { + case .avatar: + let fraction = max(0.0, min(1.0, CGFloat(value - minValue) / CGFloat(max(1, maxValue - minValue)))) + let cornerRadius = (size.height / 2.0) * fraction + context.saveGState() + UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius).addClip() + if let avatarImage = avatarImage { + avatarImage.draw(in: bounds) + } else { + let colors = [UIColor(rgb: 0x4FB3F0).cgColor, UIColor(rgb: 0x2D7FD6).cgColor] as CFArray + if let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors, locations: [0.0, 1.0]) { + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: size.height), options: []) + } + if let glyph = UIImage(systemName: "person.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: size.height * 0.46, weight: .semibold))?.withTintColor(.white, renderingMode: .alwaysOriginal) { + let glyphSize = glyph.size + glyph.draw(in: CGRect(x: floor((size.width - glyphSize.width) / 2.0), y: floor((size.height - glyphSize.height) / 2.0), width: glyphSize.width, height: glyphSize.height)) + } + } + context.restoreGState() + case .bubble: + let bubbleHeight = size.height * 0.74 + let bubbleRect = CGRect(x: 0.0, y: floor((size.height - bubbleHeight) / 2.0), width: size.width, height: bubbleHeight) + let cornerRadius = min(CGFloat(max(0, value)), bubbleHeight / 2.0) + theme.list.itemAccentColor.setFill() + UIBezierPath(roundedRect: bubbleRect, cornerRadius: cornerRadius).fill() + } + } +} + +class WinterGramRadiusItem: ListViewItem, ItemListItem { + let presentationData: ItemListPresentationData + let title: String + let value: Int32 + let minValue: Int32 + let maxValue: Int32 + let previewKind: WinterGramRadiusPreviewKind + let avatarSignal: Signal? + let displayValue: (Int32) -> String + let sectionId: ItemListSectionId + let valueChanged: ((Int32) -> Void)? + let updated: (Int32) -> Void + + init(presentationData: ItemListPresentationData, title: String, value: Int32, minValue: Int32, maxValue: Int32, previewKind: WinterGramRadiusPreviewKind, avatarSignal: Signal? = nil, displayValue: @escaping (Int32) -> String, sectionId: ItemListSectionId, valueChanged: ((Int32) -> Void)? = nil, updated: @escaping (Int32) -> Void) { + self.presentationData = presentationData + self.title = title + self.value = value + self.minValue = minValue + self.maxValue = maxValue + self.previewKind = previewKind + self.avatarSignal = avatarSignal + self.displayValue = displayValue + self.sectionId = sectionId + self.valueChanged = valueChanged + self.updated = updated + } + + func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { + async { + let node = WinterGramRadiusItemNode() + let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + node.contentSize = layout.contentSize + node.insets = layout.insets + Queue.mainQueue().async { + completion(node, { + return (nil, { _ in apply() }) + }) + } + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { + Queue.mainQueue().async { + if let nodeValue = node() as? WinterGramRadiusItemNode { + let makeLayout = nodeValue.asyncLayout() + async { + let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + Queue.mainQueue().async { + completion(layout, { _ in + apply() + }) + } + } + } + } + } +} + +class WinterGramRadiusItemNode: ListViewItemNode { + private let backgroundNode: ASDisplayNode + private let topStripeNode: ASDisplayNode + private let bottomStripeNode: ASDisplayNode + private let maskNode: ASImageNode + + private let titleNode: ImmediateTextNode + private let valueNode: ImmediateTextNode + private let previewNode: ASImageNode + private var sliderView: TGPhotoEditorSliderView? + + private var item: WinterGramRadiusItem? + private var layoutParams: ListViewItemLayoutParams? + private var currentValue: Int32 = 0 + private var currentAvatarImage: UIImage? + private var avatarDisposable: Disposable? + + private let previewSize = CGSize(width: 48.0, height: 48.0) + + init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isLayerBacked = true + self.topStripeNode = ASDisplayNode() + self.topStripeNode.isLayerBacked = true + self.bottomStripeNode = ASDisplayNode() + self.bottomStripeNode.isLayerBacked = true + self.maskNode = ASImageNode() + + self.titleNode = ImmediateTextNode() + self.titleNode.displaysAsynchronously = false + self.valueNode = ImmediateTextNode() + self.valueNode.displaysAsynchronously = false + self.previewNode = ASImageNode() + self.previewNode.displaysAsynchronously = false + self.previewNode.isUserInteractionEnabled = false + + super.init(layerBacked: false) + + 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 { + self.avatarDisposable?.dispose() + } + + private func sliderFrame(params: ListViewItemLayoutParams) -> CGRect { + let sliderInsetLeft = params.leftInset + 16.0 + let sliderInsetRight = params.width - params.rightInset - 16.0 + return CGRect(origin: CGPoint(x: sliderInsetLeft, y: 42.0), size: CGSize(width: max(0.0, sliderInsetRight - sliderInsetLeft), height: 44.0)) + } + + private func applySliderTheme(_ sliderView: TGPhotoEditorSliderView, theme: PresentationTheme) { + sliderView.backgroundColor = .clear + sliderView.backColor = theme.list.itemSecondaryTextColor.withAlphaComponent(0.35) + sliderView.trackColor = theme.list.itemAccentColor + sliderView.knobImage = PresentationResourcesItemList.knobImage(theme) + } + + override func didLoad() { + super.didLoad() + + let sliderView = TGPhotoEditorSliderView() + sliderView.enablePanHandling = true + sliderView.trackCornerRadius = 2.0 + sliderView.lineSize = 4.0 + sliderView.disablesInteractiveTransitionGestureRecognizer = true + if let item = self.item, let params = self.layoutParams { + self.applySliderTheme(sliderView, theme: item.presentationData.theme) + sliderView.minimumValue = CGFloat(item.minValue) + sliderView.maximumValue = CGFloat(item.maxValue) + sliderView.startValue = CGFloat(item.minValue) + sliderView.value = CGFloat(item.value) + sliderView.frame = self.sliderFrame(params: params) + } + self.view.addSubview(sliderView) + sliderView.addTarget(self, action: #selector(self.sliderValueChanged), for: .valueChanged) + sliderView.interactionEnded = { [weak self] in + guard let self, let sliderView = self.sliderView else { + return + } + self.item?.updated(Int32(round(sliderView.value))) + } + self.sliderView = sliderView + } + + func asyncLayout() -> (_ item: WinterGramRadiusItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { + return { item, params, neighbors in + let contentSize = CGSize(width: params.width, height: 96.0) + let insets = itemListNeighborsGroupedInsets(neighbors, params) + let separatorHeight = UIScreenPixel + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) + let layoutSize = layout.size + + let theme = item.presentationData.theme + let titleFont = Font.regular(item.presentationData.fontSize.itemListBaseFontSize) + let valueFont = Font.regular(floor(item.presentationData.fontSize.itemListBaseFontSize * 15.0 / 17.0)) + + return (layout, { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.item = item + strongSelf.layoutParams = params + strongSelf.currentValue = item.value + + strongSelf.backgroundNode.backgroundColor = theme.list.itemBlocksBackgroundColor + strongSelf.topStripeNode.backgroundColor = theme.list.itemBlocksSeparatorColor + strongSelf.bottomStripeNode.backgroundColor = theme.list.itemBlocksSeparatorColor + + if strongSelf.backgroundNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) + } + if strongSelf.topStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1) + } + if strongSelf.bottomStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) + } + if strongSelf.maskNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.maskNode, at: 3) + } + + let hasCorners = itemListHasRoundedBlockLayout(params) + var hasTopCorners = false + var hasBottomCorners = false + switch neighbors.top { + case .sameSection(false): + strongSelf.topStripeNode.isHidden = true + default: + hasTopCorners = true + strongSelf.topStripeNode.isHidden = hasCorners + } + let bottomStripeInset: CGFloat + let bottomStripeOffset: CGFloat + switch neighbors.bottom { + case .sameSection(false): + bottomStripeInset = params.leftInset + 16.0 + bottomStripeOffset = -separatorHeight + strongSelf.bottomStripeNode.isHidden = false + default: + bottomStripeInset = 0.0 + bottomStripeOffset = 0.0 + hasBottomCorners = true + strongSelf.bottomStripeNode.isHidden = hasCorners + } + + strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(theme, top: hasTopCorners, bottom: hasBottomCorners) : nil + + strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) + strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0) + strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)) + strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) + + strongSelf.titleNode.attributedText = NSAttributedString(string: item.title, font: titleFont, textColor: theme.list.itemPrimaryTextColor) + let titleSize = strongSelf.titleNode.updateLayout(CGSize(width: params.width - params.leftInset - params.rightInset - 120.0, height: 30.0)) + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: params.leftInset + 16.0, y: 13.0), size: titleSize) + + strongSelf.valueNode.attributedText = NSAttributedString(string: item.displayValue(item.value), font: valueFont, textColor: theme.list.itemSecondaryTextColor) + let valueSize = strongSelf.valueNode.updateLayout(CGSize(width: 160.0, height: 30.0)) + strongSelf.valueNode.frame = CGRect(origin: CGPoint(x: params.width - params.rightInset - 16.0 - valueSize.width, y: 14.0), size: valueSize) + + strongSelf.previewNode.image = winterGramRadiusPreviewImage(kind: item.previewKind, value: item.value, minValue: item.minValue, maxValue: item.maxValue, size: strongSelf.previewSize, theme: theme, avatarImage: strongSelf.currentAvatarImage) + strongSelf.previewNode.frame = CGRect(origin: CGPoint(x: params.leftInset + 16.0, y: 40.0), size: strongSelf.previewSize) + + if let sliderView = strongSelf.sliderView { + strongSelf.applySliderTheme(sliderView, theme: theme) + sliderView.minimumValue = CGFloat(item.minValue) + sliderView.maximumValue = CGFloat(item.maxValue) + sliderView.startValue = CGFloat(item.minValue) + if Int32(round(sliderView.value)) != item.value { + sliderView.value = CGFloat(item.value) + } + sliderView.frame = strongSelf.sliderFrame(params: params) + } + + if let avatarSignal = item.avatarSignal { + strongSelf.avatarDisposable?.dispose() + strongSelf.avatarDisposable = (avatarSignal + |> deliverOnMainQueue).start(next: { [weak strongSelf] image in + guard let strongSelf = strongSelf else { + return + } + strongSelf.currentAvatarImage = image + if let item = strongSelf.item, let params = strongSelf.layoutParams { + strongSelf.previewNode.image = winterGramRadiusPreviewImage(kind: item.previewKind, value: strongSelf.currentValue, minValue: item.minValue, maxValue: item.maxValue, size: strongSelf.previewSize, theme: item.presentationData.theme, avatarImage: image) + strongSelf.previewNode.frame = CGRect(origin: CGPoint(x: params.leftInset + 16.0, y: 40.0), size: strongSelf.previewSize) + } + }) + } + }) + } + } + + @objc private func sliderValueChanged() { + guard let sliderView = self.sliderView, let item = self.item, let params = self.layoutParams else { + return + } + let newValue = Int32(round(sliderView.value)) + guard newValue != self.currentValue else { + return + } + self.currentValue = newValue + item.valueChanged?(newValue) + let theme = item.presentationData.theme + let valueFont = Font.regular(floor(item.presentationData.fontSize.itemListBaseFontSize * 15.0 / 17.0)) + self.valueNode.attributedText = NSAttributedString(string: item.displayValue(newValue), font: valueFont, textColor: theme.list.itemSecondaryTextColor) + let valueSize = self.valueNode.updateLayout(CGSize(width: 160.0, height: 30.0)) + self.valueNode.frame = CGRect(origin: CGPoint(x: params.width - params.rightInset - 16.0 - valueSize.width, y: 14.0), size: valueSize) + self.previewNode.image = winterGramRadiusPreviewImage(kind: item.previewKind, value: newValue, minValue: item.minValue, maxValue: item.maxValue, size: self.previewSize, theme: theme, avatarImage: self.currentAvatarImage) + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + } +} diff --git a/submodules/SettingsUI/Sources/WinterGramSettingsController.swift b/submodules/SettingsUI/Sources/WinterGramSettingsController.swift index 97a2721faf..863ae1f440 100644 --- a/submodules/SettingsUI/Sources/WinterGramSettingsController.swift +++ b/submodules/SettingsUI/Sources/WinterGramSettingsController.swift @@ -6,68 +6,261 @@ import TelegramCore import TelegramPresentationData import TelegramUIPreferences import ItemListUI +import ItemListPeerActionItem import PresentationDataUtils import AccountContext +import PromptUI +import UndoUI private final class WinterGramSettingsArguments { let updateSettings: (@escaping (WinterGramSettings) -> WinterGramSettings) -> Void - let presentSendWithoutSound: () -> Void - let presentPeerId: () -> Void - let presentTranslationProvider: () -> Void - let presentWebviewPlatform: () -> Void - let presentIconPack: () -> Void + let toggleDropdown: (WinterGramDropdown) -> Void + let selectDropdownOption: (WinterGramDropdown, Int) -> Void + let toggleGhostExpanded: () -> Void + let editSpoofDevice: () -> Void + let addSpoofTemplate: () -> Void + let toggleSpoofTemplateSelected: (Int) -> Void + let deleteSelectedSpoofTemplates: () -> Void + let editApiId: () -> Void + let editApiHash: () -> Void + let openStash: () -> Void + let editStashPasscode: () -> Void + let editDeletedMark: () -> Void + let context: AccountContext + let openUrl: (String) -> Void + let clearDeleted: () -> Void init( + context: AccountContext, updateSettings: @escaping (@escaping (WinterGramSettings) -> WinterGramSettings) -> Void, - presentSendWithoutSound: @escaping () -> Void, - presentPeerId: @escaping () -> Void, - presentTranslationProvider: @escaping () -> Void, - presentWebviewPlatform: @escaping () -> Void, - presentIconPack: @escaping () -> Void + toggleDropdown: @escaping (WinterGramDropdown) -> Void, + selectDropdownOption: @escaping (WinterGramDropdown, Int) -> Void, + toggleGhostExpanded: @escaping () -> Void, + editSpoofDevice: @escaping () -> Void, + addSpoofTemplate: @escaping () -> Void, + toggleSpoofTemplateSelected: @escaping (Int) -> Void, + deleteSelectedSpoofTemplates: @escaping () -> Void, + editApiId: @escaping () -> Void, + editApiHash: @escaping () -> Void, + openStash: @escaping () -> Void, + editStashPasscode: @escaping () -> Void, + editDeletedMark: @escaping () -> Void, + openUrl: @escaping (String) -> Void, + clearDeleted: @escaping () -> Void ) { self.updateSettings = updateSettings - self.presentSendWithoutSound = presentSendWithoutSound - self.presentPeerId = presentPeerId - self.presentTranslationProvider = presentTranslationProvider - self.presentWebviewPlatform = presentWebviewPlatform - self.presentIconPack = presentIconPack + self.toggleDropdown = toggleDropdown + self.selectDropdownOption = selectDropdownOption + self.toggleGhostExpanded = toggleGhostExpanded + self.editSpoofDevice = editSpoofDevice + self.addSpoofTemplate = addSpoofTemplate + self.toggleSpoofTemplateSelected = toggleSpoofTemplateSelected + self.deleteSelectedSpoofTemplates = deleteSelectedSpoofTemplates + self.editApiId = editApiId + self.editApiHash = editApiHash + self.openStash = openStash + self.editStashPasscode = editStashPasscode + self.editDeletedMark = editDeletedMark + self.context = context + self.openUrl = openUrl + self.clearDeleted = clearDeleted } } -private enum WinterGramSettingsSection: Int32 { +public enum WinterGramSettingsSection: Int32, CaseIterable { + case banner case ghost case history case stash case antiFeatures - case confirmations case chat - case appearance 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, …) + + public var title: String { + switch self { + case .banner: + return "" + case .ghost: + return "Ghost Mode" + case .history: + return "History" + case .stash: + return "Hidden Archive" + case .antiFeatures: + return "Features" + case .chat: + return "Chat" + case .liquidGlass: + return "Liquid Glass" + case .spoofing: + return "Spoofing" + case .ayugram: + return "Core" + case .other: + return "Other" + } + } + + public var iconName: String { + // SF Symbols used for the main menu cells. + switch self { + case .banner: + return "" + case .ghost: + return "eye.slash" + case .history: + return "clock.arrow.circlepath" + case .stash: + return "archivebox" + case .antiFeatures: + return "sparkles" + case .chat: + return "message" + case .liquidGlass: + return "drop" + case .spoofing: + return "theatermasks" + case .ayugram: + return "shield.fill" + case .other: + return "ellipsis.circle" + } + } + + // Maps a deep-link path/section name (wnt://wintergram/) to a settings subtab. + public init?(deepLinkName: String) { + switch deepLinkName.lowercased() { + case "ghost", "ghostmode": self = .ghost + case "history": self = .history + case "stash", "archive", "hiddenarchive": self = .stash + case "features", "antifeatures", "anti": self = .antiFeatures + case "chat": self = .chat + case "glass", "liquidglass": self = .liquidGlass + case "spoofing", "spoof": self = .spoofing + case "ayugram", "ayu": self = .ayugram + case "other", "misc": self = .other + default: return nil + } + } } +private enum WinterGramDropdown: Equatable { + case sendWithoutSound + case peerId + case translationProvider + case webviewPlatform + 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: + let p = settings.stashPrivacy + return [ + ("Profile Photo", p.profilePhoto, { s in var s = s; s.stashPrivacy.profilePhoto = !s.stashPrivacy.profilePhoto; return s }), + ("Phone Number", p.phoneNumber, { s in var s = s; s.stashPrivacy.phoneNumber = !s.stashPrivacy.phoneNumber; return s }), + ("Last Seen", p.presence, { s in var s = s; s.stashPrivacy.presence = !s.stashPrivacy.presence; return s }), + ("Forwards", p.forwards, { s in var s = s; s.stashPrivacy.forwards = !s.stashPrivacy.forwards; return s }), + ("Voice Calls", p.voiceCalls, { s in var s = s; s.stashPrivacy.voiceCalls = !s.stashPrivacy.voiceCalls; return s }), + ("Birthday", p.birthday, { s in var s = s; s.stashPrivacy.birthday = !s.stashPrivacy.birthday; return s }), + ("Gifts Auto-Save", p.giftsAutoSave, { s in var s = s; s.stashPrivacy.giftsAutoSave = !s.stashPrivacy.giftsAutoSave; return s }), + ("Bio", p.bio, { s in var s = s; s.stashPrivacy.bio = !s.stashPrivacy.bio; return s }), + ("Saved Music", p.savedMusic, { s in var s = s; s.stashPrivacy.savedMusic = !s.stashPrivacy.savedMusic; return s }), + ("Group Invitations", p.groupInvitations, { s in var s = s; s.stashPrivacy.groupInvitations = !s.stashPrivacy.groupInvitations; return s }) + ] + case .sendWithoutSound: + let v = settings.sendWithoutSound + return [ + ("Never", v == .never, { (s: WinterGramSettings) -> WinterGramSettings in var s = s; s.sendWithoutSound = .never; return s }), + ("In Ghost Mode", v == .inGhostMode, { (s: WinterGramSettings) -> WinterGramSettings in var s = s; s.sendWithoutSound = .inGhostMode; return s }), + ("Always", v == .always, { (s: WinterGramSettings) -> WinterGramSettings in var s = s; s.sendWithoutSound = .always; return s }) + ] + case .peerId: + let v = settings.showPeerId + return [ + ("Hidden", v == .hidden, { (s: WinterGramSettings) -> WinterGramSettings in var s = s; s.showPeerId = .hidden; return s }), + ("Telegram API", v == .telegramApi, { (s: WinterGramSettings) -> WinterGramSettings in var s = s; s.showPeerId = .telegramApi; return s }), + ("Bot API", v == .botApi, { (s: WinterGramSettings) -> WinterGramSettings in var s = s; s.showPeerId = .botApi; return s }) + ] + case .translationProvider: + let v = settings.translationProvider + return [ + ("Disabled", !settings.translateMessages, { (s: WinterGramSettings) -> WinterGramSettings in var s = s; s.translateMessages = false; return s }), + ("Telegram", settings.translateMessages && v == .telegram, { (s: WinterGramSettings) -> WinterGramSettings in var s = s; s.translateMessages = true; s.translationProvider = .telegram; return s }), + ("Google", settings.translateMessages && v == .google, { (s: WinterGramSettings) -> WinterGramSettings in var s = s; s.translateMessages = true; s.translationProvider = .google; return s }), + ("Yandex", settings.translateMessages && v == .yandex, { (s: WinterGramSettings) -> WinterGramSettings in var s = s; s.translateMessages = true; s.translationProvider = .yandex; return s }), + ("System", settings.translateMessages && v == .system, { (s: WinterGramSettings) -> WinterGramSettings in var s = s; s.translateMessages = true; s.translationProvider = .system; return s }) + ] + case .webviewPlatform: + let v = settings.webviewSpoofPlatform + return [ + ("Automatic", v == .auto, { (s: WinterGramSettings) -> WinterGramSettings in var s = s; s.webviewSpoofPlatform = .auto; return s }), + ("iOS", v == .ios, { (s: WinterGramSettings) -> WinterGramSettings in var s = s; s.webviewSpoofPlatform = .ios; return s }), + ("Android", v == .android, { (s: WinterGramSettings) -> WinterGramSettings in var s = s; s.webviewSpoofPlatform = .android; return s }), + ("macOS", v == .macos, { (s: WinterGramSettings) -> WinterGramSettings in var s = s; s.webviewSpoofPlatform = .macos; return s }), + ("Desktop", v == .desktop, { (s: WinterGramSettings) -> WinterGramSettings in var s = s; s.webviewSpoofPlatform = .desktop; return s }) + ] + } +} + +// 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"), + ("iPhone 15 Pro Max", "iPhone16,2 · A17 Pro", "iPhone 15 Pro Max"), + ("iPhone 15 Pro", "iPhone16,1 · A17 Pro", "iPhone 15 Pro"), + ("iPhone 15", "iPhone15,4 · A16", "iPhone 15"), + ("iPhone 14 Pro Max", "iPhone15,3 · A16", "iPhone 14 Pro Max"), + ("iPhone 14", "iPhone14,7 · A15", "iPhone 14"), + ("iPhone 13 Pro", "iPhone14,2 · A15", "iPhone 13 Pro"), + ("iPhone 13 mini", "iPhone14,4 · A15", "iPhone 13 mini"), + ("iPhone 12", "iPhone13,2 · A14", "iPhone 12"), + ("iPhone SE (3rd gen)", "iPhone14,6 · A15", "iPhone SE (3rd generation)"), + ("iPhone X", "iPhone10,3 · A11", "iPhone X"), + ("iPad Pro M4", "iPad16,3 · Apple M4", "iPad Pro M4"), + ("Google Pixel 10 Pro", "Pixel 10 Pro · Tensor G5", "Pixel 10 Pro"), + ("Samsung Galaxy S25 Ultra", "SM-S938U · Snapdragon 8 Elite", "Samsung Galaxy S25 Ultra"), + ("OnePlus 13", "CPH2649 · Snapdragon 8 Elite", "OnePlus 13"), + ("Windows 11", "Windows NT 10.0 · x64", "Windows 11"), + ("Windows 10", "Windows NT 10.0 · x64", "Windows 10"), + ("Linux Desktop", "Linux · x86_64", "Linux Desktop"), + ("macOS Sequoia", "Mac15,9 · Apple M3 Max", "macOS Sequoia") +] + private enum WinterGramSettingsEntry: ItemListNodeEntry { + case banner + case ghostHeader - case ghostEnabled(Bool) - case ghostReadReceipts(Bool) - case ghostReadStories(Bool) - case ghostOnlineStatus(Bool) - case ghostUploadProgress(Bool) - case ghostOfflineAfterOnline(Bool) - case ghostMarkReadAfterAction(Bool) - case ghostUseScheduled(Bool) + case ghostExpandable(Bool, Bool, [ItemListExpandableSwitchItem.SubItem]) case ghostSendWithoutSound(String) - case ghostSuggestBeforeStory(Bool) + case ghostUseScheduledMessages(Bool) + case ghostConfirmStory(Bool) + case ghostMarkReadAfterAction(Bool) case ghostFooter case historyHeader + case historyHeaderSpy case historySaveDeleted(Bool) case historySaveEdits(Bool) + case historySaveForBots(Bool) + case historySaveSelfDestruct(Bool) case historySemiTransparent(Bool) + case historyDeletedMark(String) + case historyShowDeletedTime(Bool) case historyFooter case stashHeader + case stashList(Int) case stashMute(Bool) case stashAutoRead(Bool) + case stashPasscodeRow(String) case stashFooter case antiHeader @@ -76,26 +269,24 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry { case antiDisableStories(Bool) case antiHidePremiumStatuses(Bool) case antiDisableLinkWarning(Bool) + case antiDisableCopyProtection(Bool) + case antiAllowScreenshots(Bool) case antiFooter - case confirmHeader case confirmStickers(Bool) case confirmGif(Bool) case confirmVoice(Bool) case chatHeader - case chatShowSeconds(Bool) + case chatPreview(PresentationTheme, TelegramWallpaper, PresentationFontSize, PresentationChatBubbleCorners, PresentationStrings, PresentationDateTimeFormat, PresentationPersonNameOrder, [ChatPreviewMessageItem]) case chatShowPeerId(String) - case chatTranslate(Bool) + case chatShowRegistrationDate(Bool) + case chatHideEditedMark(Bool) case chatTranslateProvider(String) - case chatWebviewPlatform(String) case chatWebviewHeight(Bool) case chatOnlyAddedEmoji(Bool) - - case appearanceHeader - case appearanceMaterial(Bool) - case appearanceSingleCorner(Bool) - case appearanceIconPack(String) + case chatForwardWithoutAuthor(Bool) + case chatFooter case glassHeader case glassEnabled(Bool) @@ -106,81 +297,137 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry { case glassBubbles(Bool) case glassFooter + case spoofingHeader + case spoofPresetsHeader + case spoofPreset(Int, String, String, Bool, Bool) + case spoofAddTemplate + case spoofDeleteSelected + case spoofingDevice(String) + case spoofingDevicePreset(Int, Bool) + case spoofingWebviewPlatform(String) + case spoofingApiId(String) + 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 { switch self { - case .ghostHeader, .ghostEnabled, .ghostReadReceipts, .ghostReadStories, .ghostOnlineStatus, .ghostUploadProgress, .ghostOfflineAfterOnline, .ghostMarkReadAfterAction, .ghostUseScheduled, .ghostSendWithoutSound, .ghostSuggestBeforeStory, .ghostFooter: + case .banner: + return WinterGramSettingsSection.banner.rawValue + case .ghostHeader, .ghostExpandable, .ghostSendWithoutSound, .ghostUseScheduledMessages, .ghostConfirmStory, .ghostMarkReadAfterAction, .ghostFooter: return WinterGramSettingsSection.ghost.rawValue - case .historyHeader, .historySaveDeleted, .historySaveEdits, .historySemiTransparent, .historyFooter: + case .expandableSelection(let dropdown, _, _, _): + switch dropdown { + case .sendWithoutSound: + return WinterGramSettingsSection.ghost.rawValue + case .peerId: + return WinterGramSettingsSection.chat.rawValue + case .translationProvider: + return WinterGramSettingsSection.chat.rawValue + case .webviewPlatform: + return WinterGramSettingsSection.spoofing.rawValue + case .stashPrivacy: + return WinterGramSettingsSection.stash.rawValue + } + case .historyHeader, .historyHeaderSpy, .historySaveDeleted, .historySaveEdits, .historySaveForBots, .historySaveSelfDestruct, .historySemiTransparent, .historyDeletedMark, .historyShowDeletedTime, .historyFooter: return WinterGramSettingsSection.history.rawValue - case .stashHeader, .stashMute, .stashAutoRead, .stashFooter: + case .stashHeader, .stashList, .stashMute, .stashAutoRead, .stashPasscodeRow, .stashFooter: return WinterGramSettingsSection.stash.rawValue - case .antiHeader, .antiDisableAds, .antiLocalPremium, .antiDisableStories, .antiHidePremiumStatuses, .antiDisableLinkWarning, .antiFooter: + case .antiHeader, .antiDisableAds, .antiLocalPremium, .antiDisableStories, .antiHidePremiumStatuses, .antiDisableLinkWarning, .antiDisableCopyProtection, .antiAllowScreenshots, .antiFooter: return WinterGramSettingsSection.antiFeatures.rawValue - case .confirmHeader, .confirmStickers, .confirmGif, .confirmVoice: - return WinterGramSettingsSection.confirmations.rawValue - case .chatHeader, .chatShowSeconds, .chatShowPeerId, .chatTranslate, .chatTranslateProvider, .chatWebviewPlatform, .chatWebviewHeight, .chatOnlyAddedEmoji: + case .confirmStickers, .confirmGif, .confirmVoice: + return WinterGramSettingsSection.chat.rawValue + case .chatHeader, .chatPreview, .chatShowPeerId, .chatShowRegistrationDate, .chatHideEditedMark, .chatTranslateProvider, .chatWebviewHeight, .chatOnlyAddedEmoji, .chatForwardWithoutAuthor, .chatFooter: return WinterGramSettingsSection.chat.rawValue - case .appearanceHeader, .appearanceMaterial, .appearanceSingleCorner, .appearanceIconPack: - return WinterGramSettingsSection.appearance.rawValue case .glassHeader, .glassEnabled, .glassVibrancy, .glassChatList, .glassNavBars, .glassTabBar, .glassBubbles, .glassFooter: return WinterGramSettingsSection.liquidGlass.rawValue + case .spoofingHeader, .spoofingDevice, .spoofingDevicePreset, .spoofingWebviewPlatform, .spoofingApiId, .spoofingApiHash, .spoofPresetsHeader, .spoofPreset, .spoofAddTemplate, .spoofDeleteSelected, .spoofingFooter: + return WinterGramSettingsSection.spoofing.rawValue } } var stableId: Int32 { + // Device preset cards nest between spoofingDevice (30000) and WebView platform (40000). + if case let .spoofingDevicePreset(index, _) = self { + return 30100 + Int32(index) + } + return self.baseStableId * 100 + } + + private var baseStableId: Int32 { switch self { + case .banner: return -1 case .ghostHeader: return 0 - case .ghostEnabled: return 1 - case .ghostReadReceipts: return 2 - case .ghostReadStories: return 3 - case .ghostOnlineStatus: return 4 - case .ghostUploadProgress: return 5 - case .ghostOfflineAfterOnline: return 6 - case .ghostMarkReadAfterAction: return 7 - case .ghostUseScheduled: return 8 - case .ghostSendWithoutSound: return 9 - case .ghostSuggestBeforeStory: return 10 - case .ghostFooter: return 11 - case .historyHeader: return 12 - case .historySaveDeleted: return 13 - case .historySaveEdits: return 14 - case .historySemiTransparent: return 15 - case .historyFooter: return 16 - case .stashHeader: return 17 - case .stashMute: return 18 - case .stashAutoRead: return 19 - case .stashFooter: return 20 - case .antiHeader: return 21 - case .antiDisableAds: return 22 - case .antiLocalPremium: return 23 - case .antiDisableStories: return 24 - case .antiHidePremiumStatuses: return 25 - case .antiDisableLinkWarning: return 26 - case .antiFooter: return 27 - case .confirmHeader: return 28 - case .confirmStickers: return 29 - case .confirmGif: return 30 - case .confirmVoice: return 31 - case .chatHeader: return 32 - case .chatShowSeconds: return 33 - case .chatShowPeerId: return 34 - case .chatTranslate: return 35 - case .chatTranslateProvider: return 36 - case .chatWebviewPlatform: return 37 - case .chatWebviewHeight: return 38 - case .chatOnlyAddedEmoji: return 39 - case .appearanceHeader: return 40 - case .appearanceMaterial: return 41 - case .appearanceSingleCorner: return 42 - case .appearanceIconPack: return 43 - case .glassHeader: return 44 - case .glassEnabled: return 45 - case .glassVibrancy: return 46 - case .glassChatList: return 47 - case .glassNavBars: return 48 - case .glassTabBar: return 49 - case .glassBubbles: return 50 - case .glassFooter: return 51 + case .ghostExpandable: return 2 + case .ghostSendWithoutSound: return 3 + case .ghostUseScheduledMessages: return 4 + case .ghostConfirmStory: return 5 + case .ghostMarkReadAfterAction: return 6 + case .ghostFooter: return 7 + case .historyHeader: return 10 + case .historyHeaderSpy: return 11 + case .historySaveDeleted: return 12 + case .historySaveEdits: return 13 + case .historySaveForBots: return 14 + case .historySaveSelfDestruct: return 15 + case .historySemiTransparent: return 16 + case .historyDeletedMark: return 17 + case .historyShowDeletedTime: return 18 + case .historyFooter: return 19 + case .stashHeader: return 20 + case .stashList: return 21 + case .stashMute: return 23 + case .stashAutoRead: return 24 + case .stashPasscodeRow: return 25 + case .stashFooter: return 37 + case .antiHeader: return 30 + case .antiDisableAds: return 31 + case .antiLocalPremium: return 32 + case .antiDisableStories: return 33 + case .antiHidePremiumStatuses: return 34 + case .antiDisableLinkWarning: return 35 + case .antiDisableCopyProtection: return 36 + case .antiAllowScreenshots: return 37 + case .antiFooter: return 38 + case .confirmStickers: return 64 + case .confirmGif: return 65 + case .confirmVoice: return 66 + case .chatHeader: return 48 + case .chatPreview: return 49 + case .chatShowPeerId: return 52 + case .chatShowRegistrationDate: return 53 + case .chatHideEditedMark: return 54 + case .chatTranslateProvider: return 56 + case .chatWebviewHeight: return 58 + case .chatOnlyAddedEmoji: return 59 + case .chatForwardWithoutAuthor: return 60 + case .chatFooter: return 67 + case .glassHeader: return 80 + case .glassEnabled: return 81 + case .glassVibrancy: return 82 + case .glassChatList: return 83 + case .glassNavBars: return 84 + case .glassTabBar: return 85 + case .glassBubbles: return 86 + case .glassFooter: return 87 + case .spoofingHeader: return 100 + case .spoofPresetsHeader: return 101 + case let .spoofPreset(index, _, _, _, _): return 102 + Int32(index) + case .spoofAddTemplate: return 203 + case .spoofDeleteSelected: return 204 + case .spoofingDevice: return 300 + case .spoofingDevicePreset: return 301 + case .spoofingWebviewPlatform: return 400 + case .spoofingApiId: return 401 + case .spoofingApiHash: return 402 + case .spoofingFooter: return 403 + case .expandableSelection(.sendWithoutSound, _, _, _): return 3 + case .expandableSelection(.peerId, _, _, _): return 52 + case .expandableSelection(.translationProvider, _, _, _): return 56 + case .expandableSelection(.webviewPlatform, _, _, _): return 400 + case .expandableSelection(.stashPrivacy, _, _, _): return 26 } } @@ -190,196 +437,287 @@ private enum WinterGramSettingsEntry: ItemListNodeEntry { func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { let arguments = arguments as! WinterGramSettingsArguments + let lang = presentationData.strings switch self { + case .banner: + return WinterGramBannerItem(theme: presentationData.theme, title: "WinterGram", subtitle: "", iconImage: UIImage(bundleImageName: "WinterGramDark"), sectionId: self.section) case .ghostHeader: - return ItemListSectionHeaderItem(presentationData: presentationData, text: "GHOST MODE", sectionId: self.section) - case let .ghostEnabled(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Ghost Mode", value: value, sectionId: self.section, style: .blocks, updated: { value in - arguments.updateSettings { var s = $0; s.ghostModeEnabled = value; return s } - }) - case let .ghostReadReceipts(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Send Read Receipts", value: value, sectionId: self.section, style: .blocks, updated: { value in - arguments.updateSettings { var s = $0; s.sendReadReceipts = value; return s } - }) - case let .ghostReadStories(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Send Story Views", value: value, sectionId: self.section, style: .blocks, updated: { value in - arguments.updateSettings { var s = $0; s.sendReadStories = value; return s } - }) - case let .ghostOnlineStatus(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Send Online Status", value: value, sectionId: self.section, style: .blocks, updated: { value in - arguments.updateSettings { var s = $0; s.sendOnlineStatus = value; return s } - }) - case let .ghostUploadProgress(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Send Typing & Upload Status", value: value, sectionId: self.section, style: .blocks, updated: { value in - arguments.updateSettings { var s = $0; s.sendUploadProgress = value; return s } - }) - case let .ghostOfflineAfterOnline(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Go Offline After Online", value: value, sectionId: self.section, style: .blocks, updated: { value in - arguments.updateSettings { var s = $0; s.sendOfflineAfterOnline = value; return s } - }) - case let .ghostMarkReadAfterAction(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Mark Read After Action", value: value, sectionId: self.section, style: .blocks, updated: { value in - arguments.updateSettings { var s = $0; s.markReadAfterAction = value; return s } - }) - case let .ghostUseScheduled(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Use Scheduled Messages", value: value, sectionId: self.section, style: .blocks, updated: { value in - arguments.updateSettings { var s = $0; s.useScheduledMessages = value; return s } + return ItemListSectionHeaderItem(presentationData: presentationData, text: lang.WinterGram_GHOSTMODE, sectionId: self.section) + case let .ghostExpandable(value, isExpanded, subItems): + return ItemListExpandableSwitchItem(presentationData: presentationData, systemStyle: .glass, title: lang.WinterGram_GhostMode, value: value, isExpanded: isExpanded, subItems: subItems, sectionId: self.section, style: .blocks, updated: { newValue in + arguments.updateSettings { var s = $0; s.ghostModeEnabled = newValue; return s } + }, selectAction: { + arguments.toggleGhostExpanded() + }, subAction: { subItem in + guard let id = subItem.id as? String else { + return + } + arguments.updateSettings { settings in + var s = settings + switch id { + case "readMessages": + s.sendReadReceipts = !subItem.isSelected + case "readStories": + s.sendReadStories = !subItem.isSelected + case "online": + s.sendOnlineStatus = !subItem.isSelected + case "typing": + s.sendUploadProgress = !subItem.isSelected + case "autoOffline": + s.sendOfflineAfterOnline = subItem.isSelected + default: + break + } + s.ghostModeEnabled = true + return s + } }) case let .ghostSendWithoutSound(value): - return ItemListDisclosureItem(presentationData: presentationData, title: "Send Without Sound", label: value, sectionId: self.section, style: .blocks, action: { - arguments.presentSendWithoutSound() + return ItemListDisclosureItem(presentationData: presentationData, title: lang.WinterGram_SendWithoutSound, label: wntOption(value, lang), sectionId: self.section, style: .blocks, action: { + arguments.toggleDropdown(.sendWithoutSound) }) - case let .ghostSuggestBeforeStory(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Ask Before Viewing Stories", value: value, sectionId: self.section, style: .blocks, updated: { value in - arguments.updateSettings { var s = $0; s.suggestGhostBeforeStory = value; return s } + case let .ghostUseScheduledMessages(value): + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_UseScheduledMessages, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateSettings { var s = $0; s.useScheduledMessages = value; return s } }) + case let .ghostConfirmStory(value): + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_ConfirmStoryView, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateSettings { var s = $0; s.confirmStoryView = value; return s } + }) + case let .ghostMarkReadAfterAction(value): + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_ReadAfterAction, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateSettings { var s = $0; s.markReadAfterAction = value; return s } + }) + case .ghostFooter: - return ItemListTextItem(presentationData: presentationData, text: .plain("When Ghost Mode is on, WinterGram stops sending read receipts, online status and typing activity."), sectionId: self.section) + return ItemListTextItem(presentationData: presentationData, text: .plain(lang.WinterGram_WhenGhostModeIsOnWinterGramStopsSendingReadReceiptsOnlineStatusAndTypingActivity), sectionId: self.section) case .historyHeader: - return ItemListSectionHeaderItem(presentationData: presentationData, text: "HISTORY", sectionId: self.section) + return ItemListSectionHeaderItem(presentationData: presentationData, text: lang.WinterGram_HISTORY, sectionId: self.section) + case .historyHeaderSpy: + return ItemListSectionHeaderItem(presentationData: presentationData, text: lang.WinterGram_SPYMODE, sectionId: self.section) case let .historySaveDeleted(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Save Deleted Messages", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_SaveDeletedMessages, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.saveDeletedMessages = value; return s } }) case let .historySaveEdits(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Save Edit History", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_SaveEditHistory, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.saveMessagesHistory = value; return s } }) + case let .historySaveForBots(value): + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_SaveDeletedFromBots, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateSettings { var s = $0; s.saveForBots = value; return s } + }) + case let .historySaveSelfDestruct(value): + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_SaveSelfDestructMessages, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateSettings { var s = $0; s.saveSelfDestructMessages = value; return s } + }) case let .historySemiTransparent(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Dim Deleted Messages", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_DimDeletedMessages, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.semiTransparentDeletedMessages = value; return s } }) + case let .historyDeletedMark(value): + return ItemListDisclosureItem(presentationData: presentationData, title: lang.WinterGram_DeletedMark, label: value.isEmpty ? "🧹" : value, sectionId: self.section, style: .blocks, action: { + arguments.editDeletedMark() + }) + case let .historyShowDeletedTime(value): + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_ShowDeletionTime, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateSettings { var s = $0; s.showDeletedTime = value; return s } + }) case .historyFooter: - return ItemListTextItem(presentationData: presentationData, text: .plain("Deleted and edited messages are kept locally on this device only."), sectionId: self.section) + return ItemListTextItem(presentationData: presentationData, text: .plain(lang.WinterGram_DeletedAndEditedMessagesAreKeptLocallyOnThisDeviceOnly), sectionId: self.section) case .stashHeader: - return ItemListSectionHeaderItem(presentationData: presentationData, text: "HIDDEN ARCHIVE", sectionId: self.section) + return ItemListSectionHeaderItem(presentationData: presentationData, text: lang.WinterGram_HIDDENARCHIVE, sectionId: self.section) + case let .stashList(count): + return ItemListDisclosureItem(presentationData: presentationData, title: lang.WinterGram_StashedChats, label: count == 0 ? "" : "\(count)", sectionId: self.section, style: .blocks, action: { + arguments.openStash() + }) case let .stashMute(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Mute Notifications", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_MuteNotifications, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.stashMuteNotifications = value; return s } }) case let .stashAutoRead(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Auto Mark as Read", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_AutoMarkAsRead, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.stashAutoMarkRead = value; return s } }) + case let .stashPasscodeRow(value): + return ItemListDisclosureItem(presentationData: presentationData, title: lang.WinterGram_StashPasscode, label: wntOption(value, lang), sectionId: self.section, style: .blocks, action: { + arguments.editStashPasscode() + }) case .stashFooter: - return ItemListTextItem(presentationData: presentationData, text: .plain("Stashed chats are hidden from the main list and accessible only here."), sectionId: self.section) + return ItemListTextItem(presentationData: presentationData, text: .plain(lang.WinterGram_StashedChatsAreHiddenFromTheMainListAndAccessibleOnlyHere), sectionId: self.section) case .antiHeader: - return ItemListSectionHeaderItem(presentationData: presentationData, text: "FEATURES", sectionId: self.section) + return ItemListSectionHeaderItem(presentationData: presentationData, text: lang.WinterGram_FEATURES, sectionId: self.section) case let .antiDisableAds(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Disable Ads", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_DisableAds, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.disableAds = value; return s } }) case let .antiLocalPremium(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Local Premium", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_LocalPremium, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.localPremium = value; return s } }) case let .antiDisableStories(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Hide Stories", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_HideStories, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.disableStories = value; return s } }) case let .antiHidePremiumStatuses(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Hide Premium Statuses", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_HidePremiumStatuses, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.hidePremiumStatuses = value; return s } }) case let .antiDisableLinkWarning(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Disable Open Link Warning", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_DisableOpenLinkWarning, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.disableOpenLinkWarning = value; return s } }) + case let .antiDisableCopyProtection(value): + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_AllowSavingRestrictedContent, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateSettings { var s = $0; s.disableCopyProtection = value; return s } + }) + case let .antiAllowScreenshots(value): + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_AllowScreenshotsEverywhere, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateSettings { var s = $0; s.allowScreenshots = value; return s } + }) case .antiFooter: - return ItemListTextItem(presentationData: presentationData, text: .plain("Local Premium unlocks Premium-only UI on this device; it does not grant server-side Premium."), sectionId: self.section) + return ItemListTextItem(presentationData: presentationData, text: .plain(lang.WinterGram_LocalPremiumUnlocksPremiumOnlyUIOnThisDeviceItDoesNotGrantServerSidePremium), sectionId: self.section) - case .confirmHeader: - return ItemListSectionHeaderItem(presentationData: presentationData, text: "SEND CONFIRMATIONS", sectionId: self.section) case let .confirmStickers(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Confirm Stickers", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_ConfirmStickers, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.stickerConfirmation = value; return s } }) case let .confirmGif(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Confirm GIFs", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_ConfirmGIFs, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.gifConfirmation = value; return s } }) case let .confirmVoice(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Confirm Voice Messages", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_ConfirmVoiceMessages, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.voiceConfirmation = value; return s } }) case .chatHeader: - return ItemListSectionHeaderItem(presentationData: presentationData, text: "CHAT", sectionId: self.section) - case let .chatShowSeconds(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Show Message Seconds", value: value, sectionId: self.section, style: .blocks, updated: { value in - arguments.updateSettings { var s = $0; s.showMessageSeconds = value; return s } - }) + return ItemListSectionHeaderItem(presentationData: presentationData, text: lang.WinterGram_CHAT, sectionId: self.section) + case let .chatPreview(theme, wallpaper, fontSize, chatBubbleCorners, strings, dateTimeFormat, nameDisplayOrder, messageItems): + return ThemeSettingsChatPreviewItem(context: arguments.context, systemStyle: .glass, theme: theme, componentTheme: theme, strings: strings, sectionId: self.section, fontSize: fontSize, chatBubbleCorners: chatBubbleCorners, wallpaper: wallpaper, dateTimeFormat: dateTimeFormat, nameDisplayOrder: nameDisplayOrder, messageItems: messageItems) case let .chatShowPeerId(value): - return ItemListDisclosureItem(presentationData: presentationData, title: "Show Peer ID", label: value, sectionId: self.section, style: .blocks, action: { - arguments.presentPeerId() + return ItemListDisclosureItem(presentationData: presentationData, title: lang.WinterGram_ShowPeerID, label: wntOption(value, lang), sectionId: self.section, style: .blocks, action: { + arguments.toggleDropdown(.peerId) }) - case let .chatTranslate(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Message Translation", value: value, sectionId: self.section, style: .blocks, updated: { value in - arguments.updateSettings { var s = $0; s.translateMessages = value; return s } + case let .chatShowRegistrationDate(value): + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_ShowRegistrationDate, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateSettings { var s = $0; s.showRegistrationDate = value; return s } + }) + case let .chatHideEditedMark(value): + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_HideEditedMark, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateSettings { var s = $0; s.hideEditedMark = value; return s } }) case let .chatTranslateProvider(value): - return ItemListDisclosureItem(presentationData: presentationData, title: "Translation Provider", label: value, sectionId: self.section, style: .blocks, action: { - arguments.presentTranslationProvider() - }) - case let .chatWebviewPlatform(value): - return ItemListDisclosureItem(presentationData: presentationData, title: "WebView Platform", label: value, sectionId: self.section, style: .blocks, action: { - arguments.presentWebviewPlatform() + return ItemListDisclosureItem(presentationData: presentationData, title: lang.WinterGram_TranslationProvider, label: wntOption(value, lang), sectionId: self.section, style: .blocks, action: { + arguments.toggleDropdown(.translationProvider) }) case let .chatWebviewHeight(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Increase WebView Height", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_IncreaseWebViewHeight, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.increaseWebviewHeight = value; return s } }) case let .chatOnlyAddedEmoji(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Only Added Emoji & Stickers", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_OnlyAddedEmojiStickers, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.showOnlyAddedEmojisAndStickers = value; return s } }) - - case .appearanceHeader: - return ItemListSectionHeaderItem(presentationData: presentationData, text: "APPEARANCE", sectionId: self.section) - case let .appearanceMaterial(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Material Design", value: value, sectionId: self.section, style: .blocks, updated: { value in - arguments.updateSettings { var s = $0; s.materialDesign = value; return s } - }) - case let .appearanceSingleCorner(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Single Corner Radius", value: value, sectionId: self.section, style: .blocks, updated: { value in - arguments.updateSettings { var s = $0; s.singleCornerRadius = value; return s } - }) - case let .appearanceIconPack(value): - return ItemListDisclosureItem(presentationData: presentationData, title: "Icon Pack", label: value, sectionId: self.section, style: .blocks, action: { - arguments.presentIconPack() + case let .chatForwardWithoutAuthor(value): + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_ForwardWithoutAuthor, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updateSettings { var s = $0; s.forwardWithoutAuthor = value; return s } }) + case .chatFooter: + return ItemListTextItem(presentationData: presentationData, text: .plain(lang.WinterGram_TheseOptionsChangeHowMessageMetadataAndActionsAreShownInChats), sectionId: self.section) case .glassHeader: - return ItemListSectionHeaderItem(presentationData: presentationData, text: "LIQUID GLASS", sectionId: self.section) + return ItemListSectionHeaderItem(presentationData: presentationData, text: lang.WinterGram_LIQUIDGLASS, sectionId: self.section) case let .glassEnabled(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Liquid Glass", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_LiquidGlass, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.liquidGlass.enabled = value; return s } }) case let .glassVibrancy(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Vibrancy", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_Vibrancy, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.liquidGlass.vibrancy = value; return s } }) case let .glassChatList(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Apply to Chat List", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_ApplyToChatList, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.liquidGlass.applyToChatList = value; return s } }) case let .glassNavBars(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Apply to Navigation Bars", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_ApplyToNavigationBars, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.liquidGlass.applyToNavigationBars = value; return s } }) case let .glassTabBar(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Apply to Tab Bar", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_ApplyToTabBar, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.liquidGlass.applyToTabBar = value; return s } }) case let .glassBubbles(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Apply to Bubbles", value: value, sectionId: self.section, style: .blocks, updated: { value in + return ItemListSwitchItem(presentationData: presentationData, title: lang.WinterGram_ApplyToBubbles, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updateSettings { var s = $0; s.liquidGlass.applyToBubbles = value; return s } }) case .glassFooter: - return ItemListTextItem(presentationData: presentationData, text: .plain("Transparency, blur and tint can be fine-tuned per surface. Turn Liquid Glass off for the standard opaque look."), sectionId: self.section) + return ItemListTextItem(presentationData: presentationData, text: .plain(lang.WinterGram_TransparencyBlurAndTintCanBeFineTunedPerSurfaceTurnLiquidGlassOffForTheStandardOpaqueLook), sectionId: self.section) + + case .spoofingHeader: + return ItemListSectionHeaderItem(presentationData: presentationData, text: lang.WinterGram_SPOOFING, sectionId: self.section) + case let .spoofingDevice(value): + return ItemListDisclosureItem(presentationData: presentationData, title: lang.WinterGram_SpoofDeviceModel, label: wntOption(value, lang), sectionId: self.section, style: .blocks, action: { + arguments.editSpoofDevice() + }) + case let .spoofingDevicePreset(index, selected): + let preset = winterGramDevicePresets[index] + return ItemListCheckboxItem(presentationData: presentationData, title: preset.name, subtitle: preset.subtitle, style: .left, checked: selected, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.updateSettings { var s = $0; s.spoofDeviceModel = preset.model; return s } + }) + case let .spoofingWebviewPlatform(value): + return ItemListDisclosureItem(presentationData: presentationData, title: lang.WinterGram_WebViewPlatform, label: wntOption(value, lang), sectionId: self.section, style: .blocks, action: { + arguments.toggleDropdown(.webviewPlatform) + }) + case let .spoofingApiId(value): + return ItemListDisclosureItem(presentationData: presentationData, title: lang.WinterGram_APIID, label: wntOption(value, lang), sectionId: self.section, style: .blocks, action: { + arguments.editApiId() + }) + case let .spoofingApiHash(value): + return ItemListDisclosureItem(presentationData: presentationData, title: lang.WinterGram_APIHash, label: wntOption(value, lang), sectionId: self.section, style: .blocks, action: { + arguments.editApiHash() + }) + 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: { + arguments.toggleSpoofTemplateSelected(index) + }) + } else { + return ItemListDisclosureItem(presentationData: presentationData, title: name, label: subtitle, labelStyle: .detailText, sectionId: self.section, style: .blocks, disclosureStyle: .none, action: { + arguments.updateSettings { settings in + var settings = settings + if index < settings.spoofPresets.count { + let preset = settings.spoofPresets[index] + settings.spoofDeviceModel = preset.deviceModel.isEmpty ? nil : preset.deviceModel + settings.spoofAppVersion = preset.appVersion.isEmpty ? nil : preset.appVersion + } + 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() + }) + case .spoofDeleteSelected: + return ItemListPeerActionItem(presentationData: presentationData, icon: PresentationResourcesItemList.deleteIconImage(presentationData.theme), title: lang.WinterGram_DeleteSelected, sectionId: self.section, height: .generic, color: .destructive, editing: false, action: { + arguments.deleteSelectedSpoofTemplates() + }) + case .spoofingFooter: + return ItemListTextItem(presentationData: presentationData, text: .plain(lang.WinterGram_SpoofTheDeviceModelAppVersionAndWebViewPlatformReportedToTelegramAndMiniAppsAPIIDHashUseYourOwnCredentialsFromMyTelegramOrgChangingThemRequiresReLogin), sectionId: self.section) + case let .expandableSelection(dropdown, title, isExpanded, options): + let mode: ItemListExpandableSelectionItem.SelectionMode = dropdown == .stashPrivacy ? .multiple : .single + return ItemListExpandableSelectionItem(presentationData: presentationData, systemStyle: .glass, title: title, options: options, mode: mode, isExpanded: isExpanded, sectionId: self.section, style: .blocks, updated: { option in + arguments.selectDropdownOption(dropdown, option.index) + }, toggleExpanded: { + arguments.toggleDropdown(dropdown) + }) } } } @@ -419,189 +757,543 @@ private func webviewPlatformLabel(_ value: WinterGramWebviewPlatform) -> String } } +private func avatarRadiusLabel(_ value: Int32) -> String { + switch value { + case 50: return "Round" + case 30: return "Squircle" + case 15: return "Rounded" + case 0: return "Square" + default: return "\(value)" + } +} + +private func spoofLabel(_ value: String?) -> String { + if let value = value, !value.isEmpty { + return value + } + return "Default" +} + private func iconPackLabel(_ value: WinterGramIconPack) -> String { switch value { case .wintergram: return "WinterGram" - case .ayugram: return "AyuGram" + case .ayugram: return "Ayu" case .exteragram: return "exteraGram" case .telegram: return "Telegram" } } -private func winterGramSettingsEntries(settings: WinterGramSettings) -> [WinterGramSettingsEntry] { +private func winterGramSettingsEntries(presentationData: PresentationData, settings: WinterGramSettings, deletedCount: Int, expandedDropdown: WinterGramDropdown?, ghostExpanded: Bool, spoofTemplatesEditing: Bool, selectedTemplates: Set, category: WinterGramSettingsSection? = nil) -> [WinterGramSettingsEntry] { + let lang = presentationData.strings var entries: [WinterGramSettingsEntry] = [] - entries.append(.ghostHeader) - entries.append(.ghostEnabled(settings.ghostModeEnabled)) - entries.append(.ghostReadReceipts(settings.sendReadReceipts)) - entries.append(.ghostReadStories(settings.sendReadStories)) - entries.append(.ghostOnlineStatus(settings.sendOnlineStatus)) - entries.append(.ghostUploadProgress(settings.sendUploadProgress)) - entries.append(.ghostOfflineAfterOnline(settings.sendOfflineAfterOnline)) - entries.append(.ghostMarkReadAfterAction(settings.markReadAfterAction)) - entries.append(.ghostUseScheduled(settings.useScheduledMessages)) - entries.append(.ghostSendWithoutSound(sendWithoutSoundLabel(settings.sendWithoutSound))) - entries.append(.ghostSuggestBeforeStory(settings.suggestGhostBeforeStory)) - entries.append(.ghostFooter) + // 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) + } + entries.append(.expandableSelection(dropdown, title, expandedDropdown == dropdown, options)) + } - entries.append(.historyHeader) - entries.append(.historySaveDeleted(settings.saveDeletedMessages)) - entries.append(.historySaveEdits(settings.saveMessagesHistory)) - entries.append(.historySemiTransparent(settings.semiTransparentDeletedMessages)) - entries.append(.historyFooter) + 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), + .init(id: "online", title: lang.WinterGram_DontSendOnline, isSelected: settings.suppressesOnlinePresence, isEnabled: true), + .init(id: "typing", title: lang.WinterGram_DontSendTyping, isSelected: settings.suppressesTypingStatus, isEnabled: true), + .init(id: "autoOffline", title: lang.WinterGram_AutoOffline, isSelected: settings.sendOfflineAfterOnline, isEnabled: true) + ] + entries.append(.ghostExpandable(settings.ghostModeEnabled, ghostExpanded, subItems)) + appendDropdown(.sendWithoutSound, lang.WinterGram_SendWithoutSound, .ghost) + entries.append(.ghostUseScheduledMessages(settings.useScheduledMessages)) + entries.append(.ghostConfirmStory(settings.confirmStoryView)) + entries.append(.ghostMarkReadAfterAction(settings.markReadAfterAction)) + entries.append(.ghostFooter) + } - entries.append(.stashHeader) - entries.append(.stashMute(settings.stashMuteNotifications)) - entries.append(.stashAutoRead(settings.stashAutoMarkRead)) - entries.append(.stashFooter) + func appendHistory() { + if category == .ayugram { + entries.append(.historyHeaderSpy) + entries.append(.historySaveDeleted(settings.saveDeletedMessages)) + entries.append(.historySaveEdits(settings.saveMessagesHistory)) + entries.append(.historyDeletedMark(settings.deletedMark)) + entries.append(.historyShowDeletedTime(settings.showDeletedTime)) + } else { + entries.append(.historyHeader) + entries.append(.historySaveDeleted(settings.saveDeletedMessages)) + entries.append(.historySaveEdits(settings.saveMessagesHistory)) + entries.append(.historySaveForBots(settings.saveForBots)) + entries.append(.historySaveSelfDestruct(settings.saveSelfDestructMessages)) + entries.append(.historySemiTransparent(settings.semiTransparentDeletedMessages)) + entries.append(.historyDeletedMark(settings.deletedMark)) + entries.append(.historyShowDeletedTime(settings.showDeletedTime)) + entries.append(.historyFooter) + } + } - entries.append(.antiHeader) - entries.append(.antiDisableAds(settings.disableAds)) - entries.append(.antiLocalPremium(settings.localPremium)) - entries.append(.antiDisableStories(settings.disableStories)) - entries.append(.antiHidePremiumStatuses(settings.hidePremiumStatuses)) - entries.append(.antiDisableLinkWarning(settings.disableOpenLinkWarning)) - entries.append(.antiFooter) + func appendStash() { + entries.append(.stashHeader) + entries.append(.stashList(Set(settings.stashedPeerIds).count)) + 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) + } - entries.append(.confirmHeader) - entries.append(.confirmStickers(settings.stickerConfirmation)) - entries.append(.confirmGif(settings.gifConfirmation)) - entries.append(.confirmVoice(settings.voiceConfirmation)) + func appendAntiFeatures() { + entries.append(.antiHeader) + entries.append(.antiDisableAds(settings.disableAds)) + entries.append(.antiLocalPremium(settings.localPremium)) + entries.append(.antiDisableStories(settings.disableStories)) + entries.append(.antiHidePremiumStatuses(settings.hidePremiumStatuses)) + entries.append(.antiDisableLinkWarning(settings.disableOpenLinkWarning)) + entries.append(.antiDisableCopyProtection(settings.disableCopyProtection)) + entries.append(.antiAllowScreenshots(settings.allowScreenshots)) + entries.append(.antiFooter) + } - entries.append(.chatHeader) - entries.append(.chatShowSeconds(settings.showMessageSeconds)) - entries.append(.chatShowPeerId(peerIdLabel(settings.showPeerId))) - entries.append(.chatTranslate(settings.translateMessages)) - entries.append(.chatTranslateProvider(translationProviderLabel(settings.translationProvider))) - entries.append(.chatWebviewPlatform(webviewPlatformLabel(settings.webviewSpoofPlatform))) - entries.append(.chatWebviewHeight(settings.increaseWebviewHeight)) - entries.append(.chatOnlyAddedEmoji(settings.showOnlyAddedEmojisAndStickers)) + func appendChat() { + entries.append(.chatHeader) + appendDropdown(.peerId, lang.WinterGram_ShowPeerID, .chat) + entries.append(.chatShowRegistrationDate(settings.showRegistrationDate)) + entries.append(.chatHideEditedMark(settings.hideEditedMark)) + appendDropdown(.translationProvider, lang.WinterGram_MessageTranslation, .chat) + entries.append(.chatWebviewHeight(settings.increaseWebviewHeight)) + entries.append(.chatOnlyAddedEmoji(settings.showOnlyAddedEmojisAndStickers)) + entries.append(.chatForwardWithoutAuthor(settings.forwardWithoutAuthor)) + 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) + } - entries.append(.appearanceHeader) - entries.append(.appearanceMaterial(settings.materialDesign)) - entries.append(.appearanceSingleCorner(settings.singleCornerRadius)) - entries.append(.appearanceIconPack(iconPackLabel(settings.iconPack))) + func appendLiquidGlass() { + entries.append(.glassHeader) + entries.append(.glassEnabled(settings.liquidGlass.enabled)) + entries.append(.glassVibrancy(settings.liquidGlass.vibrancy)) + entries.append(.glassChatList(settings.liquidGlass.applyToChatList)) + entries.append(.glassNavBars(settings.liquidGlass.applyToNavigationBars)) + entries.append(.glassTabBar(settings.liquidGlass.applyToTabBar)) + entries.append(.glassBubbles(settings.liquidGlass.applyToBubbles)) + entries.append(.glassFooter) + } - entries.append(.glassHeader) - entries.append(.glassEnabled(settings.liquidGlass.enabled)) - entries.append(.glassVibrancy(settings.liquidGlass.vibrancy)) - entries.append(.glassChatList(settings.liquidGlass.applyToChatList)) - entries.append(.glassNavBars(settings.liquidGlass.applyToNavigationBars)) - entries.append(.glassTabBar(settings.liquidGlass.applyToTabBar)) - entries.append(.glassBubbles(settings.liquidGlass.applyToBubbles)) - entries.append(.glassFooter) + 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: " · ") + entries.append(.spoofPreset(index, preset.name, subtitle.isEmpty ? "Default" : subtitle, spoofTemplatesEditing, selectedTemplates.contains(index))) + } + entries.append(.spoofAddTemplate) + 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)) + } + appendDropdown(.webviewPlatform, lang.WinterGram_WebViewPlatform, .spoofing) + entries.append(.spoofingApiId(settings.customApiId.flatMap { $0 == 0 ? nil : "\($0)" } ?? "Default")) + entries.append(.spoofingApiHash(spoofLabel(settings.customApiHash))) + entries.append(.spoofingFooter) + } + + if let category = category { + switch category { + case .banner: + break + case .ghost: + appendGhost() + case .history: + appendHistory() + case .stash: + appendStash() + case .antiFeatures: + appendAntiFeatures() + case .chat: + appendChat() + case .liquidGlass: + appendLiquidGlass() + case .spoofing: + appendSpoofing() + case .ayugram: + appendGhost() + appendHistory() + case .other: + appendChat() + } + } else { + entries.append(.banner) + appendGhost() + appendHistory() + appendStash() + appendAntiFeatures() + appendChat() + appendLiquidGlass() + appendSpoofing() + } return entries } -public func winterGramSettingsController(context: AccountContext) -> ViewController { +public func winterGramSettingsController(context: AccountContext, category: WinterGramSettingsSection? = nil) -> ViewController { let accountManager = context.sharedContext.accountManager var presentControllerImpl: ((ViewController, Any?) -> Void)? + var pushControllerImpl: ((ViewController) -> Void)? + var refreshDeletedCount: (() -> Void)? + + // The combined super-categories (.ayugram/.other) are not standalone tabs in the legacy + // no-category segmented view. + let sectionTabs: [WinterGramSettingsSection] = WinterGramSettingsSection.allCases.filter { $0 != .ayugram && $0 != .other } + let selectedCategoryPromise = ValuePromise(category ?? .ghost, ignoreRepeated: true) + + let initialSettings = currentWinterGramSettings + let requiresRestartPromise = ValuePromise(false) let updateSettings: (@escaping (WinterGramSettings) -> WinterGramSettings) -> Void = { f in - let _ = updateWinterGramSettingsInteractively(accountManager: accountManager, f).start() + let _ = (updateWinterGramSettingsInteractively(accountManager: accountManager, f) + |> deliverOnMainQueue).startStandalone(next: { _ in + let newSettings = currentWinterGramSettings + let needsRestart = newSettings.spoofDeviceModel != initialSettings.spoofDeviceModel || + newSettings.spoofAppVersion != initialSettings.spoofAppVersion || + newSettings.customApiId != initialSettings.customApiId || + newSettings.customApiHash != initialSettings.customApiHash || + newSettings.materialDesign != initialSettings.materialDesign || + newSettings.customFont != initialSettings.customFont || + newSettings.monoFont != initialSettings.monoFont + requiresRestartPromise.set(needsRestart) + }) } - func presentChoice(title: String, options: [(String, T)], apply: @escaping (T, WinterGramSettings) -> WinterGramSettings) { - let presentationData = context.sharedContext.currentPresentationData.with { $0 } - let controller = ActionSheetController(presentationData: presentationData) - var items: [ActionSheetItem] = [] - items.append(ActionSheetTextItem(title: title)) - for (label, value) in options { - items.append(ActionSheetButtonItem(title: label, action: { [weak controller] in - controller?.dismissAnimated() - updateSettings { apply(value, $0) } - })) + // 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 + var settings = settings + settings.stashPrivacy = f(settings.stashPrivacy) + return settings + } + let updated = f(previous) + if updated != previous { + for rawPeerId in Set(currentWinterGramSettings.stashedPeerIds) { + let peerId = EnginePeer.Id(rawPeerId) + let _ = winterGramApplyStashPrivacy(engine: context.engine, peerId: peerId, stashed: true, privacySettings: updated).startStandalone() + } } - controller.setItemGroups([ - ActionSheetItemGroup(items: items), - ActionSheetItemGroup(items: [ - ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak controller] in - controller?.dismissAnimated() - }) - ]) - ]) - presentControllerImpl?(controller, nil) } + // Which inline dropdown (if any) is currently expanded. Atomic mirror for synchronous reads in the + // toggle/select closures; promise drives the list rebuild. + let expandedDropdownValue = Atomic(value: nil) + let expandedDropdownPromise = ValuePromise(nil, ignoreRepeated: true) + + // Whether the Ghost Mode expandable section in the Core menu is open. + let ghostExpandedValue = Atomic(value: false) + let ghostExpandedPromise = ValuePromise(false, ignoreRepeated: true) + let spoofTemplatesEditingValue = Atomic(value: false) + let spoofTemplatesEditingPromise = ValuePromise(false, ignoreRepeated: true) + let selectedTemplatesValue = Atomic>(value: Set()) + let selectedTemplatesPromise = ValuePromise>(Set(), ignoreRepeated: true) + let arguments = WinterGramSettingsArguments( + context: context, updateSettings: updateSettings, - presentSendWithoutSound: { - presentChoice(title: "Send Without Sound", options: [ - ("Never", WinterGramSendWithoutSound.never), - ("In Ghost Mode", WinterGramSendWithoutSound.inGhostMode), - ("Always", WinterGramSendWithoutSound.always) - ], apply: { value, settings in - var settings = settings - settings.sendWithoutSound = value - return settings - }) + toggleDropdown: { dropdown in + let newValue: WinterGramDropdown? = expandedDropdownValue.with { $0 == dropdown ? nil : dropdown } + let _ = expandedDropdownValue.swap(newValue) + expandedDropdownPromise.set(newValue) }, - presentPeerId: { - presentChoice(title: "Show Peer ID", options: [ - ("Hidden", WinterGramPeerIdDisplay.hidden), - ("Telegram API", WinterGramPeerIdDisplay.telegramApi), - ("Bot API", WinterGramPeerIdDisplay.botApi) - ], apply: { value, settings in - var settings = settings - settings.showPeerId = value - return settings - }) + selectDropdownOption: { dropdown, index in + let options = winterGramDropdownOptions(dropdown, settings: currentWinterGramSettings) + if index >= 0, index < options.count { + if dropdown == .stashPrivacy { + updateStashPrivacy { privacy in + var settings = currentWinterGramSettings + settings.stashPrivacy = privacy + return options[index].apply(settings).stashPrivacy + } + } else { + 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) + } }, - presentTranslationProvider: { - presentChoice(title: "Translation Provider", options: [ - ("Telegram", WinterGramTranslationProvider.telegram), - ("Google", WinterGramTranslationProvider.google), - ("Yandex", WinterGramTranslationProvider.yandex), - ("System", WinterGramTranslationProvider.system) - ], apply: { value, settings in - var settings = settings - settings.translationProvider = value - return settings - }) + toggleGhostExpanded: { + let newValue = !ghostExpandedValue.with { $0 } + let _ = ghostExpandedValue.swap(newValue) + ghostExpandedPromise.set(newValue) }, - presentWebviewPlatform: { - presentChoice(title: "WebView Platform", options: [ - ("Automatic", WinterGramWebviewPlatform.auto), - ("iOS", WinterGramWebviewPlatform.ios), - ("Android", WinterGramWebviewPlatform.android), - ("macOS", WinterGramWebviewPlatform.macos), - ("Desktop", WinterGramWebviewPlatform.desktop) - ], apply: { value, settings in - var settings = settings - settings.webviewSpoofPlatform = value - return settings - }) + editSpoofDevice: { + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let lang = presentationData.strings + let current = currentWinterGramSettings.spoofDeviceModel + + let controller = ActionSheetController(presentationData: presentationData) + var items: [ActionSheetItem] = [] + items.append(ActionSheetTextItem(title: lang.WinterGram_SpoofDeviceModel)) + + let templates = [ + ("iPhone 16 Pro Max", "iPhone17,2", "11.1"), + ("iPhone 15 Pro", "iPhone16,1", "11.1"), + ("iPad Pro M4", "iPad16,3", "11.1"), + ("Pixel 9 Pro", "Pixel 9 Pro", "11.1") + ] + + for (name, model, version) in templates { + items.append(ActionSheetButtonItem(title: name, action: { [weak controller] in + controller?.dismissAnimated() + updateSettings { var s = $0; s.spoofDeviceModel = model; s.spoofAppVersion = version; return s } + })) + } + + for preset in currentWinterGramSettings.spoofPresets { + items.append(ActionSheetButtonItem(title: preset.name, action: { [weak controller] in + controller?.dismissAnimated() + updateSettings { var s = $0; s.spoofDeviceModel = preset.deviceModel; s.spoofAppVersion = preset.appVersion; return s } + })) + } + + items.append(ActionSheetButtonItem(title: lang.WinterGram_Custom, action: { [weak controller] in + controller?.dismissAnimated() + let prompt = promptController(context: context, text: lang.WinterGram_SpoofDeviceModel, value: current, placeholder: current ?? "iPhone17,2", characterLimit: 64, apply: { value in + updateSettings { var s = $0; s.spoofDeviceModel = (value?.isEmpty ?? true) ? nil : value; return s } + }) + presentControllerImpl?(prompt, nil) + })) + + items.append(ActionSheetButtonItem(title: lang.WinterGram_SaveCurrentAsProfile, color: .accent, action: { [weak controller] in + controller?.dismissAnimated() + let prompt = promptController(context: context, text: lang.WinterGram_ProfileName, value: nil, placeholder: "My Profile", characterLimit: 32, apply: { name in + if let name = name, !name.isEmpty { + updateSettings { var s = $0 + let preset = WinterGramSpoofPreset(name: name, deviceModel: s.spoofDeviceModel ?? "", appVersion: s.spoofAppVersion ?? "") + s.spoofPresets.append(preset) + return s + } + } + }) + presentControllerImpl?(prompt, nil) + })) + + items.append(ActionSheetButtonItem(title: lang.WinterGram_DefaultRealDevice, color: .destructive, action: { [weak controller] in + controller?.dismissAnimated() + updateSettings { var s = $0; s.spoofDeviceModel = nil; s.spoofAppVersion = nil; return s } + })) + + controller.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak controller] in + controller?.dismissAnimated() + }) + ]) + ]) + presentControllerImpl?(controller, nil) }, - presentIconPack: { - presentChoice(title: "Icon Pack", options: [ - ("WinterGram", WinterGramIconPack.wintergram), - ("AyuGram", WinterGramIconPack.ayugram), - ("exteraGram", WinterGramIconPack.exteragram), - ("Telegram", WinterGramIconPack.telegram) - ], apply: { value, settings in + addSpoofTemplate: { + let lang = context.sharedContext.currentPresentationData.with { $0 }.strings + let controller = promptController(context: context, text: lang.WinterGram_ProfileName, value: nil, placeholder: "My Profile", characterLimit: 32, apply: { name in + if let name = name?.trimmingCharacters(in: .whitespaces), !name.isEmpty { + updateSettings { settings in + var settings = settings + settings.spoofPresets.append(WinterGramSpoofPreset(name: name, deviceModel: settings.spoofDeviceModel ?? "", appVersion: settings.spoofAppVersion ?? "")) + return settings + } + } + }) + presentControllerImpl?(controller, nil) + }, + toggleSpoofTemplateSelected: { index in + let updated = selectedTemplatesValue.modify { selected in + var selected = selected + if selected.contains(index) { + selected.remove(index) + } else { + selected.insert(index) + } + return selected + } + selectedTemplatesPromise.set(updated) + }, + deleteSelectedSpoofTemplates: { + let selected = selectedTemplatesValue.with { $0 } + guard !selected.isEmpty else { return } + updateSettings { settings in var settings = settings - settings.iconPack = value + let sorted = selected.sorted(by: >) + for index in sorted { + if index >= 0 && index < settings.spoofPresets.count { + settings.spoofPresets.remove(at: index) + } + } return settings + } + let _ = selectedTemplatesValue.swap(Set()) + selectedTemplatesPromise.set(Set()) + }, + editApiId: { + let lang = context.sharedContext.currentPresentationData.with { $0 }.strings + let current = currentWinterGramSettings.customApiId.flatMap { $0 == 0 ? nil : "\($0)" } + let controller = promptController(context: context, text: lang.WinterGram_APIID, value: current, placeholder: "1234567", characterLimit: 16, apply: { value in + updateSettings { var s = $0 + if let value = value, let parsed = Int32(value.trimmingCharacters(in: .whitespaces)), parsed != 0 { + s.customApiId = parsed + } else { + s.customApiId = nil + } + return s + } + }) + presentControllerImpl?(controller, nil) + }, + editApiHash: { + let lang = context.sharedContext.currentPresentationData.with { $0 }.strings + let current = currentWinterGramSettings.customApiHash + let controller = promptController(context: context, text: lang.WinterGram_APIHash, value: current, placeholder: "0123456789abcdef0123456789abcdef", characterLimit: 64, apply: { value in + updateSettings { var s = $0 + let trimmed = value?.trimmingCharacters(in: .whitespaces) + s.customApiHash = (trimmed?.isEmpty ?? true) ? nil : trimmed + return s + } + }) + presentControllerImpl?(controller, nil) + }, + openStash: { + let passcode = currentWinterGramSettings.stashPasscode + if passcode.isEmpty { + pushControllerImpl?(winterGramStashController(context: context)) + } else { + let lang = context.sharedContext.currentPresentationData.with { $0 }.strings + let controller = promptController(context: context, text: lang.WinterGram_EnterPasscode, value: nil, placeholder: "••••", characterLimit: 16, apply: { value in + if (value ?? "") == passcode { + pushControllerImpl?(winterGramStashController(context: context)) + } + }) + presentControllerImpl?(controller, nil) + } + }, + editStashPasscode: { + let lang = context.sharedContext.currentPresentationData.with { $0 }.strings + let current = currentWinterGramSettings.stashPasscode + let controller = promptController(context: context, text: lang.WinterGram_StashPasscode, value: current.isEmpty ? nil : current, placeholder: lang.WinterGram_EmptyNoPasscode, characterLimit: 16, apply: { value in + updateSettings { var s = $0; s.stashPasscode = (value ?? "").trimmingCharacters(in: .whitespaces); return s } + }) + presentControllerImpl?(controller, nil) + }, + editDeletedMark: { + let lang = context.sharedContext.currentPresentationData.with { $0 }.strings + let current = currentWinterGramSettings.deletedMark + let controller = promptController(context: context, text: lang.WinterGram_DeletedMark, value: current.isEmpty ? nil : current, placeholder: "🧹", characterLimit: 8, apply: { value in + updateSettings { var s = $0; s.deletedMark = (value ?? "").trimmingCharacters(in: .whitespaces); return s } + }) + presentControllerImpl?(controller, nil) + }, + openUrl: { url in + context.sharedContext.applicationBindings.openUrl(url) + }, + clearDeleted: { + let _ = (winterGramClearDeletedMessages(postbox: context.account.postbox) + |> deliverOnMainQueue).startStandalone(next: { _ in + refreshDeletedCount?() }) } ) + let deletedCountPromise = Promise() + deletedCountPromise.set(winterGramDeletedMessagesCount(postbox: context.account.postbox)) + refreshDeletedCount = { + deletedCountPromise.set(winterGramDeletedMessagesCount(postbox: context.account.postbox)) + } + let signal = combineLatest(queue: .mainQueue(), context.sharedContext.presentationData, - winterGramSettings(accountManager: accountManager) + winterGramSettings(accountManager: accountManager), + deletedCountPromise.get(), + selectedCategoryPromise.get(), + expandedDropdownPromise.get(), + ghostExpandedPromise.get(), + spoofTemplatesEditingPromise.get(), + selectedTemplatesPromise.get() ) |> deliverOnMainQueue - |> map { presentationData, settings -> (ItemListControllerState, (ItemListNodeState, Any)) in - let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text("WinterGram"), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)) - let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: winterGramSettingsEntries(settings: settings), style: .blocks, animateChanges: true) + |> map { presentationData, settings, deletedCount, selectedCategory, expandedDropdown, ghostExpanded, spoofTemplatesEditing, selectedTemplates -> (ItemListControllerState, (ItemListNodeState, Any)) in + let lang = presentationData.strings + let controllerState: ItemListControllerState + if let category = category { + let controllerStateTitle = wntOption(category.title, presentationData.strings) + let rightNavigationButton: ItemListNavigationButton? + if category == .spoofing, !settings.spoofPresets.isEmpty { + rightNavigationButton = ItemListNavigationButton(content: .text(spoofTemplatesEditing ? presentationData.strings.Common_Done : presentationData.strings.Common_Edit), style: spoofTemplatesEditing ? .bold : .regular, enabled: true, action: { + let nextValue = !spoofTemplatesEditingValue.with { $0 } + let _ = spoofTemplatesEditingValue.swap(nextValue) + if !nextValue { + let _ = selectedTemplatesValue.swap(Set()) + selectedTemplatesPromise.set(Set()) + } + spoofTemplatesEditingPromise.set(nextValue) + }) + } else { + rightNavigationButton = nil + } + controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(controllerStateTitle), leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)) + } else { + let titles = sectionTabs.map { wntOption($0.title, lang) } + let selectedIndex = sectionTabs.firstIndex(of: selectedCategory) ?? 0 + controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .sectionControl(titles, selectedIndex), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)) + } + let activeCategory = category ?? selectedCategory + let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: winterGramSettingsEntries(presentationData: presentationData, settings: settings, deletedCount: deletedCount, expandedDropdown: expandedDropdown, ghostExpanded: ghostExpanded, spoofTemplatesEditing: spoofTemplatesEditing, selectedTemplates: selectedTemplates, category: activeCategory), style: .blocks, animateChanges: true) return (controllerState, (listState, arguments)) } let controller = ItemListController(context: context, state: signal) + controller.titleControlValueChanged = { index in + selectedCategoryPromise.set(sectionTabs[index]) + } + + var bannerController: UndoOverlayController? + let restartBannerDisposable = (requiresRestartPromise.get() + |> deliverOnMainQueue).start(next: { [weak controller] needsRestart in + guard let controller = controller else { return } + if needsRestart { + if bannerController == nil { + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let banner = UndoOverlayController(presentationData: presentationData, content: .info(title: presentationData.strings.WinterGram_RestartRequired, text: presentationData.strings.WinterGram_SomeSettingsWillTakeEffectAfterRestart, timeout: nil, customUndoText: presentationData.strings.WinterGram_Restart), elevatedLayout: false, action: { action in + if action == .undo { + exit(0) + } + return true + }) + bannerController = banner + controller.present(banner, in: .window(.root)) + } + } else { + bannerController?.dismiss() + bannerController = nil + } + }) + presentControllerImpl = { [weak controller] c, a in 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) + } return controller } diff --git a/submodules/SettingsUI/Sources/WinterGramStashController.swift b/submodules/SettingsUI/Sources/WinterGramStashController.swift new file mode 100644 index 0000000000..c25af8528d --- /dev/null +++ b/submodules/SettingsUI/Sources/WinterGramStashController.swift @@ -0,0 +1,193 @@ +import Foundation +import UIKit +import Display +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData +import TelegramUIPreferences +import ItemListUI +import ItemListPeerItem +import PresentationDataUtils +import AccountContext + +private final class WinterGramStashArguments { + let context: AccountContext + let openPeer: (EnginePeer) -> Void + let removePeer: (EnginePeer.Id) -> Void + let updateRevealedPeerId: (EnginePeer.Id?) -> Void + + init( + context: AccountContext, + openPeer: @escaping (EnginePeer) -> Void, + removePeer: @escaping (EnginePeer.Id) -> Void, + updateRevealedPeerId: @escaping (EnginePeer.Id?) -> Void + ) { + self.context = context + self.openPeer = openPeer + self.removePeer = removePeer + self.updateRevealedPeerId = updateRevealedPeerId + } +} + +private enum WinterGramStashSection: Int32 { + case peers +} + +private enum WinterGramStashEntry: ItemListNodeEntry { + case header + case peer(Int32, EnginePeer, Bool) + case empty + + var section: ItemListSectionId { + return WinterGramStashSection.peers.rawValue + } + + var stableId: Int64 { + switch self { + case .header: + return 0 + case let .peer(_, peer, _): + return peer.id.id._internalGetInt64Value() + case .empty: + return 1 + } + } + + static func ==(lhs: WinterGramStashEntry, rhs: WinterGramStashEntry) -> Bool { + switch lhs { + case .header: + if case .header = rhs { + return true + } + return false + case let .peer(lhsIndex, lhsPeer, lhsRevealed): + if case let .peer(rhsIndex, rhsPeer, rhsRevealed) = rhs, lhsIndex == rhsIndex, lhsPeer == rhsPeer, lhsRevealed == rhsRevealed { + return true + } + return false + case .empty: + if case .empty = rhs { + return true + } + return false + } + } + + static func <(lhs: WinterGramStashEntry, rhs: WinterGramStashEntry) -> Bool { + return lhs.sortIndex < rhs.sortIndex + } + + private var sortIndex: Int32 { + switch self { + case .header: + return 0 + case let .peer(index, _, _): + return 10 + index + case .empty: + return 1 + } + } + + func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { + let arguments = arguments as! WinterGramStashArguments + switch self { + case .header: + return ItemListTextItem(presentationData: presentationData, text: .plain(presentationData.strings.WinterGram_HiddenArchiveInfo), sectionId: self.section) + case let .peer(_, peer, revealed): + let chatPresentationData = arguments.context.sharedContext.currentPresentationData.with { $0 } + return ItemListPeerItem(presentationData: presentationData, dateTimeFormat: chatPresentationData.dateTimeFormat, nameDisplayOrder: chatPresentationData.nameDisplayOrder, context: arguments.context, peer: peer, presence: nil, text: .none, label: .none, editing: ItemListPeerItemEditing(editable: true, editing: false, revealed: revealed), switchValue: nil, enabled: true, selectable: true, sectionId: self.section, action: { + arguments.openPeer(peer) + }, setPeerIdWithRevealedOptions: { peerId, _ in + arguments.updateRevealedPeerId(peerId) + }, removePeer: { peerId in + arguments.removePeer(peerId) + }) + case .empty: + return ItemListTextItem(presentationData: presentationData, text: .plain(presentationData.strings.WinterGram_HiddenArchiveEmpty), sectionId: self.section) + } + } +} + +public func winterGramStashController(context: AccountContext) -> ViewController { + let revealedPeerId = ValuePromise(nil) + let revealedPeerIdValue = Atomic(value: nil) + + let accountManager = context.sharedContext.accountManager + + let arguments = WinterGramStashArguments( + context: context, + openPeer: { peer in + if let navigationController = context.sharedContext.mainWindow?.viewController as? NavigationController { + context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer))) + } + }, + removePeer: { peerId in + let rawId = peerId.toInt64() + let _ = updateWinterGramSettingsInteractively(accountManager: accountManager, { settings in + var settings = settings + settings.stashedPeerIds.removeAll(where: { $0 == rawId }) + return settings + }).start() + // Remove the peer from the server-side privacy exceptions added when it was stashed. + let _ = winterGramApplyStashPrivacy(engine: context.engine, peerId: peerId, stashed: false, privacySettings: currentWinterGramSettings.stashPrivacy).startStandalone() + }, + updateRevealedPeerId: { peerId in + let _ = revealedPeerIdValue.swap(peerId) + revealedPeerId.set(peerId) + } + ) + + let peers = winterGramSettings(accountManager: accountManager) + |> map { settings -> [EnginePeer.Id] in + var seen = Set() + var result: [EnginePeer.Id] = [] + for rawPeerId in settings.stashedPeerIds { + if seen.insert(rawPeerId).inserted { + result.append(EnginePeer.Id(rawPeerId)) + } + } + return result + } + |> distinctUntilChanged + |> mapToSignal { peerIds -> Signal<[EnginePeer], NoError> in + return context.engine.data.subscribe( + EngineDataMap(peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:))) + ) + |> map { peerMap -> [EnginePeer] in + var result: [EnginePeer] = [] + for id in peerIds { + if let maybePeer = peerMap[id], let peer = maybePeer { + result.append(peer) + } + } + return result + } + } + + let signal = combineLatest( + context.sharedContext.presentationData, + peers, + revealedPeerId.get() + ) + |> map { presentationData, peers, revealedPeerId -> (ItemListControllerState, (ItemListNodeState, Any)) in + var entries: [WinterGramStashEntry] = [] + entries.append(.header) + if peers.isEmpty { + entries.append(.empty) + } else { + var index: Int32 = 0 + for peer in peers { + entries.append(.peer(index, peer, revealedPeerId == peer.id)) + index += 1 + } + } + + let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(presentationData.strings.WinterGram_HiddenArchive), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)) + let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks) + + return (controllerState, (listState, arguments)) + } + + let controller = ItemListController(context: context, state: signal) + return controller +} diff --git a/submodules/TelegramCore/Sources/Account/AccountManager.swift b/submodules/TelegramCore/Sources/Account/AccountManager.swift index 24f9d915ae..2037a94031 100644 --- a/submodules/TelegramCore/Sources/Account/AccountManager.swift +++ b/submodules/TelegramCore/Sources/Account/AccountManager.swift @@ -217,6 +217,9 @@ private var declaredEncodables: Void = { declareEncodable(AuthSessionInfoAttribute.self, f: { AuthSessionInfoAttribute(decoder: $0) }) declareEncodable(TranslationMessageAttribute.self, f: { TranslationMessageAttribute(decoder: $0) }) declareEncodable(TranslationMessageAttribute.Additional.self, f: { TranslationMessageAttribute.Additional(decoder: $0) }) + declareEncodable(WinterGramEditHistoryAttribute.self, f: { WinterGramEditHistoryAttribute(decoder: $0) }) + declareEncodable(WinterGramEditHistoryAttribute.Revision.self, f: { WinterGramEditHistoryAttribute.Revision(decoder: $0) }) + declareEncodable(WinterGramDeletedMessageAttribute.self, f: { WinterGramDeletedMessageAttribute(decoder: $0) }) declareEncodable(SynchronizeAutosaveItemOperation.self, f: { SynchronizeAutosaveItemOperation(decoder: $0) }) declareEncodable(TelegramMediaStory.self, f: { TelegramMediaStory(decoder: $0) }) declareEncodable(SynchronizeViewStoriesOperation.self, f: { SynchronizeViewStoriesOperation(decoder: $0) }) @@ -277,7 +280,7 @@ public func performAppGroupUpgrades(appGroupPath: String, rootPath: String) { } } } - + do { // WinterGram: include account data in the device's iCloud/iTunes backup so accounts // survive a restore. Tradeoff: session auth keys are then part of the iCloud backup. @@ -343,10 +346,10 @@ public func currentAccount(allocateIfNotExists: Bool, networkArguments: NetworkI } } |> distinctUntilChanged - + return Signal { subscriber in subscriber.putNext(accountResult) - + return updatedKind.start(next: { value in if value { reload.set(true) @@ -413,19 +416,19 @@ public func managedCleanupAccounts(networkArguments: NetworkInitializationArgume } } } - + var disposables = disposables - + for id in disposables.keys { if validIds[id] == nil { disposeList.append((id, disposables[id]!)) } } - + for (id, _) in disposeList { disposables.removeValue(forKey: id) } - + for (id, attributes) in validIds { if disposables[id] == nil { let disposable = MetaDisposable() @@ -433,7 +436,7 @@ public func managedCleanupAccounts(networkArguments: NetworkInitializationArgume disposables[id] = disposable } } - + return disposables } for (_, disposable) in disposeList { @@ -443,7 +446,7 @@ public func managedCleanupAccounts(networkArguments: NetworkInitializationArgume Logger.shared.log("managedCleanupAccounts", "cleanup \(id), current is \(String(describing: view.currentRecord?.id))") disposable.set(cleanupAccount(networkArguments: networkArguments, accountManager: accountManager, id: id, encryptionParameters: encryptionParameters, attributes: attributes, rootPath: rootPath, auxiliaryMethods: auxiliaryMethods).start()) } - + var validPaths = Set() for record in view.records { if let temporarySessionId = record.temporarySessionId, temporarySessionId != currentTemporarySessionId { @@ -454,7 +457,7 @@ public func managedCleanupAccounts(networkArguments: NetworkInitializationArgume if let record = view.currentAuthAccount { validPaths.insert("\(accountRecordIdPathName(record.id))") } - + DispatchQueue.global(qos: .utility).async { if let files = try? FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: rootPath), includingPropertiesForKeys: [], options: []) { for url in files { @@ -467,7 +470,7 @@ public func managedCleanupAccounts(networkArguments: NetworkInitializationArgume } } }) - + return ActionDisposable { disposable.dispose() } diff --git a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift index d80793f3b2..840a50e907 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift @@ -8,7 +8,7 @@ import EncryptionProvider private func reactionGeneratedEvent(_ previousReactions: ReactionsMessageAttribute?, _ updatedReactions: ReactionsMessageAttribute?, message: Message, transaction: Transaction) -> (reactionAuthor: Peer, reaction: MessageReaction.Reaction, message: Message, timestamp: Int32)? { if let updatedReactions = updatedReactions, !message.flags.contains(.Incoming), message.id.peerId.namespace == Namespaces.Peer.CloudUser { let prev = previousReactions?.reactions ?? [] - + let updated = updatedReactions.reactions.filter { value in return !prev.contains(where: { $0.value == value.value && $0.count == value.count @@ -20,18 +20,18 @@ private func reactionGeneratedEvent(_ previousReactions: ReactionsMessageAttribu let myPrevious = prev.filter { value in return value.chosenOrder != nil }.first - + let previousCount = prev.reduce(0, { $0 + $1.count }) let updatedCount = updatedReactions.reactions.reduce(0, { $0 + $1.count }) - + let newReaction = updated.filter { $0.chosenOrder == nil }.first?.value - + if !updated.isEmpty && myUpdated == myPrevious, updatedCount >= previousCount, let value = newReaction { if let reactionAuthor = transaction.getPeer(message.id.peerId) { return (reactionAuthor: reactionAuthor, reaction: value, message: message, timestamp: Int32(Date().timeIntervalSince1970)) @@ -44,7 +44,7 @@ private func reactionGeneratedEvent(_ previousReactions: ReactionsMessageAttribu private func peerIdsFromUpdateGroups(_ groups: [UpdateGroup]) -> Set { var peerIds = Set() - + for group in groups { for update in group.updates { for peerId in update.peerIds { @@ -64,13 +64,13 @@ private func peerIdsFromUpdateGroups(_ groups: [UpdateGroup]) -> Set { break } } - + return peerIds } private func activeChannelsFromUpdateGroups(_ groups: [UpdateGroup]) -> Set { var peerIds = Set() - + for group in groups { for chat in group.chats { switch chat { @@ -85,14 +85,14 @@ private func activeChannelsFromUpdateGroups(_ groups: [UpdateGroup]) -> Set (replyIds: ReferencedReplyMessageIds, generalIds: Set) { var replyIds = ReferencedReplyMessageIds() var generalIds = Set() - + for group in groups { for update in group.updates { if let associatedMessageIds = update.associatedMessageIds { @@ -101,7 +101,7 @@ private func associatedMessageIdsFromUpdateGroups(_ groups: [UpdateGroup]) -> (r } } } - + return (replyIds, generalIds) } @@ -147,7 +147,7 @@ private func peerIdsRequiringLocalChatStateFromUpdates(_ updates: [Api.Update]) private func peerIdsRequiringLocalChatStateFromUpdateGroups(_ groups: [UpdateGroup]) -> Set { var peerIds = Set() - + for group in groups { peerIds.formUnion(peerIdsRequiringLocalChatStateFromUpdates(group.updates)) @@ -169,7 +169,7 @@ private func peerIdsRequiringLocalChatStateFromUpdateGroups(_ groups: [UpdateGro } } } - + switch group { case let .ensurePeerHasLocalState(peerId): peerIds.insert(peerId) @@ -177,7 +177,7 @@ private func peerIdsRequiringLocalChatStateFromUpdateGroups(_ groups: [UpdateGro break } } - + return peerIds } @@ -200,27 +200,27 @@ private func locallyGeneratedMessageTimestampsFromUpdateGroups(_ groups: [Update } } } - + return messageTimestamps } private func associatedStoredStories(_ groups: [UpdateGroup]) -> [StoryId: UpdatesStoredStory] { var storedStories: [StoryId: UpdatesStoredStory] = [:] storedStories.removeAll() - + return storedStories } private func associatedStoredStories(_ difference: Api.updates.Difference) -> [StoryId: UpdatesStoredStory] { var storedStories: [StoryId: UpdatesStoredStory] = [:] storedStories.removeAll() - + return storedStories } private func peerIdsFromDifference(_ difference: Api.updates.Difference) -> Set { var peerIds = Set() - + switch difference { case let .difference(differenceData): let (newMessages, _, otherUpdates, chats, users, _) = (differenceData.newMessages, differenceData.newEncryptedMessages, differenceData.otherUpdates, differenceData.chats, differenceData.users, differenceData.state) @@ -264,13 +264,13 @@ private func peerIdsFromDifference(_ difference: Api.updates.Difference) -> Set< assertionFailure() break } - + return peerIds } private func activeChannelsFromDifference(_ difference: Api.updates.Difference) -> Set { var peerIds = Set() - + var chats: [Api.Chat] = [] switch difference { case let .difference(differenceData): @@ -284,7 +284,7 @@ private func activeChannelsFromDifference(_ difference: Api.updates.Difference) case .differenceTooLong: break } - + for chat in chats { switch chat { case .channel: @@ -297,14 +297,14 @@ private func activeChannelsFromDifference(_ difference: Api.updates.Difference) break } } - + return peerIds } private func associatedMessageIdsFromDifference(_ difference: Api.updates.Difference) -> (replyIds: ReferencedReplyMessageIds, generalIds: Set) { var replyIds = ReferencedReplyMessageIds() var generalIds = Set() - + switch difference { case let .difference(differenceData): let (newMessages, _, otherUpdates, _, _, _) = (differenceData.newMessages, differenceData.newEncryptedMessages, differenceData.otherUpdates, differenceData.chats, differenceData.users, differenceData.state) @@ -346,7 +346,7 @@ private func associatedMessageIdsFromDifference(_ difference: Api.updates.Differ private func peerIdsRequiringLocalChatStateFromDifference(_ difference: Api.updates.Difference) -> Set { var peerIds = Set() - + switch difference { case let .difference(differenceData): let (newMessages, _, otherUpdates, _, _, _) = (differenceData.newMessages, differenceData.newEncryptedMessages, differenceData.otherUpdates, differenceData.chats, differenceData.users, differenceData.state) @@ -402,13 +402,13 @@ private func peerIdsRequiringLocalChatStateFromDifference(_ difference: Api.upda case .differenceTooLong: break } - + return peerIds } private func locallyGeneratedMessageTimestampsFromDifference(_ difference: Api.updates.Difference) -> [PeerId: [(MessageId.Namespace, Int32)]] { var messageTimestamps: [PeerId: [(MessageId.Namespace, Int32)]] = [:] - + var otherUpdates: [Api.Update]? switch difference { case let .difference(differenceData): @@ -422,7 +422,7 @@ private func locallyGeneratedMessageTimestampsFromDifference(_ difference: Api.u case .differenceTooLong: break } - + if let otherUpdates = otherUpdates { for update in otherUpdates { switch update { @@ -440,21 +440,21 @@ private func locallyGeneratedMessageTimestampsFromDifference(_ difference: Api.u } } } - + return messageTimestamps } func initialStateWithPeerIds(_ transaction: Transaction, peerIds: Set, activeChannelIds: Set, referencedReplyMessageIds: ReferencedReplyMessageIds, referencedGeneralMessageIds: Set, peerIdsRequiringLocalChatState: Set, locallyGeneratedMessageTimestamps: [PeerId: [(MessageId.Namespace, Int32)]], storedStories: [StoryId: UpdatesStoredStory]) -> AccountMutableState { var peers: [PeerId: Peer] = [:] var channelStates: [PeerId: AccountStateChannelState] = [:] - + var channelsToPollExplicitely = Set() - + for peerId in peerIds { if let peer = transaction.getPeer(peerId) { peers[peerId] = peer } - + if peerId.namespace == Namespaces.Peer.CloudChannel { if let channelState = transaction.getPeerChatState(peerId) as? ChannelState { channelStates[peerId] = AccountStateChannelState(pts: channelState.pts) @@ -465,7 +465,7 @@ func initialStateWithPeerIds(_ transaction: Transaction, peerIds: Set, a } } } - + for peerId in activeChannelIds { if transaction.getTopPeerMessageIndex(peerId: peerId, namespace: Namespaces.Message.Cloud) == nil { channelsToPollExplicitely.insert(peerId) @@ -473,9 +473,9 @@ func initialStateWithPeerIds(_ transaction: Transaction, peerIds: Set, a channelsToPollExplicitely.insert(peerId) } } - + let storedMessages = transaction.filterStoredMessageIds(Set(referencedReplyMessageIds.targetIdsBySourceId.keys).union(referencedGeneralMessageIds)) - + var storedMessagesByPeerIdAndTimestamp: [PeerId: Set] = [:] if !locallyGeneratedMessageTimestamps.isEmpty { for (peerId, namespacesAndTimestamps) in locallyGeneratedMessageTimestamps { @@ -490,11 +490,11 @@ func initialStateWithPeerIds(_ transaction: Transaction, peerIds: Set, a } } } - + var peerChatInfos: [PeerId: PeerChatInfo] = [:] var readInboxMaxIds: [PeerId: MessageId] = [:] var cloudReadStates: [PeerId: PeerReadState] = [:] - + for peerId in peerIdsRequiringLocalChatState { let inclusion = transaction.getPeerChatListInclusion(peerId) var hasValidInclusion = false @@ -537,7 +537,7 @@ func initialStateWithPeerIds(_ transaction: Transaction, peerIds: Set, a } } } - + let state = AccountMutableState(initialState: AccountInitialState(state: (transaction.getState() as? AuthorizedAccountState)!.state!, peerIds: peerIds, peerIdsRequiringLocalChatState: peerIdsRequiringLocalChatState, channelStates: channelStates, peerChatInfos: peerChatInfos, locallyGeneratedMessageTimestamps: locallyGeneratedMessageTimestamps, cloudReadStates: cloudReadStates, channelsToPollExplicitely: channelsToPollExplicitely), initialPeers: peers, initialReferencedReplyMessageIds: referencedReplyMessageIds, initialReferencedGeneralMessageIds: referencedGeneralMessageIds, initialStoredMessages: storedMessages, initialStoredStories: storedStories, initialReadInboxMaxIds: readInboxMaxIds, storedMessagesByPeerIdAndTimestamp: storedMessagesByPeerIdAndTimestamp, initialSentScheduledMessageIds: Set()) return state } @@ -548,7 +548,7 @@ func initialStateWithUpdateGroups(postbox: Postbox, groups: [UpdateGroup]) -> Si let activeChannelIds = activeChannelsFromUpdateGroups(groups) let associatedMessageIds = associatedMessageIdsFromUpdateGroups(groups) let peerIdsRequiringLocalChatState = peerIdsRequiringLocalChatStateFromUpdateGroups(groups) - + return initialStateWithPeerIds(transaction, peerIds: peerIds, activeChannelIds: activeChannelIds, referencedReplyMessageIds: associatedMessageIds.replyIds, referencedGeneralMessageIds: associatedMessageIds.generalIds, peerIdsRequiringLocalChatState: peerIdsRequiringLocalChatState, locallyGeneratedMessageTimestamps: locallyGeneratedMessageTimestampsFromUpdateGroups(groups), storedStories: associatedStoredStories(groups)) } } @@ -565,28 +565,28 @@ func initialStateWithDifference(postbox: Postbox, difference: Api.updates.Differ func finalStateWithUpdateGroups(accountPeerId: PeerId, postbox: Postbox, network: Network, state: AccountMutableState, groups: [UpdateGroup], asyncResetChannels: (([(peer: Peer, pts: Int32?)]) -> Void)?) -> Signal { var updatedState = state - + var hadReset = false var ptsUpdatesAfterHole: [PtsUpdate] = [] var qtsUpdatesAfterHole: [QtsUpdate] = [] var seqGroupsAfterHole: [SeqUpdates] = [] - + for case .reset in groups { hadReset = true break } - + var currentPtsUpdates = ptsUpdates(groups) currentPtsUpdates.sort(by: { $0.ptsRange.0 < $1.ptsRange.0 }) - + var currentQtsUpdates = qtsUpdates(groups) currentQtsUpdates.sort(by: { $0.qtsRange.0 < $1.qtsRange.0 }) - + var currentSeqGroups = seqGroups(groups) currentSeqGroups.sort(by: { $0.seqRange.0 < $1.seqRange.0 }) - + var collectedUpdates: [Api.Update] = [] - + for update in currentPtsUpdates { if updatedState.state.pts >= update.ptsRange.0 { if let update = update.update, case .updateWebPage = update { @@ -596,14 +596,14 @@ func finalStateWithUpdateGroups(accountPeerId: PeerId, postbox: Postbox, network } else if ptsUpdatesAfterHole.count == 0 && updatedState.state.pts == update.ptsRange.0 - update.ptsRange.1 { //TODO: apply pts update - + updatedState.mergeChats(update.chats) updatedState.mergeUsers(update.users) - + if let ptsUpdate = update.update { collectedUpdates.append(ptsUpdate) } - + updatedState.updateState(AuthorizedAccountState.State(pts: update.ptsRange.0, qts: updatedState.state.qts, date: updatedState.state.date, seq: updatedState.state.seq)) } else { if ptsUpdatesAfterHole.count == 0 { @@ -612,18 +612,18 @@ func finalStateWithUpdateGroups(accountPeerId: PeerId, postbox: Postbox, network ptsUpdatesAfterHole.append(update) } } - + for update in currentQtsUpdates { if updatedState.state.qts >= update.qtsRange.0 + update.qtsRange.1 { //skip old update } else if qtsUpdatesAfterHole.count == 0 && updatedState.state.qts == update.qtsRange.0 - update.qtsRange.1 { //TODO apply qts update - + updatedState.mergeChats(update.chats) updatedState.mergeUsers(update.users) - + collectedUpdates.append(update.update) - + updatedState.updateState(AuthorizedAccountState.State(pts: updatedState.state.pts, qts: update.qtsRange.0, date: updatedState.state.date, seq: updatedState.state.seq)) } else { if qtsUpdatesAfterHole.count == 0 { @@ -632,16 +632,16 @@ func finalStateWithUpdateGroups(accountPeerId: PeerId, postbox: Postbox, network qtsUpdatesAfterHole.append(update) } } - + for group in currentSeqGroups { if updatedState.state.seq >= group.seqRange.0 + group.seqRange.1 { //skip old update } else if seqGroupsAfterHole.count == 0 && updatedState.state.seq == group.seqRange.0 - group.seqRange.1 { collectedUpdates.append(contentsOf: group.updates) - + updatedState.mergeChats(group.chats) updatedState.mergeUsers(group.users) - + updatedState.updateState(AuthorizedAccountState.State(pts: updatedState.state.pts, qts: updatedState.state.qts, date: group.date, seq: group.seqRange.0)) } else { if seqGroupsAfterHole.count == 0 { @@ -650,7 +650,7 @@ func finalStateWithUpdateGroups(accountPeerId: PeerId, postbox: Postbox, network seqGroupsAfterHole.append(group) } } - + var currentDateGroups = dateGroups(groups) currentDateGroups.sort(by: { group1, group2 -> Bool in switch group1 { @@ -665,14 +665,14 @@ func finalStateWithUpdateGroups(accountPeerId: PeerId, postbox: Postbox, network return false } }) - + var updatesDate: Int32? - + for group in currentDateGroups { switch group { case let .withDate(updates, date, users, chats): collectedUpdates.append(contentsOf: updates) - + updatedState.mergeChats(chats) updatedState.mergeUsers(users) if updatesDate == nil { @@ -682,23 +682,23 @@ func finalStateWithUpdateGroups(accountPeerId: PeerId, postbox: Postbox, network break } } - + for case let .updateChannelPts(channelId, pts, ptsCount) in groups { collectedUpdates.append(Api.Update.updateDeleteChannelMessages(.init(channelId: channelId, messages: [], pts: pts, ptsCount: ptsCount))) } - + return finalStateWithUpdates(accountPeerId: accountPeerId, postbox: postbox, network: network, state: updatedState, updates: collectedUpdates, shouldPoll: hadReset, missingUpdates: !ptsUpdatesAfterHole.isEmpty || !qtsUpdatesAfterHole.isEmpty || !seqGroupsAfterHole.isEmpty, shouldResetChannels: false, updatesDate: updatesDate, asyncResetChannels: asyncResetChannels) } func finalStateWithDifference(accountPeerId: PeerId, postbox: Postbox, network: Network, state: AccountMutableState, difference: Api.updates.Difference, asyncResetChannels: (([(peer: Peer, pts: Int32?)]) -> Void)?) -> Signal { var updatedState = state - + var messages: [Api.Message] = [] var encryptedMessages: [Api.EncryptedMessage] = [] var updates: [Api.Update] = [] var chats: [Api.Chat] = [] var users: [Api.User] = [] - + switch difference { case let .difference(differenceData): let (newMessages, newEncryptedMessages, otherUpdates, apiChats, apiUsers, apiState) = (differenceData.newMessages, differenceData.newEncryptedMessages, differenceData.otherUpdates, differenceData.chats, differenceData.users, differenceData.state) @@ -731,10 +731,10 @@ func finalStateWithDifference(accountPeerId: PeerId, postbox: Postbox, network: assertionFailure() break } - + updatedState.mergeChats(chats) updatedState.mergeUsers(users) - + let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) for message in messages { if let preCachedResources = message.preCachedResources { @@ -753,24 +753,24 @@ func finalStateWithDifference(accountPeerId: PeerId, postbox: Postbox, network: } if let message = StoreMessage(apiMessage: message, accountPeerId: accountPeerId, peerIsForum: peerIsForum) { updatedState.addMessages([message], location: .UpperHistoryBlock) - + if let reportDeliveryAttribute = message.attributes.first(where: { $0 is ReportDeliveryMessageAttribute }) as? ReportDeliveryMessageAttribute, case let .Id(id) = message.id, reportDeliveryAttribute.untilDate > currentTime { updatedState.addReportMessageDelivery(messageIds: [id]) } } } - + if !encryptedMessages.isEmpty { updatedState.addSecretMessages(encryptedMessages) } - + return finalStateWithUpdates(accountPeerId: accountPeerId, postbox: postbox, network: network, state: updatedState, updates: updates, shouldPoll: false, missingUpdates: false, shouldResetChannels: true, updatesDate: nil, asyncResetChannels: asyncResetChannels) } private func sortedUpdates(_ updates: [Api.Update]) -> [Api.Update] { var otherUpdates: [Api.Update] = [] var updatesByChannel: [PeerId: [Api.Update]] = [:] - + for update in updates { switch update { case let .updateChannelTooLong(updateChannelTooLongData): @@ -846,14 +846,14 @@ private func sortedUpdates(_ updates: [Api.Update]) -> [Api.Update] { otherUpdates.append(update) } } - + var result: [Api.Update] = [] - + for (_, updates) in updatesByChannel { let sortedUpdates = updates.sorted(by: { lhs, rhs in var lhsPts: Int32? var rhsPts: Int32? - + switch lhs { case let .updateDeleteChannelMessages(updateDeleteChannelMessagesData): lhsPts = updateDeleteChannelMessagesData.pts @@ -868,7 +868,7 @@ private func sortedUpdates(_ updates: [Api.Update]) -> [Api.Update] { default: break } - + switch rhs { case let .updateDeleteChannelMessages(updateDeleteChannelMessagesData): rhsPts = updateDeleteChannelMessagesData.pts @@ -883,7 +883,7 @@ private func sortedUpdates(_ updates: [Api.Update]) -> [Api.Update] { default: break } - + if let lhsPts = lhsPts, let rhsPts = rhsPts { return lhsPts < rhsPts } else if let _ = lhsPts { @@ -895,7 +895,7 @@ private func sortedUpdates(_ updates: [Api.Update]) -> [Api.Update] { result.append(contentsOf: sortedUpdates) } result.append(contentsOf: otherUpdates) - + return result } @@ -906,12 +906,12 @@ private func finalStateWithUpdates(accountPeerId: PeerId, postbox: Postbox, netw return finalStateWithUpdatesAndServerTime(accountPeerId: accountPeerId, postbox: postbox, network: network, state: state, updates: updates, shouldPoll: shouldPoll, missingUpdates: missingUpdates, shouldResetChannels: shouldResetChannels, updatesDate: updatesDate, serverTime: Int32(serverTime), asyncResetChannels: asyncResetChannels) } } - + private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: Postbox, network: Network, state: AccountMutableState, updates: [Api.Update], shouldPoll: Bool, missingUpdates: Bool, shouldResetChannels: Bool, updatesDate: Int32?, serverTime: Int32, asyncResetChannels: (([(peer: Peer, pts: Int32?)]) -> Void)?) -> Signal { var updatedState = state - + var channelsToPoll: [PeerId: Int32?] = [:] - + if !updatedState.initialState.channelsToPollExplicitely.isEmpty { for peerId in updatedState.initialState.channelsToPollExplicitely { if case .none = channelsToPoll[peerId] { @@ -919,16 +919,16 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: } } } - + let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) - + var missingUpdatesFromChannels = Set() - + enum TypingDraftText { case plain(Api.TextWithEntities) case rich(Api.RichMessage) } - + for update in sortedUpdates(updates) { switch update { case let .updateChannelTooLong(updateChannelTooLongData): @@ -1029,7 +1029,7 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: updatedState.updateMedia(webpage.webpageId, media: webpage) } } - + updatedState.updateChannelState(peerId, pts: pts) } else { if case .none = channelsToPoll[peerId] { @@ -1146,7 +1146,7 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: } } updatedState.addMessages([message], location: .UpperHistoryBlock) - + if let reportDeliveryAttribute = message.attributes.first(where: { $0 is ReportDeliveryMessageAttribute }) as? ReportDeliveryMessageAttribute, case let .Id(id) = message.id, reportDeliveryAttribute.untilDate > currentTime { updatedState.addReportMessageDelivery(messageIds: [id]) } @@ -1158,7 +1158,7 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: updatedState.addDisplayAlert(text, isDropAuth: type.hasPrefix("AUTH_KEY_DROP_")) } else if let date = date { let peerId = PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(777000)) - + if updatedState.peers[peerId] == nil { updatedState.updatePeer(peerId, { peer in if peer == nil { @@ -1168,7 +1168,7 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: } }) } - + var alreadyStored = false if let storedMessages = updatedState.storedMessagesByPeerIdAndTimestamp[peerId] { for index in storedMessages { @@ -1178,7 +1178,7 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: } } } - + if alreadyStored { Logger.shared.log("State", "skipping message at \(date) for \(peerId): already stored") } else { @@ -1188,11 +1188,11 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: } let messageText = text var medias: [Media] = [] - + let (mediaValue, expirationTimer, nonPremium, hasSpoiler, webpageAttributes, videoTimestamp) = textMediaAndExpirationTimerFromApiMedia(media, peerId) if let mediaValue = mediaValue { medias.append(mediaValue) - + if mediaValue is TelegramMediaWebpage { if let webpageAttributes = webpageAttributes { attributes.append(WebpagePreviewMessageAttribute(leadingPreview: false, forceLargeMedia: webpageAttributes.forceLargeMedia, isManuallyAdded: webpageAttributes.isManuallyAdded, isSafe: webpageAttributes.isSafe)) @@ -1205,15 +1205,15 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: if let videoTimestamp { attributes.append(ForwardVideoTimestampAttribute(timestamp: videoTimestamp)) } - + if let nonPremium = nonPremium, nonPremium { attributes.append(NonPremiumMessageAttribute()) } - + if let hasSpoiler = hasSpoiler, hasSpoiler { attributes.append(MediaSpoilerMessageAttribute()) } - + if type.hasPrefix("auth") { updatedState.authorizationListUpdated = true let string = type.dropFirst(4).components(separatedBy: "_") @@ -1221,7 +1221,7 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: attributes.append(AuthSessionInfoAttribute(hash: hash, timestamp: timestamp)) } } - + let message = StoreMessage(peerId: peerId, namespace: Namespaces.Message.Local, customStableId: nil, globallyUniqueId: nil, groupingKey: nil, threadId: nil, timestamp: date, flags: [.Incoming], tags: [], globalTags: [], localTags: [], forwardInfo: nil, authorId: peerId, text: messageText, attributes: attributes, media: medias) updatedState.addMessages([message], location: .UpperHistoryBlock) } @@ -1519,10 +1519,10 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: case let .updateUserTyping(updateUserTypingData): let (userId, topMsgId, type) = (updateUserTypingData.userId, updateUserTypingData.topMsgId, updateUserTypingData.action) let threadId = topMsgId.flatMap { Int64($0) } - + if let date = updatesDate, date + 60 > serverTime { var typingDraftData: (randomId: Int64, text: TypingDraftText)? - + if case let .sendMessageTextDraftAction(sendMessageTextDraftActionData) = type { typingDraftData = (sendMessageTextDraftActionData.randomId, .plain(sendMessageTextDraftActionData.text)) } else if case let .sendMessageRichMessageDraftAction(sendMessageRichMessageDraftActionData) = type { @@ -1544,7 +1544,7 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: if case .speakingInGroupCall = activity { category = .voiceChat } - + updatedState.addPeerInputActivity(chatPeerId: PeerActivitySpace(peerId: PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(userId)), category: category), peerId: PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(userId)), activity: activity) } } @@ -1556,7 +1556,7 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: if case .speakingInGroupCall = activity { category = .voiceChat } - + updatedState.addPeerInputActivity(chatPeerId: PeerActivitySpace(peerId: PeerId(namespace: Namespaces.Peer.CloudGroup, id: PeerId.Id._internalFromInt64Value(chatId)), category: category), peerId: userId.peerId, activity: activity) } case let .updateChannelUserTyping(updateChannelUserTypingData): @@ -1583,7 +1583,7 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: } else if let threadId = threadId { category = .thread(threadId) } - + updatedState.addPeerInputActivity(chatPeerId: PeerActivitySpace(peerId: channelPeerId, category: category), peerId: userId.peerId, activity: activity) } } @@ -1715,7 +1715,7 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: let (replyToMsgId, topMsgId, replyToPeerId, quoteText, quoteEntities, quoteOffset, monoforumPeerId, todoItemId, pollOption) = (inputReplyToMessageData.replyToMsgId, inputReplyToMessageData.topMsgId, inputReplyToMessageData.replyToPeerId, inputReplyToMessageData.quoteText, inputReplyToMessageData.quoteEntities, inputReplyToMessageData.quoteOffset, inputReplyToMessageData.monoforumPeerId, inputReplyToMessageData.todoItemId, inputReplyToMessageData.pollOption) let _ = topMsgId let _ = monoforumPeerId - + var quote: EngineMessageReplyQuote? if let quoteText = quoteText { quote = EngineMessageReplyQuote( @@ -1725,7 +1725,7 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: media: nil ) } - + var parsedReplyToPeerId: PeerId? switch replyToPeerId { case let .inputPeerChannel(inputPeerChannelData): @@ -1750,14 +1750,14 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: case .none: break } - + var innerSubject: EngineMessageReplyInnerSubject? if let todoItemId { innerSubject = .todoItem(todoItemId) } else if let pollOption { innerSubject = .pollOption(pollOption.makeData()) } - + replySubject = EngineMessageReplySubject( messageId: MessageId(peerId: parsedReplyToPeerId ?? peer.peerId, namespace: Namespaces.Message.Cloud, id: replyToMsgId), quote: quote, @@ -1960,7 +1960,7 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: } else if let topMsgId { threadId = Int64(topMsgId) } - + updatedState.updateMessageReactions(MessageId(peerId: peer.peerId, namespace: Namespaces.Message.Cloud, id: msgId), threadId: threadId, reactions: reactions, eventTimestamp: updatesDate) case .updateAttachMenuBots: updatedState.addUpdateAttachMenuBots() @@ -2071,7 +2071,7 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: break } } - + var pollChannelSignals: [Signal<(AccountMutableState, Bool, Int32?), NoError>] = [] if channelsToPoll.isEmpty && missingUpdatesFromChannels.isEmpty { pollChannelSignals = [] @@ -2084,7 +2084,7 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: Logger.shared.log("State", "can't reset channel \(peerId): no peer found") } } - + if let asyncResetChannels = asyncResetChannels { pollChannelSignals = [] asyncResetChannels(channelPeers.map({ peer -> (peer: Peer, pts: Int32?) in @@ -2114,12 +2114,12 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: } } } - + return combineLatest(pollChannelSignals) |> mapToSignal { states -> Signal in var finalState: AccountMutableState = updatedState var hadError = false - + if shouldResetChannels && states.count != 0 { assert(states.count == 1) finalState = states[0].0 @@ -2131,7 +2131,7 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: } } } - + return resolveForumThreads(accountPeerId: accountPeerId, postbox: postbox, source: .network(network), state: finalState) |> mapToSignal { finalState in return resolveAssociatedMessages(accountPeerId: accountPeerId, postbox: postbox, network: network, state: finalState) @@ -2153,7 +2153,7 @@ final class FetchedForumThreads { case savedDialog(Api.SavedDialog) case forum(Api.ForumTopic) } - + let items: [Item] let totalCount: Int let orderByDate: Bool @@ -2161,7 +2161,7 @@ final class FetchedForumThreads { let messages: [Api.Message] let users: [Api.User] let chats: [Api.Chat] - + init(items: [Item], totalCount: Int, orderByDate: Bool, pts: Int32?, messages: [Api.Message], users: [Api.User], chats: [Api.Chat]) { self.items = items self.totalCount = totalCount @@ -2171,7 +2171,7 @@ final class FetchedForumThreads { self.users = users self.chats = chats } - + convenience init(forumTopics: Api.messages.ForumTopics) { switch forumTopics { case let .forumTopics(forumTopicsData): @@ -2180,7 +2180,7 @@ final class FetchedForumThreads { self.init(items: topics.map(Item.forum), totalCount: Int(count), orderByDate: orderByDate, pts: pts, messages: messages, users: users, chats: chats) } } - + convenience init(savedDialogs: Api.messages.SavedDialogs) { switch savedDialogs { case let .savedDialogs(savedDialogsData): @@ -2197,7 +2197,7 @@ final class FetchedForumThreads { func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchMessageHistoryHoleSource, state: AccountMutableState) -> Signal { var forumThreadIds = Set() - + for operation in state.operations { switch operation { case let .AddMessages(messages, _): @@ -2216,7 +2216,7 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM break } } - + if forumThreadIds.isEmpty { return .single(state) } else { @@ -2228,7 +2228,7 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM missingForumThreadIds[threadId.peerId, default: []].append(threadId.threadId) } } - + if missingForumThreadIds.isEmpty { return .single(state) } else { @@ -2238,7 +2238,7 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM Logger.shared.log("State", "can't fetch thread infos \(threadIds) for peer \(peerId): can't create inputPeer") continue } - + if let peer = peer as? TelegramChannel, peer.flags.contains(.isMonoforum) { let signal = source.request(Api.functions.messages.getSavedDialogsByID(flags: 1 << 1, parentPeer: inputPeer, ids: threadIds.compactMap { threadId in let threadPeerId = PeerId(threadId) @@ -2269,27 +2269,27 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM signals.append(signal) } } - + return combineLatest(signals) |> map { results -> AccountMutableState in var state = state - + var storeMessages: [StoreMessage] = [] - + for maybeResult in results { if let (peer, result) = maybeResult { let peerIsForum = peer.isForum let peerId = peer.id - + state.mergeChats(result.chats) state.mergeUsers(result.users) - + for message in result.messages { if let message = StoreMessage(apiMessage: message, accountPeerId: accountPeerId, peerIsForum: peerIsForum) { storeMessages.append(message) } } - + for topic in result.items { switch topic { case let .forum(topic): @@ -2371,9 +2371,9 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM } } } - + state.addMessages(storeMessages, location: .Random) - + return state } } @@ -2384,7 +2384,7 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchMessageHistoryHoleSource, additionalPeers: AccumulatedPeers, ids: [PeerAndBoundThreadId]) -> Signal { let forumThreadIds = Set(ids) - + if forumThreadIds.isEmpty { return .single(Void()) } else { @@ -2396,7 +2396,7 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM missingForumThreadIds[threadId.peerId, default: []].append(threadId.threadId) } } - + if missingForumThreadIds.isEmpty { return .single(Void()) } else { @@ -2406,7 +2406,7 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM Logger.shared.log("State", "can't fetch thread infos \(threadIds) for peer \(peerId): can't create inputChannel") continue } - + if let peer = peer as? TelegramChannel, peer.flags.contains(.isMonoforum) { let signal = source.request(Api.functions.messages.getSavedDialogsByID(flags: 1 << 1, parentPeer: inputPeer, ids: threadIds.compactMap { threadId in let threadPeerId = PeerId(threadId) @@ -2437,28 +2437,28 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM signals.append(signal) } } - + return combineLatest(signals) |> mapToSignal { results -> Signal in return postbox.transaction { transaction in var chats: [Api.Chat] = [] var users: [Api.User] = [] var storeMessages: [StoreMessage] = [] - + for maybeResult in results { if let (peer, result) = maybeResult { let peerIsForum = peer.isForum let peerId = peer.id - + chats.append(contentsOf: result.chats) users.append(contentsOf: result.users) - + for message in result.messages { if let message = StoreMessage(apiMessage: message, accountPeerId: accountPeerId, peerIsForum: peerIsForum) { storeMessages.append(message) } } - + for item in result.items { switch item { case let .forum(topic): @@ -2490,7 +2490,7 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM if let entry = StoredMessageHistoryThreadInfo(data) { transaction.setMessageHistoryThreadInfo(peerId: peerId, threadId: Int64(id), info: entry) } - + transaction.replaceMessageTagSummary(peerId: peerId, threadId: Int64(id), tagMask: .unseenPersonalMessage, namespace: Namespaces.Message.Cloud, customTag: nil, count: unreadMentionsCount, maxId: topMessage) transaction.replaceMessageTagSummary(peerId: peerId, threadId: Int64(id), tagMask: .unseenReaction, namespace: Namespaces.Message.Cloud, customTag: nil, count: unreadReactionsCount, maxId: topMessage) transaction.replaceMessageTagSummary(peerId: peerId, threadId: Int64(id), tagMask: .unseenPollVote, namespace: Namespaces.Message.Cloud, customTag: nil, count: unreadPollVoteCount, maxId: topMessage) @@ -2523,7 +2523,7 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM if let entry = StoredMessageHistoryThreadInfo(data) { transaction.setMessageHistoryThreadInfo(peerId: peerId, threadId: peer.peerId.toInt64(), info: entry) } - + transaction.replaceMessageTagSummary(peerId: peerId, threadId: peer.peerId.toInt64(), tagMask: .unseenPersonalMessage, namespace: Namespaces.Message.Cloud, customTag: nil, count: 0, maxId: topMessage) transaction.replaceMessageTagSummary(peerId: peerId, threadId: peer.peerId.toInt64(), tagMask: .unseenReaction, namespace: Namespaces.Message.Cloud, customTag: nil, count: unreadReactionsCount, maxId: topMessage) transaction.replaceMessageTagSummary(peerId: peerId, threadId: peer.peerId.toInt64(), tagMask: .unseenPollVote, namespace: Namespaces.Message.Cloud, customTag: nil, count: 0, maxId: topMessage) @@ -2534,10 +2534,10 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM } } } - + let parsedPeers = AccumulatedPeers(transaction: transaction, chats: chats, users: users) updatePeers(transaction: transaction, accountPeerId: accountPeerId, peers: parsedPeers) - + let _ = transaction.addMessages(storeMessages, location: .Random) } } @@ -2549,7 +2549,7 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchMessageHistoryHoleSource, fetchedChatList: FetchedChatList) -> Signal { var forumThreadIds = Set() - + for message in fetchedChatList.storeMessages { if let threadId = message.threadId { if let channel = fetchedChatList.peers.peers.first(where: { $0.key == message.id.peerId })?.value as? TelegramChannel, case .group = channel.info, (channel.flags.contains(.isForum) || channel.flags.contains(.isMonoforum)) { @@ -2559,7 +2559,7 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM } } } - + if forumThreadIds.isEmpty { return .single(fetchedChatList) } else { @@ -2571,7 +2571,7 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM missingForumThreadIds[threadId.peerId, default: []].append(threadId.threadId) } } - + if missingForumThreadIds.isEmpty { return .single(fetchedChatList) } else { @@ -2581,7 +2581,7 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM Logger.shared.log("resolveForumThreads", "can't fetch thread infos \(threadIds) for peer \(peerId): can't create inputPeer") continue } - + if let peer = peer as? TelegramChannel, peer.flags.contains(.isMonoforum) { let signal = source.request(Api.functions.messages.getSavedDialogsByID(flags: 1 << 1, parentPeer: inputPeer, ids: threadIds.compactMap { threadId in let threadPeerId = PeerId(threadId) @@ -2612,24 +2612,24 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM signals.append(signal) } } - + return combineLatest(signals) |> map { results -> FetchedChatList in var fetchedChatList = fetchedChatList - + for maybeResult in results { if let (peer, result) = maybeResult { let peerIsForum = peer.isForum let peerId = peer.id - + fetchedChatList.peers = fetchedChatList.peers.union(with: AccumulatedPeers(chats: result.chats, users: result.users)) - + for message in result.messages { if let message = StoreMessage(apiMessage: message, accountPeerId: accountPeerId, peerIsForum: peerIsForum) { fetchedChatList.storeMessages.append(message) } } - + for item in result.items { switch item { case let .forum(topic): @@ -2704,7 +2704,7 @@ func resolveForumThreads(accountPeerId: PeerId, postbox: Postbox, source: FetchM } } } - + return fetchedChatList } } @@ -2721,7 +2721,7 @@ func resolveStories(postbox: Postbox, source: FetchMessageHistoryHoleSource, } storyBuckets[id.peerId]?.append(id.id) } - + var signals: [Signal] = [] for (peerId, allIds) in storyBuckets { var idOffset = 0 @@ -2755,7 +2755,7 @@ func resolveStories(postbox: Postbox, source: FetchMessageHistoryHoleSource, idOffset += bucketLength } } - + return combineLatest(signals) |> ignoreValues |> map { _ -> T in @@ -2766,7 +2766,7 @@ func resolveStories(postbox: Postbox, source: FetchMessageHistoryHoleSource, func resolveAssociatedStories(postbox: Postbox, network: Network, accountPeerId: PeerId, state: AccountMutableState) -> Signal { return postbox.transaction { transaction -> Signal in var missingStoryIds = Set() - + for operation in state.operations { switch operation { case let .AddMessages(messages, _): @@ -2785,7 +2785,7 @@ func resolveAssociatedStories(postbox: Postbox, network: Network, accountPeerId: break } } - + if !missingStoryIds.isEmpty { return resolveStories(postbox: postbox, source: .network(network), accountPeerId: accountPeerId, storyIds: missingStoryIds, additionalPeers: AccumulatedPeers(peers: Array(state.insertedPeers.values)), result: state) } else { @@ -2798,7 +2798,7 @@ func resolveAssociatedStories(postbox: Postbox, network: Network, accountPeerId: func resolveAssociatedStories(postbox: Postbox, source: FetchMessageHistoryHoleSource, accountPeerId: PeerId, messages: [StoreMessage], additionalPeers: AccumulatedPeers, result: T) -> Signal { return postbox.transaction { transaction -> Signal in var missingStoryIds = Set() - + for message in messages { for media in message.media { for id in media.storyIds { @@ -2809,7 +2809,7 @@ func resolveAssociatedStories(postbox: Postbox, source: FetchMessageHistoryHo } } } - + if !missingStoryIds.isEmpty { return resolveStories(postbox: postbox, source: source, accountPeerId: accountPeerId, storyIds: missingStoryIds, additionalPeers: additionalPeers, result: result) } else { @@ -2882,7 +2882,7 @@ private func reactionsFromState(_ state: AccountMutableState) -> [MessageReactio private func resolveAssociatedMessages(accountPeerId: PeerId, postbox: Postbox, network: Network, state: AccountMutableState) -> Signal { let missingReplyMessageIds = state.referencedReplyMessageIds.subtractingStoredIds(state.storedMessages) let missingGeneralMessageIds = state.referencedGeneralMessageIds.subtracting(state.storedMessages) - + if missingReplyMessageIds.isEmpty && missingGeneralMessageIds.isEmpty { return resolveUnknownEmojiFiles(postbox: postbox, source: .network(network), messages: messagesFromOperations(state: state), reactions: reactionsFromState(state), result: state) |> mapToSignal { state in @@ -2891,7 +2891,7 @@ private func resolveAssociatedMessages(accountPeerId: PeerId, postbox: Postbox, } else { var missingPeers = false let _ = missingPeers - + var signals: [Signal<([Api.Message], [Api.Chat], [Api.User]), NoError>] = [] for (peerId, messageIds) in messagesIdsGroupedByPeerId(missingReplyMessageIds) { if let peer = state.peers[peerId] { @@ -2963,9 +2963,9 @@ private func resolveAssociatedMessages(accountPeerId: PeerId, postbox: Postbox, missingPeers = true } } - + let fetchMessages = combineLatest(signals) - + return fetchMessages |> map { results in var updatedState = state @@ -2976,7 +2976,7 @@ private func resolveAssociatedMessages(accountPeerId: PeerId, postbox: Postbox, if !users.isEmpty { updatedState.mergeUsers(users) } - + if !messages.isEmpty { var storeMessages: [StoreMessage] = [] for message in messages { @@ -3005,7 +3005,7 @@ private func resolveAssociatedMessages(accountPeerId: PeerId, postbox: Postbox, private func resolveMissingPeerChatInfos(accountPeerId: PeerId, network: Network, state: AccountMutableState) -> Signal<(AccountMutableState, Bool), NoError> { var missingPeers: [PeerId: Api.InputPeer] = [:] var hadError = false - + for peerId in state.initialState.peerIdsRequiringLocalChatState { if state.peerChatInfos[peerId] == nil { if let peer = state.peers[peerId], let inputPeer = apiInputPeer(peer) { @@ -3016,14 +3016,14 @@ private func resolveMissingPeerChatInfos(accountPeerId: PeerId, network: Network } } } - + if missingPeers.isEmpty { return .single((state, hadError)) } else { Logger.shared.log("State", "will fetch chat info for \(missingPeers.count) peers") let signal = network.request(Api.functions.messages.getPeerDialogs(peers: missingPeers.values.map { .inputDialogPeer(.init(peer: $0)) })) |> map(Optional.init) - + return signal |> `catch` { _ -> Signal in return .single(nil) @@ -3032,30 +3032,30 @@ private func resolveMissingPeerChatInfos(accountPeerId: PeerId, network: Network guard let result = result else { return (state, hadError) } - + var channelStates: [PeerId: ChannelState] = [:] - + var updatedState = state switch result { case let .peerDialogs(peerDialogsData): let (dialogs, messages, chats, users) = (peerDialogsData.dialogs, peerDialogsData.messages, peerDialogsData.chats, peerDialogsData.users) updatedState.mergeChats(chats) updatedState.mergeUsers(users) - + var topMessageIds = Set() - + for dialog in dialogs { switch dialog { case let .dialog(dialogData): let (peer, topMessage, readInboxMaxId, readOutboxMaxId, unreadCount, unreadMentionsCount, unreadReactionsCount, unreadPollVoteCount, notifySettings, pts, folderId, ttlPeriod) = (dialogData.peer, dialogData.topMessage, dialogData.readInboxMaxId, dialogData.readOutboxMaxId, dialogData.unreadCount, dialogData.unreadMentionsCount, dialogData.unreadReactionsCount, dialogData.unreadPollVotesCount, dialogData.notifySettings, dialogData.pts, dialogData.folderId, dialogData.ttlPeriod) let peerId = peer.peerId - + updatedState.setNeedsHoleFromPreviousState(peerId: peerId, namespace: Namespaces.Message.Cloud, validateChannelPts: pts) - + if topMessage != 0 { topMessageIds.insert(MessageId(peerId: peerId, namespace: Namespaces.Message.Cloud, id: topMessage)) } - + var isExcludedFromChatList = false for chat in chats { if chat.peerId == peerId { @@ -3083,16 +3083,16 @@ private func resolveMissingPeerChatInfos(accountPeerId: PeerId, network: Network break } } - + if !isExcludedFromChatList { updatedState.updatePeerChatInclusion(peerId: peerId, groupId: PeerGroupId(rawValue: folderId ?? 0), changedGroup: false) } - + updatedState.updateAutoremoveTimeout(peer: peer, value: ttlPeriod.flatMap(CachedPeerAutoremoveTimeout.Value.init(peerValue:))) - + let notificationSettings = TelegramPeerNotificationSettings(apiSettings: notifySettings) updatedState.updateNotificationSettings(.peer(peerId: peer.peerId, threadId: nil), notificationSettings: notificationSettings) - + updatedState.resetReadState(peer.peerId, namespace: Namespaces.Message.Cloud, maxIncomingReadId: readInboxMaxId, maxOutgoingReadId: readOutboxMaxId, maxKnownId: topMessage, count: unreadCount, markedUnread: nil) updatedState.resetMessageTagSummary(peer.peerId, tag: .unseenPersonalMessage, namespace: Namespaces.Message.Cloud, count: unreadMentionsCount, range: MessageHistoryTagNamespaceCountValidityRange(maxId: topMessage)) updatedState.resetMessageTagSummary(peer.peerId, tag: .unseenReaction, namespace: Namespaces.Message.Cloud, count: unreadReactionsCount, range: MessageHistoryTagNamespaceCountValidityRange(maxId: topMessage)) @@ -3106,7 +3106,7 @@ private func resolveMissingPeerChatInfos(accountPeerId: PeerId, network: Network break } } - + var storeMessages: [StoreMessage] = [] for message in messages { var peerIsForum = false @@ -3125,7 +3125,7 @@ private func resolveMissingPeerChatInfos(accountPeerId: PeerId, network: Network storeMessages.append(updatedStoreMessage) } } - + for message in storeMessages { if case let .Id(id) = message.id { updatedState.addMessages([message], location: topMessageIds.contains(id) ? .UpperHistoryBlock : .Random) @@ -3269,10 +3269,10 @@ func resetChannels(accountPeerId: PeerId, postbox: Postbox, network: Network, pe } |> mapToSignal { result -> Signal in var updatedState = state - + var dialogsChats: [Api.Chat] = [] var dialogsUsers: [Api.User] = [] - + var storeMessages: [StoreMessage] = [] var readStates: [PeerId: [MessageId.Namespace: PeerReadState]] = [:] var mentionTagSummaries: [PeerId: [(tag: MessageTags, summary: MessageHistoryTagNamespaceSummary)]] = [:] @@ -3280,16 +3280,16 @@ func resetChannels(accountPeerId: PeerId, postbox: Postbox, network: Network, pe var invalidateChannelStates: [PeerId: Int32] = [:] var channelSynchronizedUntilMessage: [PeerId: MessageId.Id] = [:] var notificationSettings: [PeerId: TelegramPeerNotificationSettings] = [:] - + var resetForumTopics = Set() - + if let result = result { switch result { case let .peerDialogs(peerDialogsData): let (dialogs, messages, chats, users) = (peerDialogsData.dialogs, peerDialogsData.messages, peerDialogsData.chats, peerDialogsData.users) dialogsChats.append(contentsOf: chats) dialogsUsers.append(contentsOf: users) - + loop: for dialog in dialogs { let apiPeer: Api.Peer let apiReadInboxMaxId: Int32 @@ -3324,14 +3324,14 @@ func resetChannels(accountPeerId: PeerId, postbox: Postbox, network: Network, pe assertionFailure() continue loop } - + let peerId: PeerId = apiPeer.peerId - + if readStates[peerId] == nil { readStates[peerId] = [:] } readStates[peerId]![Namespaces.Message.Cloud] = .idBased(maxIncomingReadId: apiReadInboxMaxId, maxOutgoingReadId: apiReadOutboxMaxId, maxKnownId: apiTopMessage, count: apiUnreadCount, markedUnread: apiMarkedUnread) - + if apiTopMessage != 0 { mentionTagSummaries[peerId] = [ (MessageTags.unseenPersonalMessage, MessageHistoryTagNamespaceSummary(version: 1, count: apiUnreadMentionsCount, range: MessageHistoryTagNamespaceCountValidityRange(maxId: apiTopMessage))), @@ -3339,18 +3339,18 @@ func resetChannels(accountPeerId: PeerId, postbox: Postbox, network: Network, pe (MessageTags.unseenPollVote, MessageHistoryTagNamespaceSummary(version: 1, count: apiUnreadPollVoteCount, range: MessageHistoryTagNamespaceCountValidityRange(maxId: apiTopMessage))) ] } - + if let apiChannelPts = apiChannelPts { channelStates[peerId] = AccountStateChannelState(pts: apiChannelPts) invalidateChannelStates[peerId] = apiChannelPts } - + notificationSettings[peerId] = TelegramPeerNotificationSettings(apiSettings: apiNotificationSettings) - + updatedState.updatePeerChatInclusion(peerId: peerId, groupId: groupId, changedGroup: false) - + updatedState.updateAutoremoveTimeout(peer: apiPeer, value: apiTtlPeriod.flatMap(CachedPeerAutoremoveTimeout.Value.init(peerValue:))) - + resetForumTopics.insert(peerId) } @@ -3373,10 +3373,10 @@ func resetChannels(accountPeerId: PeerId, postbox: Postbox, network: Network, pe } } } - + updatedState.mergeChats(dialogsChats) updatedState.mergeUsers(dialogsUsers) - + for message in storeMessages { if case let .Id(id) = message.id, id.namespace == Namespaces.Message.Cloud { var channelPts: Int32? @@ -3387,9 +3387,9 @@ func resetChannels(accountPeerId: PeerId, postbox: Postbox, network: Network, pe channelSynchronizedUntilMessage[id.peerId] = id.id } } - + updatedState.addMessages(storeMessages, location: .UpperHistoryBlock) - + for (peerId, peerReadStates) in readStates { for (namespace, state) in peerReadStates { switch state { @@ -3401,13 +3401,13 @@ func resetChannels(accountPeerId: PeerId, postbox: Postbox, network: Network, pe } } } - + for (peerId, tagSummaries) in mentionTagSummaries { for tagSummary in tagSummaries { updatedState.resetMessageTagSummary(peerId, tag: tagSummary.tag, namespace: Namespaces.Message.Cloud, count: tagSummary.summary.count, range: tagSummary.summary.range) } } - + for (peerId, channelState) in channelStates { updatedState.updateChannelState(peerId, pts: channelState.pts) } @@ -3417,11 +3417,11 @@ func resetChannels(accountPeerId: PeerId, postbox: Postbox, network: Network, pe for (peerId, id) in channelSynchronizedUntilMessage { updatedState.updateChannelSynchronizedUntilMessage(peerId, id: id) } - + for (peerId, settings) in notificationSettings { updatedState.updateNotificationSettings(.peer(peerId: peerId, threadId: nil), notificationSettings: settings) } - + var resetTopicsSignals: [Signal] = [] for resetForumTopicPeerId in resetForumTopics { resetTopicsSignals.append(_internal_requestMessageHistoryThreads(accountPeerId: accountPeerId, postbox: postbox, network: network, peerId: resetForumTopicPeerId, query: nil, offsetIndex: nil, limit: 20) @@ -3433,7 +3433,7 @@ func resetChannels(accountPeerId: PeerId, postbox: Postbox, network: Network, pe return combineLatest(resetTopicsSignals) |> mapToSignal { results -> Signal in var updatedState = updatedState - + for result in results { let peerId: PeerId switch result { @@ -3444,7 +3444,7 @@ func resetChannels(accountPeerId: PeerId, postbox: Postbox, network: Network, pe } updatedState.resetForumTopicLists[peerId] = result } - + // TODO: delete messages later than top return resolveAssociatedMessages(accountPeerId: accountPeerId, postbox: postbox, network: network, state: updatedState) |> mapToSignal { resultingState -> Signal in @@ -3462,7 +3462,7 @@ private func pollChannel(accountPeerId: PeerId, postbox: Postbox, network: Netwo #else limit = 100 #endif - + let pollPts: Int32 if let channelState = state.channelStates[peer.id] { pollPts = channelState.pts @@ -3484,13 +3484,13 @@ private func pollChannel(accountPeerId: PeerId, postbox: Postbox, network: Netwo guard let difference = difference else { return .single((state, false, nil)) } - + switch difference { case let .channelDifference(channelDifferenceData): let (_, pts, timeout, newMessages, otherUpdates, chats, users) = (channelDifferenceData.flags, channelDifferenceData.pts, channelDifferenceData.timeout, channelDifferenceData.newMessages, channelDifferenceData.otherUpdates, channelDifferenceData.chats, channelDifferenceData.users) var updatedState = state var apiTimeout: Int32? - + apiTimeout = timeout let channelPts: Int32 if let _ = updatedState.channelStates[peer.id] { @@ -3499,12 +3499,12 @@ private func pollChannel(accountPeerId: PeerId, postbox: Postbox, network: Netwo channelPts = pts } updatedState.updateChannelState(peer.id, pts: channelPts) - + updatedState.mergeChats(chats) updatedState.mergeUsers(users) - + var forumThreadIds = Set() - + for apiMessage in newMessages { var peerIsForum = peer.isForum if let peerId = apiMessage.peerId, updatedState.isPeerForum(peerId: peerId) { @@ -3514,7 +3514,7 @@ private func pollChannel(accountPeerId: PeerId, postbox: Postbox, network: Netwo var attributes = message.attributes attributes.append(ChannelMessageStateVersionAttribute(pts: pts)) message = message.withUpdatedAttributes(attributes) - + if let preCachedResources = apiMessage.preCachedResources { for (resource, data) in preCachedResources { updatedState.addPreCachedResource(resource, data: data) @@ -3528,7 +3528,7 @@ private func pollChannel(accountPeerId: PeerId, postbox: Postbox, network: Netwo updatedState.addMessages([message], location: .UpperHistoryBlock) if case let .Id(id) = message.id { updatedState.updateChannelSynchronizedUntilMessage(id.peerId, id: id.id) - + if let threadId = message.threadId { if let channel = updatedState.peers[message.id.peerId] as? TelegramChannel, case .group = channel.info, channel.flags.contains(.isForum) { forumThreadIds.insert(MessageId(peerId: message.id.peerId, namespace: message.id.namespace, id: Int32(clamping: threadId))) @@ -3539,7 +3539,7 @@ private func pollChannel(accountPeerId: PeerId, postbox: Postbox, network: Netwo } } } - + for update in otherUpdates { switch update { case let .updateDeleteChannelMessages(updateDeleteChannelMessagesData): @@ -3565,7 +3565,7 @@ private func pollChannel(accountPeerId: PeerId, postbox: Postbox, network: Netwo var attributes = message.attributes attributes.append(ChannelMessageStateVersionAttribute(pts: pts)) updatedState.editMessage(messageId, message: message.withUpdatedAttributes(attributes)) - + if let threadId = message.threadId { if let channel = updatedState.peers[message.id.peerId] as? TelegramChannel, case .group = channel.info, channel.flags.contains(.isForum) { forumThreadIds.insert(MessageId(peerId: message.id.peerId, namespace: message.id.namespace, id: Int32(clamping: threadId))) @@ -3623,7 +3623,7 @@ private func pollChannel(accountPeerId: PeerId, postbox: Postbox, network: Netwo break } } - + return resolveForumThreads(accountPeerId: accountPeerId, postbox: postbox, source: .network(network), state: updatedState) |> mapToSignal { updatedState in return resolveAssociatedStories(postbox: postbox, network: network, accountPeerId: accountPeerId, state: updatedState) @@ -3651,11 +3651,11 @@ private func pollChannel(accountPeerId: PeerId, postbox: Postbox, network: Netwo let (_, timeout, dialog, messages, chats, users) = (channelDifferenceTooLongData.flags, channelDifferenceTooLongData.timeout, channelDifferenceTooLongData.dialog, channelDifferenceTooLongData.messages, channelDifferenceTooLongData.chats, channelDifferenceTooLongData.users) var updatedState = state var apiTimeout: Int32? - + apiTimeout = timeout - + var parameters: (peer: Api.Peer, pts: Int32, topMessage: Int32, readInboxMaxId: Int32, readOutboxMaxId: Int32, unreadCount: Int32, unreadMentionsCount: Int32, unreadReactionsCount: Int32, unreadPollVoteCount: Int32, ttlPeriod: Int32?)? - + switch dialog { case let .dialog(dialogData): let (peer, topMessage, readInboxMaxId, readOutboxMaxId, unreadCount, unreadMentionsCount, unreadReactionsCount, unreadPollVoteCount, pts, ttlPeriod) = (dialogData.peer, dialogData.topMessage, dialogData.readInboxMaxId, dialogData.readOutboxMaxId, dialogData.unreadCount, dialogData.unreadMentionsCount, dialogData.unreadReactionsCount, dialogData.unreadPollVotesCount, dialogData.pts, dialogData.ttlPeriod) @@ -3665,32 +3665,32 @@ private func pollChannel(accountPeerId: PeerId, postbox: Postbox, network: Netwo case .dialogFolder: break } - + var resetForumTopics = Set() - + var peerIsForum = peer.isForum if updatedState.isPeerForum(peerId: peer.id) { peerIsForum = true } - + if let (peer, pts, topMessage, readInboxMaxId, readOutboxMaxId, unreadCount, unreadMentionsCount, unreadReactionsCount, unreadPollVoteCount, ttlPeriod) = parameters { updatedState.updateChannelState(peer.peerId, pts: pts) updatedState.updateChannelInvalidationPts(peer.peerId, invalidationPts: pts) - + updatedState.updateAutoremoveTimeout(peer: peer, value: ttlPeriod.flatMap(CachedPeerAutoremoveTimeout.Value.init(peerValue:))) - + updatedState.mergeChats(chats) updatedState.mergeUsers(users) - + updatedState.setNeedsHoleFromPreviousState(peerId: peer.peerId, namespace: Namespaces.Message.Cloud, validateChannelPts: pts) resetForumTopics.insert(peer.peerId) - + for apiMessage in messages { if var message = StoreMessage(apiMessage: apiMessage, accountPeerId: accountPeerId, peerIsForum: peerIsForum) { var attributes = message.attributes attributes.append(ChannelMessageStateVersionAttribute(pts: pts)) message = message.withUpdatedAttributes(attributes) - + if let preCachedResources = apiMessage.preCachedResources { for (resource, data) in preCachedResources { updatedState.addPreCachedResource(resource, data: data) @@ -3701,7 +3701,7 @@ private func pollChannel(accountPeerId: PeerId, postbox: Postbox, network: Netwo updatedState.addPreCachedStory(id: id, story: story) } } - + let location: AddMessagesLocation if case let .Id(id) = message.id, id.id == topMessage { location = .UpperHistoryBlock @@ -3712,16 +3712,16 @@ private func pollChannel(accountPeerId: PeerId, postbox: Postbox, network: Netwo updatedState.addMessages([message], location: location) } } - + updatedState.resetReadState(peer.peerId, namespace: Namespaces.Message.Cloud, maxIncomingReadId: readInboxMaxId, maxOutgoingReadId: readOutboxMaxId, maxKnownId: topMessage, count: unreadCount, markedUnread: nil) - + updatedState.resetMessageTagSummary(peer.peerId, tag: .unseenPersonalMessage, namespace: Namespaces.Message.Cloud, count: unreadMentionsCount, range: MessageHistoryTagNamespaceCountValidityRange(maxId: topMessage)) updatedState.resetMessageTagSummary(peer.peerId, tag: .unseenReaction, namespace: Namespaces.Message.Cloud, count: unreadReactionsCount, range: MessageHistoryTagNamespaceCountValidityRange(maxId: topMessage)) updatedState.resetMessageTagSummary(peer.peerId, tag: .unseenPollVote, namespace: Namespaces.Message.Cloud, count: unreadPollVoteCount, range: MessageHistoryTagNamespaceCountValidityRange(maxId: topMessage)) } else { assertionFailure() } - + var resetTopicsSignals: [Signal] = [] for resetForumTopicPeerId in resetForumTopics { resetTopicsSignals.append(_internal_requestMessageHistoryThreads(accountPeerId: accountPeerId, postbox: postbox, network: network, peerId: resetForumTopicPeerId, query: nil, offsetIndex: nil, limit: 20) @@ -3733,7 +3733,7 @@ private func pollChannel(accountPeerId: PeerId, postbox: Postbox, network: Netwo return combineLatest(resetTopicsSignals) |> mapToSignal { results -> Signal<(AccountMutableState, Bool, Int32?), NoError> in var updatedState = updatedState - + for result in results { let peerId: PeerId switch result { @@ -3744,7 +3744,7 @@ private func pollChannel(accountPeerId: PeerId, postbox: Postbox, network: Netwo } updatedState.resetForumTopicLists[peerId] = result } - + return .single((updatedState, true, apiTimeout)) } } @@ -3758,19 +3758,19 @@ private func pollChannel(accountPeerId: PeerId, postbox: Postbox, network: Netwo private func verifyTransaction(_ transaction: Transaction, finalState: AccountMutableState) -> Bool { var hadUpdateState = false var channelsWithUpdatedStates = Set() - + var missingPeerIds: [PeerId] = [] for peerId in finalState.initialState.peerIds { if finalState.peers[peerId] == nil { missingPeerIds.append(peerId) } } - + if !missingPeerIds.isEmpty { Logger.shared.log("State", "missing peers \(missingPeerIds)") return false } - + for operation in finalState.operations { switch operation { case let .UpdateChannelState(peerId, _): @@ -3781,9 +3781,9 @@ private func verifyTransaction(_ transaction: Transaction, finalState: AccountMu break } } - + var failed = false - + if hadUpdateState { var previousStateMatches = false let currentState = (transaction.getState() as? AuthorizedAccountState)?.state @@ -3793,13 +3793,13 @@ private func verifyTransaction(_ transaction: Transaction, finalState: AccountMu } else { previousStateMatches = false } - + if !previousStateMatches { Logger.shared.log("State", ".UpdateState previous state \(previousState) doesn't match current state \(String(describing: currentState))") failed = true } } - + for peerId in channelsWithUpdatedStates { let currentState = transaction.getPeerChatState(peerId) var previousStateMatches = false @@ -3816,14 +3816,14 @@ private func verifyTransaction(_ transaction: Transaction, finalState: AccountMu failed = true } } - + return !failed } private final class OptimizeAddMessagesState { var messages: [StoreMessage] var location: AddMessagesLocation - + init(messages: [StoreMessage], location: AddMessagesLocation) { self.messages = messages self.location = location @@ -3832,12 +3832,12 @@ private final class OptimizeAddMessagesState { private func optimizedOperations(_ operations: [AccountStateMutationOperation]) -> [AccountStateMutationOperation] { var result: [AccountStateMutationOperation] = [] - + var updatedState: AuthorizedAccountState.State? var updatedChannelStates: [PeerId: AccountStateChannelState] = [:] var invalidateChannelPts: [PeerId: Int32] = [:] var updateChannelSynchronizedUntilMessage: [PeerId: MessageId.Id] = [:] - + var currentAddMessages: OptimizeAddMessagesState? var currentAddScheduledMessages: OptimizeAddMessagesState? var currentAddQuickReplyMessages: OptimizeAddMessagesState? @@ -3891,31 +3891,31 @@ private func optimizedOperations(_ operations: [AccountStateMutationOperation]) if let currentAddMessages = currentAddMessages, !currentAddMessages.messages.isEmpty { result.append(.AddMessages(currentAddMessages.messages, currentAddMessages.location)) } - + if let currentAddScheduledMessages = currentAddScheduledMessages, !currentAddScheduledMessages.messages.isEmpty { result.append(.AddScheduledMessages(currentAddScheduledMessages.messages)) } - + if let currentAddQuickReplyMessages = currentAddQuickReplyMessages, !currentAddQuickReplyMessages.messages.isEmpty { result.append(.AddQuickReplyMessages(currentAddQuickReplyMessages.messages)) } - + if let updatedState = updatedState { result.append(.UpdateState(updatedState)) } - + for (peerId, state) in updatedChannelStates { result.append(.UpdateChannelState(peerId, state.pts)) } - + for (peerId, pts) in invalidateChannelPts { result.append(.UpdateChannelInvalidationPts(peerId, pts)) } - + for (peerId, id) in updateChannelSynchronizedUntilMessage { result.append(.UpdateChannelSynchronizedUntilMessage(peerId, id)) } - + return result } @@ -3949,9 +3949,9 @@ func replayFinalState( return nil } } - + var peerIdsWithAddedSecretMessages = Set() - + var updatedTypingActivities: [PeerActivitySpace: [PeerId: PeerInputActivity?]] = [:] var updatedIncomingThreadReadStates: [PeerAndBoundThreadId: MessageId.Id] = [:] var updatedOutgoingThreadReadStates: [PeerAndBoundThreadId: MessageId.Id] = [:] @@ -3988,10 +3988,10 @@ func replayFinalState( var updatedEmojiGameInfo: EmojiGameInfo? var recentlyUsedGuestChatBots = Set() var webBrowserSettingsUpdates: [(AccountWebBrowserSettings) -> AccountWebBrowserSettings] = [] - + var holesFromPreviousStateMessageIds: [MessageId] = [] var clearHolesFromPreviousStateForChannelMessagesWithPts: [PeerIdAndMessageNamespace: Int32] = [:] - + for (id, story) in finalState.state.preCachedStories { if let storyItem = Stories.StoredItem(apiStoryItem: story, peerId: id.peerId, transaction: transaction) { if let entry = CodableEntry(storyItem) { @@ -4001,13 +4001,13 @@ func replayFinalState( transaction.setStory(id: id, value: CodableEntry(data: Data())) } } - + for (peerId, namespaces) in finalState.state.namespacesWithHolesFromPreviousState { for (namespace, namespaceState) in namespaces { if let pts = namespaceState.validateChannelPts { clearHolesFromPreviousStateForChannelMessagesWithPts[PeerIdAndMessageNamespace(peerId: peerId, namespace: namespace)] = pts } - + var topId: Int32? if namespace == Namespaces.Message.Cloud, let channelState = transaction.getPeerChatState(peerId) as? ChannelState { if let synchronizedUntilMessageId = channelState.synchronizedUntilMessageId { @@ -4017,7 +4017,7 @@ func replayFinalState( if topId == nil { topId = transaction.getTopPeerMessageId(peerId: peerId, namespace: namespace)?.id } - + if let id = topId { holesFromPreviousStateMessageIds.append(MessageId(peerId: peerId, namespace: namespace, id: id + 1)) } else { @@ -4025,14 +4025,14 @@ func replayFinalState( } } } - + var wasOperationScheduledMessageIds: [MessageId] = [] - + var readInboxCloudMessageIds: [PeerId: Int32] = [:] - + var addedOperationIncomingMessageIds: [MessageId] = [] var addedConferenceInvitationMessagesIds: [MessageId] = [] - + enum LiveTypingDraftUpdate { struct Update { var id: Int64 @@ -4040,7 +4040,7 @@ func replayFinalState( var authorId: PeerId var timestamp: Int32 var content: PeerLiveTypingDraftUpdateContent - + init(id: Int64, threadId: Int64?, authorId: PeerId, timestamp: Int32, content: PeerLiveTypingDraftUpdateContent) { self.id = id self.threadId = threadId @@ -4049,11 +4049,11 @@ func replayFinalState( self.content = content } } - + case update(Update) case cancel(updatedTimestamp: Int32) } - + var liveTypingDraftUpdates: [PeerAndThreadId: [LiveTypingDraftUpdate]] = [:] for operation in finalState.state.operations { @@ -4130,7 +4130,7 @@ func replayFinalState( var wasScheduledMessageIds:[MessageId] = [] var addedIncomingMessageIds: [MessageId] = [] var addedReactionEvents: [(reactionAuthor: Peer, reaction: MessageReaction.Reaction, message: Message, timestamp: Int32)] = [] - + if !wasOperationScheduledMessageIds.isEmpty { let existingIds = transaction.filterStoredMessageIds(Set(wasOperationScheduledMessageIds)) for id in wasOperationScheduledMessageIds { @@ -4147,16 +4147,16 @@ func replayFinalState( } } } - + var invalidateGroupStats = Set() - + struct PeerIdAndMessageNamespace: Hashable { let peerId: PeerId let namespace: MessageId.Namespace } - + var topUpperHistoryBlockMessages: [PeerIdAndMessageNamespace: MessageId.Id] = [:] - + final class MessageThreadStatsRecord { var removedCount: Int = 0 var peers: [ReplyThreadUserMessage] = [] @@ -4177,9 +4177,9 @@ func replayFinalState( } } } - + var isPremiumUpdated = false - + for operation in optimizedOperations(finalState.state.operations) { switch operation { case let .AddMessages(messages, location): @@ -4195,7 +4195,7 @@ func replayFinalState( case let .topicEdited(components): if let initialData = transaction.getMessageHistoryThreadInfo(peerId: id.peerId, threadId: threadId)?.data.get(MessageHistoryThreadData.self) { var data = initialData - + for component in components { switch component { case let .title(title): @@ -4208,7 +4208,7 @@ func replayFinalState( data.isHidden = isHidden } } - + if data != initialData { if let entry = StoredMessageHistoryThreadInfo(data) { transaction.setMessageHistoryThreadInfo(peerId: id.peerId, threadId: threadId, info: entry) @@ -4220,34 +4220,34 @@ func replayFinalState( } } } - + if id.peerId.namespace == Namespaces.Peer.CloudChannel { if !transaction.messageExists(id: id) { addMessageThreadStatsDifference(threadKey: MessageThreadKey(peerId: message.id.peerId, threadId: threadId), remove: 0, addedMessagePeer: message.authorId, addedMessageId: id, isOutgoing: !message.flags.contains(.Incoming)) } } - + if message.flags.contains(.Incoming) { if var data = transaction.getMessageHistoryThreadInfo(peerId: id.peerId, threadId: threadId)?.data.get(MessageHistoryThreadData.self) { var combinedMaxIncomingReadId = data.maxIncomingReadId if combinedMaxIncomingReadId == 0 { assert(true) } - + if let maxId = readInboxCloudMessageIds[id.peerId] { combinedMaxIncomingReadId = max(combinedMaxIncomingReadId, maxId) } else if let groupReadState = transaction.getCombinedPeerReadState(id.peerId), let state = groupReadState.states.first(where: { $0.0 == Namespaces.Message.Cloud })?.1, case let .idBased(maxIncomingReadId, _, _, _, _) = state { combinedMaxIncomingReadId = max(combinedMaxIncomingReadId, maxIncomingReadId) } - + if combinedMaxIncomingReadId != data.maxIncomingReadId { assert(true) } - + if combinedMaxIncomingReadId != 0 && id.id >= data.maxKnownMessageId { data.maxKnownMessageId = id.id data.incomingUnreadCount += 1 - + if let entry = StoredMessageHistoryThreadInfo(data) { transaction.setMessageHistoryThreadInfo(peerId: id.peerId, threadId: threadId, info: entry) } @@ -4258,16 +4258,16 @@ func replayFinalState( } } } - + var messages = messages - + if case .UpperHistoryBlock = location { for i in 0 ..< messages.count { let message = messages[i] let chatPeerId = message.id.peerId let key = PeerAndThreadId(peerId: chatPeerId, threadId: message.threadId) let allKey = PeerAndThreadId(peerId: chatPeerId, threadId: nil) - + if liveTypingDraftUpdates[key] != nil { liveTypingDraftUpdates[key] = [.cancel(updatedTimestamp: message.timestamp)] liveTypingDraftUpdates[allKey] = [.cancel(updatedTimestamp: message.timestamp)] @@ -4278,7 +4278,7 @@ func replayFinalState( } } } - + let _ = transaction.addMessages(messages, location: location) if case .UpperHistoryBlock = location { for message in messages { @@ -4296,13 +4296,13 @@ func replayFinalState( } else { updatedTypingActivities[PeerActivitySpace(peerId: chatPeerId, category: .thread(threadId))]![authorId] = activityValue } - + } } - + if case let .Id(id) = message.id { let peerIdAndMessageNamespace = PeerIdAndMessageNamespace(peerId: id.peerId, namespace: id.namespace) - + if let currentId = topUpperHistoryBlockMessages[peerIdAndMessageNamespace] { if currentId < id.id { topUpperHistoryBlockMessages[peerIdAndMessageNamespace] = id.id @@ -4310,7 +4310,7 @@ func replayFinalState( } else { topUpperHistoryBlockMessages[peerIdAndMessageNamespace] = id.id } - + for media in message.media { if let action = media as? TelegramMediaAction { if message.id.peerId.namespace == Namespaces.Peer.CloudGroup, case let .groupMigratedToChannel(channelId) = action.action { @@ -4384,7 +4384,7 @@ func replayFinalState( } } } - + if message.flags.contains(.Incoming), let authorId = message.authorId { for attribute in message.attributes { if let attribute = attribute as? GuestChatMessageAttribute, attribute.peerId == accountPeerId { @@ -4399,12 +4399,12 @@ func replayFinalState( slowModeLastMessageTimeouts[message.id.peerId] = max(slowModeLastMessageTimeouts[message.id.peerId] ?? 0, message.timestamp) } } - + if !message.flags.contains(.Incoming), message.forwardInfo == nil { if [Namespaces.Peer.CloudGroup, Namespaces.Peer.CloudChannel].contains(message.id.peerId.namespace), let peer = transaction.getPeer(message.id.peerId), peer.isCopyProtectionEnabled { - + } else if message.id.peerId.namespace == Namespaces.Peer.CloudUser, let cachedUserData = transaction.getPeerCachedData(peerId: message.id.peerId) as? CachedUserData, cachedUserData.flags.contains(.copyProtectionEnabled) || cachedUserData.flags.contains(.myCopyProtectionEnabled) { - + } else { inner: for media in message.media { if let file = media as? TelegramMediaFile { @@ -4462,6 +4462,9 @@ func replayFinalState( } } case let .DeleteMessagesWithGlobalIds(ids): + if currentWinterGramCoreSettings.saveDeletedMessages { + break + } var resourceIds: [MediaResourceId] = [] transaction.deleteMessagesWithGlobalIds(ids, forEachMedia: { media in addMessageMediaResourceIdsToRemove(media: media, resourceIds: &resourceIds) @@ -4471,6 +4474,32 @@ func replayFinalState( } 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. + if winterGramShouldSave && !currentWinterGramCoreSettings.saveForBots { + if let firstPeerId = ids.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: ids) + for id in ids { + 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)) + }) + } + break + } _internal_deleteMessages(transaction: transaction, mediaBox: mediaBox, ids: ids, manualAddMessageThreadStatsDifference: { id, add, remove in addMessageThreadStatsDifference(threadKey: id, remove: remove, addedMessagePeer: nil, addedMessageId: nil, isOutgoing: false) }) @@ -4517,13 +4546,13 @@ func replayFinalState( } else { updatedFlags.remove(.Incoming) } - + let peers: [PeerId:Peer] = previousMessage.peers.reduce([:], { current, value in var current = current current[value.0] = value.1 return current }) - + if previousMessage.text == message.text { let previousEntities = previousMessage.textEntitiesAttribute?.entities ?? [] let updatedEntities = (message.attributes.first(where: { $0 is TextEntitiesMessageAttribute }) as? TextEntitiesMessageAttribute)?.entities ?? [] @@ -4532,24 +4561,33 @@ func replayFinalState( updatedAttributes.append(translation) } } + } else if currentWinterGramCoreSettings.saveMessageEditHistory { + var revisions = (previousMessage.attributes.first(where: { $0 is WinterGramEditHistoryAttribute }) as? WinterGramEditHistoryAttribute)?.revisions ?? [] + revisions.append(WinterGramEditHistoryAttribute.Revision( + text: previousMessage.text, + entities: previousMessage.textEntitiesAttribute?.entities ?? [], + timestamp: previousMessage.timestamp + )) + updatedAttributes.removeAll(where: { $0 is WinterGramEditHistoryAttribute }) + updatedAttributes.append(WinterGramEditHistoryAttribute(revisions: revisions)) } - + if let previousFactCheckAttribute = previousMessage.attributes.first(where: { $0 is FactCheckMessageAttribute }) as? FactCheckMessageAttribute, let updatedFactCheckAttribute = message.attributes.first(where: { $0 is FactCheckMessageAttribute }) as? FactCheckMessageAttribute { if case .Pending = updatedFactCheckAttribute.content, updatedFactCheckAttribute.hash == previousFactCheckAttribute.hash { updatedAttributes.removeAll(where: { $0 is FactCheckMessageAttribute }) updatedAttributes.append(previousFactCheckAttribute) } } - + if let message = locallyRenderedMessage(message: message, peers: peers) { generatedEvent = reactionGeneratedEvent(previousMessage.reactionsAttribute, message.reactionsAttribute, message: message, transaction: transaction) } - + var updatedMedia = message.media if let previousPaidContent = previousMessage.media.first(where: { $0 is TelegramMediaPaidContent }) as? TelegramMediaPaidContent, case .full = previousPaidContent.extendedMedia.first { updatedMedia = previousMessage.media } - + return .update(message.withUpdatedLocalTags(updatedLocalTags).withUpdatedFlags(updatedFlags).withUpdatedAttributes(updatedAttributes).withUpdatedMedia(updatedMedia)) }) if let generatedEvent = generatedEvent { @@ -4635,7 +4673,7 @@ func replayFinalState( data.incomingUnreadCount = max(0, data.incomingUnreadCount - Int32(count)) } } - + if let topMessageIndex = transaction.getMessageHistoryThreadTopMessage(peerId: peerAndThreadId.peerId, threadId: threadId, namespaces: Set([Namespaces.Message.Cloud])) { if readMaxId >= topMessageIndex.id.id { let containingHole = transaction.getThreadIndexHole(peerId: peerAndThreadId.peerId, threadId: threadId, namespace: topMessageIndex.id.namespace, containing: topMessageIndex.id.id) @@ -4645,10 +4683,10 @@ func replayFinalState( } } } - + data.maxKnownMessageId = max(data.maxKnownMessageId, readMaxId) data.maxIncomingReadId = max(data.maxIncomingReadId, readMaxId) - + if let entry = StoredMessageHistoryThreadInfo(data) { transaction.setMessageHistoryThreadInfo(peerId: peerAndThreadId.peerId, threadId: peerAndThreadId.threadId, info: entry) } @@ -4683,7 +4721,7 @@ func replayFinalState( if var data = transaction.getMessageHistoryThreadInfo(peerId: peerAndThreadId.peerId, threadId: peerAndThreadId.threadId)?.data.get(MessageHistoryThreadData.self) { if readMaxId >= data.maxOutgoingReadId { data.maxOutgoingReadId = readMaxId - + if let entry = StoredMessageHistoryThreadInfo(data) { transaction.setMessageHistoryThreadInfo(peerId: peerAndThreadId.peerId, threadId: peerAndThreadId.threadId, info: entry) } @@ -4693,7 +4731,7 @@ func replayFinalState( if var data = transaction.getMessageHistoryThreadInfo(peerId: peerAndThreadId.peerId, threadId: peerAndThreadId.threadId)?.data.get(MessageHistoryThreadData.self) { if readMaxId >= data.maxOutgoingReadId { data.maxOutgoingReadId = readMaxId - + if let entry = StoredMessageHistoryThreadInfo(data) { transaction.setMessageHistoryThreadInfo(peerId: peerAndThreadId.peerId, threadId: peerAndThreadId.threadId, info: entry) } @@ -4713,7 +4751,7 @@ func replayFinalState( } } } - + var ignore = false if let currentReadState = transaction.getCombinedPeerReadState(peerId) { loop: for (currentNamespace, currentState) in currentReadState.states { @@ -4723,9 +4761,9 @@ func replayFinalState( if count != 0 || markedUnreadValue { if localMaxIncomingReadId > maxIncomingReadId { transaction.setNeedsIncomingReadStateSynchronization(peerId) - + transaction.resetIncomingReadStates([peerId: [namespace: .idBased(maxIncomingReadId: localMaxIncomingReadId, maxOutgoingReadId: maxOutgoingReadId, maxKnownId: maxKnownId, count: localCount, markedUnread: localMarkedUnread)]]) - + Logger.shared.log("State", "not applying incoming read state for \(peerId): \(localMaxIncomingReadId) > \(maxIncomingReadId)") ignore = true } @@ -4755,7 +4793,7 @@ func replayFinalState( } } } - + if ptsMatchesState { var updatedStates: [(MessageId.Namespace, PeerReadState)] = transaction.getPeerReadStates(peerId) ?? [] var foundState = false @@ -4835,9 +4873,9 @@ func replayFinalState( if let threadId = threadId { if let initialData = transaction.getMessageHistoryThreadInfo(peerId: peerId, threadId: threadId)?.data.get(MessageHistoryThreadData.self) { var data = initialData - + data.notificationSettings = notificationSettings - + if data != initialData { if let entry = StoredMessageHistoryThreadInfo(data) { transaction.setMessageHistoryThreadInfo(peerId: peerId, threadId: threadId, info: entry) @@ -4897,7 +4935,7 @@ func replayFinalState( isPremiumUpdated = true } } - + updatePeers(transaction: transaction, accountPeerId: accountPeerId, peers: parsedPeers) updateContacts(transaction: transaction, apiUsers: users) case let .UpdatePeer(id, f): @@ -4907,7 +4945,7 @@ func replayFinalState( isPremiumUpdated = true } } - + updatePeersCustom(transaction: transaction, peers: [peer], update: { _, updated in return updated }) @@ -4923,7 +4961,7 @@ func replayFinalState( if let forwardInfo = currentMessage.forwardInfo { storeForwardInfo = StoreMessageForwardInfo(forwardInfo) } - + var tags = currentMessage.tags let attributes = currentMessage.attributes if pinned { @@ -4931,11 +4969,11 @@ func replayFinalState( } else { tags.remove(.pinned) } - + if tags == currentMessage.tags { return .skip } - + 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: tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) }) } @@ -5170,7 +5208,7 @@ func replayFinalState( } }) } - + switch call { case let .groupCall(groupCallData): let (flags, participantsCount, title, recordStartDate, scheduleDate, sendPaidMessagesStars) = (groupCallData.flags, groupCallData.participantsCount, groupCallData.title, groupCallData.recordStartDate, groupCallData.scheduleDate, groupCallData.sendPaidMessagesStars) @@ -5196,7 +5234,7 @@ func replayFinalState( callId, .call(isTerminated: true, defaultParticipantsAreMuted: GroupCallParticipantsContext.State.DefaultParticipantsAreMuted(isMuted: false, canChange: false), messagesAreEnabled: GroupCallParticipantsContext.State.MessagesAreEnabled(isEnabled: false, canChange: false, sendPaidMessagesStars: nil), title: nil, recordingStartTimestamp: nil, scheduleTimestamp: nil, isVideoEnabled: false, participantCount: nil, isMin: false) )) - + if let peerId { transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, current in if let current = current as? CachedChannelData { @@ -5296,7 +5334,7 @@ func replayFinalState( case let .UpdateMessageReactions(messageId, _, reactions, _): transaction.updateMessage(messageId, update: { currentMessage in var updatedReactions = ReactionsMessageAttribute(apiReactions: reactions) - + let storeForwardInfo = currentMessage.forwardInfo.flatMap(StoreMessageForwardInfo.init) var attributes = currentMessage.attributes var previousReactions: ReactionsMessageAttribute? @@ -5307,7 +5345,7 @@ func replayFinalState( added = true previousReactions = attribute updatedReactions = attribute.withUpdatedResults(reactions) - + if updatedReactions == attribute { return .skip } @@ -5318,14 +5356,14 @@ func replayFinalState( if !added { attributes.append(updatedReactions) } - + var tags = currentMessage.tags if updatedReactions.hasUnseen { tags.insert(.unseenReaction) } else { tags.remove(.unseenReaction) } - + 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: tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) }) case .UpdateAttachMenuBots: @@ -5348,7 +5386,7 @@ func replayFinalState( if !found { attributes.append(AudioTranscriptionMessageAttribute(id: id, text: text, isPending: isPending, didRate: false, error: nil)) } - + return .update(StoreMessage( id: currentMessage.id, customStableId: nil, @@ -5374,14 +5412,14 @@ func replayFinalState( var media = currentMessage.media let invoice = media.first(where: { $0 is TelegramMediaInvoice }) as? TelegramMediaInvoice let paidContent = media.first(where: { $0 is TelegramMediaPaidContent }) as? TelegramMediaPaidContent - + 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) } - + let updatedExtendedMedia = apiExtendedMedia.compactMap { TelegramExtendedMedia(apiExtendedMedia: $0, peerId: messageId.peerId) } - + if let first = updatedExtendedMedia.first, case .full = first { if var invoice = invoice { media = media.filter { !($0 is TelegramMediaInvoice) } @@ -5394,7 +5432,7 @@ func replayFinalState( media.append(paidContent) } } - + 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: currentMessage.attributes, media: media)) }) case let .ResetForumTopic(topicId, data, pts): @@ -5420,7 +5458,7 @@ func replayFinalState( return nil } } - + if let storedItem = Stories.StoredItem(apiStoryItem: story, existingItem: previousEntryStory, peerId: peerId, transaction: transaction) { if let currentIndex = updatedPeerEntries.firstIndex(where: { $0.id == storedItem.id }) { if case .item = storedItem { @@ -5445,7 +5483,7 @@ func replayFinalState( } } } - + var appliedMaxReadId: Int32? if let currentState = transaction.getPeerStoryState(peerId: peerId)?.entry.get(Stories.PeerState.self) { if let appliedMaxReadIdValue = appliedMaxReadId { @@ -5454,12 +5492,12 @@ func replayFinalState( appliedMaxReadId = currentState.maxReadId } } - + transaction.setStoryItems(peerId: peerId, items: updatedPeerEntries) transaction.setPeerStoryState(peerId: peerId, state: Stories.PeerState( maxReadId: appliedMaxReadId ?? 0 ).postboxRepresentation) - + if let parsedItem = Stories.StoredItem(apiStoryItem: story, peerId: peerId, transaction: transaction) { storyUpdates.append(InternalStoryUpdate.added(peerId: peerId, item: parsedItem)) } else { @@ -5470,11 +5508,11 @@ func replayFinalState( if let currentState = transaction.getPeerStoryState(peerId: peerId)?.entry.get(Stories.PeerState.self) { appliedMaxReadId = max(appliedMaxReadId, currentState.maxReadId) } - + transaction.setPeerStoryState(peerId: peerId, state: Stories.PeerState( maxReadId: appliedMaxReadId ).postboxRepresentation) - + storyUpdates.append(InternalStoryUpdate.read(peerId: peerId, maxId: maxId)) case let .UpdateStoryStealthMode(data): var configuration = _internal_getStoryConfigurationState(transaction: transaction) @@ -5482,13 +5520,13 @@ func replayFinalState( _internal_setStoryConfigurationState(transaction: transaction, state: configuration) case let .UpdateStorySentReaction(peerId, id, reaction): var updatedPeerEntries: [StoryItemsTableEntry] = transaction.getStoryItems(peerId: peerId) - + if let index = updatedPeerEntries.firstIndex(where: { item in return item.id == id }) { if let value = updatedPeerEntries[index].value.get(Stories.StoredItem.self), case let .item(item) = value { let updatedReaction = MessageReaction.Reaction(apiReaction: reaction) - + let updatedItem: Stories.StoredItem = .item(Stories.Item( id: item.id, timestamp: item.timestamp, @@ -5521,10 +5559,10 @@ func replayFinalState( } } transaction.setStoryItems(peerId: peerId, items: updatedPeerEntries) - + if let value = transaction.getStory(id: StoryId(peerId: peerId, id: id))?.get(Stories.StoredItem.self), case let .item(item) = value { let updatedReaction = MessageReaction.Reaction(apiReaction: reaction) - + let updatedItem: Stories.StoredItem = .item(Stories.Item( id: item.id, timestamp: item.timestamp, @@ -5606,7 +5644,7 @@ func replayFinalState( case let .UpdateMonoForumNoPaidException(peerId, threadId, isFree): if var data = transaction.getMessageHistoryThreadInfo(peerId: peerId, threadId: threadId)?.data.get(MessageHistoryThreadData.self) { data.isMessageFeeRemoved = isFree - + if let entry = StoredMessageHistoryThreadInfo(data) { transaction.setMessageHistoryThreadInfo(peerId: peerId, threadId: threadId, info: entry) } @@ -5619,7 +5657,7 @@ func replayFinalState( updatedEmojiGameInfo = info } } - + for messageId in holesFromPreviousStateMessageIds { let upperId: MessageId.Id if let value = topUpperHistoryBlockMessages[PeerIdAndMessageNamespace(peerId: messageId.peerId, namespace: messageId.namespace)], value < Int32.max { @@ -5629,26 +5667,26 @@ func replayFinalState( } if upperId >= messageId.id { transaction.addHole(peerId: messageId.peerId, threadId: nil, namespace: messageId.namespace, space: .everywhere, range: messageId.id ... upperId) - + transaction.addHole(peerId: messageId.peerId, threadId: nil, namespace: messageId.namespace, space: .tag(.pinned), range: 1 ... upperId) - + Logger.shared.log("State", "adding hole for peer \(messageId.peerId), \(messageId.id) ... \(upperId)") } else { Logger.shared.log("State", "not adding hole for peer \(messageId.peerId), \(upperId) >= \(messageId.id) = false") } } - + var resetForumTopicResults: [LoadMessageHistoryThreadsResult] = [] for (peerId, result) in finalState.state.resetForumTopicLists { for item in transaction.getMessageHistoryThreadIndex(peerId: peerId, limit: 10000) { let holeLowerBound = transaction.holeLowerBoundForTopValidRange(peerId: peerId, threadId: item.threadId, namespace: Namespaces.Message.Cloud, space: .everywhere) - + transaction.addHole(peerId: peerId, threadId: item.threadId, namespace: Namespaces.Message.Cloud, space: .everywhere, range: holeLowerBound ... (Int32.max - 1)) for tag in MessageTags.all { transaction.addHole(peerId: peerId, threadId: item.threadId, namespace: Namespaces.Message.Cloud, space: .tag(tag), range: holeLowerBound ... (Int32.max - 1)) } } - + switch result { case let .result(value): resetForumTopicResults.append(value) @@ -5659,9 +5697,9 @@ func replayFinalState( if !resetForumTopicResults.isEmpty { applyLoadMessageHistoryThreadsResults(accountPeerId: accountPeerId, transaction: transaction, results: resetForumTopicResults) } - + //TODO Please do not forget fix holes space. - + // could be the reason for unbounded slowdown, needs investigation // for (peerIdAndNamespace, pts) in clearHolesFromPreviousStateForChannelMessagesWithPts { // var upperMessageId: Int32? @@ -5692,15 +5730,15 @@ func replayFinalState( // } // } // } - + for (threadKey, difference) in messageThreadStatsDifferences { updateMessageThreadStats(transaction: transaction, threadKey: threadKey, removedCount: difference.removedCount, addedMessagePeers: difference.peers) } - + if !peerActivityTimestamps.isEmpty { updatePeerPresenceLastActivities(transaction: transaction, accountPeerId: accountPeerId, activities: peerActivityTimestamps) } - + if !stickerPackOperations.isEmpty { if stickerPackOperations.contains(where: { if case .sync = $0 { @@ -5756,7 +5794,7 @@ func replayFinalState( } } } - + for apiDocument in documents { if let file = telegramMediaFileFromApiDocument(apiDocument, altDocuments: []), let id = file.id { let fileIndexKeys: [MemoryBuffer] @@ -5779,9 +5817,9 @@ func replayFinalState( namespace = Namespaces.ItemCollection.CloudStickerPacks } } - + info = StickerPackCollectionInfo(apiSet: set, namespace: namespace) - + if namespace == Namespaces.ItemCollection.CloudMaskPacks && syncMasks { continue loop } else if namespace == Namespaces.ItemCollection.CloudStickerPacks && syncStickers { @@ -5789,7 +5827,7 @@ func replayFinalState( } else if namespace == Namespaces.ItemCollection.CloudEmojiPacks && syncEmoji { continue loop } - + var updatedInfos = transaction.getItemCollectionsInfos(namespace: info.id.namespace).map { $0.1 as! StickerPackCollectionInfo } if let index = updatedInfos.firstIndex(where: { $0.id == info.id }) { let currentInfo = updatedInfos[index] @@ -5844,7 +5882,7 @@ func replayFinalState( collectionNamespace = Namespaces.ItemCollection.CloudEmojiPacks } let currentInfos = transaction.getItemCollectionsInfos(namespace: collectionNamespace).map { $0.1 as! StickerPackCollectionInfo } - + var currentDict: [ItemCollectionId: StickerPackCollectionInfo] = [:] for info in currentInfos { currentDict[info.id] = info @@ -5879,7 +5917,7 @@ func replayFinalState( } } } - + if !recentlyUsedStickers.isEmpty { let stickerFiles: [TelegramMediaFile] = recentlyUsedStickers.values.sorted(by: { return $0.0 < $1.0 @@ -5890,7 +5928,7 @@ func replayFinalState( } } } - + if !slowModeLastMessageTimeouts.isEmpty { var peerIds:Set = Set() var cachedDatas:[PeerId : CachedChannelData] = [:] @@ -5917,7 +5955,7 @@ func replayFinalState( return cachedDatas[peerId] ?? current }) } - + if syncRecentGifs { addSynchronizeSavedGifsOperation(transaction: transaction, operation: .sync) } else { @@ -5932,19 +5970,19 @@ func replayFinalState( } } } - + for peerId in recentlyUsedGuestChatBots { _internal_addRecentlyUsedInlineBot(transaction: transaction, peerId: peerId) } - + if syncAttachMenuBots { // addSynchronizeAttachMenuBotsOperation(transaction: transaction) } - + for groupId in invalidateGroupStats { transaction.setNeedsPeerGroupMessageStatsSynchronization(groupId: groupId, namespace: Namespaces.Message.Cloud) } - + for chatPeerId in updatedSecretChatTypingActivities { if let peer = transaction.getPeer(chatPeerId) as? TelegramSecretChat { let authorId = peer.regularPeerId @@ -5956,11 +5994,11 @@ func replayFinalState( } } } - + var addedSecretMessageIds: [MessageId] = [] var addedSecretMessageAuthorIds: [PeerId: PeerId] = [:] let keepArchivedUnmuted = fetchGlobalPrivacySettings(transaction: transaction).keepArchivedUnmuted - + for peerId in peerIdsWithAddedSecretMessages { inner: while true { let keychain = (transaction.getPeerChatState(peerId) as? SecretChatState)?.keychain @@ -5971,7 +6009,7 @@ func replayFinalState( if let groupId = currentInclusion.groupId, groupId == Namespaces.PeerGroup.archive, !keepArchivedUnmuted { if let peer = transaction.getPeer(peerId) as? TelegramSecretChat { let isRemovedFromTotalUnreadCount = resolvedIsRemovedFromTotalUnreadCount(globalSettings: transaction.getGlobalNotificationSettings(), peer: peer, peerSettings: transaction.getPeerNotificationSettings(id: peer.regularPeerId)) - + if !isRemovedFromTotalUnreadCount { transaction.updatePeerChatListInclusion(peerId, inclusion: currentInclusion.withGroupId(groupId: .root)) } @@ -5995,7 +6033,7 @@ func replayFinalState( } } } - + for (chatPeerId, authorId) in addedSecretMessageAuthorIds { let activityValue: PeerInputActivity? = nil if updatedTypingActivities[PeerActivitySpace(peerId: chatPeerId, category: .global)] == nil { @@ -6004,7 +6042,7 @@ func replayFinalState( updatedTypingActivities[PeerActivitySpace(peerId: chatPeerId, category: .global)]![authorId] = activityValue } } - + if !pollLangPacks.isEmpty { addSynchronizeLocalizationUpdatesOperation(transaction: transaction) } else { @@ -6026,7 +6064,7 @@ func replayFinalState( } return lhsVersion < rhsVersion }) - + for difference in sortedLangPackDifference { if !tryApplyingLanguageDifference(transaction: transaction, langCode: langCode, difference: difference) { let _ = (postbox.transaction { transaction -> Void in @@ -6039,7 +6077,7 @@ func replayFinalState( } }).start() } - + if !updatedThemes.isEmpty { let entries = transaction.getOrderedListItems(collectionId: Namespaces.OrderedItemList.CloudThemes) let themes = entries.map { entry -> TelegramTheme in @@ -6078,7 +6116,7 @@ func replayFinalState( return PreferencesEntry(settings) }) } - + if !updatedWallpapers.isEmpty { for (peerId, wallpaper) in updatedWallpapers { transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, current in @@ -6092,20 +6130,20 @@ func replayFinalState( }) } } - + addedIncomingMessageIds.append(contentsOf: addedSecretMessageIds) - + for (uniqueId, messageIdValue) in finalState.state.updatedOutgoingUniqueMessageIds { if let peerId = removePossiblyDeliveredMessagesUniqueIds[uniqueId] { let messageId = MessageId(peerId: peerId, namespace: Namespaces.Message.Cloud, id: messageIdValue) deleteMessagesInteractively(transaction: transaction, stateManager: nil, postbox: postbox, messageIds: [messageId], type: .forEveryone, deleteAllInGroup: false, removeIfPossiblyDelivered: false) } } - + if syncChatListFilters { requestChatListFiltersSync(transaction: transaction) } - + for update in storyUpdates { switch update { case let .added(peerId, _): @@ -6121,7 +6159,7 @@ func replayFinalState( isContactOrMember = true } } - + if shouldKeepUserStoriesInFeed(peerId: peerId, isContactOrMember: isContactOrMember) { if !transaction.storySubscriptionsContains(key: .hidden, peerId: peerId) && !transaction.storySubscriptionsContains(key: .filtered, peerId: peerId) { _internal_addSynchronizePeerStoriesOperation(peerId: peerId, transaction: transaction) @@ -6131,11 +6169,11 @@ func replayFinalState( break } } - + if let updatedStarsReactionsDefaultPrivacy { _internal_setStarsReactionDefaultPrivacy(privacy: updatedStarsReactionsDefaultPrivacy, transaction: transaction) } - + if !liveTypingDraftUpdates.isEmpty { for (key, updates) in liveTypingDraftUpdates { if key.threadId == nil { @@ -6184,7 +6222,7 @@ func replayFinalState( timestamp = max(timestamp, index.timestamp) } } - + let draftText: String let draftAttributes: [MessageAttribute] switch update.content { @@ -6201,7 +6239,7 @@ func replayFinalState( richData ] } - + return ( update.id, Namespaces.Message.Cloud, @@ -6216,7 +6254,7 @@ func replayFinalState( } }) } - + return AccountReplayedFinalState( state: finalState, addedIncomingMessageIds: addedIncomingMessageIds, diff --git a/submodules/TelegramCore/Sources/State/ManagedAutoremoveMessageOperations.swift b/submodules/TelegramCore/Sources/State/ManagedAutoremoveMessageOperations.swift index f40bad06a6..3729451d65 100644 --- a/submodules/TelegramCore/Sources/State/ManagedAutoremoveMessageOperations.swift +++ b/submodules/TelegramCore/Sources/State/ManagedAutoremoveMessageOperations.swift @@ -8,11 +8,11 @@ private typealias SignalKitTimer = SwiftSignalKit.Timer private final class ManagedAutoremoveMessageOperationsHelper { var entry: (TimestampBasedMessageAttributesEntry, MetaDisposable)? - + func update(_ head: TimestampBasedMessageAttributesEntry?) -> (disposeOperations: [Disposable], beginOperations: [(TimestampBasedMessageAttributesEntry, MetaDisposable)]) { var disposeOperations: [Disposable] = [] var beginOperations: [(TimestampBasedMessageAttributesEntry, MetaDisposable)] = [] - + if self.entry?.0.index != head?.index { if let (_, disposable) = self.entry { self.entry = nil @@ -24,10 +24,10 @@ private final class ManagedAutoremoveMessageOperationsHelper { beginOperations.append((head, disposable)) } } - + return (disposeOperations, beginOperations) } - + func reset() -> [Disposable] { if let entry = entry { return [entry.1] @@ -40,12 +40,12 @@ private final class ManagedAutoremoveMessageOperationsHelper { func managedAutoremoveMessageOperations(network: Network, postbox: Postbox, isRemove: Bool) -> Signal { return Signal { _ in let helper = Atomic(value: ManagedAutoremoveMessageOperationsHelper()) - + let timeOffsetOnce = Signal { subscriber in subscriber.putNext(network.globalTimeDifference) return EmptyDisposable } - + let timeOffset = ( timeOffsetOnce |> then( @@ -67,11 +67,11 @@ func managedAutoremoveMessageOperations(network: Network, postbox: Postbox, isRe let (disposeOperations, beginOperations) = helper.with { helper -> (disposeOperations: [Disposable], beginOperations: [(TimestampBasedMessageAttributesEntry, MetaDisposable)]) in return helper.update(view.head) } - + for disposable in disposeOperations { disposable.dispose() } - + for (entry, disposable) in beginOperations { let timestamp = CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970 + timeOffset let delay = max(0.0, Double(entry.timestamp) - timestamp) @@ -82,7 +82,17 @@ func managedAutoremoveMessageOperations(network: Network, postbox: Postbox, isRe Logger.shared.log("Autoremove", "Performing autoremove for \(entry.messageId), isRemove: \(isRemove)") if let message = transaction.getMessage(entry.messageId) { - if message.id.peerId.namespace == Namespaces.Peer.SecretChat || isRemove { + if currentWinterGramCoreSettings.saveSelfDestructMessages && message.attributes.contains(where: { $0 is AutoremoveTimeoutMessageAttribute || $0 is AutoclearTimeoutMessageAttribute }) { + transaction.updateMessage(message.id, update: { currentMessage in + 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) + } + let updatedAttributes = currentMessage.attributes.filter { !($0 is AutoremoveTimeoutMessageAttribute || $0 is AutoclearTimeoutMessageAttribute) } + 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: updatedAttributes, media: currentMessage.media)) + }) + Logger.shared.log("Autoremove", "Preserved self-destruct message \(entry.messageId)") + } else if message.id.peerId.namespace == Namespaces.Peer.SecretChat || isRemove { _internal_deleteMessages(transaction: transaction, mediaBox: postbox.mediaBox, ids: [entry.messageId]) } else { transaction.updateMessage(message.id, update: { currentMessage in @@ -122,7 +132,7 @@ func managedAutoremoveMessageOperations(network: Network, postbox: Postbox, isRe disposable.set(signal.start()) } }) - + return ActionDisposable { disposable.dispose() let disposables = helper.with { helper -> [Disposable] in @@ -141,7 +151,7 @@ func managedAutoexpireStoryOperations(network: Network, postbox: Postbox) -> Sig subscriber.putNext(network.globalTimeDifference) return EmptyDisposable } - + let timeOffset = ( timeOffsetOnce |> then( @@ -156,7 +166,7 @@ func managedAutoexpireStoryOperations(network: Network, postbox: Postbox) -> Sig |> distinctUntilChanged Logger.shared.log("Autoexpire stories", "starting") - + let currentDisposable = MetaDisposable() let disposable = combineLatest(timeOffset, postbox.combinedView(keys: [PostboxViewKey.storyExpirationTimeItems])).start(next: { timeOffset, views in @@ -164,16 +174,16 @@ func managedAutoexpireStoryOperations(network: Network, postbox: Postbox) -> Sig currentDisposable.set(nil) return } - + let timestamp = CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970 + timeOffset let delay = max(0.0, Double(topItem.expirationTimestamp) - timestamp) - + let signal = Signal.complete() |> suspendAwareDelay(delay, queue: Queue.concurrentDefaultQueue()) |> then(postbox.transaction { transaction -> Void in var idsByPeerId: [PeerId: [Int32]] = [:] let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970 + timeOffset) - + for id in transaction.getExpiredStoryIds(belowTimestamp: timestamp + 3) { if idsByPeerId[id.peerId] == nil { idsByPeerId[id.peerId] = [id.id] @@ -181,17 +191,17 @@ func managedAutoexpireStoryOperations(network: Network, postbox: Postbox) -> Sig idsByPeerId[id.peerId]?.append(id.id) } } - + for (peerId, ids) in idsByPeerId { var items = transaction.getStoryItems(peerId: peerId) items.removeAll(where: { ids.contains($0.id) }) transaction.setStoryItems(peerId: peerId, items: items) } }) - + currentDisposable.set(signal.start()) }) - + return ActionDisposable { disposable.dispose() currentDisposable.dispose() diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift index ff5665e5f3..eced5a4bf4 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift @@ -10,7 +10,7 @@ public struct Namespaces { public static let ScheduledLocal: Int32 = 4 public static let QuickReplyCloud: Int32 = 5 public static let QuickReplyLocal: Int32 = 6 - + public static let allScheduled: Set = Set([Namespaces.Message.ScheduledCloud, Namespaces.Message.ScheduledLocal]) public static let allQuickReply: Set = Set([Namespaces.Message.QuickReplyCloud, Namespaces.Message.QuickReplyLocal]) public static let allNonRegular: Set = Set([Namespaces.Message.ScheduledCloud, Namespaces.Message.ScheduledLocal, Namespaces.Message.QuickReplyCloud, Namespaces.Message.QuickReplyLocal]) @@ -21,7 +21,7 @@ public struct Namespaces { Namespaces.Message.QuickReplyLocal ] } - + public struct Media { public static let CloudImage: Int32 = 0 public static let CloudAudio: Int32 = 2 @@ -39,7 +39,7 @@ public struct Namespaces { public static let LocalPoll: Int32 = 14 public static let CloudPoll: Int32 = 15 } - + public struct Peer { public static let CloudUser = PeerId.Namespace._internalFromInt32Value(0) public static let CloudGroup = PeerId.Namespace._internalFromInt32Value(1) @@ -47,7 +47,7 @@ public struct Namespaces { public static let SecretChat = PeerId.Namespace._internalFromInt32Value(3) public static let Empty = PeerId.Namespace.max } - + public struct ItemCollection { public static let CloudStickerPacks: Int32 = 0 public static let CloudMaskPacks: Int32 = 1 @@ -64,7 +64,7 @@ public struct Namespaces { public static let CloudIconChannelStatusEmoji: Int32 = 12 public static let CloudTonGifts: Int32 = 13 } - + public struct OrderedItemList { public static let CloudRecentStickers: Int32 = 0 public static let CloudRecentGifs: Int32 = 1 @@ -99,7 +99,7 @@ public struct Namespaces { public static let CloudUniqueStarGifts: Int32 = 30 public static let NewBotConnectionReviews: Int32 = 31 } - + public struct CachedItemCollection { public static let resolvedByNamePeers: Int8 = 0 public static let cachedTwoStepToken: Int8 = 1 @@ -152,7 +152,7 @@ public struct Namespaces { public static let cachedGiftUpgradesAttributes: Int8 = 52 public static let cachedCloudAITextStyles: Int8 = 53 } - + public struct UnorderedItemList { public static let synchronizedDeviceContacts: UnorderedItemListEntryTag = { let key = ValueBoxKey(length: 1) @@ -160,7 +160,7 @@ public struct Namespaces { return UnorderedItemListEntryTag(value: key) }() } - + public struct PeerGroup { public static let archive = PeerGroupId(rawValue: 1) } @@ -183,14 +183,14 @@ public extension MessageTags { static let roundVideo = MessageTags(rawValue: 1 << 13) static let polls = MessageTags(rawValue: 1 << 14) static let unseenPollVote = MessageTags(rawValue: 1 << 15) - + static let all: MessageTags = [.photoOrVideo, .file, .music, .webPage, .voiceOrInstantVideo, .unseenPersonalMessage, .liveLocation, .gif, .photo, .video, .pinned, .unseenReaction, .voice, .roundVideo, .polls, .unseenPollVote] } public extension GlobalMessageTags { static let Calls = GlobalMessageTags(rawValue: 1 << 0) static let MissedCalls = GlobalMessageTags(rawValue: 1 << 1) - + static let all: GlobalMessageTags = [.Calls, .MissedCalls] } @@ -243,15 +243,15 @@ public struct OperationLogTags { public struct LegacyPeerSummaryCounterTags: OptionSet, Sequence, Hashable { public var rawValue: Int32 - + public init(rawValue: Int32) { self.rawValue = rawValue } - + public static let regularChatsAndPrivateGroups = LegacyPeerSummaryCounterTags(rawValue: 1 << 0) public static let publicGroups = LegacyPeerSummaryCounterTags(rawValue: 1 << 1) public static let channels = LegacyPeerSummaryCounterTags(rawValue: 1 << 2) - + public func makeIterator() -> AnyIterator { var index = 0 return AnyIterator { () -> LegacyPeerSummaryCounterTags? in @@ -262,7 +262,7 @@ public struct LegacyPeerSummaryCounterTags: OptionSet, Sequence, Hashable { if currentTags == 0 { break } - + if (currentTags & 1) != 0 { return tag } @@ -278,7 +278,7 @@ public extension PeerSummaryCounterTags { static let group = PeerSummaryCounterTags(rawValue: 1 << 5) static let bot = PeerSummaryCounterTags(rawValue: 1 << 7) static let channel = PeerSummaryCounterTags(rawValue: 1 << 8) - + static let all: PeerSummaryCounterTags = [ .contact, .nonContact, @@ -330,6 +330,7 @@ private enum PreferencesKeyValues: Int32 { case savedMusicIds = 47 case emojiGameInfo = 48 case webBrowserSettings = 49 + case winterGramDeletedMessages = 50 } public func applicationSpecificPreferencesKey(_ value: Int32) -> ValueBoxKey { @@ -350,188 +351,188 @@ public struct PreferencesKeys { key.setInt32(0, value: PreferencesKeyValues.globalNotifications.rawValue) return key }() - + public static let suggestedLocalization: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.suggestedLocalization.rawValue) return key }() - + public static let limitsConfiguration: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.limitsConfiguration.rawValue) return key }() - + public static let contentPrivacySettings: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.contentPrivacySettings.rawValue) return key }() - + public static let networkSettings: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.networkSettings.rawValue) return key }() - + public static let remoteStorageConfiguration: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.remoteStorageConfiguration.rawValue) return key }() - + public static let voipConfiguration: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.voipConfiguration.rawValue) return key }() - + public static let appChangelogState: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.appChangelogState.rawValue) return key }() - + public static let localizationListState: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.localizationListState.rawValue) return key }() - + public static let appConfiguration: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.appConfiguration.rawValue) return key }() - + public static let searchBotsConfiguration: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.searchBotsConfiguration.rawValue) return key }() - + public static let contactsSettings: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.contactsSettings.rawValue) return key }() - + public static let secretChatSettings: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.secretChatSettings.rawValue) return key }() - + public static let contentSettings: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.contentSettings.rawValue) return key }() - + public static let webBrowserSettings: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.webBrowserSettings.rawValue) return key }() - + public static let chatListFilters: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.chatListFilters.rawValue) return key }() - + public static let chatListFiltersFeaturedState: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.chatListFiltersFeaturedState.rawValue) return key }() - + public static let reactionSettings: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.reactionSettings.rawValue) return key }() - + public static let premiumPromo: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.premiumPromo.rawValue) return key }() - + public static let globalMessageAutoremoveTimeoutSettings: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.globalMessageAutoremoveTimeoutSettings.rawValue) return key }() - + public static let accountSpecificCacheStorageSettings: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.accountSpecificCacheStorageSettings.rawValue) return key }() - + public static let linksConfiguration: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.linksConfiguration.rawValue) return key }() - + public static let chatListFilterUpdates: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.chatListFilterUpdates.rawValue) return key }() - + public static let globalPrivacySettings: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.globalPrivacySettings.rawValue) return key }() - + public static let storiesConfiguration: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.storiesConfiguration.rawValue) return key }() - + public static let audioTranscriptionTrialState: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.audioTranscriptionTrialState.rawValue) return key }() - + public static func didCacheSavedMessageTags(threadId: Int64?) -> ValueBoxKey { let key = ValueBoxKey(length: 4 + 8) key.setInt32(0, value: PreferencesKeyValues.didCacheSavedMessageTagsPrefix.rawValue) key.setInt64(4, value: threadId ?? 0) return key } - + public static func displaySavedChatsAsTopics() -> ValueBoxKey { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.displaySavedChatsAsTopics.rawValue) return key } - + public static func shortcutMessages() -> ValueBoxKey { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.shortcutMessages.rawValue) return key } - + public static func timezoneList() -> ValueBoxKey { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.timezoneList.rawValue) return key } - + static func botBiometricsStatePrefix() -> ValueBoxKey { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.botBiometricsState.rawValue) return key } - + static func extractBotBiometricsStatePeerId(key: ValueBoxKey) -> PeerId? { if key.length != 4 + 8 { return nil @@ -541,69 +542,75 @@ public struct PreferencesKeys { } return PeerId(key.getInt64(4)) } - + public static func botBiometricsState(peerId: PeerId) -> ValueBoxKey { let key = ValueBoxKey(length: 4 + 8) key.setInt32(0, value: PreferencesKeyValues.botBiometricsState.rawValue) key.setInt64(4, value: peerId.toInt64()) return key } - + public static func businessLinks() -> ValueBoxKey { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.businessLinks.rawValue) return key } - + public static func starGifts() -> ValueBoxKey { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.starGifts.rawValue) return key } - + public static func botStorageState(peerId: PeerId) -> ValueBoxKey { let key = ValueBoxKey(length: 4 + 8) key.setInt32(0, value: PreferencesKeyValues.botStorageState.rawValue) key.setInt64(4, value: peerId.toInt64()) return key } - + public static func secureBotStorageState() -> ValueBoxKey { let key = ValueBoxKey(length: 4 + 8) key.setInt32(0, value: PreferencesKeyValues.secureBotStorageState.rawValue) return key } - + public static func serverSuggestionInfo() -> ValueBoxKey { let key = ValueBoxKey(length: 4 + 8) key.setInt32(0, value: PreferencesKeyValues.serverSuggestionInfo.rawValue) return key } - + public static func persistentChatInterfaceData(peerId: PeerId) -> ValueBoxKey { let key = ValueBoxKey(length: 4 + 8) key.setInt32(0, value: PreferencesKeyValues.persistentChatInterfaceData.rawValue) key.setInt64(4, value: peerId.toInt64()) return key } - + public static func globalPostSearchState() -> ValueBoxKey { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.globalPostSearchState.rawValue) return key } - + public static func savedMusicIds() -> ValueBoxKey { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.savedMusicIds.rawValue) return key } - + public static func emojiGameInfo() -> ValueBoxKey { let key = ValueBoxKey(length: 4) key.setInt32(0, value: PreferencesKeyValues.emojiGameInfo.rawValue) return key } + + public static let winterGramDeletedMessages: ValueBoxKey = { + let key = ValueBoxKey(length: 4) + key.setInt32(0, value: PreferencesKeyValues.winterGramDeletedMessages.rawValue) + return key + }() } private enum SharedDataKeyValues: Int32 { @@ -625,37 +632,37 @@ public struct SharedDataKeys { key.setInt32(0, value: SharedDataKeyValues.loggingSettings.rawValue) return key }() - + public static let cacheStorageSettings: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: SharedDataKeyValues.cacheStorageSettings.rawValue) return key }() - + public static let localizationSettings: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: SharedDataKeyValues.localizationSettings.rawValue) return key }() - + public static let proxySettings: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: SharedDataKeyValues.proxySettings.rawValue) return key }() - + public static let autodownloadSettings: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: SharedDataKeyValues.autodownloadSettings.rawValue) return key }() - + public static let themeSettings: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: SharedDataKeyValues.themeSettings.rawValue) return key }() - + public static let countriesList: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: SharedDataKeyValues.countriesList.rawValue) @@ -667,13 +674,13 @@ public struct SharedDataKeys { key.setInt32(0, value: SharedDataKeyValues.wallapersState.rawValue) return key }() - + public static let chatThemes: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: SharedDataKeyValues.chatThemes.rawValue) return key }() - + public static let deviceContacts: ValueBoxKey = { let key = ValueBoxKey(length: 4) key.setInt32(0, value: SharedDataKeyValues.deviceContacts.rawValue) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/AccountData/TelegramEngineAccountData.swift b/submodules/TelegramCore/Sources/TelegramEngine/AccountData/TelegramEngineAccountData.swift index cb64547812..15bdc4c095 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/AccountData/TelegramEngineAccountData.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/AccountData/TelegramEngineAccountData.swift @@ -42,15 +42,15 @@ public extension TelegramEngine { public func updateAbout(about: String?) -> Signal { return _internal_updateAbout(account: self.account, about: about) } - + public func updateBirthday(birthday: TelegramBirthday?) -> Signal { return _internal_updateBirthday(account: self.account, birthday: birthday) } - + public func observeAvailableColorOptions(scope: PeerColorsScope) -> Signal { return _internal_observeAvailableColorOptions(postbox: self.account.postbox, scope: scope) } - + public func updateNameColorAndEmoji(nameColor: UpdateNameColor, profileColor: PeerNameColor?, profileBackgroundEmojiId: Int64?) -> Signal { return _internal_updateNameColorAndEmoji(account: self.account, nameColor: nameColor, profileColor: profileColor, profileBackgroundEmojiId: profileBackgroundEmojiId) } @@ -76,7 +76,7 @@ public extension TelegramEngine { public func removeAccountPhoto(reference: TelegramMediaImageReference?) -> Signal { return _internal_removeAccountPhoto(account: self.account, reference: reference, fallback: false) } - + public func updateFallbackPhoto(resource: EngineMediaResource?, videoResource: EngineMediaResource?, videoStartTimestamp: Double?, markup: UploadPeerPhotoMarkup?, mapResourceToAvatarSizes: @escaping (EngineMediaResource, [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError>) -> Signal { return _internal_updateAccountPhoto(account: self.account, resource: resource?._asResource(), videoResource: videoResource?._asResource(), videoStartTimestamp: videoStartTimestamp, markup: markup, fallback: true, mapResourceToAvatarSizes: { rawResource, representations in return mapResourceToAvatarSizes(EngineMediaResource(rawResource), representations) @@ -86,10 +86,10 @@ public extension TelegramEngine { public func removeFallbackPhoto(reference: TelegramMediaImageReference?) -> Signal { return _internal_removeAccountPhoto(account: self.account, reference: reference, fallback: true) } - + public func setStarGiftStatus(starGift: StarGift.UniqueGift, expirationDate: Int32?) -> Signal { let peerId = self.account.peerId - + var flags: Int32 = 0 if let _ = expirationDate { flags |= (1 << 0) @@ -123,13 +123,13 @@ public extension TelegramEngine { } else { apiEmojiStatus = .emojiStatusEmpty } - + let remoteApply = self.account.network.request(Api.functions.account.updateEmojiStatus(emojiStatus: apiEmojiStatus)) |> `catch` { _ -> Signal in return .single(.boolFalse) } |> ignoreValues - + return self.account.postbox.transaction { transaction -> Void in if let file, let patternFile { transaction.storeMediaIfNotPresent(media: file) @@ -146,10 +146,10 @@ public extension TelegramEngine { |> ignoreValues |> then(remoteApply) } - + public func setEmojiStatus(file: TelegramMediaFile?, expirationDate: Int32?) -> Signal { let peerId = self.account.peerId - + let remoteApply = self.account.network.request(Api.functions.account.updateEmojiStatus(emojiStatus: file.flatMap({ file in var flags: Int32 = 0 if let _ = expirationDate { @@ -161,17 +161,17 @@ public extension TelegramEngine { return .single(.boolFalse) } |> ignoreValues - + return self.account.postbox.transaction { transaction -> Void in if let file = file { transaction.storeMediaIfNotPresent(media: file) - + if let entry = CodableEntry(RecentMediaItem(file)) { let itemEntry = OrderedItemListEntry(id: RecentMediaItemId(file.fileId).rawValue, contents: entry) transaction.addOrMoveToFirstPositionOrderedItemListItem(collectionId: Namespaces.OrderedItemList.CloudRecentStatusEmoji, item: itemEntry, removeTailIfCountExceeds: 32) } } - + if let peer = transaction.getPeer(peerId) as? TelegramUser { updatePeersCustom(transaction: transaction, peers: [peer.withUpdatedEmojiStatus(file.flatMap({ PeerEmojiStatus(content: .emoji(fileId: $0.fileId.id), expirationDate: expirationDate) }))], update: { _, updated in updated @@ -181,10 +181,10 @@ public extension TelegramEngine { |> ignoreValues |> then(remoteApply) } - + public func updateAccountBusinessHours(businessHours: TelegramBusinessHours?) -> Signal { let peerId = self.account.peerId - + var flags: Int32 = 0 if businessHours != nil { flags |= 1 << 0 @@ -194,7 +194,7 @@ public extension TelegramEngine { return .single(.boolFalse) } |> ignoreValues - + return self.account.postbox.transaction { transaction -> Void in transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, current in let current = current as? CachedUserData ?? CachedUserData() @@ -204,30 +204,30 @@ public extension TelegramEngine { |> ignoreValues |> then(remoteApply) } - + public func updateAccountBusinessLocation(businessLocation: TelegramBusinessLocation?) -> Signal { let peerId = self.account.peerId - + var flags: Int32 = 0 - + var inputGeoPoint: Api.InputGeoPoint? var inputAddress: String? if let businessLocation { flags |= 1 << 0 inputAddress = businessLocation.address - + inputGeoPoint = businessLocation.coordinates?.apiInputGeoPoint if inputGeoPoint != nil { flags |= 1 << 1 } } - + let remoteApply: Signal = self.account.network.request(Api.functions.account.updateBusinessLocation(flags: flags, geoPoint: inputGeoPoint, address: inputAddress)) |> `catch` { _ -> Signal in return .single(.boolFalse) } |> ignoreValues - + return self.account.postbox.transaction { transaction -> Void in transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, current in let current = current as? CachedUserData ?? CachedUserData() @@ -237,7 +237,7 @@ public extension TelegramEngine { |> ignoreValues |> then(remoteApply) } - + public func shortcutMessageList(onlyRemote: Bool) -> Signal { return _internal_shortcutMessageList(account: self.account, onlyRemote: onlyRemote) } @@ -245,23 +245,23 @@ public extension TelegramEngine { public func keepShortcutMessageListUpdated() -> Signal { return _internal_keepShortcutMessagesUpdated(account: self.account) } - + public func editMessageShortcut(id: Int32, shortcut: String) { let _ = _internal_editMessageShortcut(account: self.account, id: id, shortcut: shortcut).startStandalone() } - + public func deleteMessageShortcuts(ids: [Int32]) { let _ = _internal_deleteMessageShortcuts(account: self.account, ids: ids).startStandalone() } - + public func reorderMessageShortcuts(ids: [Int32], completion: @escaping () -> Void) { let _ = _internal_reorderMessageShortcuts(account: self.account, ids: ids, localCompletion: completion).startStandalone() } - + public func sendMessageShortcut(peerId: EnginePeer.Id, id: Int32) { let _ = _internal_sendMessageShortcut(account: self.account, peerId: peerId, id: id).startStandalone() } - + public func cachedTimeZoneList() -> Signal { return _internal_cachedTimeZoneList(account: self.account) } @@ -269,49 +269,60 @@ public extension TelegramEngine { public func keepCachedTimeZoneListUpdated() -> Signal { return _internal_keepCachedTimeZoneListUpdated(account: self.account) } - + public func updateBusinessGreetingMessage(greetingMessage: TelegramBusinessGreetingMessage?) -> Signal { return _internal_updateBusinessGreetingMessage(account: self.account, greetingMessage: greetingMessage) } - + public func updateBusinessAwayMessage(awayMessage: TelegramBusinessAwayMessage?) -> Signal { return _internal_updateBusinessAwayMessage(account: self.account, awayMessage: awayMessage) } - + public func setAccountConnectedBot(bot: TelegramAccountConnectedBot?) -> Signal { return _internal_setAccountConnectedBot(account: self.account, bot: bot) } - + public func confirmBotConnectionReview(botId: PeerId) -> Signal { return _internal_confirmBotConnectionReview(account: self.account, botId: botId) } - + public func updateBusinessIntro(intro: TelegramBusinessIntro?) -> Signal { return _internal_updateBusinessIntro(account: self.account, intro: intro) } - + public func createBusinessChatLink(message: String, entities: [MessageTextEntity], title: String?) -> Signal { return _internal_createBusinessChatLink(account: self.account, message: message, entities: entities, title: title) } - + public func editBusinessChatLink(url: String, message: String, entities: [MessageTextEntity], title: String?) -> Signal { return _internal_editBusinessChatLink(account: self.account, url: url, message: message, entities: entities, title: title) } - + public func deleteBusinessChatLink(url: String) -> Signal { return _internal_deleteBusinessChatLink(account: self.account, url: url) } - + public func refreshBusinessChatLinks() -> Signal { return _internal_refreshBusinessChatLinks(postbox: self.account.postbox, network: self.account.network, accountPeerId: self.account.peerId) } - + public func updatePersonalChannel(personalChannel: TelegramPersonalChannel?) -> Signal { return _internal_updatePersonalChannel(account: self.account, personalChannel: personalChannel) } - + public func updateAdMessagesEnabled(enabled: Bool) -> Signal { return _internal_updateAdMessagesEnabled(account: self.account, enabled: enabled) } + + // WinterGram: send a one-shot offline status packet. Used by the "go offline after + // going online" ghost toggle to immediately drop back to offline after an action + // (e.g. sending a message) has forced the client online. + public func wntSendOfflinePresenceUpdate() -> Signal { + return self.account.network.request(Api.functions.account.updateStatus(offline: .boolTrue)) + |> `catch` { _ -> Signal in + return .single(.boolFalse) + } + |> ignoreValues + } } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/BotWebView.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/BotWebView.swift index 93d1d77c46..07df45070f 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/BotWebView.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/BotWebView.swift @@ -5,11 +5,15 @@ import TelegramApi import MtProtoKit #if os(macOS) -private let botWebViewPlatform = "macos" +private let defaultBotWebViewPlatform = "macos" #else -private let botWebViewPlatform = "ios" +private let defaultBotWebViewPlatform = "ios" #endif +private var botWebViewPlatform: String { + return currentWinterGramCoreSettings.webviewPlatform ?? defaultBotWebViewPlatform +} + public enum RequestSimpleWebViewSource : Equatable { case generic case inline(startParam: String?) @@ -30,9 +34,9 @@ func _internal_requestSimpleWebView(postbox: Postbox, network: Network, botId: P if let _ = serializedThemeParams { flags |= (1 << 0) } - + var startParam: String? = nil - + switch source { case let .inline(_startParam): startParam = _startParam @@ -89,7 +93,7 @@ func _internal_requestMainWebView(postbox: Postbox, network: Network, peerId: Pe flags |= (1 << 0) } var startParam: String? = nil - + switch source { case let .inline(_startParam): startParam = _startParam @@ -132,20 +136,20 @@ public enum KeepWebViewError { public struct RequestWebViewResult { public struct Flags: OptionSet { public var rawValue: Int32 - + public init(rawValue: Int32) { self.rawValue = rawValue } - + public init() { self.rawValue = 0 } - + public static let fullSize = Flags(rawValue: 1 << 0) public static let fullScreen = Flags(rawValue: 1 << 1) public static let sameOrigin = Flags(rawValue: 1 << 2) } - + public let flags: Flags public let queryId: Int64? public let url: String @@ -183,7 +187,7 @@ private func keepWebViewSignal(network: Network, stateManager: AccountStateManag return .generic } |> ignoreValues - + return signal.start(error: { error in subscriber.putError(error) }, completed: { @@ -196,11 +200,11 @@ private func keepWebViewSignal(network: Network, stateManager: AccountStateManag |> then (poll) ) |> restart - + let pollDisposable = keepAliveSignal.start(error: { error in subscriber.putError(error) }) - + let dismissDisposable = (stateManager.dismissBotWebViews |> filter { $0.contains(queryId) @@ -208,7 +212,7 @@ private func keepWebViewSignal(network: Network, stateManager: AccountStateManag |> take(1)).start(completed: { subscriber.putCompletion() }) - + let disposableSet = DisposableSet() disposableSet.add(pollDisposable) disposableSet.add(dismissDisposable) @@ -222,7 +226,7 @@ func _internal_requestWebView(postbox: Postbox, network: Network, stateManager: if let themeParams = themeParams, let data = try? JSONSerialization.data(withJSONObject: themeParams, options: []), let dataString = String(data: data, encoding: .utf8) { serializedThemeParams = .dataJSON(.init(data: dataString)) } - + return postbox.transaction { transaction -> Signal in guard let peer = transaction.getPeer(peerId), let inputPeer = apiInputPeer(peer), let bot = transaction.getPeer(botId), let inputBot = apiInputUser(bot) else { return .fail(.generic) @@ -241,9 +245,9 @@ func _internal_requestWebView(postbox: Postbox, network: Network, stateManager: if fromMenu { flags |= (1 << 4) } - + var replyTo: Api.InputReplyTo? - + var monoforumPeerId: Api.InputPeer? var topMsgId: Int32? if let threadId { @@ -253,12 +257,12 @@ func _internal_requestWebView(postbox: Postbox, network: Network, stateManager: topMsgId = Int32(clamping: threadId) } } - + if let replyToMessageId = replyToMessageId { flags |= (1 << 0) - + var replyFlags: Int32 = 0 - + if monoforumPeerId != nil { replyFlags |= 1 << 5 } else if topMsgId != nil { @@ -311,7 +315,7 @@ func _internal_sendWebViewData(postbox: Postbox, network: Network, stateManager: guard let bot = transaction.getPeer(botId), let inputBot = apiInputUser(bot) else { return .fail(.generic) } - + return network.request(Api.functions.messages.sendWebViewData(bot: inputBot, randomId: Int64.random(in: Int64.min ... Int64.max), buttonText: buttonText, data: data)) |> mapError { _ -> SendWebViewDataError in return .generic @@ -331,12 +335,12 @@ func _internal_requestAppWebView(postbox: Postbox, network: Network, stateManage if let themeParams = themeParams, let data = try? JSONSerialization.data(withJSONObject: themeParams, options: []), let dataString = String(data: data, encoding: .utf8) { serializedThemeParams = .dataJSON(.init(data: dataString)) } - + return postbox.transaction { transaction -> Signal in guard let peer = transaction.getPeer(peerId), let inputPeer = apiInputPeer(peer) else { return .fail(.generic) } - + let app: Api.InputBotApp switch appReference { case let .id(id, accessHash): @@ -364,7 +368,7 @@ func _internal_requestAppWebView(postbox: Postbox, network: Network, stateManage if fullscreen { flags |= (1 << 8) } - + return network.request(Api.functions.messages.requestAppWebView(flags: flags, peer: inputPeer, app: app, startParam: payload, themeParams: serializedThemeParams, platform: botWebViewPlatform)) |> mapError { _ -> RequestWebViewError in return .generic @@ -463,20 +467,20 @@ func _internal_invokeBotCustomMethod(postbox: Postbox, network: Network, botId: public struct TelegramSecureBotStorageState: Codable, Equatable { public let uuid: String - + public init(uuid: String) { self.uuid = uuid } - + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: StringCodingKey.self) - + self.uuid = try container.decode(String.self, forKey: "uuid") } - + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: StringCodingKey.self) - + try container.encode(self.uuid, forKey: "uuid") } } @@ -486,7 +490,7 @@ func _internal_secureBotStorageUuid(account: Account) -> Signal if let current = transaction.getPreferencesEntry(key: PreferencesKeys.secureBotStorageState())?.get(TelegramSecureBotStorageState.self) { return current.uuid } - + let uuid = "\(Int64.random(in: 0 ..< .max))" transaction.setPreferencesEntry(key: PreferencesKeys.secureBotStorageState(), value: PreferencesEntry(TelegramSecureBotStorageState(uuid: uuid))) return uuid @@ -499,18 +503,18 @@ public struct TelegramBotStorageState: Codable, Equatable { var key: String var value: String } - + public var data: [String: String] - + public init( data: [String: String] ) { self.data = data } - + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: StringCodingKey.self) - + let values = try container.decode([KeyValue].self, forKey: "data") var data: [String: String] = [:] for pair in values { @@ -518,10 +522,10 @@ public struct TelegramBotStorageState: Codable, Equatable { } self.data = data } - + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: StringCodingKey.self) - + var values: [KeyValue] = [] for (key, value) in self.data { values.append(KeyValue(key: key, value: value)) @@ -534,7 +538,7 @@ private func _internal_updateBotStorageState(account: Account, peerId: EnginePee return account.postbox.transaction { transaction -> Signal in let previousState = transaction.getPreferencesEntry(key: PreferencesKeys.botStorageState(peerId: peerId))?.get(TelegramBotStorageState.self) let updatedState = update(previousState) - + var totalSize = 0 for (_, value) in updatedState.data { totalSize += value.utf8.count @@ -542,7 +546,7 @@ private func _internal_updateBotStorageState(account: Account, peerId: EnginePee guard totalSize <= maxBotStorageSize else { return .fail(.quotaExceeded) } - + transaction.setPreferencesEntry(key: PreferencesKeys.botStorageState(peerId: peerId), value: PreferencesEntry(updatedState)) return .never() } @@ -577,18 +581,18 @@ public struct TelegramBotBiometricsState: Codable, Equatable { public struct OpaqueToken: Codable, Equatable { public let publicKey: Data public let data: Data - + public init(publicKey: Data, data: Data) { self.publicKey = publicKey self.data = data } } - + public var deviceId: Data public var accessRequested: Bool public var accessGranted: Bool public var opaqueToken: OpaqueToken? - + public static func create() -> TelegramBotBiometricsState { var deviceId = Data(count: 32) deviceId.withUnsafeMutableBytes { buffer -> Void in @@ -602,7 +606,7 @@ public struct TelegramBotBiometricsState: Codable, Equatable { opaqueToken: nil ) } - + public init(deviceId: Data, accessRequested: Bool, accessGranted: Bool, opaqueToken: OpaqueToken?) { self.deviceId = deviceId self.accessRequested = accessRequested @@ -614,7 +618,7 @@ public struct TelegramBotBiometricsState: Codable, Equatable { func _internal_updateBotBiometricsState(account: Account, peerId: EnginePeer.Id, update: @escaping (TelegramBotBiometricsState?) -> TelegramBotBiometricsState) -> Signal { return account.postbox.transaction { transaction -> Void in let previousState = transaction.getPreferencesEntry(key: PreferencesKeys.botBiometricsState(peerId: peerId))?.get(TelegramBotBiometricsState.self) - + transaction.setPreferencesEntry(key: PreferencesKeys.botBiometricsState(peerId: peerId), value: PreferencesEntry(update(previousState))) } |> ignoreValues @@ -627,7 +631,7 @@ func _internal_botsWithBiometricState(account: Account) -> Signal() for (key, value) in view.values { guard let peerId = PreferencesKeys.extractBotBiometricsStatePeerId(key: key) else { @@ -638,7 +642,7 @@ func _internal_botsWithBiometricState(account: Account) -> Signal guard let current = current as? CachedUserData else { return current } - + if var peerStatusSettings = current.peerStatusSettings { peerStatusSettings.managingBot = nil - + return current.withUpdatedPeerStatusSettings(peerStatusSettings) } else { return current @@ -703,7 +707,7 @@ func _internal_removeChatManagingBot(account: Account, chatId: EnginePeer.Id) -> guard let current = current as? CachedUserData else { return current } - + if let connectedBot = current.connectedBot { var additionalPeers = connectedBot.recipients.additionalPeers var excludePeers = connectedBot.recipients.excludePeers @@ -713,7 +717,7 @@ func _internal_removeChatManagingBot(account: Account, chatId: EnginePeer.Id) -> additionalPeers.remove(chatId) excludePeers.insert(chatId) } - + return current.withUpdatedConnectedBot(TelegramAccountConnectedBot( id: connectedBot.id, recipients: TelegramBusinessRecipients( @@ -775,7 +779,7 @@ public final class EngineConnectedStarRefBotsContext { public let durationMonths: Int32? public let participants: Int64 public let revenue: Int64 - + public init(peer: EnginePeer, url: String, timestamp: Int32, commissionPermille: Int32, durationMonths: Int32?, participants: Int64, revenue: Int64) { self.peer = peer self.url = url @@ -785,7 +789,7 @@ public final class EngineConnectedStarRefBotsContext { self.participants = participants self.revenue = revenue } - + public static func ==(lhs: Item, rhs: Item) -> Bool { if lhs.peer != rhs.peer { return false @@ -811,25 +815,25 @@ public final class EngineConnectedStarRefBotsContext { return true } } - + public struct State: Equatable { public struct Offset: Equatable { fileprivate var isInitial: Bool fileprivate var timestamp: Int32 fileprivate var link: String - + fileprivate init(isInitial: Bool, timestamp: Int32, link: String) { self.isInitial = isInitial self.timestamp = timestamp self.link = link } } - + public var items: [Item] public var totalCount: Int public var nextOffset: Offset? public var isLoaded: Bool - + public init(items: [Item], totalCount: Int, nextOffset: Offset?, isLoaded: Bool) { self.items = items self.totalCount = totalCount @@ -837,31 +841,31 @@ public final class EngineConnectedStarRefBotsContext { self.isLoaded = isLoaded } } - + private final class Impl { let queue: Queue let account: Account let peerId: EnginePeer.Id - + var state: State var pendingRemoveItems = Set() var statePromise = Promise() - + var loadMoreDisposable: Disposable? var isLoadingMore: Bool = false - + var eventsDisposable: Disposable? - + init(queue: Queue, account: Account, peerId: EnginePeer.Id) { self.queue = queue self.account = account self.peerId = peerId - + self.state = State(items: [], totalCount: 0, nextOffset: State.Offset(isInitial: true, timestamp: 0, link: ""), isLoaded: false) self.updateState() - + self.loadMore() - + self.eventsDisposable = (account.stateManager.starRefBotConnectionEvents() |> deliverOn(self.queue)).startStrict(next: { [weak self] event in guard let self else { @@ -881,13 +885,13 @@ public final class EngineConnectedStarRefBotsContext { } }) } - + deinit { assert(self.queue.isCurrent()) self.loadMoreDisposable?.dispose() self.eventsDisposable?.dispose() } - + func loadMore() { if self.isLoadingMore { return @@ -896,7 +900,7 @@ public final class EngineConnectedStarRefBotsContext { return } self.isLoadingMore = true - + var effectiveOffset: (timestamp: Int32, link: String)? if !offset.isInitial { effectiveOffset = (timestamp: offset.timestamp, link: offset.link) @@ -907,9 +911,9 @@ public final class EngineConnectedStarRefBotsContext { guard let self else { return } - + self.isLoadingMore = false - + self.state.isLoaded = true if let result, !result.items.isEmpty { for item in result.items { @@ -929,11 +933,11 @@ public final class EngineConnectedStarRefBotsContext { self.state.totalCount = self.state.items.count self.state.nextOffset = nil } - + self.updateState() }) } - + private func updateState() { var state = self.state if !self.pendingRemoveItems.isEmpty { @@ -943,23 +947,23 @@ public final class EngineConnectedStarRefBotsContext { } self.statePromise.set(.single(state)) } - + func remove(url: String) { self.pendingRemoveItems.insert(url) let _ = _internal_removeConnectedStarRefBot(account: self.account, id: self.peerId, link: url).startStandalone() self.updateState() } } - + private let queue: Queue private let impl: QueueLocalObject - + public var state: Signal { return self.impl.signalWith { impl, subscriber in return impl.statePromise.get().start(next: subscriber.putNext) } } - + init(account: Account, peerId: EnginePeer.Id) { let queue = Queue.mainQueue() self.queue = queue @@ -967,13 +971,13 @@ public final class EngineConnectedStarRefBotsContext { return Impl(queue: queue, account: account, peerId: peerId) }) } - + public func loadMore() { self.impl.with { impl in impl.loadMore() } } - + public func remove(url: String) { self.impl.with { impl in impl.remove(url: url) @@ -985,12 +989,12 @@ public final class EngineSuggestedStarRefBotsContext { public final class Item: Equatable { public let peer: EnginePeer public let program: TelegramStarRefProgram - + public init(peer: EnginePeer, program: TelegramStarRefProgram) { self.peer = peer self.program = program } - + public static func ==(lhs: Item, rhs: Item) -> Bool { if lhs.peer != rhs.peer { return false @@ -1001,13 +1005,13 @@ public final class EngineSuggestedStarRefBotsContext { return true } } - + public struct State: Equatable { public var items: [Item] public var totalCount: Int public var nextOffset: String? public var isLoaded: Bool - + public init(items: [Item], totalCount: Int, nextOffset: String?, isLoaded: Bool) { self.items = items self.totalCount = totalCount @@ -1015,42 +1019,42 @@ public final class EngineSuggestedStarRefBotsContext { self.isLoaded = isLoaded } } - + public enum SortMode { case date case profitability case revenue } - + private final class Impl { let queue: Queue let account: Account let peerId: EnginePeer.Id let sortMode: SortMode - + var state: State var statePromise = Promise() - + var loadMoreDisposable: Disposable? var isLoadingMore: Bool = false - + init(queue: Queue, account: Account, peerId: EnginePeer.Id, sortMode: SortMode) { self.queue = queue self.account = account self.peerId = peerId self.sortMode = sortMode - + self.state = State(items: [], totalCount: 0, nextOffset: "", isLoaded: false) self.updateState() - + self.loadMore() } - + deinit { assert(self.queue.isCurrent()) self.loadMoreDisposable?.dispose() } - + func loadMore() { if self.isLoadingMore { return @@ -1059,7 +1063,7 @@ public final class EngineSuggestedStarRefBotsContext { return } self.isLoadingMore = true - + self.loadMoreDisposable?.dispose() self.loadMoreDisposable = (_internal_requestSuggestedStarRefBots(account: self.account, id: self.peerId, sortMode: self.sortMode, offset: offset, limit: 100) |> deliverOn(self.queue)).startStrict(next: { [weak self] result in @@ -1067,7 +1071,7 @@ public final class EngineSuggestedStarRefBotsContext { return } self.isLoadingMore = false - + self.state.isLoaded = true if let result, !result.items.isEmpty { for item in result.items { @@ -1085,26 +1089,26 @@ public final class EngineSuggestedStarRefBotsContext { self.state.totalCount = self.state.items.count self.state.nextOffset = nil } - + self.updateState() }) } - + private func updateState() { self.statePromise.set(.single(self.state)) } } - + private let queue: Queue public let sortMode: SortMode private let impl: QueueLocalObject - + public var state: Signal { return self.impl.signalWith { impl, subscriber in return impl.statePromise.get().start(next: subscriber.putNext) } } - + init(account: Account, peerId: EnginePeer.Id, sortMode: SortMode) { let queue = Queue.mainQueue() self.queue = queue @@ -1113,7 +1117,7 @@ public final class EngineSuggestedStarRefBotsContext { return Impl(queue: queue, account: account, peerId: peerId, sortMode: sortMode) }) } - + public func loadMore() { self.impl.with { impl in impl.loadMore() @@ -1129,12 +1133,12 @@ func _internal_updateStarRefProgram(account: Account, id: EnginePeer.Id, program guard let inputPeer else { return .complete() } - + var flags: Int32 = 0 if let program, program.durationMonths != nil { flags |= 1 << 0 } - + return account.network.request(Api.functions.bots.updateStarRefProgram( flags: flags, bot: inputPeer, @@ -1195,7 +1199,7 @@ fileprivate func _internal_requestConnectedStarRefBots(account: Account, id: En case let .connectedStarRefBots(connectedStarRefBotsData): let (count, connectedBots, users) = (connectedStarRefBotsData.count, connectedStarRefBotsData.connectedBots, connectedStarRefBotsData.users) updatePeers(transaction: transaction, accountPeerId: account.peerId, peers: AccumulatedPeers(users: users)) - + var items: [EngineConnectedStarRefBotsContext.Item] = [] for connectedBot in connectedBots { switch connectedBot { @@ -1215,14 +1219,14 @@ fileprivate func _internal_requestConnectedStarRefBots(account: Account, id: En )) } } - + var nextOffset: (timestamp: Int32, link: String)? if !connectedBots.isEmpty { nextOffset = items.last.flatMap { item in return (item.timestamp, item.url) } } - + return (items: items, totalCount: Int(count), nextOffset: nextOffset) } } @@ -1266,7 +1270,7 @@ fileprivate func _internal_requestSuggestedStarRefBots(account: Account, id: Eng case let .suggestedStarRefBots(suggestedStarRefBotsData): let (count, suggestedBots, users, nextOffset) = (suggestedStarRefBotsData.count, suggestedStarRefBotsData.suggestedBots, suggestedStarRefBotsData.users, suggestedStarRefBotsData.nextOffset) updatePeers(transaction: transaction, accountPeerId: account.peerId, peers: AccumulatedPeers(users: users)) - + var items: [EngineSuggestedStarRefBotsContext.Item] = [] for starRefProgram in suggestedBots { let parsedProgram = TelegramStarRefProgram(apiStarRefProgram: starRefProgram) @@ -1278,7 +1282,7 @@ fileprivate func _internal_requestSuggestedStarRefBots(account: Account, id: Eng program: parsedProgram )) } - + return (items: items, totalCount: Int(count), nextOffset: nextOffset) } } @@ -1372,7 +1376,7 @@ fileprivate func _internal_removeConnectedStarRefBot(account: Account, id: Engin let _ = connectedBots } - + account.stateManager.addStarRefBotConnectionEvent(event: .remove(peerId: id, url: link)) } |> castError(ConnectStarRefBotError.self) @@ -1415,7 +1419,7 @@ func _internal_getStarRefBotConnection(account: Account, id: EnginePeer.Id, targ if isRevoked { return nil } - + guard let botPeer = transaction.getPeer(PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(botId))) else { return nil } @@ -1453,18 +1457,18 @@ func _internal_getPossibleStarRefBotTargets(account: Account) -> Signal<[EngineP |> mapToSignal { apiBots, apiChannels -> Signal<[EnginePeer], NoError> in return account.postbox.transaction { transaction -> [EnginePeer] in var result: [EnginePeer] = [] - + if let peer = transaction.getPeer(account.peerId) { result.append(EnginePeer(peer)) } - + updatePeers(transaction: transaction, accountPeerId: account.peerId, peers: AccumulatedPeers(users: apiBots)) for bot in apiBots { if let peer = transaction.getPeer(bot.peerId) { result.append(EnginePeer(peer)) } } - + if let apiChannels { switch apiChannels { case let .chats(chatsData): @@ -1487,7 +1491,7 @@ func _internal_getPossibleStarRefBotTargets(account: Account) -> Signal<[EngineP } } } - + return result } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/MarkMessageContentAsConsumedInteractively.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/MarkMessageContentAsConsumedInteractively.swift index 43408c150c..8e678ed533 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/MarkMessageContentAsConsumedInteractively.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/MarkMessageContentAsConsumedInteractively.swift @@ -8,13 +8,13 @@ func _internal_markMessageContentAsConsumedInteractively(postbox: Postbox, messa if let message = transaction.getMessage(messageId), message.flags.contains(.Incoming) { var updateMessage = false var updatedAttributes = message.attributes - + for i in 0 ..< updatedAttributes.count { if let attribute = updatedAttributes[i] as? ConsumableContentMessageAttribute { if !attribute.consumed { updatedAttributes[i] = ConsumableContentMessageAttribute(consumed: true) updateMessage = true - + if message.id.peerId.namespace == Namespaces.Peer.SecretChat { if let state = transaction.getPeerChatState(message.id.peerId) as? SecretChatState { var layer: SecretChatLayer? @@ -46,7 +46,7 @@ func _internal_markMessageContentAsConsumedInteractively(postbox: Postbox, messa updatedAttributes[i] = ConsumablePersonalMentionMessageAttribute(consumed: attribute.consumed, pending: true) } } - + let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) for i in 0 ..< updatedAttributes.count { if let attribute = updatedAttributes[i] as? AutoremoveTimeoutMessageAttribute { @@ -57,7 +57,7 @@ func _internal_markMessageContentAsConsumedInteractively(postbox: Postbox, messa } updatedAttributes[i] = AutoremoveTimeoutMessageAttribute(timeout: timeout, countdownBeginTime: timestamp) updateMessage = true - + if messageId.peerId.namespace == Namespaces.Peer.SecretChat { var layer: SecretChatLayer? let state = transaction.getPeerChatState(message.id.peerId) as? SecretChatState @@ -71,7 +71,7 @@ func _internal_markMessageContentAsConsumedInteractively(postbox: Postbox, messa layer = sequenceState.layerNegotiationState.activeLayer.secretChatLayer } } - + if let state = state, let layer = layer, let globallyUniqueId = message.globallyUniqueId { let updatedState = addSecretChatOutgoingOperation(transaction: transaction, peerId: messageId.peerId, operation: .readMessagesContent(layer: layer, actionGloballyUniqueId: Int64.random(in: Int64.min ... Int64.max), globallyUniqueIds: [globallyUniqueId]), state: state) if updatedState != state { @@ -88,7 +88,7 @@ func _internal_markMessageContentAsConsumedInteractively(postbox: Postbox, messa } updatedAttributes[i] = AutoclearTimeoutMessageAttribute(timeout: timeout, countdownBeginTime: timestamp) updateMessage = true - + if messageId.peerId.namespace == Namespaces.Peer.SecretChat { var layer: SecretChatLayer? let state = transaction.getPeerChatState(message.id.peerId) as? SecretChatState @@ -102,7 +102,7 @@ func _internal_markMessageContentAsConsumedInteractively(postbox: Postbox, messa layer = sequenceState.layerNegotiationState.activeLayer.secretChatLayer } } - + if let state = state, let layer = layer, let globallyUniqueId = message.globallyUniqueId { let updatedState = addSecretChatOutgoingOperation(transaction: transaction, peerId: messageId.peerId, operation: .readMessagesContent(layer: layer, actionGloballyUniqueId: Int64.random(in: Int64.min ... Int64.max), globallyUniqueIds: [globallyUniqueId]), state: state) if updatedState != state { @@ -113,7 +113,7 @@ func _internal_markMessageContentAsConsumedInteractively(postbox: Postbox, messa } } } - + if updateMessage { transaction.updateMessage(message.id, update: { currentMessage in var storeForwardInfo: StoreMessageForwardInfo? @@ -133,12 +133,12 @@ func _internal_markReactionsOrPollVotesAsSeenInteractively(postbox: Postbox, mes var updateMessage = false var updatedAttributes = message.attributes var updatedMedia = message.media - + for i in 0 ..< updatedAttributes.count { if let attribute = updatedAttributes[i] as? ReactionsMessageAttribute, attribute.hasUnseen { updatedAttributes[i] = attribute.withAllSeen() updateMessage = true - + if message.id.peerId.namespace == Namespaces.Peer.SecretChat { } else { transaction.setPendingMessageAction(type: .readReactionOrPollVote, id: messageId, action: ReadReactionAction()) @@ -149,14 +149,14 @@ func _internal_markReactionsOrPollVotesAsSeenInteractively(postbox: Postbox, mes if let poll = updatedMedia[i] as? TelegramMediaPoll { updatedMedia[i] = poll.withoutUnreadResults() updateMessage = true - + if message.id.peerId.namespace == Namespaces.Peer.SecretChat { } else { transaction.setPendingMessageAction(type: .readReactionOrPollVote, id: messageId, action: ReadReactionAction()) } } } - + if updateMessage { transaction.updateMessage(message.id, update: { currentMessage in var storeForwardInfo: StoreMessageForwardInfo? @@ -179,7 +179,7 @@ func markMessageContentAsConsumedRemotely(transaction: Transaction, messageId: M var updatedAttributes = message.attributes var updatedMedia = message.media var updatedTags = message.tags - + for i in 0 ..< updatedAttributes.count { if let attribute = updatedAttributes[i] as? ConsumableContentMessageAttribute { if !attribute.consumed { @@ -195,18 +195,18 @@ func markMessageContentAsConsumedRemotely(transaction: Transaction, messageId: M updateMessage = true } } - + let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) let countdownBeginTime = consumeDate ?? timestamp - + for i in 0 ..< updatedAttributes.count { if let attribute = updatedAttributes[i] as? AutoremoveTimeoutMessageAttribute { if (attribute.countdownBeginTime == nil || attribute.countdownBeginTime == 0) && message.containsSecretMedia { updatedAttributes[i] = AutoremoveTimeoutMessageAttribute(timeout: attribute.timeout, countdownBeginTime: countdownBeginTime) updateMessage = true - + if message.id.peerId.namespace == Namespaces.Peer.SecretChat { - } else { + } else if !currentWinterGramCoreSettings.saveSelfDestructMessages { if attribute.timeout == viewOnceTimeout || timestamp >= countdownBeginTime + attribute.timeout { for i in 0 ..< updatedMedia.count { if let _ = updatedMedia[i] as? TelegramMediaImage { @@ -228,9 +228,9 @@ func markMessageContentAsConsumedRemotely(transaction: Transaction, messageId: M if (attribute.countdownBeginTime == nil || attribute.countdownBeginTime == 0) && message.containsSecretMedia { updatedAttributes[i] = AutoclearTimeoutMessageAttribute(timeout: attribute.timeout, countdownBeginTime: countdownBeginTime) updateMessage = true - + if message.id.peerId.namespace == Namespaces.Peer.SecretChat { - } else { + } else if !currentWinterGramCoreSettings.saveSelfDestructMessages { for i in 0 ..< updatedMedia.count { if attribute.timeout == viewOnceTimeout || timestamp >= countdownBeginTime + attribute.timeout { if let _ = updatedMedia[i] as? TelegramMediaImage { @@ -250,7 +250,7 @@ func markMessageContentAsConsumedRemotely(transaction: Transaction, messageId: M } } } - + if updateMessage { transaction.updateMessage(message.id, update: { currentMessage in var storeForwardInfo: StoreMessageForwardInfo? diff --git a/submodules/TelegramCore/Sources/WinterGram/WinterGramCoreSettings.swift b/submodules/TelegramCore/Sources/WinterGram/WinterGramCoreSettings.swift new file mode 100644 index 0000000000..27e3006aa4 --- /dev/null +++ b/submodules/TelegramCore/Sources/WinterGram/WinterGramCoreSettings.swift @@ -0,0 +1,41 @@ +import Foundation +import SwiftSignalKit + +public struct WinterGramCoreSettings: Equatable { + public var saveDeletedMessages: Bool + public var saveMessageEditHistory: Bool + // When false, deletions in bot chats are not preserved even if saveDeletedMessages is on. + public var saveForBots: Bool + // Preserve self-destructing / view-once secret-chat messages instead of letting them expire. + public var saveSelfDestructMessages: Bool + // Disable screenshot reporting in secret chats and capture-protection in galleries. + public var allowScreenshots: Bool + // Platform string sent to bots when opening a web view ("ios", "android", + // "macos", "tdesktop"); nil means the real platform. + public var webviewPlatform: String? + + public init(saveDeletedMessages: Bool, saveMessageEditHistory: Bool, saveForBots: Bool = false, saveSelfDestructMessages: Bool = false, allowScreenshots: Bool = false, webviewPlatform: String? = nil) { + self.saveDeletedMessages = saveDeletedMessages + self.saveMessageEditHistory = saveMessageEditHistory + self.saveForBots = saveForBots + self.saveSelfDestructMessages = saveSelfDestructMessages + self.allowScreenshots = allowScreenshots + self.webviewPlatform = webviewPlatform + } + + public static var defaultSettings: WinterGramCoreSettings { + return WinterGramCoreSettings(saveDeletedMessages: false, saveMessageEditHistory: false, saveForBots: false, saveSelfDestructMessages: false, allowScreenshots: false, webviewPlatform: nil) + } +} + +private let currentValue = Atomic(value: .defaultSettings) + +// TelegramCore cannot depend on TelegramUIPreferences, so the UI layer pushes +// the relevant flags here at startup and on every settings change. +public var currentWinterGramCoreSettings: WinterGramCoreSettings { + return currentValue.with { $0 } +} + +public func setCurrentWinterGramCoreSettings(_ settings: WinterGramCoreSettings) { + let _ = currentValue.swap(settings) +} diff --git a/submodules/TelegramCore/Sources/WinterGram/WinterGramDeletedMessageAttribute.swift b/submodules/TelegramCore/Sources/WinterGram/WinterGramDeletedMessageAttribute.swift new file mode 100644 index 0000000000..81d2be6d39 --- /dev/null +++ b/submodules/TelegramCore/Sources/WinterGram/WinterGramDeletedMessageAttribute.swift @@ -0,0 +1,25 @@ +import Foundation +import Postbox + +// Marks a message that the peer deleted remotely but which WinterGram kept locally +// (the "save deleted messages" feature). The marker lets the cache screen enumerate and +// purge kept-deleted messages on demand. `date` is when the remote deletion arrived. +public class WinterGramDeletedMessageAttribute: MessageAttribute, Equatable { + public let date: Int32 + + public init(date: Int32) { + self.date = date + } + + required public init(decoder: PostboxDecoder) { + self.date = decoder.decodeInt32ForKey("date", orElse: 0) + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeInt32(self.date, forKey: "date") + } + + public static func ==(lhs: WinterGramDeletedMessageAttribute, rhs: WinterGramDeletedMessageAttribute) -> Bool { + return lhs.date == rhs.date + } +} diff --git a/submodules/TelegramCore/Sources/WinterGram/WinterGramDeletedMessagesCache.swift b/submodules/TelegramCore/Sources/WinterGram/WinterGramDeletedMessagesCache.swift new file mode 100644 index 0000000000..23d2e7fc3f --- /dev/null +++ b/submodules/TelegramCore/Sources/WinterGram/WinterGramDeletedMessagesCache.swift @@ -0,0 +1,264 @@ +import Foundation +import Postbox +import SwiftSignalKit + +// A lightweight index of message ids that WinterGram kept after the peer deleted them. +// Stored in postbox preferences so the cache screen can show a count and purge them +// without scanning every chat. +public struct WinterGramDeletedMessagesIndex: Codable, Equatable { + public struct Ref: Codable, Equatable { + public var peerId: Int64 + public var namespace: Int32 + public var id: Int32 + } + + public var refs: [Ref] + + public init(refs: [Ref] = []) { + self.refs = refs + } +} + +func winterGramRecordDeletedMessages(transaction: Transaction, ids: [MessageId]) { + var index = transaction.getPreferencesEntry(key: PreferencesKeys.winterGramDeletedMessages)?.get(WinterGramDeletedMessagesIndex.self) ?? WinterGramDeletedMessagesIndex() + var existing = Set(index.refs.map { "\($0.peerId):\($0.namespace):\($0.id)" }) + for id in ids { + let key = "\(id.peerId.toInt64()):\(id.namespace):\(id.id)" + if !existing.contains(key) { + existing.insert(key) + index.refs.append(WinterGramDeletedMessagesIndex.Ref(peerId: id.peerId.toInt64(), namespace: id.namespace, id: id.id)) + } + } + transaction.setPreferencesEntry(key: PreferencesKeys.winterGramDeletedMessages, value: PreferencesEntry(index)) +} + +public func winterGramDeletedMessagesCount(postbox: Postbox) -> Signal { + return postbox.transaction { transaction -> Int in + let index = transaction.getPreferencesEntry(key: PreferencesKeys.winterGramDeletedMessages)?.get(WinterGramDeletedMessagesIndex.self) ?? WinterGramDeletedMessagesIndex() + return index.refs.count + } +} + +public func winterGramDeletedMessagesSize(postbox: Postbox) -> Signal { + return postbox.transaction { transaction -> Int64 in + let index = transaction.getPreferencesEntry(key: PreferencesKeys.winterGramDeletedMessages)?.get(WinterGramDeletedMessagesIndex.self) ?? WinterGramDeletedMessagesIndex() + var totalSize: Int64 = 0 + for ref in index.refs { + guard let message = transaction.getMessage(MessageId(peerId: PeerId(ref.peerId), namespace: ref.namespace, id: ref.id)) else { + continue + } + totalSize += Int64(message.text.utf8.count) + for media in message.media { + if let file = media as? TelegramMediaFile, let size = file.size { + totalSize += size + } + } + } + return totalSize + } +} + +public func winterGramClearDeletedMessages(postbox: Postbox) -> Signal { + return postbox.transaction { transaction -> Int64 in + let index = transaction.getPreferencesEntry(key: PreferencesKeys.winterGramDeletedMessages)?.get(WinterGramDeletedMessagesIndex.self) ?? WinterGramDeletedMessagesIndex() + let ids = index.refs.map { MessageId(peerId: PeerId($0.peerId), namespace: $0.namespace, id: $0.id) } + var freedSize: Int64 = 0 + if !ids.isEmpty { + for messageId in ids { + guard let message = transaction.getMessage(messageId) else { + continue + } + freedSize += Int64(message.text.utf8.count) + for media in message.media { + if let file = media as? TelegramMediaFile, let size = file.size { + freedSize += size + } + } + } + transaction.deleteMessages(ids, forEachMedia: nil) + } + transaction.setPreferencesEntry(key: PreferencesKeys.winterGramDeletedMessages, value: PreferencesEntry(WinterGramDeletedMessagesIndex())) + return freedSize + } +} + +public enum WinterGramDeletedMessageCategory: Int32, CaseIterable, Codable { + case text + case photo + case video + case voice + case videoMessage + case music + case sticker + case other + + public var titleKey: String { + switch self { + case .text: + return "WinterGram.DeletedMessages.Text" + case .photo: + return "WinterGram.DeletedMessages.Photos" + case .video: + return "WinterGram.DeletedMessages.Videos" + case .voice: + return "WinterGram.DeletedMessages.Voice" + case .videoMessage: + return "WinterGram.DeletedMessages.VideoMessages" + case .music: + return "WinterGram.DeletedMessages.Music" + case .sticker: + return "WinterGram.DeletedMessages.Stickers" + case .other: + return "WinterGram.DeletedMessages.Other" + } + } +} + +private func winterGramDeletedMessageCategory(for message: Message) -> WinterGramDeletedMessageCategory { + if message.media.isEmpty { + return .text + } + for media in message.media { + if let file = media as? TelegramMediaFile { + if file.isVoice { + return .voice + } + if file.isInstantVideo { + return .videoMessage + } + if file.isSticker { + return .sticker + } + if file.isMusic { + return .music + } + if file.isVideo || file.isAnimated { + return .video + } + if file.mimeType.hasPrefix("image/") { + return .photo + } + for attribute in file.attributes { + if case .ImageSize = attribute { + return .photo + } + } + return .other + } else if media is TelegramMediaImage { + return .photo + } + } + return .other +} + +public struct WinterGramDeletedMessagesStats: Codable, Equatable { + public struct CategoryStat: Codable, Equatable { + public var category: WinterGramDeletedMessageCategory + public var count: Int + public var size: Int64 + + public init(category: WinterGramDeletedMessageCategory, count: Int, size: Int64) { + self.category = category + self.count = count + self.size = size + } + } + + public struct TopChatStat: Codable, Equatable { + public var peerId: Int64 + public var count: Int + public var size: Int64 + + public init(peerId: Int64, count: Int, size: Int64) { + self.peerId = peerId + self.count = count + self.size = size + } + } + + public var categories: [CategoryStat] + public var topChats: [TopChatStat] + public var totalCount: Int + public var totalSize: Int64 + + public init(categories: [CategoryStat], topChats: [TopChatStat], totalCount: Int, totalSize: Int64) { + self.categories = categories + self.topChats = topChats + self.totalCount = totalCount + self.totalSize = totalSize + } +} + +public func winterGramDeletedMessagesStats(postbox: Postbox) -> Signal { + return postbox.transaction { transaction -> WinterGramDeletedMessagesStats in + let index = transaction.getPreferencesEntry(key: PreferencesKeys.winterGramDeletedMessages)?.get(WinterGramDeletedMessagesIndex.self) ?? WinterGramDeletedMessagesIndex() + var stats: [WinterGramDeletedMessageCategory: WinterGramDeletedMessagesStats.CategoryStat] = [:] + var chatStats: [Int64: WinterGramDeletedMessagesStats.TopChatStat] = [:] + var totalCount = 0 + var totalSize: Int64 = 0 + for ref in index.refs { + guard let message = transaction.getMessage(MessageId(peerId: PeerId(ref.peerId), namespace: ref.namespace, id: ref.id)) else { + continue + } + let category = winterGramDeletedMessageCategory(for: message) + var size = Int64(message.text.utf8.count) + for media in message.media { + if let file = media as? TelegramMediaFile, let fileSize = file.size { + size += fileSize + } + } + stats[category, default: WinterGramDeletedMessagesStats.CategoryStat(category: category, count: 0, size: 0)].count += 1 + stats[category, default: WinterGramDeletedMessagesStats.CategoryStat(category: category, count: 0, size: 0)].size += size + chatStats[ref.peerId, default: WinterGramDeletedMessagesStats.TopChatStat(peerId: ref.peerId, count: 0, size: 0)].count += 1 + chatStats[ref.peerId, default: WinterGramDeletedMessagesStats.TopChatStat(peerId: ref.peerId, count: 0, size: 0)].size += size + totalCount += 1 + totalSize += size + } + let topChats = chatStats.values.sorted { lhs, rhs in + if lhs.count != rhs.count { + return lhs.count > rhs.count + } else { + return lhs.size > rhs.size + } + }.prefix(10).map { $0 } + return WinterGramDeletedMessagesStats( + categories: WinterGramDeletedMessageCategory.allCases.map { stats[$0] ?? WinterGramDeletedMessagesStats.CategoryStat(category: $0, count: 0, size: 0) }, + topChats: topChats, + totalCount: totalCount, + totalSize: totalSize + ) + } +} + +public func winterGramClearDeletedMessages(postbox: Postbox, categories: Set) -> Signal { + return postbox.transaction { transaction -> Int64 in + let index = transaction.getPreferencesEntry(key: PreferencesKeys.winterGramDeletedMessages)?.get(WinterGramDeletedMessagesIndex.self) ?? WinterGramDeletedMessagesIndex() + var remainingRefs: [WinterGramDeletedMessagesIndex.Ref] = [] + var freedSize: Int64 = 0 + var deletedIds: [MessageId] = [] + for ref in index.refs { + let messageId = MessageId(peerId: PeerId(ref.peerId), namespace: ref.namespace, id: ref.id) + guard let message = transaction.getMessage(messageId) else { + continue + } + let category = winterGramDeletedMessageCategory(for: message) + if categories.contains(category) { + var size = Int64(message.text.utf8.count) + for media in message.media { + if let file = media as? TelegramMediaFile, let fileSize = file.size { + size += fileSize + } + } + freedSize += size + deletedIds.append(messageId) + } else { + remainingRefs.append(ref) + } + } + if !deletedIds.isEmpty { + transaction.deleteMessages(deletedIds, forEachMedia: nil) + } + transaction.setPreferencesEntry(key: PreferencesKeys.winterGramDeletedMessages, value: PreferencesEntry(WinterGramDeletedMessagesIndex(refs: remainingRefs))) + return freedSize + } +} diff --git a/submodules/TelegramCore/Sources/WinterGram/WinterGramEditHistoryAttribute.swift b/submodules/TelegramCore/Sources/WinterGram/WinterGramEditHistoryAttribute.swift new file mode 100644 index 0000000000..3bba8e2101 --- /dev/null +++ b/submodules/TelegramCore/Sources/WinterGram/WinterGramEditHistoryAttribute.swift @@ -0,0 +1,46 @@ +import Foundation +import Postbox + +public class WinterGramEditHistoryAttribute: MessageAttribute, Equatable { + public struct Revision: PostboxCoding, Equatable { + public let text: String + public let entities: [MessageTextEntity] + public let timestamp: Int32 + + public init(text: String, entities: [MessageTextEntity], timestamp: Int32) { + self.text = text + self.entities = entities + self.timestamp = timestamp + } + + public init(decoder: PostboxDecoder) { + self.text = decoder.decodeStringForKey("text", orElse: "") + self.entities = decoder.decodeObjectArrayWithDecoderForKey("entities") + self.timestamp = decoder.decodeInt32ForKey("timestamp", orElse: 0) + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeString(self.text, forKey: "text") + encoder.encodeObjectArray(self.entities, forKey: "entities") + encoder.encodeInt32(self.timestamp, forKey: "timestamp") + } + } + + public let revisions: [Revision] + + public init(revisions: [Revision]) { + self.revisions = revisions + } + + required public init(decoder: PostboxDecoder) { + self.revisions = decoder.decodeObjectArrayWithDecoderForKey("revisions") + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeObjectArray(self.revisions, forKey: "revisions") + } + + public static func ==(lhs: WinterGramEditHistoryAttribute, rhs: WinterGramEditHistoryAttribute) -> Bool { + return lhs.revisions == rhs.revisions + } +} diff --git a/submodules/TelegramCore/Sources/WinterGram/WinterGramLocalEdit.swift b/submodules/TelegramCore/Sources/WinterGram/WinterGramLocalEdit.swift new file mode 100644 index 0000000000..a2a04942d6 --- /dev/null +++ b/submodules/TelegramCore/Sources/WinterGram/WinterGramLocalEdit.swift @@ -0,0 +1,22 @@ +import Foundation +import Postbox +import SwiftSignalKit + +// Replaces a message's text locally only — no network edit is performed, 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 { + return postbox.transaction { transaction -> Void in + transaction.updateMessage(messageId, update: { currentMessage in + 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.removeAll(where: { $0 is TextEntitiesMessageAttribute }) + + 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: text, attributes: attributes, media: currentMessage.media)) + }) + } +} diff --git a/submodules/TelegramCore/Sources/WinterGram/WinterGramRegistrationDate.swift b/submodules/TelegramCore/Sources/WinterGram/WinterGramRegistrationDate.swift new file mode 100644 index 0000000000..e55aa52df4 --- /dev/null +++ b/submodules/TelegramCore/Sources/WinterGram/WinterGramRegistrationDate.swift @@ -0,0 +1,53 @@ +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. +public func winterGramEstimatedRegistrationDate(userId: Int64) -> Date? { + guard userId > 0 else { + return nil + } + + // Anchor points: (user id, unix timestamp). Sourced from widely-used community datasets + // mapping account id ranges to registration months. + let anchors: [(id: Double, timestamp: Double)] = [ + (1_000_000, 1_383_264_000), // ~Nov 2013 + (10_000_000, 1_388_448_000), // ~Jan 2014 + (50_000_000, 1_404_086_400), // ~Jul 2014 + (100_000_000, 1_414_972_800), // ~Nov 2014 + (150_000_000, 1_426_723_200), // ~Mar 2015 + (200_000_000, 1_437_523_200), // ~Jul 2015 + (300_000_000, 1_460_073_600), // ~Apr 2016 + (400_000_000, 1_483_228_800), // ~Jan 2017 + (500_000_000, 1_508_716_800), // ~Oct 2017 + (700_000_000, 1_534_809_600), // ~Aug 2018 + (1_000_000_000, 1_561_939_200), // ~Jul 2019 + (1_300_000_000, 1_585_699_200), // ~Apr 2020 + (1_700_000_000, 1_609_459_200), // ~Jan 2021 + (2_000_000_000, 1_630_454_400), // ~Sep 2021 + (3_000_000_000, 1_657_756_800), // ~Jul 2022 + (4_000_000_000, 1_682_899_200), // ~May 2023 + (5_000_000_000, 1_704_067_200), // ~Jan 2024 + (6_000_000_000, 1_722_470_400), // ~Aug 2024 + (7_500_000_000, 1_743_465_600) // ~Apr 2025 + ] + + let value = Double(userId) + if value <= anchors[0].id { + return Date(timeIntervalSince1970: anchors[0].timestamp) + } + if value >= anchors[anchors.count - 1].id { + return Date(timeIntervalSince1970: anchors[anchors.count - 1].timestamp) + } + for i in 0 ..< (anchors.count - 1) { + let lower = anchors[i] + let upper = anchors[i + 1] + if value >= lower.id && value <= upper.id { + let fraction = (value - lower.id) / (upper.id - lower.id) + let timestamp = lower.timestamp + fraction * (upper.timestamp - lower.timestamp) + return Date(timeIntervalSince1970: timestamp) + } + } + return nil +} diff --git a/submodules/TelegramPresentationData/Sources/PresentationData.swift b/submodules/TelegramPresentationData/Sources/PresentationData.swift index f557750fd4..82433c59a7 100644 --- a/submodules/TelegramPresentationData/Sources/PresentationData.swift +++ b/submodules/TelegramPresentationData/Sources/PresentationData.swift @@ -18,7 +18,7 @@ public struct PresentationDateTimeFormat: Equatable { public let requiresFullYear: Bool public let decimalSeparator: String public let groupingSeparator: String - + public init() { self.timeFormat = .regular self.dateFormat = .monthFirst @@ -28,7 +28,7 @@ public struct PresentationDateTimeFormat: Equatable { self.decimalSeparator = "." self.groupingSeparator = "." } - + public init(timeFormat: PresentationTimeFormat, dateFormat: PresentationDateFormat, dateSeparator: String, dateSuffix: String, requiresFullYear: Bool, decimalSeparator: String, groupingSeparator: String) { self.timeFormat = timeFormat self.dateFormat = dateFormat @@ -45,7 +45,7 @@ public struct PresentationAppIcon: Equatable { public let imageName: String public let isDefault: Bool public let isPremium: Bool - + public init(name: String, imageName: String, isDefault: Bool = false, isPremium: Bool = false) { self.name = name self.imageName = imageName @@ -64,12 +64,22 @@ public enum PresentationDateFormat { case dayFirst } +// WinterGram: bubble corner radius. The messageBubbleRadius setting overrides Telegram's +// own bubble radius only when the user changed it from the WinterGram default (16). +public func winterGramEffectiveBubbleRadius(_ themeValue: Int32) -> CGFloat { + let wnt = currentWinterGramSettings.messageBubbleRadius + if wnt != 16 { + return CGFloat(max(0, min(20, wnt))) + } + return CGFloat(themeValue) +} + public struct PresentationChatBubbleCorners: Equatable, Hashable { public var mainRadius: CGFloat public var auxiliaryRadius: CGFloat public var mergeBubbleCorners: Bool public var hasTails: Bool - + public init(mainRadius: CGFloat, auxiliaryRadius: CGFloat, mergeBubbleCorners: Bool, hasTails: Bool = true) { self.mainRadius = mainRadius self.auxiliaryRadius = auxiliaryRadius @@ -91,7 +101,7 @@ public final class PresentationData: Equatable { public let nameSortOrder: PresentationPersonNameOrder public let reduceMotion: Bool public let largeEmoji: Bool - + public init(strings: PresentationStrings, theme: PresentationTheme, autoNightModeTriggered: Bool, chatWallpaper: TelegramWallpaper, chatFontSize: PresentationFontSize, chatBubbleCorners: PresentationChatBubbleCorners, listsFontSize: PresentationFontSize, dateTimeFormat: PresentationDateTimeFormat, nameDisplayOrder: PresentationPersonNameOrder, nameSortOrder: PresentationPersonNameOrder, reduceMotion: Bool, largeEmoji: Bool) { self.strings = strings self.theme = theme @@ -106,19 +116,19 @@ public final class PresentationData: Equatable { self.reduceMotion = reduceMotion self.largeEmoji = largeEmoji } - + public func withUpdated(theme: PresentationTheme) -> PresentationData { return PresentationData(strings: self.strings, theme: theme, autoNightModeTriggered: self.autoNightModeTriggered, chatWallpaper: self.chatWallpaper, chatFontSize: self.chatFontSize, chatBubbleCorners: self.chatBubbleCorners, listsFontSize: self.listsFontSize, dateTimeFormat: self.dateTimeFormat, nameDisplayOrder: self.nameDisplayOrder, nameSortOrder: self.nameSortOrder, reduceMotion: self.reduceMotion, largeEmoji: self.largeEmoji) } - + public func withUpdated(chatWallpaper: TelegramWallpaper) -> PresentationData { return PresentationData(strings: self.strings, theme: self.theme, autoNightModeTriggered: self.autoNightModeTriggered, chatWallpaper: chatWallpaper, chatFontSize: self.chatFontSize, chatBubbleCorners: self.chatBubbleCorners, listsFontSize: self.listsFontSize, dateTimeFormat: self.dateTimeFormat, nameDisplayOrder: self.nameDisplayOrder, nameSortOrder: self.nameSortOrder, reduceMotion: self.reduceMotion, largeEmoji: self.largeEmoji) } - + public func withUpdate(listsFontSize: PresentationFontSize) -> PresentationData { return PresentationData(strings: self.strings, theme: self.theme, autoNightModeTriggered: self.autoNightModeTriggered, chatWallpaper: self.chatWallpaper, chatFontSize: self.chatFontSize, chatBubbleCorners: self.chatBubbleCorners, listsFontSize: listsFontSize, dateTimeFormat: self.dateTimeFormat, nameDisplayOrder: self.nameDisplayOrder, nameSortOrder: self.nameSortOrder, reduceMotion: self.reduceMotion, largeEmoji: self.largeEmoji) } - + public static func ==(lhs: PresentationData, rhs: PresentationData) -> Bool { return lhs.strings === rhs.strings && lhs.theme === rhs.theme && lhs.autoNightModeTriggered == rhs.autoNightModeTriggered && lhs.chatWallpaper == rhs.chatWallpaper && lhs.chatFontSize == rhs.chatFontSize && lhs.chatBubbleCorners == rhs.chatBubbleCorners && lhs.listsFontSize == rhs.listsFontSize && lhs.dateTimeFormat == rhs.dateTimeFormat && lhs.reduceMotion == rhs.reduceMotion && lhs.largeEmoji == rhs.largeEmoji } @@ -152,6 +162,22 @@ public func dictFromLocalization(_ value: Localization) -> [String: String] { return dict } +// Merges WinterGram's bundled fork-string translations into a language's active string dictionary. +// Telegram's server language packs don't carry our `WinterGram.*` keys, so seeding them here lets +// the standard generated accessors (`strings.WinterGram_*`) resolve to localized text. Server +// values always win, so an upstream key of the same name would never be shadowed. +private func winterGramAugmentedDict(_ dict: [String: String], languageCode: String) -> [String: String] { + let seed = winterGramSeedStrings(languageCode: languageCode) + if seed.isEmpty { + return dict + } + var dict = dict + for (key, value) in seed where dict[key] == nil { + dict[key] = value + } + return dict +} + private func currentDateTimeFormat() -> PresentationDateTimeFormat { let locale = Locale.current let dateFormatter = DateFormatter() @@ -160,14 +186,14 @@ private func currentDateTimeFormat() -> PresentationDateTimeFormat { dateFormatter.timeStyle = .medium dateFormatter.timeZone = TimeZone.current let dateString = dateFormatter.string(from: Date()) - + let timeFormat: PresentationTimeFormat if dateString.contains(dateFormatter.amSymbol) || dateString.contains(dateFormatter.pmSymbol) { timeFormat = .regular } else { timeFormat = .military } - + let dateFormat: PresentationDateFormat var dateSeparator = "/" var dateSuffix = "" @@ -219,7 +245,7 @@ public final class InitialPresentationDataAndSettings { public let stickerSettings: StickerSettings public let chatSettings: ChatSettings public let experimentalUISettings: ExperimentalUISettings - + public init(presentationData: PresentationData, automaticMediaDownloadSettings: MediaAutoDownloadSettings, autodownloadSettings: AutodownloadSettings, callListSettings: CallListSettings, inAppNotificationSettings: InAppNotificationSettings, mediaInputSettings: MediaInputSettings, mediaDisplaySettings: MediaDisplaySettings, stickerSettings: StickerSettings, chatSettings: ChatSettings, experimentalUISettings: ExperimentalUISettings) { self.presentationData = presentationData self.automaticMediaDownloadSettings = automaticMediaDownloadSettings @@ -248,7 +274,7 @@ public func currentPresentationDataAndSettings(accountManager: AccountManager InternalData in let localizationSettings = transaction.getSharedData(SharedDataKeys.localizationSettings) let presentationThemeSettings = transaction.getSharedData(ApplicationSpecificSharedDataKeys.presentationThemeSettings) @@ -291,7 +317,7 @@ public func currentPresentationDataAndSettings(accountManager: AccountManager then(chatServiceBackgroundColor(wallpaper: currentWallpaper, mediaBox: accountManager.mediaBox))) |> mapToSignal { serviceBackgroundColor in @@ -760,19 +786,19 @@ public func updatedPresentationData(accountManager: AccountManager PresentationData { let dateTimeFormat = currentDateTimeFormat() let nameDisplayOrder: PresentationPersonNameOrder = .firstLast let nameSortOrder = currentPersonNameSortOrder() - + let themeSettings = PresentationThemeSettings.defaultSettings - + let (chatFontSize, listsFontSize) = resolveFontSize(settings: themeSettings) - - let chatBubbleCorners = PresentationChatBubbleCorners(mainRadius: CGFloat(themeSettings.chatBubbleSettings.mainRadius), auxiliaryRadius: CGFloat(themeSettings.chatBubbleSettings.auxiliaryRadius), mergeBubbleCorners: themeSettings.chatBubbleSettings.mergeBubbleCorners) - + + let chatBubbleCorners = PresentationChatBubbleCorners(mainRadius: winterGramEffectiveBubbleRadius(themeSettings.chatBubbleSettings.mainRadius), auxiliaryRadius: CGFloat(themeSettings.chatBubbleSettings.auxiliaryRadius), mergeBubbleCorners: themeSettings.chatBubbleSettings.mergeBubbleCorners) + return PresentationData(strings: defaultPresentationStrings, theme: defaultPresentationTheme, autoNightModeTriggered: false, chatWallpaper: defaultPresentationTheme.chat.defaultWallpaper, chatFontSize: chatFontSize, chatBubbleCorners: chatBubbleCorners, listsFontSize: listsFontSize, dateTimeFormat: dateTimeFormat, nameDisplayOrder: nameDisplayOrder, nameSortOrder: nameSortOrder, reduceMotion: themeSettings.reduceMotion, largeEmoji: themeSettings.largeEmoji) } @@ -876,11 +902,11 @@ public extension PresentationData { func withFontSizes(chatFontSize: PresentationFontSize, listsFontSize: PresentationFontSize) -> PresentationData { return PresentationData(strings: self.strings, theme: self.theme, autoNightModeTriggered: self.autoNightModeTriggered, chatWallpaper: self.chatWallpaper, chatFontSize: chatFontSize, chatBubbleCorners: self.chatBubbleCorners, listsFontSize: listsFontSize, dateTimeFormat: self.dateTimeFormat, nameDisplayOrder: self.nameDisplayOrder, nameSortOrder: self.nameSortOrder, reduceMotion: self.reduceMotion, largeEmoji: self.largeEmoji) } - + func withChatBubbleCorners(_ chatBubbleCorners: PresentationChatBubbleCorners) -> PresentationData { return PresentationData(strings: self.strings, theme: self.theme, autoNightModeTriggered: self.autoNightModeTriggered, chatWallpaper: self.chatWallpaper, chatFontSize: self.chatFontSize, chatBubbleCorners: chatBubbleCorners, listsFontSize: self.listsFontSize, dateTimeFormat: self.dateTimeFormat, nameDisplayOrder: self.nameDisplayOrder, nameSortOrder: self.nameSortOrder, reduceMotion: self.reduceMotion, largeEmoji: self.largeEmoji) } - + func withStrings(_ strings: PresentationStrings) -> PresentationData { return PresentationData(strings: strings, theme: self.theme, autoNightModeTriggered: self.autoNightModeTriggered, chatWallpaper: self.chatWallpaper, chatFontSize: self.chatFontSize, chatBubbleCorners: chatBubbleCorners, listsFontSize: self.listsFontSize, dateTimeFormat: self.dateTimeFormat, nameDisplayOrder: self.nameDisplayOrder, nameSortOrder: self.nameSortOrder, reduceMotion: self.reduceMotion, largeEmoji: self.largeEmoji) } diff --git a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesSettings.swift b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesSettings.swift index 0c7e9317a8..0563558787 100644 --- a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesSettings.swift +++ b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesSettings.swift @@ -9,39 +9,39 @@ public func renderSettingsIcon(name: String, scaleFactor: CGFloat = 1.0, backgro return generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) - + if let backgroundColors { var locations: [CGFloat] = [0.0, 1.0] let colors: [CGColor] = backgroundColors.map(\.cgColor) - + let colorSpace = CGColorSpaceCreateDeviceRGB() let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! - + context.drawLinearGradient(gradient, start: CGPoint(x: size.width, y: size.height), end: CGPoint(x: 0.0, y: 0.0), options: CGGradientDrawingOptions()) - + if let gradientImage, let cgImage = gradientImage.cgImage { context.setBlendMode(.plusLighter) context.draw(cgImage, in: CGRect(origin: .zero, size: size)) } - + if let backdropImage, let cgImage = backdropImage.cgImage { context.setBlendMode(.overlay) context.draw(cgImage, in: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: size)) } - + context.setBlendMode(.normal) - + if let image = UIImage(bundleImageName: name), let maskImage = image.cgImage { let imageSize = CGSize(width: image.size.width * scaleFactor, height: image.size.height * scaleFactor) let imageRect = CGRect(origin: CGPoint(x: (bounds.width - imageSize.width) * 0.5, y: (bounds.height - imageSize.height) * 0.5), size: imageSize) - + context.saveGState() context.clip(to: imageRect, mask: maskImage) context.setFillColor(UIColor.white.cgColor) context.fill(imageRect) context.restoreGState() } - + let outerPath = UIBezierPath(rect: CGRect(origin: .zero, size: size)) let innerPath = UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 8.0) outerPath.append(innerPath) @@ -72,7 +72,7 @@ public func renderAttachAppIcon(iconImage: UIImage?) -> UIImage? { return generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) - + if let iconImage, let cgImage = iconImage.cgImage { context.draw(cgImage, in: CGRect(origin: .zero, size: size)) } @@ -83,14 +83,14 @@ public func renderAttachAppIcon(iconImage: UIImage?) -> UIImage? { context.draw(cgImage, in: CGRect(origin: .zero, size: size)) context.restoreGState() } - + if let backdropImage, let cgImage = backdropImage.cgImage { context.saveGState() context.setBlendMode(.overlay) context.draw(cgImage, in: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: size)) context.restoreGState() } - + let outerPath = UIBezierPath(rect: CGRect(origin: .zero, size: size)) let innerPath = UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 8.0) outerPath.append(innerPath) @@ -131,24 +131,24 @@ public struct PresentationResourcesSettings { public static let powerSaving = renderSettingsIcon(name: "Item List/Icons/PowerSaving", backgroundColors: [colorOrange]) public static let business = renderSettingsIcon(name: "Item List/Icons/Business", backgroundColors: [UIColor(rgb: 0xA95CE3), UIColor(rgb: 0xF16B80)]) public static let myProfile = renderSettingsIcon(name: "Item List/Icons/Profile", backgroundColors: [colorRed]) - public static let winterGram = renderSettingsIcon(name: "Item List/Icons/WinterGram", backgroundColors: [colorLightBlue]) + public static let winterGram = renderSettingsIcon(name: "WntGramSnowflakeShape", scaleFactor: 0.16, backgroundColors: [colorLightBlue]) public static let birthday = renderSettingsIcon(name: "Item List/Icons/Cake", backgroundColors: [colorBlue]) public static let aiTools = renderSettingsIcon(name: "Item List/Icons/AITools", backgroundColors: [colorPurple]) public static let yourColor = renderSettingsIcon(name: "Item List/Icons/Brush", backgroundColors: [colorLightBlue]) - + public static let storageUsage = renderSettingsIcon(name: "Item List/Icons/Pie", backgroundColors: [colorOrange]) public static let dataUsage = renderSettingsIcon(name: "Item List/Icons/Stats", backgroundColors: [colorPurple]) - + public static let cellular = renderSettingsIcon(name: "Item List/Icons/Cellular", backgroundColors: [colorGreen]) public static let wifi = renderSettingsIcon(name: "Item List/Icons/Wifi", backgroundColors: [colorBlue]) - + public static let privateChats = renderSettingsIcon(name: "Item List/Icons/Member", backgroundColors: [colorBlue]) public static let groups = renderSettingsIcon(name: "Item List/Icons/Group", backgroundColors: [colorGreen]) public static let channels = renderSettingsIcon(name: "Item List/Icons/Channel", backgroundColors: [colorOrange]) public static let stories = renderSettingsIcon(name: "Item List/Icons/Stories", backgroundColors: [colorViolet]) public static let reactions = renderSettingsIcon(name: "Item List/Icons/Reactions", backgroundColors: [UIColor(rgb: 0xFF2D55)]) - + public static let photos = renderSettingsIcon(name: "Item List/Icons/Photo", backgroundColors: [colorOrange]) public static let videos = renderSettingsIcon(name: "Item List/Icons/Video", backgroundColors: [colorRed]) public static let files = renderSettingsIcon(name: "Item List/Icons/File", backgroundColors: [colorBlue]) @@ -163,7 +163,7 @@ public struct PresentationResourcesSettings { public static let clock = renderSettingsIcon(name: "Item List/Icons/Clock", backgroundColors: [colorPurple]) public static let photosLightBlue = renderSettingsIcon(name: "Item List/Icons/Photo", backgroundColors: [colorLightBlue]) public static let videosBlue = renderSettingsIcon(name: "Item List/Icons/Video", backgroundColors: [colorBlue]) - + public static let block = renderSettingsIcon(name: "Item List/Icons/Block", backgroundColors: [colorRed]) public static let activeSessions = renderSettingsIcon(name: "Item List/Icons/Language", backgroundColors: [colorBlue]) public static let faceId = renderSettingsIcon(name: "Item List/Icons/FaceId", backgroundColors: [colorGreen]) @@ -171,11 +171,11 @@ public struct PresentationResourcesSettings { public static let passkeys = renderSettingsIcon(name: "Item List/Icons/Key", backgroundColors: [colorViolet]) public static let timer = renderSettingsIcon(name: "Item List/Icons/Timer", backgroundColors: [colorPurple]) public static let email = renderSettingsIcon(name: "Item List/Icons/Email", backgroundColors: [colorViolet]) - + public static let premium = generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) - + let colorsArray: [CGColor] = [ UIColor(rgb: 0x6b93ff).cgColor, UIColor(rgb: 0x6b93ff).cgColor, @@ -186,23 +186,23 @@ public struct PresentationResourcesSettings { var locations: [CGFloat] = [0.0, 0.15, 0.5, 0.85, 1.0] let gradient = CGGradient(colorsSpace: deviceColorSpace, colors: colorsArray as CFArray, locations: &locations)! context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: size.height), options: CGGradientDrawingOptions()) - + if let gradientImage, let cgImage = gradientImage.cgImage { context.setBlendMode(.plusLighter) context.draw(cgImage, in: CGRect(origin: .zero, size: size)) } - + if let backdropImage, let cgImage = backdropImage.cgImage { context.setBlendMode(.overlay) context.draw(cgImage, in: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: size)) } - + context.setBlendMode(.normal) - + if let image = generateTintedImage(image: UIImage(bundleImageName: "Item List/Icons/Premium"), color: UIColor(rgb: 0xffffff)), let cgImage = image.cgImage { context.draw(cgImage, in: CGRect(origin: CGPoint(x: floorToScreenPixels((bounds.width - image.size.width) / 2.0), y: floorToScreenPixels((bounds.height - image.size.height) / 2.0)), size: image.size)) } - + let outerPath = UIBezierPath(rect: CGRect(origin: .zero, size: size)) let innerPath = UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 8.0) outerPath.append(innerPath) @@ -216,13 +216,13 @@ public struct PresentationResourcesSettings { context.fill(CGRect(origin: .zero, size: size)) context.restoreGState() }) - + public static let ton = renderSettingsIcon(name: "Ads/TonAbout", backgroundColors: [UIColor(rgb: 0x32ade6)]) - + public static let stars = generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) - + let colorsArray: [CGColor] = [ UIColor(rgb: 0xfec80f).cgColor, UIColor(rgb: 0xdd6f12).cgColor @@ -231,23 +231,23 @@ public struct PresentationResourcesSettings { let gradient = CGGradient(colorsSpace: deviceColorSpace, colors: colorsArray as CFArray, locations: &locations)! context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: size.height), options: CGGradientDrawingOptions()) - + if let gradientImage, let cgImage = gradientImage.cgImage { context.setBlendMode(.plusLighter) context.draw(cgImage, in: CGRect(origin: .zero, size: size)) } - + if let backdropImage, let cgImage = backdropImage.cgImage { context.setBlendMode(.overlay) context.draw(cgImage, in: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: size)) } - + context.setBlendMode(.normal) - + if let image = generateTintedImage(image: UIImage(bundleImageName: "Item List/Icons/Stars"), color: UIColor(rgb: 0xffffff)), let cgImage = image.cgImage { context.draw(cgImage, in: CGRect(origin: CGPoint(x: floorToScreenPixels((bounds.width - image.size.width) / 2.0), y: floorToScreenPixels((bounds.height - image.size.height) / 2.0)), size: image.size)) } - + let outerPath = UIBezierPath(rect: CGRect(origin: .zero, size: size)) let innerPath = UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 8.0) outerPath.append(innerPath) @@ -261,11 +261,11 @@ public struct PresentationResourcesSettings { context.fill(CGRect(origin: .zero, size: size)) context.restoreGState() }) - + public static let premiumGift = generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) - + let colorsArray: [CGColor] = [ UIColor(rgb: 0x3ba1f2).cgColor, UIColor(rgb: 0x3ba1f2).cgColor, @@ -276,23 +276,23 @@ public struct PresentationResourcesSettings { var locations: [CGFloat] = [0.0, 0.15, 0.5, 0.85, 1.0] let gradient = CGGradient(colorsSpace: deviceColorSpace, colors: colorsArray as CFArray, locations: &locations)! context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: size.height), options: CGGradientDrawingOptions()) - + if let gradientImage, let cgImage = gradientImage.cgImage { context.setBlendMode(.plusLighter) context.draw(cgImage, in: CGRect(origin: .zero, size: size)) } - + if let backdropImage, let cgImage = backdropImage.cgImage { context.setBlendMode(.overlay) context.draw(cgImage, in: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: size)) } - + context.setBlendMode(.normal) - + if let image = generateTintedImage(image: UIImage(bundleImageName: "Item List/Icons/Gift"), color: UIColor(rgb: 0xffffff)), let cgImage = image.cgImage { context.draw(cgImage, in: CGRect(origin: CGPoint(x: floorToScreenPixels((bounds.width - image.size.width) / 2.0), y: floorToScreenPixels((bounds.height - image.size.height) / 2.0)), size: image.size)) } - + let outerPath = UIBezierPath(rect: CGRect(origin: .zero, size: size)) let innerPath = UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 8.0) outerPath.append(innerPath) @@ -306,22 +306,22 @@ public struct PresentationResourcesSettings { context.fill(CGRect(origin: .zero, size: size)) context.restoreGState() }) - + public static let bot = renderSettingsIcon(name: "Item List/Icons/Bot", backgroundColors: [colorBlue]) public static let passport = renderAttachAppIcon(iconImage: UIImage(bundleImageName: "Settings/Menu/Passport")) public static let watch = renderAttachAppIcon(iconImage: UIImage(bundleImageName: "Settings/Menu/Watch")) - + public static let support = renderSettingsIcon(name: "Item List/Icons/Support", backgroundColors: [colorOrange]) public static let faq = renderSettingsIcon(name: "Item List/Icons/Faq", backgroundColors: [colorLightBlue]) public static let tips = renderSettingsIcon(name: "Item List/Icons/Tips", backgroundColors: [UIColor(rgb: 0xffcc02)]) - + public static let changePhoneNumber = renderSettingsIcon(name: "Item List/Icons/ChangePhone", backgroundColors: [colorPurple]) public static let deleteAddAccount = renderSettingsIcon(name: "Item List/Icons/Member", backgroundColors: [colorBlue]) public static let deleteSetTwoStepAuth = renderSettingsIcon(name: "Item List/Icons/Key", backgroundColors: [colorViolet]) public static let deleteChats = renderSettingsIcon(name: "Item List/Icons/Delete", backgroundColors: [colorRed]) public static let clearSynced = renderSettingsIcon(name: "Item List/Icons/Group", backgroundColors: [colorOrange]) - + public static let groupType = renderSettingsIcon(name: "Item List/Icons/Members", backgroundColors: [colorBlue]) public static let channelType = renderSettingsIcon(name: "Item List/Icons/Channel", backgroundColors: [colorBlue]) public static let chatHistory = renderSettingsIcon(name: "Item List/Icons/Chat", backgroundColors: [colorGreen]) @@ -343,7 +343,7 @@ public struct PresentationResourcesSettings { public static let emojiStatus = renderSettingsIcon(name: "Item List/Icons/Status", backgroundColors: [colorBlue]) public static let location = renderSettingsIcon(name: "Item List/Icons/Location", backgroundColors: [colorLightBlue]) public static let groupRequests = renderAttachAppIcon(iconImage: UIImage(bundleImageName: "Chat/Info/GroupRequestsIcon")) - + public static let calls = renderSettingsIcon(name: "Item List/Icons/Phone", backgroundColors: [colorOrange]) public static let messages = renderSettingsIcon(name: "Item List/Icons/Chat", backgroundColors: [colorViolet]) public static let filesGreen = renderSettingsIcon(name: "Item List/Icons/File", backgroundColors: [colorGreen]) diff --git a/submodules/TelegramPresentationData/Sources/WinterGramBadge.swift b/submodules/TelegramPresentationData/Sources/WinterGramBadge.swift new file mode 100644 index 0000000000..c713fa15f4 --- /dev/null +++ b/submodules/TelegramPresentationData/Sources/WinterGramBadge.swift @@ -0,0 +1,66 @@ +import Foundation +import UIKit +import Display +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] = [:] + +private func winterGramColorKey(_ color: UIColor) -> UInt32 { + var r: CGFloat = 0.0, g: CGFloat = 0.0, b: CGFloat = 0.0, a: CGFloat = 0.0 + color.getRed(&r, green: &g, blue: &b, alpha: &a) + func channel(_ v: CGFloat) -> UInt32 { return UInt32(max(0.0, min(1.0, v)) * 255.0) } + 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() + if let cached = winterGramComposedBadgeCache[key] { + winterGramBadgeCacheLock.unlock() + return cached + } + winterGramBadgeCacheLock.unlock() + + guard let backplate = UIImage(bundleImageName: "WntGramBackplateShape"), let snowflake = UIImage(bundleImageName: "WntGramSnowflakeShape") else { + return nil + } + let size = CGSize(width: 36.0, height: 36.0) + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { _ in + backplate.withTintColor(backplateColor, renderingMode: .alwaysOriginal).draw(in: CGRect(origin: .zero, size: size)) + let snowflakeSize = size.width * 756.0 / 1024.0 + let snowflakeOffset = size.width * 134.0 / 1024.0 + snowflake.withTintColor(.white, renderingMode: .alwaysOriginal).draw(in: CGRect(x: snowflakeOffset, y: snowflakeOffset, width: snowflakeSize, height: snowflakeSize)) + } + winterGramBadgeCacheLock.lock() + winterGramComposedBadgeCache[key] = image + winterGramBadgeCacheLock.unlock() + 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. +public func winterGramBadgeImage(for peer: EnginePeer, theme: PresentationTheme) -> UIImage? { + guard isWinterGramOfficialPeer(peer) else { + return nil + } + return winterGramComposedBadge(backplateColor: winterGramBadgeBackplateColor(theme: theme)) +} diff --git a/submodules/TelegramPresentationData/Sources/WinterGramStrings.swift b/submodules/TelegramPresentationData/Sources/WinterGramStrings.swift new file mode 100644 index 0000000000..16a0da798a --- /dev/null +++ b/submodules/TelegramPresentationData/Sources/WinterGramStrings.swift @@ -0,0 +1,256 @@ +import Foundation +import PresentationStrings + +// WinterGram's own UI strings live in `Telegram-iOS/en.lproj/Localizable.strings` under the +// `WinterGram.*` namespace and are consumed through Telegram's standard generated accessors +// (`strings.WinterGram_*`). English is served by the bundled `en.lproj` fallback baked into the +// generated `PresentationStrings`. Telegram's server language packs do not carry our fork keys, +// so to localize them we seed the active strings dictionary at construction time (see +// `PresentationData.swift`). This table is that seed: symbolic key -> translation. Add a new +// language by returning its table from `winterGramSeedStrings(languageCode:)`. +private let winterGramRussianSeed: [String: String] = [ + "WinterGram.APPEARANCE": "ОФОРМЛЕНИЕ", + "WinterGram.AddVisualGift": "Добавить визуальный подарок", + "WinterGram.AddVisuallyToProfile": "Добавить визуально в профиль", + "WinterGram.AllowSavingRestrictedContent": "Сохранять защищённый контент", + "WinterGram.Always": "Всегда", + "WinterGram.Appearance": "Оформление", + "WinterGram.AppearanceFooter": "Форма аватаров и сообщений, шрифты и пак иконок применяются во всём приложении.", + "WinterGram.ApplyToBubbles": "Применять к сообщениям", + "WinterGram.ApplyToChatList": "Применять к списку чатов", + "WinterGram.ApplyToNavigationBars": "Применять к панелям навигации", + "WinterGram.ApplyToTabBar": "Применять к таб-бару", + "WinterGram.AutoMarkAsRead": "Авто-прочтение", + "WinterGram.AutoPrivacy": "Автоконфиденциальность", + "WinterGram.StashPrivacy.ProfilePhoto": "Аватарка", + "WinterGram.StashPrivacy.PhoneNumber": "Номер телефона", + "WinterGram.StashPrivacy.Presence": "Время захода", + "WinterGram.StashPrivacy.Forwards": "Пересылка сообщений", + "WinterGram.StashPrivacy.VoiceCalls": "Звонки", + "WinterGram.StashPrivacy.Birthday": "День рождения", + "WinterGram.StashPrivacy.GiftsAutoSave": "Подарки", + "WinterGram.StashPrivacy.Bio": "О себе", + "WinterGram.StashPrivacy.SavedMusic": "Сохранённая музыка", + "WinterGram.StashPrivacy.GroupInvitations": "Приглашения", + "WinterGram.Automatic": "Автоматически", + "WinterGram.AvatarShape": "Форма аватара", + "WinterGram.CoreMenu": "Ядро", + "WinterGram.Core": "Ядро", + "WinterGram.BadgeDeveloperRole": "— разработчик WinterGram.", + "WinterGram.BadgeDeveloperSuffix": "поддержал(а) разработку WinterGram и получил(а) уникальный значок.", + "WinterGram.BadgeOfficialChannelSuffix": "— официальный ресурс WinterGram.", + "WinterGram.Beta": "Бета", + "WinterGram.BubbleRadius": "Скругление сообщений", + "WinterGram.CHAT": "ЧАТ", + "WinterGram.Categories": "Категории", + "WinterGram.Channel": "Канал", + "WinterGram.AddTemplate": "Добавить шаблон", + "WinterGram.LearnMore": "Подробнее", + "WinterGram.Links": "Ссылки", + "WinterGram.LinksFooter": "WinterGram\nВерсия: Beta v1.0.0", + "WinterGram.MutualContact": "Взаимный контакт", + "WinterGram.NotMutualContact": "Не взаимный контакт", + "WinterGram.Other": "Прочее", + "WinterGram.Releases": "Релизы", + "WinterGram.ReadAfterAction": "Читать при действиях", + "WinterGram.DontReadMessages": "Не читать сообщения", + "WinterGram.DontReadStories": "Не читать истории", + "WinterGram.DontSendOnline": "Не отправлять «онлайн»", + "WinterGram.DontSendTyping": "Не отправлять «печатает»", + "WinterGram.AutoOffline": "Автоматический «офлайн»", + "WinterGram.Chat": "Чат", + "WinterGram.ClearSavedDeletedMessages": "Очистить сохранённые удалённые", + "WinterGram.ConfirmGIFs": "Подтверждать GIF", + "WinterGram.ConfirmStickers": "Подтверждать стикеры", + "WinterGram.ConfirmVoiceMessages": "Подтверждать голосовые", + "WinterGram.ConfirmStoryView": "Предупреждать перед просмотром историй", + "WinterGram.Custom": "Свой...", + "WinterGram.CustomFont": "Свой шрифт", + "WinterGram.Default": "По умолчанию", + "WinterGram.Disabled": "Отключено", + "WinterGram.DefaultReaction": "Реакция по умолчанию", + "WinterGram.DefaultRealDevice": "Сбросить (реальное устройство)", + "WinterGram.DeletedAndEditedMessagesAreKeptLocallyOnThisDeviceOnly": "Удалённые и изменённые сообщения хранятся только на этом устройстве.", + "WinterGram.Desktop": "Десктоп", + "WinterGram.DeletedMark": "Метка удаления", + "WinterGram.NFTGift": "NFT", + "WinterGram.RegularGift": "Обычный подарок", + "WinterGram.DimDeletedMessages": "Затемнять удалённые сообщения", + "WinterGram.ShowDeletionTime": "Показывать время удаления", + "WinterGram.TopBanner": "Верхний баннер", + "WinterGram.Solid": "Сплошной", + "WinterGram.Glass": "Стекло", + "WinterGram.Gradient": "Градиент", + "WinterGram.Outline": "Контур", + "WinterGram.DisableAds": "Отключить рекламу", + "WinterGram.DisableOpenLinkWarning": "Без предупреждения о ссылках", + "WinterGram.DoNotChange": "Не изменять", + "WinterGram.EmptyNoPasscode": "Пусто = без пароля", + "WinterGram.EnterNFTLinkEGHttpsTMeNftSlug": "Введите ссылку на NFT (напр. https://t.me/nft/slug)", + "WinterGram.EnterPasscode": "Введите пароль", + "WinterGram.Everything": "Всё", + "WinterGram.FEATURES": "ФУНКЦИИ", + "WinterGram.Features": "Функции", + "WinterGram.ForwardWithoutAuthor": "Пересылка без автора", + "WinterGram.GHOSTMODE": "РЕЖИМ ПРИЗРАКА", + "WinterGram.GhostMode": "Режим призрака", + "WinterGram.GiftAddedVisuallyToProfile": "Подарок добавлен визуально в профиль", + "WinterGram.HIDDENARCHIVE": "СКРЫТЫЙ АРХИВ", + "WinterGram.HISTORY": "ИСТОРИЯ", + "WinterGram.Hidden": "Скрыто", + "WinterGram.HiddenArchive": "Скрытый архив", + "WinterGram.HideEditedMark": "Скрывать метку «изменено»", + "WinterGram.HideFromStashedPeers": "Скрывать от скрытых чатов", + "WinterGram.HidePremiumStatuses": "Скрыть Premium-статусы", + "WinterGram.HideStories": "Скрыть истории", + "WinterGram.History": "История", + "WinterGram.INFORMATION": "ИНФОРМАЦИЯ", + "WinterGram.IconPack": "Пак иконок", + "WinterGram.InGhostMode": "В режиме призрака", + "WinterGram.IncreaseWebViewHeight": "Увеличить высоту WebView", + "WinterGram.Information": "Информация", + "WinterGram.InfoFooter": "WinterGram · независимый iOS-клиент · GPLv2 © reekeer\n\nWntGram Beta · v1.0.0", + "WinterGram.LocalPremium": "Локальный Premium", + "WinterGram.LocalPremiumUnlocksPremiumOnlyUIOnThisDeviceItDoesNotGrantServerSidePremium": "Локальный Premium открывает Premium-интерфейс на этом устройстве, но не даёт серверный Premium.", + "WinterGram.MessageTranslation": "Перевод сообщений", + "WinterGram.Messages": "Сообщения", + "WinterGram.MonospaceFont": "Моноширинный шрифт", + "WinterGram.MuteNotifications": "Без уведомлений", + "WinterGram.Never": "Никогда", + "WinterGram.None": "Нет", + "WinterGram.Off": "Выкл.", + "WinterGram.OnlineTracker": "Трекер онлайна", + "WinterGram.OnlyAddedEmojiStickers": "Только добавленные эмодзи и стикеры", + "WinterGram.PrivacyFirstMessagingClient": "Приватный мессенджер", + "WinterGram.ProfileName": "Имя профиля", + "WinterGram.Plugins": "Плагины", + "WinterGram.Restart": "Рестарт", + "WinterGram.RestartRequired": "Нужен рестарт", + "WinterGram.Round": "Круг", + "WinterGram.Rounded": "Скруглённый", + "WinterGram.SPOOFING": "ПОДМЕНА", + "WinterGram.SPYMODE": "РЕЖИМ ШПИОНА", + "WinterGram.SpyMode": "Режим шпиона", + "WinterGram.SaveCurrentAsProfile": "Сохранить как профиль +", + "WinterGram.SaveDeletedFromBots": "Сохранять удалённые от ботов", + "WinterGram.SaveDeletedMessages": "Сохранять удалённые сообщения", + "WinterGram.SaveEditHistory": "Сохранять историю правок", + "WinterGram.SendWithoutSound": "Отправлять без звука", + "WinterGram.ShowMessageSeconds": "Секунды в сообщениях", + "WinterGram.ShowPeerID": "Показывать ID", + "WinterGram.ShowRegistrationDate": "Показывать дату регистрации", + "WinterGram.SingleCornerRadius": "Одиночное скругление", + "WinterGram.SomeSettingsWillTakeEffectAfterRestart": "Некоторые настройки вступят в силу после перезапуска.", + "WinterGram.SpoofAppVersion": "Версия приложения", + "WinterGram.SpoofDeviceModel": "Модель устройства", + "WinterGram.SpoofTheDeviceModelAppVersionAndWebViewPlatformReportedToTelegramAndMiniAppsAPIIDHashUseYourOwnCredentialsFromMyTelegramOrgChangingThemRequiresReLogin": "Подменяет модель устройства, версию приложения и платформу WebView, которые сообщаются Telegram и мини-приложениям. API ID/Hash — это ваши данные с my.telegram.org; их изменение требует повторного входа.", + "WinterGram.Spoofing": "Подмена", + "WinterGram.Square": "Квадрат", + "WinterGram.Squircle": "Сквиркл", + "WinterGram.StashPasscode": "Пароль скрытых", + "WinterGram.StashedChats": "Скрытые чаты", + "WinterGram.StashedChatsAreHiddenFromTheMainListAndAccessibleOnlyHere": "Скрытые чаты не показываются в основном списке и доступны только здесь.", + "WinterGram.HiddenArchiveInfo": "Чаты в скрытом архиве не показываются в списке чатов. Зажмите чат в списке, чтобы скрыть или вернуть его.", + "WinterGram.HiddenArchiveEmpty": "Скрытый архив пуст.", + "WinterGram.Stories": "Истории", + "WinterGram.System": "Системный", + "WinterGram.Templates": "Шаблоны", + "WinterGram.TrackOnlineStatus": "Отслеживать статус «в сети»", + "WinterGram.TranslationProvider": "Сервис перевода", + "WinterGram.TransparencyBlurAndTintCanBeFineTunedPerSurfaceTurnLiquidGlassOffForTheStandardOpaqueLook": "Прозрачность, блюр и оттенок настраиваются по каждой поверхности. Выключите Liquid Glass для обычного непрозрачного вида.", + "WinterGram.UseDefaultTelegramBranding": "Стандартный бренд", + "WinterGram.UseScheduledMessages": "Отложенная отправка", + "WinterGram.Version": "Версия", + "WinterGram.VisualGift": "Визуал. подарок", + "WinterGram.WebViewPlatform": "Платформа WebView", + "WinterGram.WhenGhostModeIsOnWinterGramStopsSendingReadReceiptsOnlineStatusAndTypingActivity": "Когда режим призрака включён, WinterGram не отправляет отметки о прочтении, статус «в сети» и набор текста.", + "WinterGram.WinterGramWntIsAPrivacyFocusedMessagingClientForIPhoneANativePortOfTheAyuGramExperienceItAddsGhostModeSavedDeletedMessagesAndEditHistoryAHiddenArchiveLocalPremiumAdRemovalDeepCustomizationAndLiquidGlass": "Модифицированный клиент Telegram.", + "WinterGram.Yandex": "Яндекс", + "WinterGram.DeletedMessages.Title": "Удалённые сообщения", + "WinterGram.DeletedMessages.Total": "Всего", + "WinterGram.DeletedMessages.SelectTypes": "Выберите типы", + "WinterGram.DeletedMessages.DeleteSelected": "Удалить выбранное", + "WinterGram.DeletedMessages.ConfirmDelete": "Удалить выбранные сохранённые удалённые сообщения? Это нельзя отменить.", + "WinterGram.DeletedMessages.Deleted": "Освобождено %@", + "WinterGram.DeletedMessages.Text": "Сообщения", + "WinterGram.DeletedMessages.Photos": "Фото", + "WinterGram.DeletedMessages.Videos": "Видео", + "WinterGram.DeletedMessages.Voice": "Голосовые", + "WinterGram.DeletedMessages.VideoMessages": "Кружочки", + "WinterGram.DeletedMessages.Music": "Музыка", + "WinterGram.DeletedMessages.Stickers": "Стикеры", + "WinterGram.DeletedMessages.Other": "Другое", + "WinterGram.DeletedMessages.TopChats": "Топ чатов", + "WinterGram.DeleteSelected": "Удалить выбранное", +] + +/// Returns WinterGram's fork-string translations for the given base language code, to be merged +/// into the active `PresentationStrings` component dictionaries. Empty when the language has no +/// bundled translation (English is handled by the generated `en.lproj` fallback). +public func winterGramSeedStrings(languageCode: String) -> [String: String] { + if languageCode.hasPrefix("ru") { + return winterGramRussianSeed + } + return [:] +} + +/// 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. +public func wntOption(_ english: String, _ strings: PresentationStrings) -> String { + switch english { + case "Off": return strings.WinterGram_Off + case "Solid": return strings.WinterGram_Solid + case "Glass": return strings.WinterGram_Glass + case "Gradient": return strings.WinterGram_Gradient + case "Outline": return strings.WinterGram_Outline + case "Messages": return strings.WinterGram_Messages + case "Stories": return strings.WinterGram_Stories + case "Everything": return strings.WinterGram_Everything + case "Disabled": return strings.WinterGram_Disabled + case "Never": return strings.WinterGram_Never + case "In Ghost Mode": return strings.WinterGram_InGhostMode + case "Always": return strings.WinterGram_Always + case "Hidden": return strings.WinterGram_Hidden + case "Telegram API": return strings.WinterGram_TelegramAPI + case "Bot API": return strings.WinterGram_BotAPI + case "Telegram": return strings.WinterGram_Telegram + case "Google": return strings.WinterGram_Google + case "Yandex": return strings.WinterGram_Yandex + case "System": return strings.WinterGram_System + case "Automatic": return strings.WinterGram_Automatic + case "iOS": return strings.WinterGram_IOS + case "Android": return strings.WinterGram_Android + case "macOS": return strings.WinterGram_MacOS + case "Desktop": return strings.WinterGram_Desktop + case "WinterGram": return strings.WinterGram_WinterGram + case "Ayu": return strings.WinterGram_AyuGram + case "exteraGram": return strings.WinterGram_ExteraGram + case "Round": return strings.WinterGram_Round + case "Squircle": return strings.WinterGram_Squircle + case "Rounded": return strings.WinterGram_Rounded + case "Square": return strings.WinterGram_Square + case "Ghost Mode": return strings.WinterGram_GhostMode + case "History": return strings.WinterGram_History + case "Hidden Archive": return strings.WinterGram_HiddenArchive + case "Features": return strings.WinterGram_Features + case "Chat": return strings.WinterGram_Chat + case "Appearance": return strings.WinterGram_Appearance + case "Liquid Glass": return strings.WinterGram_LiquidGlass + case "Spoofing": return strings.WinterGram_Spoofing + case "Information": return strings.WinterGram_Information + case "None": return strings.WinterGram_None + case "Releases": return strings.WinterGram_Releases + case "Channel": return strings.WinterGram_Channel + case "Beta": return strings.WinterGram_Beta + case "Other": return strings.WinterGram_Other + case "Core": return strings.WinterGram_Core + case "Core Menu": return strings.WinterGram_CoreMenu + case "Default": return strings.WinterGram_Default + case "Do Not Change": return strings.WinterGram_DoNotChange + case "Hide From Stashed Peers": return strings.WinterGram_HideFromStashedPeers + case "Add Template": return strings.WinterGram_AddTemplate + case "Plugins": return strings.WinterGram_Plugins + default: return english + } +} diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 2079e6767c..485bb24547 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -93,7 +93,7 @@ private struct BubbleItemAttributes { var isAttachment: Bool var neighborType: ChatMessageBubbleRelativePosition.NeighbourType var neighborSpacing: ChatMessageBubbleRelativePosition.NeighbourSpacing - + init(index: Int? = nil, isAttachment: Bool, neighborType: ChatMessageBubbleRelativePosition.NeighbourType, neighborSpacing: ChatMessageBubbleRelativePosition.NeighbourSpacing) { self.index = index self.isAttachment = isAttachment @@ -121,20 +121,20 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ var isUnsupportedMedia = false var isStoryWithText = false var isAction = false - + var previousItemIsFile = false var hasFiles = false - + var needReactions = true - + let hideAllAdditionalInfo = item.presentationData.isPreview - + var hasSeparateCommentsButton = false - + var addedPriceInfo = false var addedPollMedia = false var addedQuizAnswer = false - + outer: for (message, itemAttributes) in item.content { for attribute in message.attributes { if let attribute = attribute as? RestrictedContentMessageAttribute, attribute.platformText(platform: "ios", contentSettings: item.context.currentContentSettings.with { $0 }) != nil { @@ -146,12 +146,12 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ addedPriceInfo = true } } - + var messageMedia = message.media if let updatingMedia = itemAttributes.updatingMedia, messageMedia.isEmpty, case let .update(media) = updatingMedia.media { messageMedia.append(media.media) } - + var isFile = false inner: for media in messageMedia { if let media = media as? TelegramMediaPaidContent { @@ -184,13 +184,13 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ hideStory = true } } - + if !hideStory { if let storyItem = message.associatedStories[story.storyId], storyItem.data.isEmpty { } else { result.append((message, ChatMessageMediaBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .media, neighborSpacing: .default))) } - + if let storyItem = message.associatedStories[story.storyId], let storedItem = storyItem.get(Stories.StoredItem.self), case let .item(item) = storedItem { if !item.text.isEmpty { isStoryWithText = true @@ -291,7 +291,7 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ result.append((message, ChatMessageQuizAnswerBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .media, neighborSpacing: .default))) addedQuizAnswer = true } - + if let attachedMedia = poll.attachedMedia { if let _ = attachedMedia as? TelegramMediaImage { result.append((message, ChatMessageMediaBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .media, neighborSpacing: .default))) @@ -324,12 +324,12 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ } previousItemIsFile = isFile } - + var messageText = message.text if let updatingMedia = itemAttributes.updatingMedia { messageText = updatingMedia.text } - + var richText: RichTextMessageAttribute? for attribute in item.message.attributes { if let attribute = attribute as? RichTextMessageAttribute { @@ -337,7 +337,7 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ break } } - + if !messageText.isEmpty || (message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) && richText == nil) || isUnsupportedMedia || isStoryWithText { if !skipText { if case .group = item.content, !isFile { @@ -352,7 +352,7 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ } else if let _ = message.media.first(where: { $0 is TelegramMediaPoll }) { isMediaInverted = true } - + if isMediaInverted { var targetIndex = 0 if addedPriceInfo || addedPollMedia { @@ -373,11 +373,11 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ } } } - + if let attribute = message.factCheckAttribute, case .Loaded = attribute.content, messageWithFactCheckToAdd == nil { messageWithFactCheckToAdd = (message, itemAttributes) } - + inner: for media in message.media { if let webpage = media as? TelegramMediaWebpage { if case let .Loaded(content) = webpage.content { @@ -387,7 +387,7 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ break inner } } - + if let attribute = message.attributes.first(where: { $0 is WebpagePreviewMessageAttribute }) as? WebpagePreviewMessageAttribute, attribute.leadingPreview { result.insert((message, ChatMessageWebpageBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default)), at: addedPriceInfo ? 1 : 0) } else { @@ -398,25 +398,25 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ break inner } } - + if richText != nil && !skipText { result.append((message, ChatMessageRichDataBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) needReactions = false } - + if message.adAttribute != nil { result.removeAll() result.append((message, ChatMessageWebpageBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) needReactions = false } - + if isUnsupportedMedia { result.append((message, ChatMessageUnsupportedBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) needReactions = false } } - + if let (messageWithCaptionToAdd, itemAttributes) = messageWithCaptionToAdd { var isMediaInverted = false if let updatingMedia = itemAttributes.updatingMedia { @@ -424,7 +424,7 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ } else if let _ = messageWithCaptionToAdd.attributes.first(where: { $0 is InvertMediaMessageAttribute }) { isMediaInverted = true } - + if isMediaInverted { if result.isEmpty { needReactions = false @@ -435,12 +435,12 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ needReactions = false } } - + if let (messageWithFactCheckToAdd, itemAttributes) = messageWithFactCheckToAdd, !hasSeparateCommentsButton { result.append((messageWithFactCheckToAdd, ChatMessageFactCheckBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) needReactions = false } - + if let additionalContent = item.additionalContent { switch additionalContent { case let .eventLogPreviousMessage(previousMessage): @@ -456,21 +456,21 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ break } } - + let firstMessage = item.content.firstMessage - + let reactionsAreInline: Bool reactionsAreInline = shouldDisplayInlineDateReactions(message: EngineMessage(firstMessage), isPremium: item.associatedData.isPremium, forceInline: item.associatedData.forceInlineReactions) if reactionsAreInline { needReactions = false } - + if !isAction && !hasSeparateCommentsButton && !Namespaces.Message.allNonRegular.contains(firstMessage.id.namespace) && !hideAllAdditionalInfo { if hasCommentButton(item: item) { result.append((firstMessage, ChatMessageCommentFooterContentNode.self, ChatMessageEntryAttributes(), BubbleItemAttributes(isAttachment: true, neighborType: .footer, neighborSpacing: .default))) } } - + if !reactionsAreInline && !hideAllAdditionalInfo, let reactionsAttribute = mergedMessageReactions(attributes: firstMessage.attributes, isTags: firstMessage.areReactionsTags(accountPeerId: item.context.account.peerId)), !reactionsAttribute.reactions.isEmpty { if result.last?.1 == ChatMessageTextBubbleContentNode.self { } else { @@ -494,13 +494,13 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ } } } - + var needSeparateContainers = false if case .group = item.content, hasFiles { needSeparateContainers = true needReactions = false } - + return (result, needSeparateContainers, needReactions) } @@ -517,9 +517,9 @@ private func mapVisibility(_ visibility: ListViewItemNodeVisibility, boundsSize: var subRect = subRect subRect.origin.x = 0.0 subRect.size.width = 10000.0 - + subRect.origin.y = boundsSize.height - insets.bottom - (subRect.origin.y + subRect.height) - + let contentNodeFrame = contentNode.frame if contentNodeFrame.intersects(subRect) { let intersectionRect = contentNodeFrame.intersection(subRect) @@ -530,6 +530,65 @@ private func mapVisibility(_ visibility: ListViewItemNodeVisibility, boundsSize: } } +// WinterGram: a marker shown beside a message that was preserved after the peer deleted it. +// The marker is the user-chosen `deletedMark` emoji (default 🧹). When the "show deletion time" +// preference is on, the emoji + the time the deletion arrived are drawn inside a coloured pill; +// otherwise just the bare emoji glyph is shown. Cached per (colour, emoji, time) so we don't +// re-rasterise every layout pass. +private var winterGramDeletedBadgeImageCache: [String: UIImage] = [:] +private let winterGramDeletedTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter +}() +func winterGramDeletedBadgeTimeText(_ timestamp: Int32) -> String { + return winterGramDeletedTimeFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(timestamp))) +} +func winterGramDeletedBadgeImage(emoji: String, pillColor: UIColor, timeText: String?) -> UIImage? { + let markEmoji = emoji.isEmpty ? "🧹" : emoji + var r: CGFloat = 0.0, g: CGFloat = 0.0, b: CGFloat = 0.0, a: CGFloat = 0.0 + pillColor.getRed(&r, green: &g, blue: &b, alpha: &a) + let colorKey = (UInt32(max(0.0, min(1.0, r)) * 255.0) << 16) | (UInt32(max(0.0, min(1.0, g)) * 255.0) << 8) | UInt32(max(0.0, min(1.0, b)) * 255.0) + let key = "\(colorKey)|\(markEmoji)|\(timeText ?? "")" + if let cached = winterGramDeletedBadgeImageCache[key] { + return cached + } + + let emojiFont = UIFont.systemFont(ofSize: 15.0) + let emojiAttributed = NSAttributedString(string: markEmoji, attributes: [.font: emojiFont]) + let emojiSize = emojiAttributed.size() + + let image: UIImage + if let timeText = timeText { + let textColor = UIColor.white + let font = UIFont.systemFont(ofSize: 11.0, weight: .medium) + let attributedText = NSAttributedString(string: timeText, attributes: [.font: font, .foregroundColor: textColor]) + let textSize = attributedText.size() + let iconTextSpacing: CGFloat = 3.0 + let hPadding: CGFloat = 6.0 + let vPadding: CGFloat = 3.0 + let contentWidth = ceil(emojiSize.width) + iconTextSpacing + ceil(textSize.width) + let contentHeight = max(ceil(emojiSize.height), ceil(textSize.height)) + let totalSize = CGSize(width: contentWidth + hPadding * 2.0, height: contentHeight + vPadding * 2.0) + let renderer = UIGraphicsImageRenderer(size: totalSize) + image = renderer.image { _ in + let pillPath = UIBezierPath(roundedRect: CGRect(origin: .zero, size: totalSize), cornerRadius: totalSize.height / 2.0) + pillColor.setFill() + pillPath.fill() + emojiAttributed.draw(at: CGPoint(x: hPadding, y: floor((totalSize.height - emojiSize.height) / 2.0))) + attributedText.draw(at: CGPoint(x: hPadding + ceil(emojiSize.width) + iconTextSpacing, y: floor((totalSize.height - textSize.height) / 2.0))) + } + } else { + let totalSize = CGSize(width: max(1.0, ceil(emojiSize.width)), height: max(1.0, ceil(emojiSize.height))) + let renderer = UIGraphicsImageRenderer(size: totalSize) + image = renderer.image { _ in + emojiAttributed.draw(at: CGPoint(x: 0.0, y: 0.0)) + } + } + winterGramDeletedBadgeImageCache[key] = image + return image +} + public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode { public class ContentContainer { public let contentMessageStableId: UInt32 @@ -538,16 +597,16 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI public var backgroundWallpaperNode: ChatMessageBubbleBackdrop? public var backgroundNode: ChatMessageBackground? public var selectionBackgroundNode: ASDisplayNode? - + private var currentParams: (size: CGSize, contentOrigin: CGPoint, presentationData: ChatPresentationData, graphics: PrincipalThemeEssentialGraphics, backgroundType: ChatMessageBackgroundType, presentationContext: ChatPresentationContext, mediaBox: MediaBox, messageSelection: Bool?, selectionInsets: UIEdgeInsets)? - + public init(contentMessageStableId: UInt32) { self.contentMessageStableId = contentMessageStableId - + self.sourceNode = ContextExtractedContentContainingNode() self.containerNode = ContextControllerSourceNode() } - + fileprivate var absoluteRect: (CGRect, CGSize)? fileprivate func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { self.absoluteRect = (rect, containerSize) @@ -560,21 +619,21 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI let mappedRect = CGRect(origin: CGPoint(x: rect.minX + backgroundWallpaperNode.frame.minX, y: rect.minY + backgroundWallpaperNode.frame.minY), size: rect.size) backgroundWallpaperNode.update(rect: mappedRect, within: containerSize) } - + fileprivate func applyAbsoluteOffset(value: CGPoint, animationCurve: ContainedViewLayoutTransitionCurve, duration: Double) { guard let backgroundWallpaperNode = self.backgroundWallpaperNode else { return } backgroundWallpaperNode.offset(value: value, animationCurve: animationCurve, duration: duration) } - + fileprivate func applyAbsoluteOffsetSpring(value: CGFloat, duration: Double, damping: CGFloat) { guard let backgroundWallpaperNode = self.backgroundWallpaperNode else { return } backgroundWallpaperNode.offsetSpring(value: value, duration: duration, damping: damping) } - + fileprivate func willUpdateIsExtractedToContextPreview(isExtractedToContextPreview: Bool, transition: ContainedViewLayoutTransition) { if isExtractedToContextPreview { var offset: CGFloat = 0.0 @@ -588,34 +647,34 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI type = .outgoing(.Extracted) inset = 5.0 } - + if let _ = self.backgroundNode { } else if let currentParams = self.currentParams { let backgroundWallpaperNode = ChatMessageBubbleBackdrop() backgroundWallpaperNode.alpha = 0.0 - + let backgroundNode = ChatMessageBackground() backgroundNode.alpha = 0.0 - + self.sourceNode.contentNode.insertSubnode(backgroundNode, at: 0) self.sourceNode.contentNode.insertSubnode(backgroundWallpaperNode, at: 0) - + self.backgroundWallpaperNode = backgroundWallpaperNode self.backgroundNode = backgroundNode - + transition.updateAlpha(node: backgroundNode, alpha: 1.0) transition.updateAlpha(node: backgroundWallpaperNode, alpha: 1.0) - + backgroundNode.setType(type: type, highlighted: false, graphics: currentParams.graphics, maskMode: true, hasWallpaper: currentParams.presentationData.theme.wallpaper.hasWallpaper, transition: .immediate, backgroundNode: currentParams.presentationContext.backgroundNode) backgroundWallpaperNode.setType(type: type, theme: currentParams.presentationData.theme, essentialGraphics: currentParams.graphics, maskMode: true, backgroundNode: currentParams.presentationContext.backgroundNode) } - + if let currentParams = self.currentParams { let backgroundFrame = CGRect(x: currentParams.contentOrigin.x + offset, y: 0.0, width: currentParams.size.width + inset, height: currentParams.size.height) self.backgroundNode?.updateLayout(size: backgroundFrame.size, transition: .immediate) self.backgroundNode?.frame = backgroundFrame self.backgroundWallpaperNode?.frame = backgroundFrame - + if let (rect, containerSize) = self.absoluteRect { let mappedRect = CGRect(origin: CGPoint(x: rect.minX + backgroundFrame.minX, y: rect.minY + backgroundFrame.minY), size: rect.size) self.backgroundWallpaperNode?.update(rect: mappedRect, within: containerSize) @@ -636,21 +695,21 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + fileprivate func isExtractedToContextPreviewUpdated(_ isExtractedToContextPreview: Bool) { } - + fileprivate func update(size: CGSize, contentOrigin: CGPoint, selectionInsets: UIEdgeInsets, index: Int, presentationData: ChatPresentationData, graphics: PrincipalThemeEssentialGraphics, backgroundType: ChatMessageBackgroundType, presentationContext: ChatPresentationContext, mediaBox: MediaBox, messageSelection: Bool?) { self.currentParams = (size, contentOrigin, presentationData, graphics, backgroundType, presentationContext, mediaBox, messageSelection, selectionInsets) let bounds = CGRect(origin: CGPoint(), size: size) - + var incoming: Bool = false if case .incoming = backgroundType { incoming = true } - + let messageTheme = incoming ? presentationData.theme.theme.chat.message.incoming : presentationData.theme.theme.chat.message.outgoing - + if let messageSelection = messageSelection, messageSelection { if let _ = self.selectionBackgroundNode { } else { @@ -658,17 +717,17 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI self.containerNode.insertSubnode(selectionBackgroundNode, at: 0) self.selectionBackgroundNode = selectionBackgroundNode } - + var selectionBackgroundFrame = bounds.offsetBy(dx: contentOrigin.x, dy: 0.0) if index == 0 && contentOrigin.y > 0.0 { selectionBackgroundFrame.origin.y -= contentOrigin.y selectionBackgroundFrame.size.height += contentOrigin.y } selectionBackgroundFrame = selectionBackgroundFrame.inset(by: selectionInsets) - + let bubbleColor = graphics.hasWallpaper ? messageTheme.bubble.withWallpaper.fill : messageTheme.bubble.withoutWallpaper.fill let selectionColor = bubbleColor[0].withAlphaComponent(1.0).mixedWith(messageTheme.accentTextColor.withAlphaComponent(1.0), alpha: 0.08) - + self.selectionBackgroundNode?.backgroundColor = selectionColor self.selectionBackgroundNode?.frame = selectionBackgroundFrame } else if let selectionBackgroundNode = self.selectionBackgroundNode { @@ -677,7 +736,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + public let mainContextSourceNode: ContextExtractedContentContainingNode private let mainContainerNode: ContextControllerSourceNode private let backgroundWallpaperNode: ChatMessageBubbleBackdrop @@ -685,105 +744,106 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI private var backgroundHighlightNode: ChatMessageBackground? private let shadowNode: ChatMessageShadowNode private var clippingNode: ChatMessageBubbleClippingNode - + private var suggestedPostInfoNode: ChatMessageSuggestedPostInfoNode? - + override public var extractedBackgroundNode: ASDisplayNode? { return self.shadowNode } - + private var selectionNode: ChatMessageSelectionNode? private var deliveryFailedNode: ChatMessageDeliveryFailedNode? + private var winterGramDeletedIconNode: ASImageNode? private var swipeToReplyNode: ChatMessageSwipeToReplyNode? private var swipeToReplyFeedback: HapticFeedback? - + private var nameAvatarNode: AvatarNode? private var nameNode: TextNode? private var nameButtonNode: HighlightTrackingButtonNode? private var nameHighlightNode: ASImageNode? private var viaMeasureNode: TextNode? private var nameNavigateButton: NameNavigateButton? - + private var rankButtonNode: HighlightTrackingButtonNode? private var rankBackgroundNode: ASImageNode? private var rankBadgeNode: TextNode? - + private var credibilityIconView: ComponentHostView? private var credibilityIconComponent: EmojiStatusComponent? private var credibilityIconContent: EmojiStatusComponent.Content? private var credibilityButtonNode: HighlightTrackingButtonNode? private var credibilityHighlightNode: ASImageNode? - + private var boostBadgeNode: TextNode? private var boostIconNode: UIImageView? private var boostCount: Int = 0 - + private var boostButtonNode: HighlightTrackingButtonNode? private var boostHighlightNode: ASImageNode? - + private var closeButtonNode: HighlightTrackingButtonNode? private var closeIconNode: ASImageNode? - + private var forwardInfoNode: ChatMessageForwardInfoNode? public var forwardInfoReferenceNode: ASDisplayNode? { return self.forwardInfoNode } - + private var threadInfoNode: ChatMessageThreadInfoNode? private var replyInfoNode: ChatMessageReplyInfoNode? - + private var contentContainersWrapperNode: ASDisplayNode private var contentContainers: [ContentContainer] = [] public private(set) var contentNodes: [ChatMessageBubbleContentNode] = [] private var mosaicStatusNode: ChatMessageDateAndStatusNode? private var actionButtonsNode: ChatMessageActionButtonsNode? private var reactionButtonsNode: ChatMessageReactionButtonsNode? - + private var unlockButtonNode: ChatMessageUnlockMediaNode? private var mediaInfoNode: ChatMessageStarsMediaInfoNode? - + private var summarizeButtonNode: ChatMessageShareButton? private var shareButtonNode: ChatMessageShareButton? - + private let messageAccessibilityArea: AccessibilityAreaNode private var backgroundType: ChatMessageBackgroundType? - + private struct HighlightedState: Equatable { var quote: ChatInterfaceHighlightedState.Quote? var subject: EngineMessageReplyInnerSubject? } private var highlightedState: HighlightedState? - + private var backgroundFrameTransition: (CGRect, CGRect)? - + private var currentSwipeToReplyTranslation: CGFloat = 0.0 - + private var appliedItem: ChatMessageItem? private var appliedForwardInfo: (Peer?, String?)? private var disablesComments = true - + private var wasPending: Bool = false private var didChangeFromPendingToSent: Bool = false - + private var authorNameColor: UIColor? private var authorRank: CachedChannelAdminRank? - + private var tapRecognizer: TapLongTapOrDoubleTapGestureRecognizer? - + private var replyRecognizer: ChatSwipeToReplyRecognizer? private var currentSwipeAction: ChatControllerInteractionSwipeAction? - + override public var visibility: ListViewItemNodeVisibility { didSet { if self.visibility != oldValue { self.visibilityStatus = self.visibility != .none - + self.updateVisibility(isScroll: true) } } } - + private var visibilityStatus: Bool = false { didSet { if self.visibilityStatus != oldValue { @@ -798,9 +858,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + private var forceStopAnimations: Bool = false - + typealias Params = (item: ChatMessageItem, params: ListViewItemLayoutParams, mergedTop: ChatMessageMerge, mergedBottom: ChatMessageMerge, dateHeaderAtBottom: ChatMessageHeaderSpec) private var currentInputParams: Params? private var currentApplyParams: ListViewItemApply? @@ -821,7 +881,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI self.mainContainerNode = ContextControllerSourceNode() self.backgroundWallpaperNode = ChatMessageBubbleBackdrop() self.contentContainersWrapperNode = ASDisplayNode() - + self.backgroundNode = ChatMessageBackground() self.backgroundNode.backdropNode = self.backgroundWallpaperNode self.shadowNode = ChatMessageShadowNode() @@ -830,14 +890,14 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI self.clippingNode.clipsToBounds = false self.messageAccessibilityArea = AccessibilityAreaNode() - + //self.debugNode = ASDisplayNode() //self.debugNode.backgroundColor = .blue - + super.init(rotated: rotated) - + //self.addSubnode(self.debugNode) - + self.mainContainerNode.shouldBeginWithCustomActivationProcess = { [weak self] location in guard let strongSelf = self else { return .none @@ -885,12 +945,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } return .default } - + self.mainContainerNode.activated = { [weak self] gesture, location in guard let strongSelf = self, let item = strongSelf.item else { return } - + if let action = strongSelf.gestureRecognized(gesture: .longTap, location: location, recognizer: nil) { switch action { case .action, .optionalAction: @@ -909,40 +969,40 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + self.mainContainerNode.addSubnode(self.mainContextSourceNode) self.mainContainerNode.targetNodeForActivationProgress = self.mainContextSourceNode.contentNode self.addSubnode(self.mainContainerNode) - + self.mainContextSourceNode.contentNode.addSubnode(self.backgroundWallpaperNode) self.mainContextSourceNode.contentNode.addSubnode(self.backgroundNode) self.mainContextSourceNode.contentNode.addSubnode(self.clippingNode) self.clippingNode.addSubnode(self.contentContainersWrapperNode) self.addSubnode(self.messageAccessibilityArea) - + self.messageAccessibilityArea.activate = { [weak self] in guard let strongSelf = self, let accessibilityData = strongSelf.accessibilityData else { return false } - + for node in strongSelf.contentNodes { if node.accessibilityActivate() { return true } } - + if let singleUrl = accessibilityData.singleUrl { strongSelf.item?.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl(url: singleUrl, concealed: false, external: false, message: strongSelf.item?.content.firstMessage)) return true } - + return false } - + self.messageAccessibilityArea.focused = { [weak self] in self?.accessibilityElementDidBecomeFocused() } - + self.mainContextSourceNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtractedToContextPreview, _ in guard let self, let _ = self.item else { return @@ -960,18 +1020,18 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if !isExtractedToContextPreview, let (rect, size) = self.absoluteRect { self.updateAbsoluteRect(rect, within: size) } - + for contentNode in self.contentNodes { contentNode.updateIsExtractedToContextPreview(isExtractedToContextPreview) } - + if !isExtractedToContextPreview { if let item = self.item { item.controllerInteraction.forceUpdateWarpContents() } } } - + self.mainContextSourceNode.updateAbsoluteRect = { [weak self] rect, size in guard let strongSelf = self, strongSelf.mainContextSourceNode.isExtractedToContextPreview else { return @@ -991,11 +1051,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.applyAbsoluteOffsetSpringInternal(value: value, duration: duration, damping: damping) } } - + required public init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + deinit { } @@ -1006,7 +1066,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI transition.updateFrame(node: self.mainContainerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -floorToScreenPixels(height / 2.0)), size: self.mainContainerNode.bounds.size)) } } - + override public func cancelInsertionAnimations() { self.shadowNode.layer.removeAllAnimations() @@ -1040,25 +1100,25 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI process(node: self) } - + override public func insertionAnimationDuration() -> Double? { return nil } - + override public func updateAnimationDuration() -> Double? { return nil } - + override public func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { super.animateInsertion(currentTimestamp, duration: duration, options: options) - + self.shadowNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) - + func process(node: ASDisplayNode) { if node === self.accessoryItemNode { return } - + if node !== self { switch node { case _ as ContextExtractedContentContainingNode, _ as ContextControllerSourceNode, _ as ContextExtractedContentNode: @@ -1071,22 +1131,22 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI return } } - + guard let subnodes = node.subnodes else { return } - + for subnode in subnodes { process(node: subnode) } } - + process(node: self) } - + override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { super.animateRemoved(currentTimestamp, duration: duration) - + self.allowsGroupOpacity = true self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak self] _ in self?.allowsGroupOpacity = false @@ -1095,10 +1155,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI self.layer.animateScale(from: 1.0, to: 0.1, duration: 0.15, removeOnCompletion: false) self.layer.animatePosition(from: CGPoint(), to: CGPoint(x: self.bounds.width / 2.0 - self.backgroundNode.frame.midX, y: self.backgroundNode.frame.midY), duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) } - + override public func animateAdded(_ currentTimestamp: Double, duration: Double) { super.animateAdded(currentTimestamp, duration: duration) - + if let subnodes = self.subnodes { for subnode in subnodes { let layer = subnode.layer @@ -1109,17 +1169,17 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + public func animateFromLoadingPlaceholder(delay: Double, transition: ContainedViewLayoutTransition) { guard let item = self.item else { return } - + let incoming = item.message.effectivelyIncoming(item.context.account.peerId) transition.animatePositionAdditive(node: self, offset: CGPoint(x: incoming ? 30.0 : -30.0, y: -30.0), delay: delay) transition.animateTransformScale(node: self, from: CGPoint(x: 0.85, y: 0.85), delay: delay) } - + public final class AnimationTransitionTextInput { let backgroundView: UIView let contentView: UIView @@ -1155,7 +1215,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI self.backgroundWallpaperNode.animateFrom(sourceView: textInput.backgroundView, transition: transition) self.backgroundNode.animateFrom(sourceView: textInput.backgroundView, transition: transition) - + if let suggestedPostInfoNode = self.suggestedPostInfoNode { transition.horizontal.animatePositionAdditive(layer: suggestedPostInfoNode.layer, offset: CGPoint(x: -widthDifference, y: 0.0)) transition.horizontal.animateTransformScale(view: suggestedPostInfoNode.view, from: 0.001) @@ -1172,7 +1232,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + public final class AnimationTransitionReplyPanel { public let titleView: UIView public let textView: UIView @@ -1220,7 +1280,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI }) transition.horizontal.animateTransformScale(node: statusContainerNode.contentNode, from: 1.0 / scale) - + contentNode.interactiveFileNode.animateSent() return statusContainerNode @@ -1232,10 +1292,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI public func animateContentFromMediaInput(snapshotView: UIView, transition: CombinedTransition) { self.mainContextSourceNode.contentNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) } - + public func animateContentFromGroupedMediaInput(transition: CombinedTransition) -> [CGRect] { self.mainContextSourceNode.contentNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) - + var rects: [CGRect] = [] for contentNode in self.contentNodes { if let contentNode = contentNode as? ChatMessageMediaBubbleContentNode { @@ -1244,7 +1304,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } return rects } - + public func animateInstantVideoFromSnapshot(snapshotView: UIView, transition: CombinedTransition) { for contentNode in self.contentNodes { if let contentNode = contentNode as? ChatMessageInstantVideoBubbleContentNode { @@ -1254,10 +1314,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + override public func didLoad() { super.didLoad() - + let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) recognizer.tapActionAtPoint = { [weak self] point in if let strongSelf = self { @@ -1273,7 +1333,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if !options.hasAlternativeLinks { return .fail } - + for contentNode in strongSelf.contentNodes { let contentNodePoint = strongSelf.view.convert(point, to: contentNode.view) let tapAction = contentNode.tapActionAtPoint(contentNodePoint, gesture: .tap, isEstimating: true) @@ -1289,7 +1349,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + return .fail } @@ -1298,43 +1358,43 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI return .fail } } - + if let summarizeButtonNode = strongSelf.summarizeButtonNode, summarizeButtonNode.frame.contains(point) { return .fail } - + if let shareButtonNode = strongSelf.shareButtonNode, shareButtonNode.frame.contains(point) { return .fail } - + if let actionButtonsNode = strongSelf.actionButtonsNode { if let _ = actionButtonsNode.hitTest(strongSelf.view.convert(point, to: actionButtonsNode.view), with: nil) { return .fail } } - + if let reactionButtonsNode = strongSelf.reactionButtonsNode { if let _ = reactionButtonsNode.hitTest(strongSelf.view.convert(point, to: reactionButtonsNode.view), with: nil) { return .fail } } - + if let nameButtonNode = strongSelf.nameButtonNode, nameButtonNode.frame.contains(point) { return .fail } - + if let credibilityButtonNode = strongSelf.credibilityButtonNode, credibilityButtonNode.frame.contains(point) { return .fail } - + if let boostButtonNode = strongSelf.boostButtonNode, boostButtonNode.frame.contains(point) { return .fail } - + if let rankButtonNode = strongSelf.rankButtonNode, rankButtonNode.frame.contains(point) { return .fail } - + if let nameNode = strongSelf.nameNode, nameNode.frame.contains(point) { if let item = strongSelf.item { for attribute in item.message.attributes { @@ -1379,16 +1439,16 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI return .waitForSingleTap } } - + if !strongSelf.backgroundNode.frame.contains(point) { return .waitForDoubleTap } - + if strongSelf.currentMessageEffect() != nil { return .waitForDoubleTap } } - + return .waitForDoubleTap } recognizer.longTap = { [weak self] point, recognizer in @@ -1481,11 +1541,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + if case let .replyThread(replyThreadMessage) = item.chatLocation, replyThreadMessage.isChannelPost, replyThreadMessage.peerId != item.content.firstMessage.id.peerId { return false } - + let action = item.controllerInteraction.canSetupReply(item.message) strongSelf.currentSwipeAction = action if case .none = action { @@ -1498,7 +1558,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } self.replyRecognizer = replyRecognizer self.view.addGestureRecognizer(replyRecognizer) - + if let item = self.item, let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject { if case .link = info { } else { @@ -1507,14 +1567,14 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI self.replyRecognizer?.isEnabled = false } } - + private func internalUpdateLayout() { if let inputParams = self.currentInputParams, let currentApplyParams = self.currentApplyParams { let (_, applyLayout) = self.asyncLayout()(inputParams.item, inputParams.params, inputParams.mergedTop, inputParams.mergedBottom, inputParams.dateHeaderAtBottom) applyLayout(.None, ListViewItemApply(isOnScreen: currentApplyParams.isOnScreen, timestamp: nil), false) } } - + override public func asyncLayout() -> (_ item: ChatMessageItem, _ params: ListViewItemLayoutParams, _ mergedTop: ChatMessageMerge, _ mergedBottom: ChatMessageMerge, _ dateHeaderAtBottom: ChatMessageHeaderSpec) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation, ListViewItemApply, Bool) -> Void) { var currentContentClassesPropertiesAndLayouts: [(Message, AnyClass, Bool, Int?, (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))))] = [] for contentNode in self.contentNodes { @@ -1524,7 +1584,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI assertionFailure() } } - + let authorNameLayout = TextNode.asyncLayout(self.nameNode) let viaMeasureLayout = TextNode.asyncLayout(self.viaMeasureNode) let rankBadgeLayout = TextNode.asyncLayout(self.rankBadgeNode) @@ -1536,20 +1596,20 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI let reactionButtonsLayout = ChatMessageReactionButtonsNode.asyncLayout(self.reactionButtonsNode) let unlockButtonLayout = ChatMessageUnlockMediaNode.asyncLayout(self.unlockButtonNode) let mediaInfoLayout = ChatMessageStarsMediaInfoNode.asyncLayout(self.mediaInfoNode) - + let mosaicStatusLayout = ChatMessageDateAndStatusNode.asyncLayout(self.mosaicStatusNode) - + let layoutConstants = self.layoutConstants - + let currentItem = self.appliedItem let currentForwardInfo = self.appliedForwardInfo - + let isSelected = self.selectionNode?.selected - + let weakSelf = Weak(self) - + let makeSuggestedPostInfoNodeLayout: ChatMessageSuggestedPostInfoNode.AsyncLayout = ChatMessageSuggestedPostInfoNode.asyncLayout(self.suggestedPostInfoNode) - + return { item, params, mergedTop, mergedBottom, dateHeaderAtBottom in let layoutConstants = chatMessageItemLayoutConstants(layoutConstants, params: params, presentationData: item.presentationData) return ChatMessageBubbleItemNode.beginLayout( @@ -1580,7 +1640,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI ) } } - + private static func beginLayout( selfReference: Weak, item: ChatMessageItem, @@ -1610,22 +1670,22 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI let isPreview = item.presentationData.isPreview let accessibilityData = ChatMessageAccessibilityData(item: item, isSelected: isSelected) let isSidePanelOpen = item.controllerInteraction.isSidePanelOpen - + let fontSize = floor(item.presentationData.fontSize.baseDisplaySize * 14.0 / 17.0) let nameFont = Font.semibold(fontSize) let regularFont = Font.regular(fontSize) let inlineBotPrefixFont = Font.regular(fontSize - 1.0) let boostBadgeFont = Font.regular(fontSize - 1.0) - + let baseWidth = params.width - params.leftInset - params.rightInset - + let content = item.content let firstMessage = content.firstMessage let incoming = item.content.effectivelyIncoming(item.context.account.peerId, associatedData: item.associatedData) - + let messageTheme = incoming ? item.presentationData.theme.theme.chat.message.incoming : item.presentationData.theme.theme.chat.message.outgoing - + var sourceReference: SourceReferenceMessageAttribute? for attribute in item.content.firstMessage.attributes { if let attribute = attribute as? SourceReferenceMessageAttribute { @@ -1634,26 +1694,26 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } let sourceAuthorInfo = item.content.firstMessage.sourceAuthorInfo - + var isCrosspostFromChannel = false if let _ = sourceReference { if !firstMessage.id.peerId.isRepliesOrSavedMessages(accountPeerId: item.context.account.peerId) { isCrosspostFromChannel = true } } - + var effectiveAuthor: Peer? var overrideEffectiveAuthor = false var ignoreForward = false var displayAuthorInfo: Bool var ignoreNameHiding = false - + var avatarInset: CGFloat var hasAvatar = false - + var allowFullWidth = false let chatLocationPeerId: PeerId = item.chatLocation.peerId ?? item.content.firstMessage.id.peerId - + /*let isInlinePage = false for attribute in item.message.attributes { if attribute is RichTextMessageAttribute { @@ -1662,16 +1722,16 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI break } }*/ - + do { let peerId = chatLocationPeerId - + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .forward = info { displayAuthorInfo = false } else if item.message.id.peerId.isRepliesOrSavedMessages(accountPeerId: item.context.account.peerId) { if let forwardInfo = item.content.firstMessage.forwardInfo { effectiveAuthor = forwardInfo.author - + if let sourceAuthorInfo, let originalAuthorId = sourceAuthorInfo.originalAuthor, let peer = item.message.peers[originalAuthorId] { effectiveAuthor = peer } else if let sourceAuthorInfo, let originalAuthorName = sourceAuthorInfo.originalAuthorName { @@ -1708,23 +1768,23 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI hasAvatar = true } else { effectiveAuthor = firstMessage.author - + var allowAuthor = incoming - + if let author = firstMessage.author, author is TelegramChannel, !incoming || item.presentationData.isPreview { allowAuthor = true ignoreNameHiding = true } - + if let subject = item.associatedData.subject, case let .customChatContents(contents) = subject, case .hashTagSearch = contents.kind { ignoreNameHiding = true } - + displayAuthorInfo = !mergedTop.merged && allowAuthor && peerId.isGroupOrChannel && effectiveAuthor != nil if let forwardInfo = firstMessage.forwardInfo, forwardInfo.psaType != nil { displayAuthorInfo = false } - + var isMonoForum = false if let peer = firstMessage.peers[firstMessage.id.peerId] as? TelegramChannel { if peer.isMonoForum { @@ -1737,28 +1797,28 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + if let channel = firstMessage.peers[firstMessage.id.peerId] as? TelegramChannel, case let .broadcast(info) = channel.info { if info.flags.contains(.messagesShouldHaveProfiles) && !item.presentationData.isPreview { var allowAuthor = incoming overrideEffectiveAuthor = true - + if let author = firstMessage.author, author is TelegramChannel, !incoming { allowAuthor = true ignoreNameHiding = true } - + if let subject = item.associatedData.subject, case let .customChatContents(contents) = subject, case .hashTagSearch = contents.kind { ignoreNameHiding = true } - + displayAuthorInfo = !mergedTop.merged && allowAuthor && peerId.isGroupOrChannel && effectiveAuthor != nil if let forwardInfo = firstMessage.forwardInfo, forwardInfo.psaType != nil { displayAuthorInfo = false } } } - + if !peerId.isRepliesOrSavedMessages(accountPeerId: item.context.account.peerId) { if peerId.isGroupOrChannel && effectiveAuthor != nil { var isBroadcastChannel = false @@ -1771,11 +1831,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI isMonoForum = true } } - + if case let .replyThread(replyThreadMessage) = item.chatLocation, replyThreadMessage.isChannelPost, replyThreadMessage.effectiveTopId == firstMessage.id { isBroadcastChannel = true } - + if !isBroadcastChannel { hasAvatar = incoming } else if case .customChatContents = item.chatLocation { @@ -1783,7 +1843,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else if overrideEffectiveAuthor { hasAvatar = true } - + if isMonoForum { if case .replyThread = item.chatLocation { hasAvatar = false @@ -1793,17 +1853,17 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else if incoming { hasAvatar = true } - + if let subject = item.associatedData.subject, case let .customChatContents(contents) = subject, case .hashTagSearch = contents.kind { hasAvatar = true } } - + if isPreview, let peer = firstMessage.peers[firstMessage.id.peerId] as? TelegramUser, peer.firstName == nil { hasAvatar = false effectiveAuthor = nil } - + var isInstantVideo = false if let forwardInfo = item.content.firstMessage.forwardInfo, forwardInfo.source == nil, forwardInfo.author?.id.namespace == Namespaces.Peer.CloudUser { for media in item.content.firstMessage.media { @@ -1815,17 +1875,17 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + avatarInset = hasAvatar ? layoutConstants.avatarInset : 0.0 if isSidePanelOpen { avatarInset = 0.0 } - + let isFailed = item.content.firstMessage.effectivelyFailed(timestamp: item.context.account.network.getApproximateRemoteTimestamp()) - + var needsShareButton = false var needsSummarizeButton = false - + if incoming, case let .customChatContents(contents) = item.associatedData.subject, case .hashTagSearch = contents.kind { needsShareButton = true } else if case .pinnedMessages = item.associatedData.subject { @@ -1851,11 +1911,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if let _ = sourceReference { needsShareButton = true } - + if let _ = item.message.attributes.first(where: { $0 is SummarizationMessageAttribute }) { needsSummarizeButton = true } - + if let peer = item.message.peers[item.message.id.peerId] { if let channel = peer as? TelegramChannel { if case .broadcast = channel.info { @@ -1863,7 +1923,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + if let info = item.message.forwardInfo { if let author = info.author as? TelegramUser, let _ = author.botInfo, !item.message.media.isEmpty && !(item.message.media.first is TelegramMediaAction) { needsShareButton = true @@ -1871,7 +1931,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI needsShareButton = true } } - + if !needsShareButton, let author = item.message.author as? TelegramUser, let _ = author.botInfo { if !item.message.media.isEmpty && !(item.message.media.first is TelegramMediaAction) { needsShareButton = true @@ -1902,7 +1962,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + if (item.associatedData.isCopyProtectionEnabled || item.message.isCopyProtected()) { if mayHaveSeparateCommentsButton && hasCommentButton(item: item) { } else { @@ -1911,7 +1971,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + if isPreview { needsShareButton = false needsSummarizeButton = false @@ -1926,15 +1986,15 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI needsSummarizeButton = false } } - + if let subject = item.associatedData.subject, case .messageOptions = subject { needsShareButton = false } - + /*if isInlinePage { needsShareButton = false }*/ - + var tmpWidth: CGFloat if allowFullWidth { tmpWidth = baseWidth @@ -1949,21 +2009,21 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI tmpWidth = baseWidth - 32.0 } } - + var deliveryFailedInset: CGFloat = 0.0 if isFailed { deliveryFailedInset += 24.0 } - + tmpWidth -= deliveryFailedInset - + let (contentNodeMessagesAndClasses, needSeparateContainers, needReactions) = contentNodeMessagesAndClassesForItem(item) - + var maximumContentWidth = floor(tmpWidth - layoutConstants.bubble.edgeInset * 3.0 - layoutConstants.bubble.contentInsets.left - layoutConstants.bubble.contentInsets.right - avatarInset) if (needsShareButton && !isSidePanelOpen) { maximumContentWidth -= 10.0 } - + var hasInstantVideo = false for contentNodeItemValue in contentNodeMessagesAndClasses { let contentNodeItem = contentNodeItemValue as (message: Message, type: AnyClass, attributes: ChatMessageEntryAttributes, bubbleAttributes: BubbleItemAttributes) @@ -1982,12 +2042,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } maximumContentWidth = max(0.0, maximumContentWidth) - + var contentPropertiesAndPrepareLayouts: [(Message, Bool, ChatMessageEntryAttributes, BubbleItemAttributes, (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))))] = [] var addedContentNodes: [(Message, Bool, ChatMessageBubbleContentNode, Int?)]? for contentNodeItemValue in contentNodeMessagesAndClasses { let contentNodeItem = contentNodeItemValue as (message: Message, type: AnyClass, attributes: ChatMessageEntryAttributes, bubbleAttributes: BubbleItemAttributes) - + var found = false for currentNodeItemValue in currentContentClassesPropertiesAndLayouts { let currentNodeItem = currentNodeItemValue as (message: Message, type: AnyClass, supportsMosaic: Bool, index: Int?, currentLayout: (ChatMessageBubbleContentItem, ChatMessageItemLayoutConstants, ChatMessageBubblePreparePosition, Bool?, CGSize, CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void)))) @@ -1999,7 +2059,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } if !found { - let contentNode = (contentNodeItem.type as! ChatMessageBubbleContentNode.Type).init() + let contentNode = (contentNodeItem.type as! ChatMessageBubbleContentNode.Type).init() contentNode.index = contentNodeItem.bubbleAttributes.index contentPropertiesAndPrepareLayouts.append((contentNodeItem.message, contentNode.supportsMosaic, contentNodeItem.attributes, contentNodeItem.bubbleAttributes, contentNode.asyncLayoutContent())) if addedContentNodes == nil { @@ -2008,7 +2068,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI addedContentNodes!.append((contentNodeItem.message, contentNodeItem.bubbleAttributes.isAttachment, contentNode, contentNodeItem.bubbleAttributes.index)) } } - + var authorNameString: String? var authorRank: CachedChannelAdminRank? var authorIsChannel: Bool = false @@ -2033,7 +2093,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } authorRank = attributes.rank } - + var enableAutoRank = false if case .admin = authorRank { } else if case .creator = authorRank { @@ -2048,7 +2108,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI authorRank = .member(item.presentationData.strings.Chat_Message_TopicAuthorBadge) } } - + if authorRank == nil { if let rankAttribute = message.attributes.first(where: { $0 is ParticipantRankMessageAttribute }) as? ParticipantRankMessageAttribute, !rankAttribute.rank.isEmpty { authorRank = .member(rankAttribute.rank) @@ -2057,7 +2117,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI case .group: break } - + var guestChatViaFromNameString: String? var inlineBotNameString: String? var replyMessage: Message? @@ -2067,7 +2127,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI var replyStory: StoryId? var replyMarkup: ReplyMarkupMessageAttribute? var authorNameColor: UIColor? - + for attribute in firstMessage.attributes { if let attribute = attribute as? GuestChatMessageAttribute { if let peer = firstMessage.peers[attribute.peerId] { @@ -2124,17 +2184,17 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + if firstMessage.isRestricted(platform: "ios", contentSettings: item.context.currentContentSettings.with { $0 }) { replyMarkup = nil } - + if let forwardInfo = firstMessage.forwardInfo, forwardInfo.psaType != nil { inlineBotNameString = nil } - + var contentPropertiesAndLayouts: [(CGSize?, ChatMessageBubbleContentProperties, ChatMessageBubblePreparePosition, BubbleItemAttributes, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void)), UInt32?, Bool?)] = [] - + var backgroundHiding: ChatMessageBubbleContentBackgroundHiding? var hasSolidWallpaper = false switch item.presentationData.theme.wallpaper { @@ -2146,15 +2206,15 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI break } var alignment: ChatMessageBubbleContentAlignment = .none - + var maximumNodeWidth = maximumContentWidth - + let contentNodeCount = contentPropertiesAndPrepareLayouts.count - + let read: Bool var isItemPinned = false var isItemEdited = false - + switch item.content { case let .message(message, value, _, attributes, _): read = value @@ -2176,11 +2236,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + if case .replyThread = item.chatLocation { isItemPinned = false } - + var mosaicStartIndex: Int? var mosaicRange: Range? for i in 0 ..< contentPropertiesAndPrepareLayouts.count { @@ -2200,7 +2260,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI mosaicRange = mosaicStartIndex ..< contentPropertiesAndPrepareLayouts.count } } - + var hidesHeaders = false var shareButtonOffset: CGPoint? var avatarOffset: CGFloat? @@ -2208,7 +2268,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI for (message, _, attributes, bubbleAttributes, prepareLayout) in contentPropertiesAndPrepareLayouts { let topPosition: ChatMessageBubbleRelativePosition let bottomPosition: ChatMessageBubbleRelativePosition - + var topBubbleAttributes = BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default) var bottomBubbleAttributes = BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default) if index != 0 { @@ -2217,10 +2277,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if index != contentPropertiesAndPrepareLayouts.count - 1 { bottomBubbleAttributes = contentPropertiesAndPrepareLayouts[index + 1].3 } - + topPosition = .Neighbour(topBubbleAttributes.isAttachment, topBubbleAttributes.neighborType, topBubbleAttributes.neighborSpacing) bottomPosition = .Neighbour(bottomBubbleAttributes.isAttachment, bottomBubbleAttributes.neighborType, bottomBubbleAttributes.neighborSpacing) - + let prepareContentPosition: ChatMessageBubblePreparePosition if let mosaicRange = mosaicRange, mosaicRange.contains(index) { let mosaicIndex = index - mosaicRange.lowerBound @@ -2236,9 +2296,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } prepareContentPosition = .linear(top: topPosition, bottom: refinedBottomPosition) } - + let contentItem = ChatMessageBubbleContentItem(context: item.context, controllerInteraction: item.controllerInteraction, message: message, topMessage: item.content.firstMessage, content: item.content, read: read, chatLocation: item.chatLocation, presentationData: item.presentationData, associatedData: item.associatedData, attributes: attributes, isItemPinned: isItemPinned, isItemEdited: isItemEdited) - + var itemSelection: Bool? switch content { case .message: @@ -2256,10 +2316,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + let (properties, unboundSize, maxNodeWidth, nodeLayout) = prepareLayout(contentItem, layoutConstants, prepareContentPosition, itemSelection, CGSize(width: maximumContentWidth, height: CGFloat.greatestFiniteMagnitude), avatarInset) maximumNodeWidth = min(maximumNodeWidth, maxNodeWidth) - + if let offset = properties.shareButtonOffset { shareButtonOffset = offset } @@ -2272,9 +2332,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI avatarInset = 0.0 } } - + contentPropertiesAndLayouts.append((unboundSize, properties, prepareContentPosition, bubbleAttributes, nodeLayout, needSeparateContainers && !bubbleAttributes.isAttachment ? message.stableId : nil, itemSelection)) - + if !properties.isDetached { switch properties.hidesBackground { case .never: @@ -2286,7 +2346,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI case .always: backgroundHiding = .always } - + switch properties.forceAlignment { case .none: break @@ -2294,13 +2354,13 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI alignment = .center } } - + index += 1 } - + let topNodeMergeStatus: ChatMessageBubbleMergeStatus = mergedTop.merged ? (incoming ? .Left : .Right) : .None(incoming ? .Incoming : .Outgoing) var bottomNodeMergeStatus: ChatMessageBubbleMergeStatus = mergedBottom.merged ? (incoming ? .Left : .Right) : .None(incoming ? .Incoming : .Outgoing) - + let bubbleReactions: ReactionsMessageAttribute if needReactions { bubbleReactions = mergedMessageReactions(attributes: item.message.attributes, isTags: item.message.areReactionsTags(accountPeerId: item.context.account.peerId)) ?? ReactionsMessageAttribute(canViewList: false, isTags: false, reactions: [], recentPeers: [], topPeers: []) @@ -2314,9 +2374,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI bottomNodeMergeStatus = .Right } } - + var currentCredibilityIcon: (EmojiStatusComponent.Content, UIColor?)? - + var initialDisplayHeader = true if hidesHeaders || item.message.adAttribute != nil { initialDisplayHeader = false @@ -2329,7 +2389,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else if firstMessage.media.contains(where: { $0 is TelegramMediaStory }) { hasForwardLikeContent = true } - + if inlineBotNameString == nil && (ignoreForward || !hasForwardLikeContent) && replyMessage == nil && replyForward == nil && replyStory == nil { if let first = contentPropertiesAndLayouts.first, first.1.hidesSimpleAuthorHeader && !ignoreNameHiding { if let author = firstMessage.author as? TelegramChannel, case .group = author.info, author.id == firstMessage.id.peerId, !incoming { @@ -2339,7 +2399,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + if let peer = firstMessage.peers[firstMessage.id.peerId] as? TelegramChannel, case .broadcast = peer.info, item.content.firstMessage.adAttribute == nil { let peer = (peer as Peer) let nameColors: PeerNameColors.Colors? @@ -2370,11 +2430,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } authorNameColor = color } - + if initialDisplayHeader && displayAuthorInfo { if let peer = firstMessage.peers[firstMessage.id.peerId] as? TelegramChannel, case .broadcast = peer.info, item.content.firstMessage.adAttribute == nil, !overrideEffectiveAuthor { authorNameString = EnginePeer(peer).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) - + let peer = (peer as Peer) let nameColors: PeerNameColors.Colors? switch peer.nameColor { @@ -2388,7 +2448,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI authorNameColor = nameColors?.main } else if let effectiveAuthor = effectiveAuthor { authorNameString = EnginePeer(effectiveAuthor).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) - + let nameColors: PeerNameColors.Colors switch effectiveAuthor.nameColor { case let .preset(nameColor): @@ -2439,7 +2499,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + let translateToLanguage = item.associatedData.translateToLanguage var isSummarized = false if item.controllerInteraction.summarizedMessageIds.contains(item.message.id) { @@ -2450,7 +2510,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + var displayHeader = false if initialDisplayHeader { if authorNameString != nil { @@ -2476,7 +2536,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } if case let .customChatContents(contents) = item.associatedData.subject, case .hashTagSearch = contents.kind, let peer = item.message.peers[item.message.id.peerId] { if let channel = peer as? TelegramChannel, case .broadcast = channel.info { - + } else { displayHeader = true } @@ -2485,7 +2545,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI displayHeader = true } } - + let firstNodeTopPosition: ChatMessageBubbleRelativePosition if displayHeader { firstNodeTopPosition = .Neighbour(false, .header, .default) @@ -2493,10 +2553,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI firstNodeTopPosition = .None(topNodeMergeStatus) } var lastNodeTopPosition: ChatMessageBubbleRelativePosition = .None(bottomNodeMergeStatus) - + var calculatedGroupFramesAndSize: ([(CGRect, MosaicItemPosition)], CGSize)? var mosaicStatusSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode)? - + if let mosaicRange = mosaicRange { let maxSize = layoutConstants.image.maxDimensions.fittedToWidthOrSmaller(maximumContentWidth - layoutConstants.image.bubbleInsets.left - layoutConstants.image.bubbleInsets.right) let (innerFramesAndPositions, innerSize) = chatMessageBubbleMosaicLayout(maxSize: maxSize, itemSizes: contentPropertiesAndLayouts[mosaicRange].map { item in @@ -2505,26 +2565,26 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } return size }) - + let framesAndPositions = innerFramesAndPositions.map { ($0.0.offsetBy(dx: layoutConstants.image.bubbleInsets.left, dy: layoutConstants.image.bubbleInsets.top), $0.1) } - + let size = CGSize(width: innerSize.width + layoutConstants.image.bubbleInsets.left + layoutConstants.image.bubbleInsets.right, height: innerSize.height + layoutConstants.image.bubbleInsets.top + layoutConstants.image.bubbleInsets.bottom) - + calculatedGroupFramesAndSize = (framesAndPositions, size) - + maximumNodeWidth = size.width - + var hasText = false for contentItem in contentNodeMessagesAndClasses { if let _ = contentItem.1 as? ChatMessageTextBubbleContentNode.Type { hasText = true } } - + if case .customChatContents = item.associatedData.subject { } else if (mosaicRange.upperBound == contentPropertiesAndLayouts.count || contentPropertiesAndLayouts[contentPropertiesAndLayouts.count - 1].3.isAttachment) && (!hasText || item.message.invertMedia) { let message = item.content.firstMessage - + var edited = false if item.content.firstMessageAttributes.updatingMedia != nil { edited = true @@ -2553,7 +2613,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI starsCount = attribute.stars.value * Int64(messageCount) } } - + let dateFormat: MessageTimestampStatusFormat if item.presentationData.isPreview { dateFormat = .full @@ -2563,7 +2623,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI dateFormat = .regular } let dateText = stringForMessageTimestampStatus(accountPeerId: item.context.account.peerId, message: EngineMessage(message), dateTimeFormat: item.presentationData.dateTimeFormat, nameDisplayOrder: item.presentationData.nameDisplayOrder, strings: item.presentationData.strings, format: dateFormat, associatedData: item.associatedData) - + let statusType: ChatMessageDateAndStatusType if incoming { statusType = .ImageIncoming @@ -2576,12 +2636,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI statusType = .ImageOutgoing(.Sent(read: item.read)) } } - + var isReplyThread = false if case .replyThread = item.chatLocation { isReplyThread = true } - + let statusSuggestedWidthAndContinue = mosaicStatusLayout(ChatMessageDateAndStatusNode.Arguments( context: item.context, presentationData: item.presentationData, @@ -2607,13 +2667,13 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI animationCache: item.controllerInteraction.presentationContext.animationCache, animationRenderer: item.controllerInteraction.presentationContext.animationRenderer )) - + mosaicStatusSizeAndApply = statusSuggestedWidthAndContinue.1(statusSuggestedWidthAndContinue.0) } } - + var headerSize = CGSize() - + var nameNodeOriginY: CGFloat = 0.0 var nameNodeSizeApply: (CGSize, () -> TextNode?) = (CGSize(), { nil }) var rankBadgeNodeSizeApply: (CGSize, () -> TextNode?, UIColor?) = (CGSize(), { nil }, nil) @@ -2622,40 +2682,40 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI let threadInfoOriginY: CGFloat = 0.0 let threadInfoSizeApply: (CGSize, (Bool) -> ChatMessageThreadInfoNode?) = (CGSize(), { _ in nil }) - + var replyInfoOriginY: CGFloat = 0.0 var replyInfoSizeApply: (CGSize, (CGSize, Bool, ListViewItemUpdateAnimation) -> ChatMessageReplyInfoNode?) = (CGSize(), { _, _, _ in nil }) - + var forwardInfoOriginY: CGFloat = 0.0 var forwardInfoSizeApply: (CGSize, (CGFloat) -> ChatMessageForwardInfoNode?) = (CGSize(), { _ in nil }) - + var forwardSource: Peer? var forwardAuthorSignature: String? - + var unlockButtonSizeApply: (CGSize, (Bool) -> ChatMessageUnlockMediaNode?) = (CGSize(), { _ in nil }) var mediaInfoSizeApply: (CGSize, (Bool) -> ChatMessageStarsMediaInfoNode?) = (CGSize(), { _ in nil }) - + var hasTitleAvatar = false var hasTitleTopicNavigation = false - + if displayHeader { let bubbleWidthInsets: CGFloat = mosaicRange == nil ? layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right : 0.0 if authorNameString != nil || inlineBotNameString != nil { if headerSize.height.isZero { headerSize.height += 7.0 } - + if isSidePanelOpen && incoming { hasTitleAvatar = true - + if let channel = item.message.peers[item.message.id.peerId], channel.isMonoForum { } else { hasTitleTopicNavigation = item.chatLocation.threadId == nil } } - + let inlineBotNameColor = messageTheme.accentTextColor - + let attributedString: NSAttributedString var rankBadgeString: NSAttributedString? var rankBadgeColor: UIColor? @@ -2698,7 +2758,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI rankBadgeString = NSAttributedString(string: " \(item.presentationData.strings.Channel_Status)", font: inlineBotPrefixFont, textColor: messageTheme.secondaryTextColor) } } - + var viaSuffix: NSAttributedString? if let authorNameString = authorNameString, let authorNameColor = authorNameColor, let inlineBotNameString = inlineBotNameString { let mutableString = NSMutableAttributedString(string: "\(authorNameString) ", attributes: [NSAttributedString.Key.font: nameFont, NSAttributedString.Key.foregroundColor: authorNameColor]) @@ -2725,7 +2785,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { attributedString = NSAttributedString(string: "", font: nameFont, textColor: inlineBotNameColor) } - + var credibilityIconWidth: CGFloat = 0.0 if let (currentCredibilityIcon, _) = currentCredibilityIcon { credibilityIconWidth += 4.0 @@ -2738,14 +2798,14 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI credibilityIconWidth += 20.0 } } - + let rankBadgeSizeAndApply = rankBadgeLayout(TextNodeLayoutArguments(attributedString: rankBadgeString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: max(0, maximumNodeWidth - layoutConstants.text.bubbleInsets.left - layoutConstants.text.bubbleInsets.right), height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) if rankBadgeSizeAndApply.0.size.width > 0.0 { rankBadgeNodeSizeApply = (rankBadgeSizeAndApply.0.size, { return rankBadgeSizeAndApply.1() }, rankBadgeColor) } - + var boostCount: Int = 0 if incoming { for attribute in item.message.attributes { @@ -2754,11 +2814,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + if boostCount > 1, let authorNameColor = authorNameColor { boostBadgeString = NSAttributedString(string: "\(boostCount)", font: boostBadgeFont, textColor: authorNameColor) } - + var boostBadgeWidth: CGFloat = 0.0 let boostBadgeSizeAndApply = boostBadgeLayout(TextNodeLayoutArguments(attributedString: boostBadgeString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: max(0, maximumNodeWidth - layoutConstants.text.bubbleInsets.left - layoutConstants.text.bubbleInsets.right), height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) if boostBadgeSizeAndApply.0.size.width > 0.0 { @@ -2769,9 +2829,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else if boostCount == 1 { boostBadgeWidth = 14.0 } - + let closeButtonWidth: CGFloat = item.message.adAttribute != nil ? 18.0 : 0.0 - + let sizeAndApply = authorNameLayout(TextNodeLayoutArguments(attributedString: attributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: max(0, maximumNodeWidth - layoutConstants.text.bubbleInsets.left - layoutConstants.text.bubbleInsets.right - credibilityIconWidth - rankBadgeSizeAndApply.0.size.width - closeButtonWidth), height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) nameNodeSizeApply = (sizeAndApply.0.size, { return sizeAndApply.1() @@ -2781,9 +2841,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI let (viaLayout, _) = viaMeasureLayout(TextNodeLayoutArguments(attributedString: viaSuffix, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: max(0, maximumNodeWidth - layoutConstants.text.bubbleInsets.left - layoutConstants.text.bubbleInsets.right - credibilityIconWidth - rankBadgeSizeAndApply.0.size.width - closeButtonWidth), height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) viaWidth = viaLayout.size.width + 3.0 } - + nameNodeOriginY = headerSize.height - + var nameAvatarSpaceWidth: CGFloat = 0.0 if hasTitleAvatar { headerSize.height += 12.0 @@ -2796,13 +2856,13 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } nameNodeOriginY += 5.0 } - + var headerSizeWidth = nameAvatarSpaceWidth + nameNodeSizeApply.0.width + 8.0 + credibilityIconWidth + boostBadgeWidth + closeButtonWidth + bubbleWidthInsets if hasTitleTopicNavigation { } else if rankBadgeSizeAndApply.0.size.width > 0.0 { headerSizeWidth += rankBadgeSizeAndApply.0.size.width + 3.0 } - + headerSize.width = max(headerSize.width, headerSizeWidth) headerSize.height += nameNodeSizeApply.0.height } @@ -2811,9 +2871,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if headerSize.height.isZero { headerSize.height += 5.0 } - + let forwardPsaType: String? = forwardInfo.psaType - + if let source = forwardInfo.source { forwardSource = source if let authorSignature = forwardInfo.authorSignature { @@ -2834,7 +2894,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } let sizeAndApply = forwardInfoLayout(item.context, item.presentationData, item.presentationData.strings, .bubble(incoming: incoming), forwardSource.flatMap(EnginePeer.init), forwardAuthorSignature, forwardPsaType, nil, CGSize(width: maximumNodeWidth - layoutConstants.text.bubbleInsets.left - layoutConstants.text.bubbleInsets.right, height: CGFloat.greatestFiniteMagnitude)) forwardInfoSizeApply = (sizeAndApply.0, { width in sizeAndApply.1(width) }) - + headerSize.height += 2.0 forwardInfoOriginY = headerSize.height headerSize.width = max(headerSize.width, forwardInfoSizeApply.0.width + bubbleWidthInsets) @@ -2844,9 +2904,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if headerSize.height.isZero { headerSize.height += 5.0 } - + forwardSource = firstMessage.peers[storyMedia.storyId.peerId] - + var storyType: ChatMessageForwardInfoNode.StoryType = .regular if let storyItem = firstMessage.associatedStories[storyMedia.storyId], storyItem.data.isEmpty { storyType = .expired @@ -2859,29 +2919,29 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI storyType = .unavailable } } - + let sizeAndApply = forwardInfoLayout(item.context, item.presentationData, item.presentationData.strings, .bubble(incoming: incoming), forwardSource.flatMap(EnginePeer.init), nil, nil, ChatMessageForwardInfoNode.StoryData(storyType: storyType), CGSize(width: maximumNodeWidth - layoutConstants.text.bubbleInsets.left - layoutConstants.text.bubbleInsets.right, height: CGFloat.greatestFiniteMagnitude)) forwardInfoSizeApply = (sizeAndApply.0, { width in sizeAndApply.1(width) }) - + if storyType != .regular { headerSize.height += 6.0 } else { headerSize.height += 2.0 } - + forwardInfoOriginY = headerSize.height headerSize.width = max(headerSize.width, forwardInfoSizeApply.0.width + bubbleWidthInsets) headerSize.height += forwardInfoSizeApply.0.height - + if storyType != .regular { headerSize.height += 16.0 } else { headerSize.height += 2.0 } } - + let hasThreadInfo = !"".isEmpty - + var hasReply = replyMessage != nil || replyForward != nil || replyStory != nil if !isInstantVideo && hasThreadInfo { if let threadId = item.message.threadId, let replyMessage = replyMessage, Int64(replyMessage.id.id) == threadId { @@ -2891,11 +2951,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if !isInstantVideo, hasReply, let threadId = item.message.threadId, let replyMessage, replyQuote == nil, case let .replyThread(replyThread) = item.chatLocation, replyThread.isChannelPost, replyMessage.id.id == Int32(clamping: threadId) { hasReply = false } - + if isSummarized { hasReply = true } - + if !isInstantVideo, hasReply, (replyMessage != nil || replyForward != nil || replyStory != nil || isSummarized) { if headerSize.height.isZero { headerSize.height += 11.0 @@ -2920,11 +2980,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI associatedData: item.associatedData )) replyInfoSizeApply = (sizeAndApply.0, { realSize, synchronousLoads, animation in sizeAndApply.1(realSize, synchronousLoads, animation) }) - + replyInfoOriginY = headerSize.height headerSize.width = max(headerSize.width, replyInfoSizeApply.0.width + bubbleWidthInsets) headerSize.height += replyInfoSizeApply.0.height + 7.0 - + if !headerSize.height.isZero { headerSize.height -= 7.0 } @@ -2934,7 +2994,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + let hideBackground: Bool if let backgroundHiding { switch backgroundHiding { @@ -2948,7 +3008,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { hideBackground = false } - + var removedContentNodeIndices: [Int]? findRemoved: for i in 0 ..< currentContentClassesPropertiesAndLayouts.count { let currentMessage = currentContentClassesPropertiesAndLayouts[i].0 @@ -2966,7 +3026,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI removedContentNodeIndices!.append(i) } } - + var updatedContentNodeOrder = false if currentContentClassesPropertiesAndLayouts.count == contentNodeMessagesAndClasses.count { for i in 0 ..< currentContentClassesPropertiesAndLayouts.count { @@ -2978,11 +3038,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + var contentNodePropertiesAndFinalize: [(ChatMessageBubbleContentProperties, ChatMessageBubbleContentPosition?, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void), UInt32?, Bool?)] = [] - + var maxContentWidth: CGFloat = headerSize.width - + var actionButtonsFinalize: ((CGFloat) -> (CGSize, (_ animation: ListViewItemUpdateAnimation) -> ChatMessageActionButtonsNode))? if let additionalContent = item.additionalContent, case let .eventLogGroupedMessages(messages, hasButton) = additionalContent, hasButton { let (minWidth, buttonsLayout) = actionButtonsLayout( @@ -3002,23 +3062,23 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI ), [:], EngineMessage(item.message), maximumNodeWidth) maxContentWidth = max(maxContentWidth, minWidth) actionButtonsFinalize = buttonsLayout - + lastNodeTopPosition = .None(.Both) } else if incoming, let action = item.message.media.first(where: { $0 is TelegramMediaAction }) as? TelegramMediaAction, case let .copyProtectionRequest(isExpired, _, _) = action.action, !isExpired { let appConfiguration = item.context.currentAppConfiguration.with { $0 } let configuration = CopyProtectionConfiguration.with(appConfiguration: appConfiguration) - + let currentTimestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) let expireDate = item.message.timestamp + configuration.requestExpirePeriod - + if expireDate <= currentTimestamp { - + } else { var buttonDeclineValue: UInt8 = 0 let buttonDecline = MemoryBuffer(data: Data(bytes: &buttonDeclineValue, count: 1)) var buttonApproveValue: UInt8 = 1 let buttonApprove = MemoryBuffer(data: Data(bytes: &buttonApproveValue, count: 1)) - + let customInfos: [MemoryBuffer: ChatMessageActionButtonsNode.CustomInfo] = [ buttonApprove: ChatMessageActionButtonsNode.CustomInfo( isEnabled: true, @@ -3047,19 +3107,19 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI ), customInfos, EngineMessage(item.message), baseWidth) maxContentWidth = max(maxContentWidth, minWidth) actionButtonsFinalize = buttonsLayout - + lastNodeTopPosition = .None(.Both) } } else if incoming, let action = item.message.media.first(where: { $0 is TelegramMediaAction }) as? TelegramMediaAction, case let .starGiftPurchaseOffer(_, _, expireDate, isAccepted, isDeclined) = action.action, !isAccepted && !isDeclined { let currentTimestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) if expireDate <= currentTimestamp { - + } else { var buttonDeclineValue: UInt8 = 0 let buttonDecline = MemoryBuffer(data: Data(bytes: &buttonDeclineValue, count: 1)) var buttonApproveValue: UInt8 = 1 let buttonApprove = MemoryBuffer(data: Data(bytes: &buttonApproveValue, count: 1)) - + let customInfos: [MemoryBuffer: ChatMessageActionButtonsNode.CustomInfo] = [ buttonApprove: ChatMessageActionButtonsNode.CustomInfo( isEnabled: true, @@ -3088,7 +3148,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI ), customInfos, EngineMessage(item.message), baseWidth) maxContentWidth = max(maxContentWidth, minWidth) actionButtonsFinalize = buttonsLayout - + lastNodeTopPosition = .None(.Both) } } else if incoming, let attribute = item.message.attributes.first(where: { $0 is SuggestedPostMessageAttribute }) as? SuggestedPostMessageAttribute, attribute.state == nil { @@ -3096,14 +3156,14 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if let peer = item.message.peers[item.message.id.peerId] as? TelegramChannel, peer.isMonoForum, let linkedMonoforumId = peer.linkedMonoforumId, let mainChannel = item.message.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.manageDirect), !mainChannel.hasPermission(.sendSomething) { canApprove = false } - + var buttonDeclineValue: UInt8 = 0 let buttonDecline = MemoryBuffer(data: Data(bytes: &buttonDeclineValue, count: 1)) var buttonApproveValue: UInt8 = 1 let buttonApprove = MemoryBuffer(data: Data(bytes: &buttonApproveValue, count: 1)) var buttonSuggestChangesValue: UInt8 = 2 let buttonSuggestChanges = MemoryBuffer(data: Data(bytes: &buttonSuggestChangesValue, count: 1)) - + let customInfos: [MemoryBuffer: ChatMessageActionButtonsNode.CustomInfo] = [ buttonDecline: ChatMessageActionButtonsNode.CustomInfo( isEnabled: true, @@ -3118,7 +3178,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI icon: .suggestedPostEdit ) ] - + let (minWidth, buttonsLayout) = actionButtonsLayout( item.context, item.presentationData.theme, @@ -3140,7 +3200,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI ), customInfos, EngineMessage(item.message), baseWidth) maxContentWidth = max(maxContentWidth, minWidth) actionButtonsFinalize = buttonsLayout - + lastNodeTopPosition = .None(.Both) } else if let replyMarkup = replyMarkup, !item.presentationData.isPreview { let (minWidth, buttonsLayout) = actionButtonsLayout(item.context, item.presentationData.theme, item.presentationData.chatBubbleCorners, item.presentationData.strings, item.controllerInteraction.presentationContext.backgroundNode, replyMarkup, [:], EngineMessage(item.message), maximumNodeWidth) @@ -3149,14 +3209,14 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else if item.content.firstMessageAttributes.displayContinueThreadFooter { var buttonValue: UInt8 = 3 let button = MemoryBuffer(data: Data(bytes: &buttonValue, count: 1)) - + let customInfos: [MemoryBuffer: ChatMessageActionButtonsNode.CustomInfo] = [ button: ChatMessageActionButtonsNode.CustomInfo( isEnabled: true, icon: .actionArrow ), ] - + let (minWidth, buttonsLayout) = actionButtonsLayout( item.context, item.presentationData.theme, @@ -3174,10 +3234,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI ), customInfos, EngineMessage(item.message), baseWidth) maxContentWidth = max(maxContentWidth, minWidth) actionButtonsFinalize = buttonsLayout - + lastNodeTopPosition = .None(.Both) } - + var reactionButtonsFinalize: ((CGFloat) -> (CGSize, (_ animation: ListViewItemUpdateAnimation) -> ChatMessageReactionButtonsNode))? if !bubbleReactions.reactions.isEmpty && !item.presentationData.isPreview { var centerAligned = false @@ -3196,7 +3256,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } break } - + var maximumNodeWidth = maximumNodeWidth if hasInstantVideo { maximumNodeWidth = min(309.0, baseWidth - 84.0) @@ -3218,20 +3278,20 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI maxContentWidth = max(maxContentWidth, minWidth) reactionButtonsFinalize = buttonsLayout } - + for i in 0 ..< contentPropertiesAndLayouts.count { let (_, contentNodeProperties, preparePosition, _, contentNodeLayout, contentGroupId, itemSelection) = contentPropertiesAndLayouts[i] - + if let mosaicRange = mosaicRange, mosaicRange.contains(i), let (framesAndPositions, size) = calculatedGroupFramesAndSize { let mosaicIndex = i - mosaicRange.lowerBound - + let position = framesAndPositions[mosaicIndex].1 - + let topLeft: ChatMessageBubbleContentMosaicNeighbor let topRight: ChatMessageBubbleContentMosaicNeighbor let bottomLeft: ChatMessageBubbleContentMosaicNeighbor let bottomRight: ChatMessageBubbleContentMosaicNeighbor - + switch firstNodeTopPosition { case .Neighbour: topLeft = .merged @@ -3252,7 +3312,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { topLeft = .merged } - + if position.contains(.top) && position.contains(.right) { switch status { case .Left: @@ -3266,14 +3326,14 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI topRight = .merged } } - + let lastMosaicBottomPosition: ChatMessageBubbleRelativePosition if mosaicRange.upperBound - 1 == contentNodeCount - 1 { lastMosaicBottomPosition = lastNodeTopPosition } else { lastMosaicBottomPosition = .Neighbour(false, .text, .default) } - + if position.contains(.bottom), case .Neighbour = lastMosaicBottomPosition { bottomLeft = .merged bottomRight = .merged @@ -3307,7 +3367,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { bottomLeft = .merged } - + if position.contains(.bottom) && position.contains(.right) { switch status { case .Left: @@ -3326,11 +3386,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + let (_, contentNodeFinalize) = contentNodeLayout(framesAndPositions[mosaicIndex].0.size, .mosaic(position: ChatMessageBubbleContentMosaicPosition(topLeft: topLeft, topRight: topRight, bottomLeft: bottomLeft, bottomRight: bottomRight), wide: position.isWide)) - + contentNodePropertiesAndFinalize.append((contentNodeProperties, nil, contentNodeFinalize, contentGroupId, itemSelection)) - + maxContentWidth = max(maxContentWidth, size.width) } else { let contentPosition: ChatMessageBubbleContentPosition @@ -3338,7 +3398,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI case .linear: let topPosition: ChatMessageBubbleRelativePosition let bottomPosition: ChatMessageBubbleRelativePosition - + var topBubbleAttributes = BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default) var bottomBubbleAttributes = BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default) if i != 0 { @@ -3353,13 +3413,13 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { topPosition = .Neighbour(topBubbleAttributes.isAttachment, topBubbleAttributes.neighborType, topBubbleAttributes.neighborSpacing) } - + if i == contentNodeCount - 1 { bottomPosition = lastNodeTopPosition } else { bottomPosition = .Neighbour(bottomBubbleAttributes.isAttachment, bottomBubbleAttributes.neighborType, bottomBubbleAttributes.neighborSpacing) } - + contentPosition = .linear(top: topPosition, bottom: bottomPosition) case .mosaic: assertionFailure() @@ -3371,35 +3431,35 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI print("contentNodeWidth \(contentNodeWidth) > \(maximumNodeWidth)") } #endif - + if contentNodeProperties.isDetached { - + } else { maxContentWidth = max(maxContentWidth, contentNodeWidth) } - + contentNodePropertiesAndFinalize.append((contentNodeProperties, contentPosition, contentNodeFinalize, contentGroupId, itemSelection)) } } - + var contentSize = CGSize(width: maxContentWidth, height: 0.0) var contentNodeFramesPropertiesAndApply: [(CGRect, ChatMessageBubbleContentProperties, Bool, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void)] = [] var contentContainerNodeFrames: [(UInt32, CGRect, Bool?, CGFloat)] = [] var currentContainerGroupId: UInt32? var currentItemSelection: Bool? - + var contentNodesHeight: CGFloat = 0.0 var totalContentNodesHeight: CGFloat = 0.0 var currentContainerGroupOverlap: CGFloat = 0.0 var detachedContentNodesHeight: CGFloat = 0.0 var additionalTopHeight: CGFloat = 0.0 - + var mosaicStatusOrigin: CGPoint? var unlockButtonPosition: CGPoint? var mediaInfoOrigin: CGPoint? for i in 0 ..< contentNodePropertiesAndFinalize.count { let (properties, position, finalize, contentGroupId, itemSelection) = contentNodePropertiesAndFinalize[i] - + if let position = position, case let .linear(top, bottom) = position { if case let .Neighbour(_, _, spacing) = top, case let .overlap(overlap) = spacing { currentContainerGroupOverlap = overlap @@ -3408,45 +3468,45 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI currentContainerGroupOverlap = overlap } } - + if let mosaicRange = mosaicRange, mosaicRange.contains(i), let (framesAndPositions, size) = calculatedGroupFramesAndSize { let mosaicIndex = i - mosaicRange.lowerBound - + if mosaicIndex == 0 && (i == 0 || (i == 1 && detachedContentNodesHeight > 0)) { if !headerSize.height.isZero { contentNodesHeight += 7.0 totalContentNodesHeight += 7.0 } } - + var contentNodeOriginY = contentNodesHeight if detachedContentNodesHeight > 0 { contentNodeOriginY -= detachedContentNodesHeight - 4.0 } - + let (_, apply) = finalize(maxContentWidth) let contentNodeFrame = framesAndPositions[mosaicIndex].0.offsetBy(dx: 0.0, dy: contentNodeOriginY) contentNodeFramesPropertiesAndApply.append((contentNodeFrame, properties, true, apply)) - + if i == mosaicRange.upperBound - 1 { unlockButtonPosition = CGPoint(x: size.width / 2.0, y: contentNodesHeight + size.height / 2.0) mediaInfoOrigin = CGPoint(x: size.width, y: contentNodesHeight) - + contentNodesHeight += size.height totalContentNodesHeight += size.height - + mosaicStatusOrigin = contentNodeFrame.bottomRight } } else { let contentProperties = contentPropertiesAndLayouts[i].3 - + if (i == 0 || (i == 1 && detachedContentNodesHeight > 0)) && !headerSize.height.isZero { if contentGroupId == nil { contentNodesHeight += properties.headerSpacing } totalContentNodesHeight += properties.headerSpacing } - + if currentContainerGroupId != contentGroupId { if let containerGroupId = currentContainerGroupId { var overlapOffset: CGFloat = 0.0 @@ -3463,7 +3523,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } let containerFrame = CGRect(x: 0.0, y: headerSize.height + totalContentNodesHeight - containerContentNodesOrigin - overlapOffset, width: maxContentWidth, height: containerContentNodesHeight) contentContainerNodeFrames.append((containerGroupId, containerFrame, currentItemSelection, currentContainerGroupOverlap)) - + if !overlapOffset.isZero { totalContentNodesHeight -= currentContainerGroupOverlap } @@ -3475,31 +3535,31 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI currentContainerGroupId = contentGroupId currentItemSelection = itemSelection } - + var contentNodeOriginY = contentNodesHeight if detachedContentNodesHeight > 0, contentContainerNodeFrames.isEmpty { contentNodeOriginY -= detachedContentNodesHeight - 4.0 } - + let (size, apply) = finalize(maxContentWidth) let containerFrame = CGRect(origin: CGPoint(x: 0.0, y: contentNodeOriginY), size: size) contentNodeFramesPropertiesAndApply.append((containerFrame, properties, contentGroupId == nil, apply)) - + if contentProperties.neighborType == .media && unlockButtonPosition == nil { unlockButtonPosition = containerFrame.center mediaInfoOrigin = CGPoint(x: containerFrame.width, y: containerFrame.minY) } - + contentNodesHeight += size.height totalContentNodesHeight += size.height - + if properties.isDetached { detachedContentNodesHeight += size.height + 4.0 totalContentNodesHeight += 4.0 } } } - + if let containerGroupId = currentContainerGroupId { var overlapOffset: CGFloat = 0.0 if !contentContainerNodeFrames.isEmpty { @@ -3518,9 +3578,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI totalContentNodesHeight -= currentContainerGroupOverlap } } - + contentSize.height += totalContentNodesHeight - + if let paidContent = item.message.media.first(where: { $0 is TelegramMediaPaidContent }) as? TelegramMediaPaidContent, let media = paidContent.extendedMedia.first { var isLocked = false if case .preview = media { @@ -3554,12 +3614,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI mediaInfoSizeApply = (sizeAndApply.0, { synchronousLoads in sizeAndApply.1(synchronousLoads) }) } } - + var actionButtonsSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageActionButtonsNode)? if let actionButtonsFinalize = actionButtonsFinalize { actionButtonsSizeAndApply = actionButtonsFinalize(maxContentWidth) } - + var reactionButtonsSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageReactionButtonsNode)? if let reactionButtonsFinalize = reactionButtonsFinalize { var maxContentWidth = maxContentWidth @@ -3568,7 +3628,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } reactionButtonsSizeAndApply = reactionButtonsFinalize(maxContentWidth) } - + var suggestedPostInfoNodeLayout: (CGSize, () -> ChatMessageSuggestedPostInfoNode)? for attribute in item.message.attributes { if let _ = attribute as? SuggestedPostMessageAttribute { @@ -3576,11 +3636,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI suggestedPostInfoNodeLayout = suggestedPostInfoNodeLayoutValue } } - + if let suggestedPostInfoNodeLayout { additionalTopHeight += 4.0 + suggestedPostInfoNodeLayout.0.height + 8.0 } - + let minimalContentSize: CGSize if hideBackground { minimalContentSize = CGSize(width: 1.0, height: 1.0) @@ -3593,7 +3653,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if minimalContentSize.height > calculatedBubbleHeight + 2.0 { contentVerticalOffset = floorToScreenPixels((minimalContentSize.height - calculatedBubbleHeight) / 2.0) } - + let availableWidth = params.width - params.leftInset - params.rightInset let backgroundFrame: CGRect let contentOrigin: CGPoint @@ -3614,18 +3674,18 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI contentOrigin = CGPoint(x: backgroundFrame.minX + contentOriginX, y: backgroundFrame.minY + layoutConstants.bubble.contentInsets.top + headerSize.height + contentVerticalOffset) contentUpperRightCorner = CGPoint(x: backgroundFrame.maxX - (incoming ? layoutConstants.bubble.contentInsets.right : layoutConstants.bubble.contentInsets.left), y: backgroundFrame.origin.y + layoutConstants.bubble.contentInsets.top + headerSize.height) } - + let bubbleContentWidth = maxContentWidth - layoutConstants.bubble.edgeInset * 2.0 - (layoutConstants.bubble.contentInsets.right + layoutConstants.bubble.contentInsets.left) var layoutSize = CGSize(width: params.width, height: layoutBubbleSize.height + detachedContentNodesHeight + additionalTopHeight) - + if let reactionButtonsSizeAndApply = reactionButtonsSizeAndApply { layoutSize.height += 4.0 + reactionButtonsSizeAndApply.0.height + 2.0 } if let actionButtonsSizeAndApply = actionButtonsSizeAndApply { layoutSize.height += 1.0 + actionButtonsSizeAndApply.0.height } - + var layoutInsets = UIEdgeInsets(top: mergedTop.merged ? layoutConstants.bubble.mergedSpacing : layoutConstants.bubble.defaultSpacing, left: 0.0, bottom: mergedBottom.merged ? layoutConstants.bubble.mergedSpacing : layoutConstants.bubble.defaultSpacing, right: 0.0) if dateHeaderAtBottom.hasDate && dateHeaderAtBottom.hasTopic { layoutInsets.top += layoutConstants.timestampDateAndTopicHeaderHeight @@ -3640,13 +3700,13 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI layoutInsets.top += 4.0 } } - + layoutSize.height += layoutInsets.top + layoutInsets.bottom - + let layout = ListViewItemNodeLayout(contentSize: layoutSize, insets: UIEdgeInsets()) - + let graphics = PresentationResourcesChat.principalGraphics(theme: item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper, bubbleCorners: item.presentationData.chatBubbleCorners) - + var updatedMergedTop = mergedBottom var updatedMergedBottom = mergedTop if mosaicRange == nil { @@ -3660,9 +3720,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI updatedMergedTop = .fullyMerged } } - + let disablesComments = !hasInstantVideo - + return (layout, { animation, applyInfo, synchronousLoads in return ChatMessageBubbleItemNode.applyLayout(selfReference: selfReference, animation, synchronousLoads, inputParams: (item, params, mergedTop, mergedBottom, dateHeaderAtBottom), @@ -3730,7 +3790,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI ) }) } - + private static func applyLayout(selfReference: Weak, _ animation: ListViewItemUpdateAnimation, _ synchronousLoads: Bool, @@ -3798,52 +3858,93 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI guard let strongSelf = selfReference.value else { return } - + strongSelf.currentInputParams = inputParams strongSelf.currentApplyParams = applyInfo strongSelf.contentLayoutInsets = layoutInsets - + if item.message.id.namespace == Namespaces.Message.Local || item.message.id.namespace == Namespaces.Message.ScheduledLocal || item.message.id.namespace == Namespaces.Message.QuickReplyLocal { strongSelf.wasPending = true } if strongSelf.wasPending && (item.message.id.namespace != Namespaces.Message.Local && item.message.id.namespace != Namespaces.Message.ScheduledLocal && item.message.id.namespace != Namespaces.Message.QuickReplyLocal) { strongSelf.didChangeFromPendingToSent = true } - + if case let .messageOptions(_, _, info) = item.associatedData.subject, case let .link(link) = info, link.isCentered { strongSelf.wantsTrailingItemSpaceUpdates = true } else { strongSelf.wantsTrailingItemSpaceUpdates = false } - + let themeUpdated = strongSelf.appliedItem?.presentationData.theme.theme !== item.presentationData.theme.theme let previousContextFrame = strongSelf.mainContainerNode.frame strongSelf.mainContainerNode.frame = CGRect(origin: CGPoint(), size: layout.contentSize) strongSelf.mainContextSourceNode.frame = CGRect(origin: CGPoint(), size: layout.contentSize) strongSelf.mainContextSourceNode.contentNode.frame = CGRect(origin: CGPoint(), size: layout.contentSize) strongSelf.contentContainersWrapperNode.frame = CGRect(origin: CGPoint(), size: layout.contentSize) - + strongSelf.appliedItem = item strongSelf.appliedForwardInfo = (forwardSource, forwardAuthorSignature) strongSelf.updateAccessibilityData(accessibilityData) strongSelf.disablesComments = disablesComments - + + // WinterGram: a message preserved after the peer deleted it carries a WinterGramDeletedMessageAttribute. + // We mark it two ways: a trash badge beside the bubble (always, so the deletion is obvious) and an + // optional dimming of the bubble content (gated by the "Dim deleted messages" preference). + let winterGramDeletedAttribute = item.content.firstMessage.attributes.first(where: { $0 is WinterGramDeletedMessageAttribute }) as? WinterGramDeletedMessageAttribute + let hasDeletedMark = !strongSelf.mainContextSourceNode.isExtractedToContextPreview && winterGramDeletedAttribute != nil + let isDeleted = hasDeletedMark && currentWinterGramSettings.semiTransparentDeletedMessages + 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 { + let deletedIconNode: ASImageNode + if let current = strongSelf.winterGramDeletedIconNode { + deletedIconNode = current + } else { + let newNode = ASImageNode() + newNode.displaysAsynchronously = false + newNode.isUserInteractionEnabled = false + strongSelf.winterGramDeletedIconNode = newNode + 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) + 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 + // Clamp into the row so wide bubbles (e.g. channel posts) don't push the marker off-screen; + // when there's no room beside the bubble it tucks into the trailing/leading corner instead. + let rowWidth = layout.contentSize.width + let iconX: CGFloat + if incoming { + iconX = min(backgroundFrame.maxX + spacing, rowWidth - iconSize.width - 4.0) + } else { + iconX = max(backgroundFrame.minX - iconSize.width - spacing, 4.0) + } + let iconFrame = CGRect(origin: CGPoint(x: iconX, y: iconY), size: iconSize) + animation.animator.updateFrame(layer: deletedIconNode.layer, frame: iconFrame, completion: nil) + } else if let deletedIconNode = strongSelf.winterGramDeletedIconNode { + strongSelf.winterGramDeletedIconNode = nil + deletedIconNode.removeFromSupernode() + } + strongSelf.authorNameColor = authorNameColor strongSelf.authorRank = authorRank - + strongSelf.replyRecognizer?.allowBothDirections = false//!item.context.sharedContext.immediateExperimentalUISettings.unidirectionalSwipeToReply strongSelf.view.disablesInteractiveTransitionGestureRecognizer = false//!item.context.sharedContext.immediateExperimentalUISettings.unidirectionalSwipeToReply - + var animation = animation if strongSelf.mainContextSourceNode.isExtractedToContextPreview { animation = .System(duration: 0.25, transition: ControlledTransition(duration: 0.25, curve: .easeInOut, interactive: false)) } - + var legacyTransition: ContainedViewLayoutTransition = .immediate if case let .System(duration, _) = animation { legacyTransition = .animated(duration: duration, curve: .spring) } - + var forceBackgroundSide = false if actionButtonsSizeAndApply != nil { forceBackgroundSide = true @@ -3872,12 +3973,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.backgroundNode.setType(type: backgroundType, highlighted: false, graphics: graphics, maskMode: strongSelf.backgroundMaskMode, hasWallpaper: hasWallpaper, transition: legacyTransition, backgroundNode: presentationContext.backgroundNode) strongSelf.backgroundWallpaperNode.setType(type: backgroundType, theme: item.presentationData.theme, essentialGraphics: graphics, maskMode: strongSelf.backgroundMaskMode, backgroundNode: presentationContext.backgroundNode) strongSelf.shadowNode.setType(type: backgroundType, hasWallpaper: hasWallpaper, graphics: graphics) - + strongSelf.backgroundType = backgroundType - + let previousBackgroundFrame = strongSelf.backgroundNode.backgroundFrame strongSelf.backgroundNode.backgroundFrame = backgroundFrame - + if let (suggestedPostInfoSize, suggestedPostInfoApply) = suggestedPostInfoNodeLayout { let suggestedPostInfoNode = suggestedPostInfoApply() if suggestedPostInfoNode !== strongSelf.suggestedPostInfoNode { @@ -3891,13 +3992,13 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.suggestedPostInfoNode = nil suggestedPostInfoNode.removeFromSupernode() } - + if let avatarOffset { strongSelf.updateAttachedAvatarNodeOffset(offset: avatarOffset, transition: .animated(duration: 0.3, curve: .spring)) } strongSelf.updateAttachedAvatarNodeIsHidden(isHidden: isSidePanelOpen, transition: animation.transition) strongSelf.updateAttachedDateHeader(hasDate: inputParams.dateHeaderAtBottom.hasDate, hasPeer: inputParams.dateHeaderAtBottom.hasTopic) - + let isFailed = item.content.firstMessage.effectivelyFailed(timestamp: item.context.account.network.getApproximateRemoteTimestamp()) if isFailed { let deliveryFailedNode: ChatMessageDeliveryFailedNode @@ -3929,18 +4030,18 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI deliveryFailedNode?.removeFromSupernode() }) } - + if let nameNode = nameNodeSizeApply.1() { strongSelf.nameNode = nameNode nameNode.displaysAsynchronously = !item.presentationData.isPreview && !item.presentationData.theme.theme.forceSync - + let previousNameNodeFrame = nameNode.frame - + var nameNodeFrame = CGRect(origin: CGPoint(x: contentOrigin.x + layoutConstants.text.bubbleInsets.left, y: layoutConstants.bubble.contentInsets.top + nameNodeOriginY), size: nameNodeSizeApply.0) - + var nameNavigateButtonOffset: CGFloat = currentCredibilityIcon == nil ? 4.0 : 28.0 nameNavigateButtonOffset += 34.0 - + if hasTitleAvatar { let nameAvatarNode: AvatarNode var animateNameAvatar = true @@ -3952,9 +4053,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.nameAvatarNode = nameAvatarNode strongSelf.clippingNode.addSubnode(nameAvatarNode) } - + let nameAvatarFrame = CGRect(origin: CGPoint(x: nameNodeFrame.minX, y: nameNodeFrame.minY - 4.0), size: CGSize(width: 26.0, height: 26.0)) - + let nameNavigationSize: CGSize var threadInfo: Message.AssociatedThreadInfo? if let channel = item.message.peers[item.message.id.peerId], channel.isForum, let threadInfoValue = item.message.associatedThreadInfo { @@ -3963,9 +4064,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { nameNavigationSize = CGSize(width: 26.0, height: 26.0) } - + let nameNavigateFrame = CGRect(origin: CGPoint(x: backgroundFrame.maxX - 10.0 - nameNavigationSize.width, y: nameNodeFrame.minY - 4.0), size: nameNavigationSize) - + if let peer = item.content.firstMessage.author, peer.smallProfileImage != nil { nameAvatarNode.setPeerV2(context: item.context, theme: item.presentationData.theme.theme, peer: EnginePeer(peer), displayDimensions: nameAvatarFrame.size) } else { @@ -3978,7 +4079,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI nameAvatarNode.setPeer(context: item.context, theme: item.presentationData.theme.theme, peer: item.content.firstMessage.author.flatMap(EnginePeer.init), overrideImage: overrideImage, displayDimensions: nameAvatarFrame.size) } nameAvatarNode.updateSize(size: nameAvatarFrame.size) - + if hasTitleTopicNavigation { let nameNavigateButton: NameNavigateButton if let current = strongSelf.nameNavigateButton { @@ -4013,7 +4114,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI animation.transition.updateTransformScale(layer: nameNavigateButton.layer, scale: CGPoint(x: 0.001, y: 0.001)) } } - + if animateNameAvatar { animation.animator.updateFrame(layer: nameAvatarNode.layer, frame: nameAvatarFrame, completion: nil) if let nameNavigateButton = strongSelf.nameNavigateButton { @@ -4026,7 +4127,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI animation.transition.animateTransformScale(view: nameAvatarNode.view, from: 0.001) nameAvatarNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) } - + if let nameNavigateButton = strongSelf.nameNavigateButton { nameNavigateButton.frame = CGRect(origin: CGPoint(x: previousBackgroundFrame.maxX - 10.0 - nameNavigateButton.bounds.width, y: previousNameNodeFrame.minY - 4.0), size: nameNavigationSize) animation.animator.updateFrame(layer: nameNavigateButton.layer, frame: nameNavigateFrame, completion: nil) @@ -4036,7 +4137,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + nameNodeFrame.origin.x += 26.0 + 5.0 } else { if let nameAvatarNode = strongSelf.nameAvatarNode { @@ -4056,21 +4157,21 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI animation.transition.updateTransformScale(layer: nameNavigateButton.layer, scale: CGPoint(x: 0.001, y: 0.001)) } } - + if nameNode.supernode == nil { if !nameNode.isNodeLoaded { nameNode.isUserInteractionEnabled = false } strongSelf.clippingNode.addSubnode(nameNode) nameNode.frame = nameNodeFrame - + if animation.isAnimated { nameNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } else { animation.animator.updateFrame(layer: nameNode.layer, frame: nameNodeFrame, completion: nil) } - + let nameButtonNode: HighlightTrackingButtonNode let nameHighlightNode: ASImageNode if let currentButton = strongSelf.nameButtonNode, let currentHighlight = strongSelf.nameHighlightNode { @@ -4083,7 +4184,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI nameHighlightNode.isUserInteractionEnabled = false strongSelf.clippingNode.addSubnode(nameHighlightNode) strongSelf.nameHighlightNode = nameHighlightNode - + nameButtonNode = HighlightTrackingButtonNode() nameButtonNode.highligthedChanged = { [weak nameHighlightNode] highlighted in guard let nameHighlightNode else { @@ -4105,12 +4206,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI nameHiglightFrame.size.width -= viaWidth nameHighlightNode.frame = nameHiglightFrame.insetBy(dx: -2.0, dy: -1.0) nameButtonNode.frame = nameHiglightFrame.insetBy(dx: -2.0, dy: -3.0) - + let nameColor = authorNameColor ?? item.presentationData.theme.theme.chat.message.outgoing.accentTextColor if themeUpdated { nameHighlightNode.image = generateFilledRoundedRectImage(size: CGSize(width: 8.0, height: 8.0), cornerRadius: 4.0, color: nameColor.withAlphaComponent(0.1))?.stretchableImage(withLeftCapWidth: 4, topCapHeight: 4) } - + if let (currentCredibilityIcon, currentParticleColor) = currentCredibilityIcon { let credibilityIconView: ComponentHostView var animateCredibilityIconFrame = true @@ -4122,12 +4223,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI credibilityIconView.isUserInteractionEnabled = false strongSelf.credibilityIconView = credibilityIconView strongSelf.clippingNode.view.addSubview(credibilityIconView) - + if animation.isAnimated { credibilityIconView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } - + let credibilityIconComponent = EmojiStatusComponent( context: item.context, animationCache: item.context.animationCache, @@ -4139,20 +4240,20 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI ) strongSelf.credibilityIconComponent = credibilityIconComponent strongSelf.credibilityIconContent = currentCredibilityIcon - + let credibilityIconSize = credibilityIconView.update( transition: .immediate, component: AnyComponent(credibilityIconComponent), environment: {}, containerSize: CGSize(width: 20.0, height: 20.0) ) - + let credibilityIconFrame = CGRect(origin: CGPoint(x: nameNode.frame.maxX + 3.0, y: nameNode.frame.minY + floor((nameNode.bounds.height - credibilityIconSize.height) / 2.0)), size: credibilityIconSize) if !animateCredibilityIconFrame { credibilityIconView.frame = CGRect(origin: CGPoint(x: previousNameNodeFrame.maxX + 3.0, y: previousNameNodeFrame.minY + floor((previousNameNodeFrame.height - credibilityIconSize.height) / 2.0)), size: credibilityIconSize) } animation.animator.updateFrame(layer: credibilityIconView.layer, frame: credibilityIconFrame, completion: nil) - + let credibilityButtonNode: HighlightTrackingButtonNode let credibilityHighlightNode: ASImageNode if let currentButton = strongSelf.credibilityButtonNode, let currentHighlight = strongSelf.credibilityHighlightNode { @@ -4165,7 +4266,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI credibilityHighlightNode.isUserInteractionEnabled = false strongSelf.clippingNode.addSubnode(credibilityHighlightNode) strongSelf.credibilityHighlightNode = credibilityHighlightNode - + credibilityButtonNode = HighlightTrackingButtonNode() credibilityButtonNode.highligthedChanged = { [weak credibilityHighlightNode] highlighted in guard let credibilityHighlightNode else { @@ -4185,7 +4286,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } credibilityHighlightNode.frame = credibilityIconFrame.insetBy(dx: -1.0, dy: -1.0) credibilityButtonNode.frame = credibilityIconFrame.insetBy(dx: -2.0, dy: -3.0) - + if themeUpdated || credibilityHighlightNode.image == nil { credibilityHighlightNode.image = generateFilledRoundedRectImage(size: CGSize(width: 8.0, height: 8.0), cornerRadius: 4.0, color: nameColor.withAlphaComponent(0.1))?.stretchableImage(withLeftCapWidth: 4, topCapHeight: 4) } @@ -4198,7 +4299,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.credibilityHighlightNode?.removeFromSupernode() strongSelf.credibilityHighlightNode = nil } - + var boostCount: Int = 0 if incoming { for attribute in item.message.attributes { @@ -4207,7 +4308,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + var rightContentOffset: CGFloat = 0.0 if let boostBadgeNode = boostNodeSizeApply.1() { boostBadgeNode.alpha = 0.75 @@ -4219,7 +4320,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } strongSelf.clippingNode.addSubnode(boostBadgeNode) boostBadgeNode.frame = boostBadgeFrame - + if animation.isAnimated { boostBadgeNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } @@ -4230,7 +4331,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.boostBadgeNode?.removeFromSupernode() strongSelf.boostBadgeNode = nil } - + if boostCount > 0 { var boostTotalWidth: CGFloat = 22.0 if boostNodeSizeApply.0.width > 0.0 { @@ -4240,12 +4341,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI boostTotalWidth -= 6.0 rightContentOffset += boostTotalWidth - 2.0 } - - + + let boostIconFrame = CGRect(origin: CGPoint(x: contentUpperRightCorner.x - layoutConstants.text.bubbleInsets.left - boostTotalWidth + 4.0, y: layoutConstants.bubble.contentInsets.top + nameNodeOriginY + 1.0 - UIScreenPixel - 3.0), size: CGSize(width: boostTotalWidth, height: 22.0)) let previousBoostCount = strongSelf.boostCount - + let boostIconNode: UIImageView let boostButtonNode: HighlightTrackingButtonNode let boostHighlightNode: ASImageNode @@ -4256,17 +4357,17 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { boostIconNode = UIImageView() boostIconNode.alpha = 0.75 - + strongSelf.clippingNode.view.addSubview(boostIconNode) strongSelf.boostIconNode = boostIconNode - + boostHighlightNode = ASImageNode() boostHighlightNode.alpha = 0.0 boostHighlightNode.displaysAsynchronously = false boostHighlightNode.isUserInteractionEnabled = false strongSelf.clippingNode.addSubnode(boostHighlightNode) strongSelf.boostHighlightNode = boostHighlightNode - + boostButtonNode = HighlightTrackingButtonNode() boostButtonNode.highligthedChanged = { [weak boostHighlightNode] highlighted in guard let boostHighlightNode else { @@ -4284,20 +4385,20 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.clippingNode.addSubnode(boostButtonNode) strongSelf.boostButtonNode = boostButtonNode } - + if boostCount != previousBoostCount { boostIconNode.image = UIImage(bundleImageName: boostCount == 1 ? "Chat/Message/Boost" : "Chat/Message/Boosts")?.withRenderingMode(.alwaysTemplate) } - + boostIconNode.tintColor = nameColor - + if let iconSize = boostIconNode.image?.size { boostIconNode.frame = CGRect(origin: CGPoint(x: boostTotalWidth > 22.0 ? boostIconFrame.minX + 3.0 : boostIconFrame.midX - iconSize.width / 2.0, y: boostIconFrame.midY - iconSize.height / 2.0), size: iconSize) } - + boostHighlightNode.frame = boostIconFrame boostButtonNode.frame = boostIconFrame.insetBy(dx: -2.0, dy: -3.0) - + if themeUpdated || boostHighlightNode.image == nil { boostHighlightNode.image = generateFilledRoundedRectImage(size: CGSize(width: 8.0, height: 8.0), cornerRadius: 4.0, color: nameColor.withAlphaComponent(0.1))?.stretchableImage(withLeftCapWidth: 4, topCapHeight: 4) } @@ -4310,7 +4411,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.boostIconNode = nil } strongSelf.boostCount = boostCount - + if let rankBadgeNode = rankBadgeNodeSizeApply.1() { strongSelf.rankBadgeNode = rankBadgeNode let rankBadgeFrame = CGRect(origin: CGPoint(x: contentUpperRightCorner.x - layoutConstants.text.bubbleInsets.left - rightContentOffset - rankBadgeNodeSizeApply.0.width - 1.0 + UIScreenPixel, y: layoutConstants.bubble.contentInsets.top + nameNodeOriginY + 1.0 - UIScreenPixel), size: rankBadgeNodeSizeApply.0) @@ -4321,7 +4422,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.clippingNode.addSubnode(rankBadgeNode) rankBadgeNode.frame = rankBadgeFrame rankBadgeNode.alpha = hasTitleTopicNavigation ? 0.0 : 1.0 - + if animation.isAnimated, rankBadgeNode.alpha != 0.0 { rankBadgeNode.layer.animateAlpha(from: 0.0, to: rankBadgeNode.alpha, duration: 0.2) } @@ -4329,7 +4430,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI animation.animator.updateFrame(layer: rankBadgeNode.layer, frame: rankBadgeFrame, completion: nil) animation.animator.updateAlpha(layer: rankBadgeNode.layer, alpha: hasTitleTopicNavigation ? 0.0 : 1.0, completion: nil) } - + var rankBackgroundColor: UIColor var rankBackgroundBaseAlpha: CGFloat = 0.0 if let rankBadgeColor = rankBadgeNodeSizeApply.2 { @@ -4339,7 +4440,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI let messageTheme = incoming ? item.presentationData.theme.theme.chat.message.incoming : item.presentationData.theme.theme.chat.message.outgoing rankBackgroundColor = messageTheme.secondaryTextColor.withMultipliedAlpha(0.1) } - + let rankBadgeSize = CGSize(width: rankBadgeFrame.width + 10.0, height: 17.0) let rankBackgroundNode: ASImageNode if let current = strongSelf.rankBackgroundNode { @@ -4359,7 +4460,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI animation.animator.updateFrame(layer: rankBackgroundNode.layer, frame: rankBackgroundFrame, completion: nil) animation.animator.updateAlpha(layer: rankBackgroundNode.layer, alpha: hasTitleTopicNavigation ? 0.0 : rankBackgroundBaseAlpha, completion: nil) } - + let rankButtonNode: HighlightTrackingButtonNode if let currentButton = strongSelf.rankButtonNode { rankButtonNode = currentButton @@ -4389,7 +4490,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.rankBackgroundNode?.removeFromSupernode() strongSelf.rankBackgroundNode = nil } - + if let _ = item.message.adAttribute { let buttonNode: HighlightTrackingButtonNode let iconNode: ASImageNode @@ -4401,7 +4502,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI iconNode = ASImageNode() iconNode.displaysAsynchronously = false iconNode.isUserInteractionEnabled = false - + buttonNode.addTarget(strongSelf, action: #selector(strongSelf.closeButtonPressed), forControlEvents: .touchUpInside) buttonNode.highligthedChanged = { [weak iconNode] highlighted in guard let iconNode else { @@ -4415,21 +4516,21 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI iconNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) } } - + strongSelf.clippingNode.addSubnode(buttonNode) strongSelf.clippingNode.addSubnode(iconNode) - + strongSelf.closeButtonNode = buttonNode strongSelf.closeIconNode = iconNode } - + iconNode.image = PresentationResourcesChat.chatBubbleCloseIcon(item.presentationData.theme.theme) - + let closeButtonSize = CGSize(width: 32.0, height: 32.0) let closeIconSize = CGSize(width: 12.0, height: 12.0) let closeButtonFrame = CGRect(origin: CGPoint(x: contentUpperRightCorner.x - closeButtonSize.width, y: layoutConstants.bubble.contentInsets.top), size: closeButtonSize) let closeButtonIconFrame = CGRect(origin: CGPoint(x: contentUpperRightCorner.x - layoutConstants.text.bubbleInsets.left - closeIconSize.width + 1.0, y: layoutConstants.bubble.contentInsets.top + nameNodeOriginY + 2.0), size: closeIconSize) - + animation.animator.updateFrame(layer: buttonNode.layer, frame: closeButtonFrame, completion: nil) animation.animator.updateFrame(layer: iconNode.layer, frame: closeButtonIconFrame, completion: nil) } else { @@ -4513,8 +4614,8 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.boostHighlightNode?.removeFromSupernode() strongSelf.boostHighlightNode = nil } - - let timingFunction = kCAMediaTimingFunctionSpring + + let timingFunction = kCAMediaTimingFunctionSpring if let forwardInfoNode = forwardInfoSizeApply.1(bubbleContentWidth) { strongSelf.forwardInfoNode = forwardInfoNode var animateFrame = true @@ -4527,7 +4628,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } item.controllerInteraction.displayPsa(type, sourceNode) } - + if animation.isAnimated { forwardInfoNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } @@ -4557,16 +4658,16 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.forwardInfoNode = nil } } - + if let threadInfoNode = threadInfoSizeApply.1(synchronousLoads) { strongSelf.threadInfoNode = threadInfoNode var animateFrame = true if threadInfoNode.supernode == nil { strongSelf.clippingNode.addSubnode(threadInfoNode) animateFrame = false - + threadInfoNode.visibility = strongSelf.visibility != .none - + if animation.isAnimated { threadInfoNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } @@ -4591,7 +4692,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.threadInfoNode = nil } } - + let replyInfoFrame = CGRect(origin: CGPoint(x: contentOrigin.x + layoutConstants.text.bubbleInsets.left, y: layoutConstants.bubble.contentInsets.top + replyInfoOriginY), size: CGSize(width: backgroundFrame.width - layoutConstants.text.bubbleInsets.left - layoutConstants.text.bubbleInsets.right - 6.0, height: replyInfoSizeApply.0.height)) if let replyInfoNode = replyInfoSizeApply.1(replyInfoFrame.size, synchronousLoads, animation) { strongSelf.replyInfoNode = replyInfoNode @@ -4599,9 +4700,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if replyInfoNode.supernode == nil { strongSelf.clippingNode.addSubnode(replyInfoNode) animateFrame = false - + replyInfoNode.visibility = strongSelf.visibility != .none - + if animation.isAnimated { replyInfoNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } @@ -4626,7 +4727,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.replyInfoNode = nil } } - + var incomingOffset: CGFloat = 0.0 switch backgroundType { case .incoming: @@ -4634,7 +4735,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI default: break } - + var index = 0 var hasSelection = false for (stableId, relativeFrame, itemSelection, groupOverlap) in contentContainerNodeFrames { @@ -4642,24 +4743,24 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI hasSelection = true } var contentContainer: ContentContainer? = strongSelf.contentContainers.first(where: { $0.contentMessageStableId == stableId }) - + let previousContextFrame = contentContainer?.containerNode.frame let previousContextContentFrame = contentContainer?.sourceNode.contentRect - + if contentContainer == nil { let container = ContentContainer(contentMessageStableId: stableId) let contextSourceNode = container.sourceNode let containerNode = container.containerNode - + container.containerNode.shouldBegin = { [weak strongSelf, weak containerNode] location in guard let strongSelf = strongSelf, let strongContainerNode = containerNode else { return false } - + if strongSelf.contentContainers.count < 2 { return false } - + let location = location.offsetBy(dx: 0.0, dy: strongContainerNode.frame.minY) if !strongSelf.backgroundNode.frame.contains(location) { return false @@ -4695,15 +4796,15 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI guard let strongSelf = strongSelf, let strongContainerNode = containerNode else { return } - + let location = location.offsetBy(dx: 0.0, dy: strongContainerNode.frame.minY) strongSelf.mainContainerNode.activated?(gesture, location) } - + containerNode.addSubnode(contextSourceNode) containerNode.targetNodeForActivationProgress = contextSourceNode.contentNode strongSelf.contentContainersWrapperNode.addSubnode(containerNode) - + contextSourceNode.willUpdateIsExtractedToContextPreview = { [weak strongSelf, weak container, weak contextSourceNode] isExtractedToContextPreview, transition in guard let strongSelf = strongSelf, let strongContextSourceNode = contextSourceNode else { return @@ -4719,20 +4820,20 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI guard let strongSelf = strongSelf, let strongContextSourceNode = contextSourceNode else { return } - + container?.isExtractedToContextPreviewUpdated(isExtractedToContextPreview) if !isExtractedToContextPreview, let (rect, size) = container?.absoluteRect { container?.updateAbsoluteRect(rect, within: size) } - + for contentNode in strongSelf.contentNodes { if contentNode.supernode === strongContextSourceNode.contentNode { contentNode.updateIsExtractedToContextPreview(isExtractedToContextPreview) } } } - + contextSourceNode.updateAbsoluteRect = { [weak strongSelf, weak container, weak contextSourceNode] rect, size in guard let _ = strongSelf, let strongContextSourceNode = contextSourceNode, strongContextSourceNode.isExtractedToContextPreview else { return @@ -4751,24 +4852,24 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } container?.applyAbsoluteOffsetSpring(value: value, duration: duration, damping: damping) } - + strongSelf.contentContainers.append(container) contentContainer = container } - + let containerFrame = CGRect(origin: relativeFrame.origin, size: CGSize(width: params.width, height: relativeFrame.size.height)) contentContainer?.sourceNode.frame = CGRect(origin: CGPoint(), size: containerFrame.size) contentContainer?.sourceNode.contentNode.frame = CGRect(origin: CGPoint(), size: containerFrame.size) - + contentContainer?.containerNode.frame = containerFrame - + contentContainer?.sourceNode.contentRect = CGRect(origin: CGPoint(x: backgroundFrame.minX + incomingOffset, y: 0.0), size: relativeFrame.size) contentContainer?.containerNode.targetNodeForActivationProgressContentRect = CGRect(origin: CGPoint(x: backgroundFrame.minX + incomingOffset, y: 0.0), size: relativeFrame.size) - + if previousContextFrame?.size != contentContainer?.containerNode.bounds.size || previousContextContentFrame != contentContainer?.sourceNode.contentRect { contentContainer?.sourceNode.layoutUpdated?(relativeFrame.size, animation) } - + var selectionInsets = UIEdgeInsets() if index == 0 { selectionInsets.bottom = groupOverlap / 2.0 @@ -4778,12 +4879,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI selectionInsets.top = groupOverlap / 2.0 selectionInsets.bottom = groupOverlap / 2.0 } - + contentContainer?.update(size: relativeFrame.size, contentOrigin: contentOrigin, selectionInsets: selectionInsets, index: index, presentationData: item.presentationData, graphics: graphics, backgroundType: backgroundType, presentationContext: item.controllerInteraction.presentationContext, mediaBox: item.context.account.postbox.mediaBox, messageSelection: itemSelection) - + index += 1 } - + if hasSelection { var currentMaskView: UIImageView? if let maskView = strongSelf.contentContainersWrapperNode.view.mask as? UIImageView { @@ -4792,19 +4893,19 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI currentMaskView = UIImageView() strongSelf.contentContainersWrapperNode.view.mask = currentMaskView } - + currentMaskView?.frame = CGRect(origin: CGPoint(x: backgroundFrame.minX, y: 0.0), size: backgroundFrame.size).insetBy(dx: -1.0, dy: -1.0) currentMaskView?.image = bubbleMaskForType(backgroundType, graphics: graphics) } else { strongSelf.contentContainersWrapperNode.view.mask = nil } - + var animateTextAndWebpagePositionSwap: Bool? var bottomStatusNodeAnimationSourcePosition: CGPoint? - + if removedContentNodeIndices?.count ?? 0 != 0 || addedContentNodes?.count ?? 0 != 0 || updatedContentNodeOrder { var updatedContentNodes = strongSelf.contentNodes - + if let removedContentNodeIndices = removedContentNodeIndices { for index in removedContentNodeIndices.reversed() { if index >= 0 && index < updatedContentNodes.count { @@ -4820,12 +4921,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + if let addedContentNodes = addedContentNodes { for (contentNodeMessage, isAttachment, contentNode, _) in addedContentNodes { let index = updatedContentNodes.count updatedContentNodes.append(contentNode) - + if index < contentNodeFramesPropertiesAndApply.count && contentNodeFramesPropertiesAndApply[index].1.isDetached { strongSelf.addSubnode(contentNode) } else { @@ -4844,7 +4945,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } contentNode.updateIsExtractedToContextPreview(contextSourceNode.isExtractedToContextPreview) } - + contentNode.itemNode = strongSelf contentNode.bubbleBackgroundNode = strongSelf.backgroundNode contentNode.bubbleBackdropNode = strongSelf.backgroundWallpaperNode @@ -4853,19 +4954,19 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI guard let strongSelf else { return } - + strongSelf.internalUpdateLayout() } contentNode.requestFullUpdate = { [weak strongSelf] customTransition in guard let strongSelf, let item = strongSelf.item else { return } - + item.controllerInteraction.requestMessageUpdate(item.message.id, false, customTransition) } } } - + var sortedContentNodes: [ChatMessageBubbleContentNode] = [] outer: for contentItemValue in contentNodeMessagesAndClasses { let contentItem = contentItemValue as (message: Message, type: AnyClass, ChatMessageEntryAttributes, attributes: BubbleItemAttributes) @@ -4884,14 +4985,14 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + assert(sortedContentNodes.count == updatedContentNodes.count) - + if animation.isAnimated, let fromTextIndex = strongSelf.contentNodes.firstIndex(where: { $0 is ChatMessageTextBubbleContentNode }), let fromWebpageIndex = strongSelf.contentNodes.firstIndex(where: { $0 is ChatMessageWebpageBubbleContentNode }) { if let toTextIndex = sortedContentNodes.firstIndex(where: { $0 is ChatMessageTextBubbleContentNode }), let toWebpageIndex = sortedContentNodes.firstIndex(where: { $0 is ChatMessageWebpageBubbleContentNode }) { if fromTextIndex == toWebpageIndex && fromWebpageIndex == toTextIndex { animateTextAndWebpagePositionSwap = fromTextIndex < toTextIndex - + if let textNode = strongSelf.contentNodes[fromTextIndex] as? ChatMessageTextBubbleContentNode, let webpageNode = strongSelf.contentNodes[fromWebpageIndex] as? ChatMessageWebpageBubbleContentNode { if fromTextIndex > toTextIndex { if let statusNode = textNode.statusNode, let contentSuperview = textNode.view.superview, statusNode.view.isDescendant(of: contentSuperview) { @@ -4906,34 +5007,34 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + strongSelf.contentNodes = sortedContentNodes } - + var shouldClipOnTransitions = true var contentNodeIndex = 0 for (relativeFrame, properties, useContentOrigin, apply) in contentNodeFramesPropertiesAndApply { apply(animation, synchronousLoads, applyInfo) - + if contentNodeIndex >= strongSelf.contentNodes.count { break } - + let contentNode = strongSelf.contentNodes[contentNodeIndex] - + if contentNode.disablesClipping { shouldClipOnTransitions = false } - + var effectiveContentOriginX = contentOrigin.x var effectiveContentOriginY = useContentOrigin ? contentOrigin.y : 0.0 if properties.isDetached { effectiveContentOriginX = floorToScreenPixels((layout.size.width - relativeFrame.width) / 2.0) effectiveContentOriginY = layoutInsets.top } - + let contentNodeFrame = relativeFrame.offsetBy(dx: effectiveContentOriginX, dy: effectiveContentOriginY) - + if case let .System(duration, _) = animation { var animateFrame = false var animateAlpha = false @@ -4946,41 +5047,41 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { animateFrame = true } - + if animateFrame { var useExpensiveSnapshot = false if case .messageOptions = item.associatedData.subject { useExpensiveSnapshot = true } - + if let animateTextAndWebpagePositionSwap, let contentNode = contentNode as? ChatMessageTextBubbleContentNode, let snapshotView = useExpensiveSnapshot ? contentNode.view.snapshotView(afterScreenUpdates: false) : contentNode.layer.snapshotContentTreeAsView() { let clippingView = UIView() clippingView.clipsToBounds = true clippingView.frame = contentNode.frame - + clippingView.addSubview(snapshotView) snapshotView.frame = CGRect(origin: CGPoint(), size: contentNode.bounds.size) - + contentNode.view.superview?.insertSubview(clippingView, belowSubview: contentNode.view) - + animation.animator.updateAlpha(layer: clippingView.layer, alpha: 0.0, completion: { [weak clippingView] _ in clippingView?.removeFromSuperview() }) - + let positionOffset: CGFloat = animateTextAndWebpagePositionSwap ? -1.0 : 1.0 - + animation.animator.updatePosition(layer: snapshotView.layer, position: CGPoint(x: snapshotView.center.x, y: snapshotView.center.y + positionOffset * contentNode.frame.height), completion: nil) - + contentNode.frame = contentNodeFrame - + if let statusNode = contentNode.statusNode, let contentSuperview = contentNode.view.superview, statusNode.view.isDescendant(of: contentSuperview), let bottomStatusNodeAnimationSourcePosition { let localSourcePosition = statusNode.view.convert(bottomStatusNodeAnimationSourcePosition, from: contentSuperview) let offset = CGPoint(x: statusNode.bounds.width - localSourcePosition.x, y: statusNode.bounds.height - localSourcePosition.y) animation.animator.animatePosition(layer: statusNode.layer, from: statusNode.layer.position.offsetBy(dx: -offset.x, dy: -offset.y), to: statusNode.layer.position, completion: nil) } - + contentNode.animateClippingTransition(offset: positionOffset * contentNodeFrame.height, animation: animation) - + contentNode.alpha = 0.0 animation.animator.updateAlpha(layer: contentNode.layer, alpha: 1.0, completion: nil) } else if animateTextAndWebpagePositionSwap != nil, let contentNode = contentNode as? ChatMessageWebpageBubbleContentNode { @@ -4989,7 +5090,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI let offset = CGPoint(x: statusNode.bounds.width - localSourcePosition.x, y: statusNode.bounds.height - localSourcePosition.y) animation.animator.animatePosition(layer: statusNode.layer, from: statusNode.layer.position.offsetBy(dx: -offset.x, dy: -offset.y), to: statusNode.layer.position, completion: nil) } - + animation.animator.updateFrame(layer: contentNode.layer, frame: contentNodeFrame, completion: nil) } else { animation.animator.updateFrame(layer: contentNode.layer, frame: contentNodeFrame, completion: nil) @@ -5006,12 +5107,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { contentNode.frame = contentNodeFrame } - + contentNode.visibility = mapVisibility(strongSelf.visibility, boundsSize: layout.contentSize, insets: strongSelf.insets, to: contentNode) - + contentNodeIndex += 1 } - + if let mosaicStatusOrigin = mosaicStatusOrigin, let (size, apply) = mosaicStatusSizeAndApply { var statusNodeAnimation = animation if strongSelf.mosaicStatusNode == nil { @@ -5025,7 +5126,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } let absoluteOrigin = mosaicStatusOrigin.offsetBy(dx: contentOrigin.x, dy: contentOrigin.y) statusNodeAnimation.animator.updateFrame(layer: mosaicStatusNode.layer, frame: CGRect(origin: CGPoint(x: absoluteOrigin.x - layoutConstants.image.statusInsets.right - size.width, y: absoluteOrigin.y - layoutConstants.image.statusInsets.bottom - size.height), size: size), completion: nil) - + if item.message.messageEffect(availableMessageEffects: item.associatedData.availableMessageEffects) != nil { mosaicStatusNode.pressed = { [weak strongSelf] in guard let strongSelf, let item = strongSelf.item else { @@ -5040,7 +5141,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.mosaicStatusNode = nil mosaicStatusNode.removeFromSupernode() } - + if let unlockButtonPosition { let (size, apply) = unlockButtonSizeAndApply var unlockButtonNodeAnimation = animation @@ -5065,7 +5166,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI unlockButtonNode.removeFromSupernode() }) } - + if let mediaInfoOrigin { let (size, apply) = mediaInfoSizeAndApply var unlockButtonNodeAnimation = animation @@ -5104,7 +5205,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.summarizeButtonNode = nil summarizeButtonNode.removeFromSupernode() } - + if needsShareButton { if strongSelf.shareButtonNode == nil { let shareButtonNode = ChatMessageShareButton() @@ -5124,14 +5225,14 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.shareButtonNode = nil shareButtonNode.removeFromSupernode() } - + let offset: CGFloat = params.leftInset + (incoming ? 42.0 : 0.0) let selectionFrame = CGRect(origin: CGPoint(x: -offset, y: 0.0), size: CGSize(width: params.width, height: layout.contentSize.height)) strongSelf.selectionNode?.frame = selectionFrame strongSelf.selectionNode?.updateLayout(size: selectionFrame.size, leftInset: params.leftInset) - + var reactionButtonsOffset: CGFloat = 0.0 - + if let actionButtonsSizeAndApply = actionButtonsSizeAndApply { let actionButtonsNode = actionButtonsSizeAndApply.1(animation) var actionButtonsOriginX = backgroundFrame.minX @@ -5152,7 +5253,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI hasMarkup = true } } - + if !hasMarkup { item.controllerInteraction.updateChatLocationThread(item.message.threadId, nil) return @@ -5170,14 +5271,14 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } strongSelf.insertSubnode(actionButtonsNode, belowSubnode: strongSelf.messageAccessibilityArea) actionButtonsNode.frame = actionButtonsFrame - + if animation.isAnimated { actionButtonsNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } } else { animation.animator.updateFrame(layer: actionButtonsNode.layer, frame: actionButtonsFrame, completion: nil) } - + reactionButtonsOffset += actionButtonsSizeAndApply.0.height } else if let actionButtonsNode = strongSelf.actionButtonsNode { strongSelf.actionButtonsNode = nil @@ -5189,10 +5290,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI actionButtonsNode.removeFromSupernode() } } - + if let reactionButtonsSizeAndApply = reactionButtonsSizeAndApply { let reactionButtonsNode = reactionButtonsSizeAndApply.1(animation) - + var reactionButtonsOriginX: CGFloat if case .center = alignment { reactionButtonsOriginX = backgroundFrame.minX + 3.0 @@ -5203,7 +5304,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if !disablesComments && !incoming { reactionButtonsFrame.origin.x = backgroundFrame.maxX - reactionButtonsSizeAndApply.0.width - layoutConstants.bubble.contentInsets.left } - + if reactionButtonsNode !== strongSelf.reactionButtonsNode { strongSelf.reactionButtonsNode = reactionButtonsNode reactionButtonsNode.reactionSelected = { [weak strongSelf] value, sourceView in @@ -5217,7 +5318,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI gesture?.cancel() return } - + item.controllerInteraction.openMessageReactionContextMenu(item.message, sourceNode, gesture, value) } reactionButtonsNode.frame = reactionButtonsFrame @@ -5225,28 +5326,28 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if animation.isAnimated { reactionButtonsNode.animateIn(animation: animation) } - + if let (rect, containerSize) = strongSelf.absoluteRect { var rect = rect rect.origin.y = containerSize.height - rect.maxY + strongSelf.insets.top - + var reactionButtonsNodeFrame = reactionButtonsFrame reactionButtonsNodeFrame.origin.x += rect.minX reactionButtonsNodeFrame.origin.y += rect.minY - + reactionButtonsNode.update(rect: rect, within: containerSize, transition: .immediate) } } else { animation.animator.updateFrame(layer: reactionButtonsNode.layer, frame: reactionButtonsFrame, completion: nil) - + if let (rect, containerSize) = strongSelf.absoluteRect { var rect = rect rect.origin.y = containerSize.height - rect.maxY + strongSelf.insets.top - + var reactionButtonsNodeFrame = reactionButtonsFrame reactionButtonsNodeFrame.origin.x += rect.minX reactionButtonsNodeFrame.origin.y += rect.minY - + reactionButtonsNode.update(rect: rect, within: containerSize, transition: animation.transition) } } @@ -5260,12 +5361,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI reactionButtonsNode.removeFromSupernode() } } - + var isCurrentlyPlayingMedia = false if item.associatedData.currentlyPlayingMessageId == item.message.index, let file = item.message.media.first(where: { $0 is TelegramMediaFile }) as? TelegramMediaFile, file.isInstantVideo { isCurrentlyPlayingMedia = true } - + if case .System = animation/*, !strongSelf.mainContextSourceNode.isExtractedToContextPreview*/ { if !strongSelf.backgroundNode.frame.equalTo(backgroundFrame) { animation.animator.updateFrame(layer: strongSelf.backgroundNode.layer, frame: backgroundFrame, completion: nil) @@ -5283,7 +5384,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI animation.animator.updateFrame(layer: strongSelf.backgroundWallpaperNode.layer, frame: backgroundFrame, completion: nil) strongSelf.shadowNode.updateLayout(backgroundFrame: backgroundFrame, animator: animation.animator) strongSelf.backgroundWallpaperNode.updateFrame(backgroundFrame, animator: animation.animator) - + if let _ = strongSelf.backgroundNode.type { if !strongSelf.mainContextSourceNode.isExtractedToContextPreview { if let (rect, size) = strongSelf.absoluteRect { @@ -5295,9 +5396,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } if let summarizeButtonNode = strongSelf.summarizeButtonNode { let buttonSize = summarizeButtonNode.update(presentationData: item.presentationData, controllerInteraction: item.controllerInteraction, chatLocation: item.chatLocation, subject: item.associatedData.subject, message: EngineMessage(item.message), accountPeerId: item.context.account.peerId, disableComments: disablesComments, isSummarize: true) - + var buttonFrame = CGRect(origin: CGPoint(x: !incoming ? backgroundFrame.minX - buttonSize.width - 8.0 : backgroundFrame.maxX + 8.0, y: backgroundFrame.minY + 1.0), size: buttonSize) - + if let shareButtonOffset = shareButtonOffset { if incoming { buttonFrame.origin.x = shareButtonOffset.x @@ -5306,12 +5407,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else if !disablesComments { buttonFrame.origin.y = buttonFrame.origin.y - (buttonSize.height - 30.0) } - + if isSidePanelOpen { buttonFrame.origin.x -= buttonFrame.width * 0.5 buttonFrame.origin.y += buttonFrame.height * 0.5 } - + animation.animator.updatePosition(layer: summarizeButtonNode.layer, position: buttonFrame.center, completion: nil) animation.animator.updateBounds(layer: summarizeButtonNode.layer, bounds: CGRect(origin: CGPoint(), size: buttonFrame.size), completion: nil) animation.animator.updateAlpha(layer: summarizeButtonNode.layer, alpha: (isCurrentlyPlayingMedia || isSidePanelOpen) ? 0.0 : 1.0, completion: nil) @@ -5319,13 +5420,13 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } if let shareButtonNode = strongSelf.shareButtonNode { let buttonSize = shareButtonNode.update(presentationData: item.presentationData, controllerInteraction: item.controllerInteraction, chatLocation: item.chatLocation, subject: item.associatedData.subject, message: EngineMessage(item.message), accountPeerId: item.context.account.peerId, disableComments: disablesComments) - + var buttonFrame = CGRect(origin: CGPoint(x: !incoming ? backgroundFrame.minX - buttonSize.width - 8.0 : backgroundFrame.maxX + 8.0, y: backgroundFrame.maxY - buttonSize.width - 1.0), size: buttonSize) - + if item.message.adAttribute != nil { buttonFrame.origin.y = backgroundFrame.minY + 1.0 } - + if let shareButtonOffset = shareButtonOffset { if incoming { buttonFrame.origin.x = shareButtonOffset.x @@ -5334,12 +5435,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else if !disablesComments { buttonFrame.origin.y = buttonFrame.origin.y - (buttonSize.height - 30.0) } - + if isSidePanelOpen { buttonFrame.origin.x -= buttonFrame.width * 0.5 buttonFrame.origin.y += buttonFrame.height * 0.5 } - + animation.animator.updatePosition(layer: shareButtonNode.layer, position: buttonFrame.center, completion: nil) animation.animator.updateBounds(layer: shareButtonNode.layer, bounds: CGRect(origin: CGPoint(), size: buttonFrame.size), completion: nil) animation.animator.updateAlpha(layer: shareButtonNode.layer, alpha: (isCurrentlyPlayingMedia || isSidePanelOpen) ? 0.0 : 1.0, completion: nil) @@ -5353,9 +5454,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.messageAccessibilityArea.frame = backgroundFrame if let summarizeButtonNode = strongSelf.summarizeButtonNode { let buttonSize = summarizeButtonNode.update(presentationData: item.presentationData, controllerInteraction: item.controllerInteraction, chatLocation: item.chatLocation, subject: item.associatedData.subject, message: EngineMessage(item.message), accountPeerId: item.context.account.peerId, disableComments: disablesComments, isSummarize: true) - + var buttonFrame = CGRect(origin: CGPoint(x: !incoming ? backgroundFrame.minX - buttonSize.width - 8.0 : backgroundFrame.maxX + 8.0, y: backgroundFrame.minY + 1.0), size: buttonSize) - + if let shareButtonOffset = shareButtonOffset { if incoming { buttonFrame.origin.x = shareButtonOffset.x @@ -5364,12 +5465,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else if !disablesComments { buttonFrame.origin.y = buttonFrame.origin.y - (buttonSize.height - 30.0) } - + if isSidePanelOpen { buttonFrame.origin.x -= buttonFrame.width * 0.5 buttonFrame.origin.y += buttonFrame.height * 0.5 } - + animation.animator.updatePosition(layer: summarizeButtonNode.layer, position: buttonFrame.center, completion: nil) animation.animator.updateBounds(layer: summarizeButtonNode.layer, bounds: CGRect(origin: CGPoint(), size: buttonFrame.size), completion: nil) animation.animator.updateAlpha(layer: summarizeButtonNode.layer, alpha: (isCurrentlyPlayingMedia || isSidePanelOpen) ? 0.0 : 1.0, completion: nil) @@ -5377,13 +5478,13 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } if let shareButtonNode = strongSelf.shareButtonNode { let buttonSize = shareButtonNode.update(presentationData: item.presentationData, controllerInteraction: item.controllerInteraction, chatLocation: item.chatLocation, subject: item.associatedData.subject, message: EngineMessage(item.message), accountPeerId: item.context.account.peerId, disableComments: disablesComments) - + var buttonFrame = CGRect(origin: CGPoint(x: !incoming ? backgroundFrame.minX - buttonSize.width - 8.0 : backgroundFrame.maxX + 8.0, y: backgroundFrame.maxY - buttonSize.width - 1.0), size: buttonSize) - + if item.message.adAttribute != nil { buttonFrame.origin.y = backgroundFrame.minY + 1.0 } - + if let shareButtonOffset = shareButtonOffset { if incoming { buttonFrame.origin.x = shareButtonOffset.x @@ -5392,18 +5493,18 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else if !disablesComments { buttonFrame.origin.y = buttonFrame.origin.y - (buttonSize.height - 30.0) } - + if isSidePanelOpen { buttonFrame.origin.x -= buttonFrame.width * 0.5 buttonFrame.origin.y += buttonFrame.height * 0.5 } - + animation.animator.updatePosition(layer: shareButtonNode.layer, position: buttonFrame.center, completion: nil) animation.animator.updateBounds(layer: shareButtonNode.layer, bounds: CGRect(origin: CGPoint(), size: buttonFrame.size), completion: nil) animation.animator.updateAlpha(layer: shareButtonNode.layer, alpha: (isCurrentlyPlayingMedia || isSidePanelOpen) ? 0.0 : 1.0, completion: nil) animation.animator.updateScale(layer: shareButtonNode.layer, scale: (isCurrentlyPlayingMedia || isSidePanelOpen) ? 0.001 : 1.0, completion: nil) } - + if case .System = animation, strongSelf.mainContextSourceNode.isExtractedToContextPreview { legacyTransition.updateFrame(node: strongSelf.backgroundNode, frame: backgroundFrame) if let backgroundHighlightNode = strongSelf.backgroundHighlightNode { @@ -5423,7 +5524,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI backgroundHighlightNode.frame = backgroundFrame backgroundHighlightNode.updateLayout(size: backgroundFrame.size, transition: .immediate) } - + strongSelf.clippingNode.frame = backgroundFrame strongSelf.clippingNode.bounds = CGRect(origin: CGPoint(x: backgroundFrame.minX, y: backgroundFrame.minY), size: backgroundFrame.size) strongSelf.backgroundNode.updateLayout(size: backgroundFrame.size, transition: .immediate) @@ -5434,15 +5535,15 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.updateAbsoluteRect(rect, within: size) } } - + let previousContextContentFrame = strongSelf.mainContextSourceNode.contentRect strongSelf.mainContextSourceNode.contentRect = backgroundFrame.offsetBy(dx: incomingOffset, dy: 0.0) strongSelf.mainContainerNode.targetNodeForActivationProgressContentRect = strongSelf.mainContextSourceNode.contentRect - + if previousContextFrame.size != strongSelf.mainContextSourceNode.bounds.size || previousContextContentFrame != strongSelf.mainContextSourceNode.contentRect { strongSelf.mainContextSourceNode.layoutUpdated?(strongSelf.mainContextSourceNode.bounds.size, animation) } - + var hasMenuGesture = true if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject { if case .link = info { @@ -5467,21 +5568,21 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI for contentContainer in strongSelf.contentContainers { contentContainer.containerNode.isGestureEnabled = hasMenuGesture } - + strongSelf.updateSearchTextHighlightState() - + strongSelf.updateVisibility(isScroll: false) - + if let (_, f) = strongSelf.awaitingAppliedReaction { strongSelf.awaitingAppliedReaction = nil - + f() } } - + override public func updateAccessibilityData(_ accessibilityData: ChatMessageAccessibilityData) { super.updateAccessibilityData(accessibilityData) - + self.messageAccessibilityArea.accessibilityLabel = accessibilityData.label self.messageAccessibilityArea.accessibilityValue = accessibilityData.value self.messageAccessibilityArea.accessibilityHint = accessibilityData.hint @@ -5494,7 +5595,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI self.messageAccessibilityArea.accessibilityCustomActions = nil } } - + @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) { if let action = action as? ChatMessageAccessibilityCustomAction { switch action.action { @@ -5518,15 +5619,15 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + override public func shouldAnimateHorizontalFrameTransition() -> Bool { return false } - + override public func animateFrameTransition(_ progress: CGFloat, _ currentValue: CGFloat) { super.animateFrameTransition(progress, currentValue) } - + @objc private func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { switch recognizer.state { case .ended: @@ -5559,7 +5660,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI break } } - + private func gestureRecognized(gesture: TapLongTapOrDoubleTapGesture, location: CGPoint, recognizer: TapLongTapOrDoubleTapGestureRecognizer?) -> InternalBubbleTapAction? { var mediaMessage: Message? var forceOpen = false @@ -5617,7 +5718,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI mediaMessage = item.message } } - + switch gesture { case .tap: if let nameNode = self.nameNode, nameNode.frame.contains(location) { @@ -5630,7 +5731,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { botAddressName = attribute.title } - + if let peerId = attribute.peerId { if let botPeer = item.message.peers[peerId] as? TelegramUser, let inlinePlaceholder = botPeer.botInfo?.inlinePlaceholder, !inlinePlaceholder.isEmpty { return .optionalAction({ @@ -5664,12 +5765,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI self.toggleSummarization() })) } - + for attribute in item.message.attributes { if let attribute = attribute as? ReplyMessageAttribute { if let threadId = item.message.threadId, Int32(clamping: threadId) == attribute.messageId.id, let quotedReply = item.message.attributes.first(where: { $0 is QuotedReplyMessageAttribute }) as? QuotedReplyMessageAttribute { let _ = quotedReply - + return .action(InternalBubbleTapAction.Action({ [weak self, weak replyInfoNode] in guard let self, let item = self.item, let replyInfoNode else { return @@ -5686,7 +5787,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI item.controllerInteraction.navigateToMessage(item.message.id, attribute.messageId, NavigateToMessageParams(timestamp: nil, quote: attribute.isQuote ? attribute.quote.flatMap { quote in NavigateToMessageParams.Quote(string: quote.text, offset: quote.offset) } : nil, progress: progress)) }, contextMenuOnLongPress: true)) } - + return .action(InternalBubbleTapAction.Action({ [weak self] in guard let self else { return @@ -5711,7 +5812,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) return } - + item.controllerInteraction.attemptedNavigationToPrivateQuote(attribute.peerId.flatMap { item.message.peers[$0] }) }, contextMenuOnLongPress: true)) } @@ -5757,7 +5858,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + if forwardInfoNode.hasAction(at: self.view.convert(location, to: forwardInfoNode.view)) { return .action(InternalBubbleTapAction.Action {}) } else { @@ -5789,7 +5890,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI rects.append(rect.offsetBy(dx: contentNode.frame.minX, dy: contentNode.frame.minY)) } } - + switch tapAction.content { case .none: if let item = self.item, self.backgroundNode.frame.contains(CGPoint(x: self.frame.width - location.x, y: location.y)), let tapMessage = self.item?.controllerInteraction.tapMessage { @@ -5857,7 +5958,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI guard let self, let item = self.item, let contentNode = self.contextContentNodeForLink(number, rects: rects) else { return } - + item.controllerInteraction.longTap(.phone(number), ChatControllerInteraction.LongTapParams(message: item.content.firstMessage, contentNode: contentNode, messageNode: self, progress: tapAction.activate?())) }, contextMenuOnLongPress: !tapAction.hasLongTapAction)) case let .peerMention(peerId, _, openProfile): @@ -6011,18 +6112,18 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI return .openContextMenu(InternalBubbleTapAction.OpenContextMenu(tapMessage: item.content.firstMessage, selectAll: false, subFrame: self.backgroundNode.frame, disableDefaultPressAnimation: true)) } } - + var tapMessage: Message? = item.content.firstMessage var selectAll = true var hasFiles = false var disableDefaultPressAnimation = false loop: for contentNode in self.contentNodes { let convertedLocation = self.view.convert(location, to: contentNode.view) - + if contentNode is ChatMessageFileBubbleContentNode { hasFiles = true } - + let convertedNodeFrame = contentNode.view.convert(contentNode.bounds, to: self.view) if !convertedNodeFrame.contains(location) { continue loop @@ -6044,7 +6145,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI rects.append(rect.offsetBy(dx: contentNode.frame.minX, dy: contentNode.frame.minY)) } } - + switch tapAction.content { case .none, .ignore: break @@ -6075,7 +6176,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { disableDefaultPressAnimation = true } - + if case .longTap = gesture, !tapAction.hasLongTapAction, let item = self.item { let tapMessage = item.content.firstMessage var subFrame = self.backgroundNode.frame @@ -6235,48 +6336,48 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } return nil } - + private func contextContentNodeForLink(_ link: String, rects: [CGRect]?) -> ContextExtractedContentContainingNode? { guard let item = self.item else { return nil } let containingNode = ContextExtractedContentContainingNode() - + let incoming = item.content.effectivelyIncoming(item.context.account.peerId, associatedData: item.associatedData) - + let textNode = ImmediateTextNode() textNode.maximumNumberOfLines = 2 textNode.attributedText = NSAttributedString(string: link, font: Font.regular(item.presentationData.fontSize.baseDisplaySize), textColor: incoming ? item.presentationData.theme.theme.chat.message.incoming.linkTextColor : item.presentationData.theme.theme.chat.message.outgoing.linkTextColor) let textSize = textNode.updateLayout(CGSize(width: self.bounds.width - 32.0, height: 100.0)) - + let backgroundNode = ASDisplayNode() backgroundNode.backgroundColor = (incoming ? item.presentationData.theme.theme.chat.message.incoming.bubble.withoutWallpaper.fill : item.presentationData.theme.theme.chat.message.outgoing.bubble.withoutWallpaper.fill).first ?? .black backgroundNode.clipsToBounds = true backgroundNode.cornerRadius = 10.0 - + let insets = UIEdgeInsets(top: 5.0, left: 8.0, bottom: 5.0, right: 8.0) let backgroundSize = CGSize(width: textSize.width + insets.left + insets.right, height: textSize.height + insets.top + insets.bottom) backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: backgroundSize) textNode.frame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: textSize) backgroundNode.addSubnode(textNode) - + var origin = CGPoint(x: self.backgroundNode.frame.minX + 3.0, y: 1.0) if let rect = rects?.first { origin = rect.origin } - + containingNode.frame = CGRect(origin: origin, size: CGSize(width: backgroundSize.width, height: backgroundSize.height + 20.0)) containingNode.contentNode.frame = CGRect(origin: .zero, size: backgroundSize) containingNode.contentRect = CGRect(origin: .zero, size: backgroundSize) containingNode.contentNode.addSubnode(backgroundNode) - + containingNode.contentNode.alpha = 0.0 - + self.addSubnode(containingNode) - + return containingNode } - + private func traceSelectionNodes(parent: ASDisplayNode, point: CGPoint) -> ASDisplayNode? { if let parent = parent as? FileMessageSelectionNode, parent.bounds.contains(point) { return parent @@ -6291,12 +6392,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } return nil } - + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if !self.bounds.contains(point) { return nil } - + if self.mainContextSourceNode.isExtractedToContextPreview { if let result = super.hitTest(point, with: event) as? TextSelectionNodeView { return result @@ -6307,32 +6408,32 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if let threadInfoNode = self.threadInfoNode, let result = threadInfoNode.hitTest(self.view.convert(point, to: threadInfoNode.view), with: event) { return result } - + if let nameButtonNode = self.nameButtonNode, nameButtonNode.frame.contains(point) { return nameButtonNode.view } - + if let credibilityButtonNode = self.credibilityButtonNode, credibilityButtonNode.frame.contains(point) { return credibilityButtonNode.view } - + if let boostButtonNode = self.boostButtonNode, boostButtonNode.frame.contains(point) { return boostButtonNode.view } - + if let summarizeButtonNode = self.summarizeButtonNode, summarizeButtonNode.frame.contains(point) { return summarizeButtonNode.view.hitTest(self.view.convert(point, to: summarizeButtonNode.view), with: event) } - + if let shareButtonNode = self.shareButtonNode, shareButtonNode.frame.contains(point) { return shareButtonNode.view.hitTest(self.view.convert(point, to: shareButtonNode.view), with: event) } - + if let selectionNode = self.selectionNode { if let result = self.traceSelectionNodes(parent: self, point: point.offsetBy(dx: -42.0, dy: 0.0)) { return result.view } - + var selectionNodeFrame = selectionNode.frame selectionNodeFrame.origin.x -= 42.0 selectionNodeFrame.size.width += 42.0 * 2.0 @@ -6342,28 +6443,28 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI return nil } } - + if !self.backgroundNode.frame.contains(point) { if let actionButtonsNode = self.actionButtonsNode, let result = actionButtonsNode.hitTest(self.view.convert(point, to: actionButtonsNode.view), with: event) { return result } } - + if let mosaicStatusNode = self.mosaicStatusNode { if let result = mosaicStatusNode.hitTest(self.view.convert(point, to: mosaicStatusNode.view), with: event) { return result } } - + for contentNode in self.contentNodes { if let result = contentNode.hitTest(self.view.convert(point, to: contentNode.view), with: event) { return result } } - + return super.hitTest(point, with: event) } - + override public func transitionNode(id: MessageId, media: Media, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { for contentNode in self.contentNodes { if let result = contentNode.transitionNode(messageId: id, media: media, adjustRect: adjustRect) { @@ -6374,19 +6475,19 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } if strongSelf.backgroundNode.supernode != nil, let backgroundView = strongSelf.backgroundNode.view.snapshotContentTree(unhide: true) { let backgroundContainer = UIView() - + let backdropView = strongSelf.backgroundWallpaperNode.view.snapshotContentTree(unhide: true, keepPortals: true) if let backdropView = backdropView { let backdropFrame = strongSelf.backgroundWallpaperNode.layer.convert(strongSelf.backgroundWallpaperNode.bounds, to: strongSelf.backgroundNode.layer) backdropView.frame = backdropFrame } - + if let backdropView = backdropView { backgroundContainer.addSubview(backdropView) } - + backgroundContainer.addSubview(backgroundView) - + let backgroundFrame = strongSelf.backgroundNode.layer.convert(strongSelf.backgroundNode.bounds, to: result.0.layer) backgroundView.frame = CGRect(origin: CGPoint(), size: backgroundFrame.size) backgroundContainer.frame = backgroundFrame @@ -6405,7 +6506,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } return nil } - + override public func updateHiddenMedia() { var hasHiddenMediaInfo = false var hasHiddenMosaicStatus = false @@ -6427,7 +6528,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + if let mosaicStatusNode = self.mosaicStatusNode { if mosaicStatusNode.alpha.isZero != hasHiddenMosaicStatus { if hasHiddenMosaicStatus { @@ -6438,7 +6539,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + if let mediaInfoNode = self.mediaInfoNode { if mediaInfoNode.alpha.isZero != hasHiddenMediaInfo { if hasHiddenMediaInfo { @@ -6449,11 +6550,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + self.backgroundNode.isHidden = hasHiddenBackground self.backgroundWallpaperNode.isHidden = hasHiddenBackground } - + override public func updateAutomaticMediaDownloadSettings() { if let item = self.item { for contentNode in self.contentNodes { @@ -6461,7 +6562,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + override public func playMediaWithSound() -> ((Double?) -> Void, Bool, Bool, Bool, ASDisplayNode?)? { for contentNode in self.contentNodes { if let playMediaWithSound = contentNode.playMediaWithSound() { @@ -6470,14 +6571,14 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } return nil } - + override public func updateSelectionState(animated: Bool) { guard let item = self.item else { return } - + let wasSelected = self.selectionNode?.selected - + var canHaveSelection = true switch item.content { case let .message(message, _, _, _, _): @@ -6502,11 +6603,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if case let .replyThread(replyThreadMessage) = item.chatLocation, replyThreadMessage.effectiveTopId == item.message.id { canHaveSelection = false } - + if let selectionState = item.controllerInteraction.selectionState, canHaveSelection { var selected = false let incoming = item.content.effectivelyIncoming(item.context.account.peerId, associatedData: item.associatedData) - + switch item.content { case let .message(message, _, _, _, _): selected = selectionState.selectedIds.contains(message.id) @@ -6520,13 +6621,13 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } selected = allSelected } - + let offset: CGFloat = incoming ? 42.0 : 0.0 - + if let selectionNode = self.selectionNode { selectionNode.updateSelected(selected, animated: animated) let selectionFrame = CGRect(origin: CGPoint(x: -offset, y: 0.0), size: CGSize(width: self.contentSize.width, height: self.contentSize.height)) - + selectionNode.frame = selectionFrame selectionNode.updateLayout(size: selectionFrame.size, leftInset: self.safeInsets.left) self.subnodeTransform = CATransform3DMakeTranslation(offset, 0.0, 0.0); @@ -6541,7 +6642,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } }) - + let selectionFrame = CGRect(origin: CGPoint(x: -offset, y: 0.0), size: CGSize(width: self.contentSize.width, height: self.contentSize.height)) selectionNode.frame = selectionFrame selectionNode.updateLayout(size: selectionFrame.size, leftInset: self.safeInsets.left) @@ -6553,7 +6654,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if animated { selectionNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.layer.animate(from: NSValue(caTransform3D: previousSubnodeTransform), to: NSValue(caTransform3D: self.subnodeTransform), keyPath: "sublayerTransform", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.2) - + if !incoming { let position = selectionNode.layer.position selectionNode.layer.animatePosition(from: CGPoint(x: position.x - 42.0, y: position.y), to: position, duration: 0.2, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue) @@ -6579,32 +6680,32 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + let isSelected = self.selectionNode?.selected if wasSelected != isSelected { self.updateAccessibilityData(ChatMessageAccessibilityData(item: item, isSelected: isSelected)) } } - + override public func updateSearchTextHighlightState() { for contentNode in self.contentNodes { contentNode.updateSearchTextHighlightState(text: self.item?.controllerInteraction.searchTextHighightState?.0, messages: self.item?.controllerInteraction.searchTextHighightState?.1) } } - + override public func updateHighlightedState(animated: Bool) { super.updateHighlightedState(animated: animated) - + guard let item = self.item, let _ = self.backgroundType else { return } - + var highlightedState: HighlightedState? - + for contentNode in self.contentNodes { let _ = contentNode.updateHighlightedState(animated: animated) } - + if let highlightedStateValue = item.controllerInteraction.highlightedState { for (message, _) in item.content { if highlightedStateValue.messageStableId == message.stableId { @@ -6613,10 +6714,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + if self.highlightedState != highlightedState { self.highlightedState = highlightedState - + for contentNode in self.contentNodes { if let contentNode = contentNode as? ChatMessageTextBubbleContentNode { contentNode.updateQuoteTextHighlightState(text: nil, offset: nil, color: .clear, animated: true) @@ -6626,10 +6727,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI contentNode.updateOptionHighlightState(id: nil, color: .clear, animated: true) } } - + if let backgroundType = self.backgroundType { let graphics = PresentationResourcesChat.principalGraphics(theme: item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper, bubbleCorners: item.presentationData.chatBubbleCorners) - + if self.highlightedState != nil, !(self.backgroundNode.layer.mask is SimpleLayer) { let backgroundHighlightNode: ChatMessageBackground if let current = self.backgroundHighlightNode { @@ -6638,11 +6739,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI backgroundHighlightNode = ChatMessageBackground() self.mainContextSourceNode.contentNode.insertSubnode(backgroundHighlightNode, aboveSubnode: self.backgroundNode) self.backgroundHighlightNode = backgroundHighlightNode - + let hasWallpaper = item.presentationData.theme.wallpaper.hasWallpaper let incoming: PresentationThemeBubbleColorComponents = !hasWallpaper ? item.presentationData.theme.theme.chat.message.incoming.bubble.withoutWallpaper : item.presentationData.theme.theme.chat.message.incoming.bubble.withWallpaper let outgoing: PresentationThemeBubbleColorComponents = !hasWallpaper ? item.presentationData.theme.theme.chat.message.outgoing.bubble.withoutWallpaper : item.presentationData.theme.theme.chat.message.outgoing.bubble.withWallpaper - + let highlightColor: UIColor if item.message.effectivelyIncoming(item.context.account.peerId) { if let authorNameColor = self.authorNameColor { @@ -6657,22 +6758,22 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI highlightColor = outgoing.highlightedFill } } - + backgroundHighlightNode.customHighlightColor = highlightColor backgroundHighlightNode.setType(type: backgroundType, highlighted: true, graphics: graphics, maskMode: true, hasWallpaper: true, transition: .immediate, backgroundNode: nil) - + backgroundHighlightNode.frame = self.backgroundNode.frame backgroundHighlightNode.updateLayout(size: backgroundHighlightNode.frame.size, transition: .immediate) - + if highlightedState?.quote != nil { Queue.mainQueue().after(0.3, { [weak self] in guard let self, let item = self.item, let backgroundHighlightNode = self.backgroundHighlightNode else { return } - + if let highlightedState = self.highlightedState, let quote = highlightedState.quote { let transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring) - + var quoteFrame: CGRect? for contentNode in self.contentNodes { if let contentNode = contentNode as? ChatMessageTextBubbleContentNode { @@ -6684,21 +6785,21 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { sourceFrame.size.width -= 6.0 } - + if let localFrame = contentNode.animateQuoteTextHighlightIn(sourceFrame: sourceFrame, transition: transition) { if self.contentNodes[0] !== contentNode && self.contentNodes[0].supernode === contentNode.supernode { contentNode.supernode?.insertSubnode(contentNode, belowSubnode: self.contentNodes[0]) } - + quoteFrame = contentNode.view.convert(localFrame, to: backgroundHighlightNode.view.superview) } break } } - + if let quoteFrame { self.backgroundHighlightNode = nil - + backgroundHighlightNode.updateLayout(size: quoteFrame.size, transition: transition) transition.updateFrame(node: backgroundHighlightNode, frame: quoteFrame) backgroundHighlightNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, delay: 0.05, removeOnCompletion: false, completion: { [weak backgroundHighlightNode] _ in @@ -6713,9 +6814,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI return } let transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring) - + var itemFrame: CGRect? - + switch replySubject { case let .todoItem(todoItemId): if let contentNode = self.contentNodes.first(where: { $0 is ChatMessageTodoBubbleContentNode }) as? ChatMessageTodoBubbleContentNode { @@ -6727,7 +6828,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { sourceFrame.size.width -= 6.0 } - + if let localFrame = contentNode.animateTaskItemHighlightIn(id: todoItemId, sourceFrame: sourceFrame, transition: transition) { itemFrame = contentNode.view.convert(localFrame, to: backgroundHighlightNode.view.superview).insetBy(dx: -3.0, dy: 0.0) } @@ -6742,7 +6843,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { sourceFrame.size.width -= 6.0 } - + if let localFrame = contentNode.animateOptionItemHighlightIn(id: pollOption, sourceFrame: sourceFrame, transition: transition) { itemFrame = contentNode.view.convert(localFrame, to: backgroundHighlightNode.view.superview).insetBy(dx: -3.0, dy: 0.0) } @@ -6751,7 +6852,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if let itemFrame { self.backgroundHighlightNode = nil - + backgroundHighlightNode.updateLayout(size: itemFrame.size, transition: transition) transition.updateFrame(node: backgroundHighlightNode, frame: itemFrame) backgroundHighlightNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, delay: 0.05, removeOnCompletion: false, completion: { [weak backgroundHighlightNode] _ in @@ -6776,7 +6877,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + @objc private func shareButtonPressed() { if let item = self.item { if item.message.adAttribute != nil { @@ -6809,19 +6910,19 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + private func openQuickShare(node: ASDisplayNode, gesture: ContextGesture) { if let item = self.item { item.controllerInteraction.displayQuickShare(item.message.id, node, gesture) } } - + @objc private func closeButtonPressed() { if let item = self.item { item.controllerInteraction.openNoAdsDemo() } } - + @objc private func nameButtonPressed() { if let item = self.item, let peer = item.message.author { let messageReference = MessageReference(item.message) @@ -6838,7 +6939,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + @objc private func credibilityButtonPressed() { if let item = self.item, let credibilityIconView = self.credibilityIconView, let iconContent = self.credibilityIconContent, let peer = item.message.author { if case let .starGift(_, _, _, slug, _, _, _, _, _) = peer.emojiStatus?.content { @@ -6857,19 +6958,19 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + @objc private func boostButtonPressed() { guard let item = self.item, let peer = item.message.author else { return } - + var boostCount: Int = 0 for attribute in item.message.attributes { if let attribute = attribute as? BoostCountMessageAttribute { boostCount = attribute.count } } - + item.controllerInteraction.openGroupBoostInfo(peer.id, boostCount) } @objc private func rankButtonPressed() { @@ -6891,7 +6992,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } item.controllerInteraction.openRankInfo(EnginePeer(peer), role, rank) } - + private var playedSwipeToReplyHaptic = false @objc private func swipeToReplyGesture(_ recognizer: ChatSwipeToReplyRecognizer) { var offset: CGFloat = 0.0 @@ -6905,7 +7006,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI leftOffset = -10.0 swipeOffset = 60.0 } - + switch recognizer.state { case .began: self.playedSwipeToReplyHaptic = false @@ -6926,7 +7027,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI let coefficient: CGFloat = 0.4 return bandingStart + (1.0 - (1.0 / ((bandedOffset * coefficient / range) + 1.0))) * range } - + if translation.x < 0.0 { translation.x = max(-180.0, min(0.0, -rubberBandingOffset(offset: abs(translation.x), bandingStart: swipeOffset))) } else { @@ -6936,13 +7037,13 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI translation.x = 0.0 } } - + if let item = self.item, self.swipeToReplyNode == nil { let swipeToReplyNode = ChatMessageSwipeToReplyNode(fillColor: selectDateFillStaticColor(theme: item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper), enableBlur: item.controllerInteraction.enableFullTranslucency && dateFillNeedsBlur(theme: item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper), foregroundColor: bubbleVariableColor(variableColor: item.presentationData.theme.theme.chat.message.shareButtonForegroundColor, wallpaper: item.presentationData.theme.wallpaper), backgroundNode: item.controllerInteraction.presentationContext.backgroundNode, action: ChatMessageSwipeToReplyNode.Action(self.currentSwipeAction)) self.swipeToReplyNode = swipeToReplyNode self.insertSubnode(swipeToReplyNode, at: 0) } - + self.currentSwipeToReplyTranslation = translation.x var bounds = self.bounds bounds.origin.x = -translation.x @@ -6952,7 +7053,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI self.shadowNode.bounds = shadowBounds self.updateAttachedAvatarNodeOffset(offset: translation.x, transition: .immediate) - + if let swipeToReplyNode = self.swipeToReplyNode { if translation.x < 0.0 { swipeToReplyNode.bounds = CGRect(origin: .zero, size: CGSize(width: 33.0, height: 33.0)) @@ -6966,10 +7067,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI let mappedRect = CGRect(origin: CGPoint(x: rect.minX + swipeToReplyNode.frame.minX, y: rect.minY + swipeToReplyNode.frame.minY), size: swipeToReplyNode.frame.size) swipeToReplyNode.updateAbsoluteRect(mappedRect, within: containerSize) } - + let progress = abs(translation.x) / swipeOffset swipeToReplyNode.updateProgress(progress) - + if progress > 1.0 - .ulpOfOne && !self.playedSwipeToReplyHaptic { self.playedSwipeToReplyHaptic = true self.swipeToReplyFeedback?.impact(.heavy) @@ -6977,7 +7078,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } case .cancelled, .ended: self.swipeToReplyFeedback = nil - + let translation = recognizer.translation(in: self.view) let gestureRecognized: Bool if recognizer.allowBothDirections { @@ -7021,9 +7122,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI break } } - + private var absoluteRect: (CGRect, CGSize)? - + override public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { self.absoluteRect = (rect, containerSize) guard !self.mainContextSourceNode.isExtractedToContextPreview else { @@ -7033,7 +7134,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI rect.origin.y = containerSize.height - rect.maxY + self.insets.top self.updateAbsoluteRectInternal(rect, within: containerSize) } - + private func updateAbsoluteRectInternal(_ rect: CGRect, within containerSize: CGSize) { var backgroundWallpaperFrame = self.backgroundWallpaperNode.frame backgroundWallpaperFrame.origin.x += rect.minX @@ -7042,77 +7143,77 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI for contentNode in self.contentNodes { contentNode.updateAbsoluteRect(CGRect(origin: CGPoint(x: rect.minX + contentNode.frame.minX, y: rect.minY + contentNode.frame.minY), size: rect.size), within: containerSize) } - + for container in self.contentContainers { var containerFrame = self.mainContainerNode.frame containerFrame.origin.x += rect.minX containerFrame.origin.y += rect.minY container.updateAbsoluteRect(containerFrame, within: containerSize) } - + if let summarizeButtonNode = self.summarizeButtonNode { var summarizeButtonNodeFrame = summarizeButtonNode.frame summarizeButtonNodeFrame.origin.x += rect.minX summarizeButtonNodeFrame.origin.y += rect.minY - + summarizeButtonNode.updateAbsoluteRect(summarizeButtonNodeFrame, within: containerSize) } - + if let shareButtonNode = self.shareButtonNode { var shareButtonNodeFrame = shareButtonNode.frame shareButtonNodeFrame.origin.x += rect.minX shareButtonNodeFrame.origin.y += rect.minY - + shareButtonNode.updateAbsoluteRect(shareButtonNodeFrame, within: containerSize) } - + if let actionButtonsNode = self.actionButtonsNode { var actionButtonsNodeFrame = actionButtonsNode.frame actionButtonsNodeFrame.origin.x += rect.minX actionButtonsNodeFrame.origin.y += rect.minY - + actionButtonsNode.updateAbsoluteRect(actionButtonsNodeFrame, within: containerSize) } - + if let reactionButtonsNode = self.reactionButtonsNode { var reactionButtonsNodeFrame = reactionButtonsNode.frame reactionButtonsNodeFrame.origin.x += rect.minX reactionButtonsNodeFrame.origin.y += rect.minY - + reactionButtonsNode.update(rect: rect, within: containerSize, transition: .immediate) } } - + override public func applyAbsoluteOffset(value: CGPoint, animationCurve: ContainedViewLayoutTransitionCurve, duration: Double) { if !self.mainContextSourceNode.isExtractedToContextPreview { self.applyAbsoluteOffsetInternal(value: CGPoint(x: -value.x, y: -value.y), animationCurve: animationCurve, duration: duration) } } - + private func applyAbsoluteOffsetInternal(value: CGPoint, animationCurve: ContainedViewLayoutTransitionCurve, duration: Double) { self.backgroundWallpaperNode.offset(value: value, animationCurve: animationCurve, duration: duration) for contentNode in self.contentNodes { contentNode.applyAbsoluteOffset(value: value, animationCurve: animationCurve, duration: duration) } - + if let reactionButtonsNode = self.reactionButtonsNode { reactionButtonsNode.offset(value: value, animationCurve: animationCurve, duration: duration) } } - + private func applyAbsoluteOffsetSpringInternal(value: CGFloat, duration: Double, damping: CGFloat) { self.backgroundWallpaperNode.offsetSpring(value: value, duration: duration, damping: damping) for contentNode in self.contentNodes { contentNode.applyAbsoluteOffsetSpring(value: value, duration: duration, damping: damping) } - + if let reactionButtonsNode = self.reactionButtonsNode { reactionButtonsNode.offsetSpring(value: value, duration: duration, damping: damping) } } - + override public func getMessageContextSourceNode(stableId: UInt32?) -> ContextExtractedContentContainingNode? { if self.contentContainers.count > 1 { return self.contentContainers.first(where: { $0.contentMessageStableId == stableId })?.sourceNode ?? self.mainContextSourceNode @@ -7120,17 +7221,17 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI return self.mainContextSourceNode } } - + override public func addAccessoryItemNode(_ accessoryItemNode: ListViewAccessoryItemNode) { self.mainContextSourceNode.contentNode.addSubnode(accessoryItemNode) } - + private var backgroundMaskMode: Bool { let hasWallpaper = self.item?.presentationData.theme.wallpaper.hasWallpaper ?? false let isPreview = self.item?.presentationData.isPreview ?? false return self.mainContextSourceNode.isExtractedToContextPreview || hasWallpaper || isPreview || !self.disablesComments } - + override public func openMessageContextMenu() { guard let item = self.item else { return @@ -7138,7 +7239,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI let subFrame = self.backgroundNode.frame item.controllerInteraction.openMessageContextMenu(item.message, true, self, subFrame, nil, nil) } - + override public func makeProgress() -> Promise? { if let unlockButtonNode = self.unlockButtonNode { return unlockButtonNode.makeProgress() @@ -7151,7 +7252,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } return nil } - + override public func targetReactionView(value: MessageReaction.Reaction) -> UIView? { if let result = self.reactionButtonsNode?.reactionTargetView(value: value) { return result @@ -7166,7 +7267,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } return nil } - + override public func targetForStoryTransition(id: StoryId) -> UIView? { guard let item = self.item else { return nil @@ -7187,15 +7288,15 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } return nil } - + override public func unreadMessageRangeUpdated() { for contentNode in self.contentNodes { contentNode.unreadMessageRangeUpdated() } - + self.updateVisibility(isScroll: false) } - + public func animateQuizInvalidOptionSelected() { if let supernode = self.supernode, let subnodes = supernode.subnodes { for i in 0 ..< subnodes.count { @@ -7204,7 +7305,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + let duration: Double = 0.5 let minScale: CGFloat = -0.03 let scaleAnimation0 = self.layer.makeAnimation(from: 0.0 as NSNumber, to: minScale as NSNumber, keyPath: "transform.scale", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: duration / 2.0, removeOnCompletion: false, additive: true, completion: { [weak self] _ in @@ -7215,15 +7316,15 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.layer.add(scaleAnimation1, forKey: "quizInvalidScale") }) self.layer.add(scaleAnimation0, forKey: "quizInvalidScale") - + let k = Float(UIView.animationDurationFactor()) var speed: Float = 1.0 if k != 0 && k != 1 { speed = Float(1.0) / k } - + let count = 4 - + let animation = CAKeyframeAnimation(keyPath: "transform.rotation.z") var values: [CGFloat] = [] values.append(0.0) @@ -7249,10 +7350,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI animation.speed = speed animation.duration = duration animation.isAdditive = true - + self.layer.add(animation, forKey: "quizInvalidRotation") } - + public func updatePsaTooltipMessageState(animated: Bool) { guard let item = self.item else { return @@ -7261,7 +7362,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI forwardInfoNode.updatePsaButtonDisplay(isVisible: item.controllerInteraction.currentPsaMessageWithTooltip != item.message.id, animated: animated) } } - + override public func getStatusNode() -> ASDisplayNode? { if let statusNode = self.mosaicStatusNode { return statusNode @@ -7273,14 +7374,14 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } return nil } - + override public func getAuthorNameNode() -> ASDisplayNode? { guard let item = self.item, item.content.firstMessage.guestChatAttribute != nil else { return nil } return self.nameNode } - + public func getQuoteRect(quote: String, offset: Int?) -> CGRect? { for contentNode in self.contentNodes { if let contentNode = contentNode as? ChatMessageTextBubbleContentNode { @@ -7314,7 +7415,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } return nil } - + public func hasExpandedAudioTranscription() -> Bool { for contentNode in self.contentNodes { if let contentNode = contentNode as? ChatMessageFileBubbleContentNode { @@ -7325,17 +7426,17 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } return false } - + override public func contentFrame() -> CGRect { return self.backgroundNode.frame } - + override public func makeContentSnapshot() -> (UIImage, CGRect)? { UIGraphicsBeginImageContextWithOptions(self.backgroundNode.view.bounds.size, false, 0.0) let context = UIGraphicsGetCurrentContext()! - + context.translateBy(x: -self.backgroundNode.frame.minX, y: -self.backgroundNode.frame.minY) - + context.translateBy(x: -self.mainContextSourceNode.contentNode.view.frame.minX, y: -self.mainContextSourceNode.contentNode.view.frame.minY) for subview in self.mainContextSourceNode.contentNode.view.subviews { if subview.isHidden || subview.alpha == 0.0 { @@ -7351,11 +7452,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + if let targetPortalView, let sourceView = getPortalViewSourceView(targetPortalView) { context.saveGState() context.translateBy(x: subview.frame.minX, y: subview.frame.minY) - + if let mask = subview.mask { let maskImage = generateImage(subview.bounds.size, rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) @@ -7367,20 +7468,20 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI context.translateBy(x: subview.frame.midX, y: subview.frame.midY) context.scaleBy(x: 1.0, y: -1.0) context.translateBy(x: -subview.frame.midX, y: -subview.frame.midY) - + context.clip(to: subview.bounds, mask: cgImage) - + context.translateBy(x: subview.frame.midX, y: subview.frame.midY) context.scaleBy(x: 1.0, y: -1.0) context.translateBy(x: -subview.frame.midX, y: -subview.frame.midY) } } - + let sourceLocalFrame = sourceView.convert(sourceView.bounds, to: subview) for sourceSubview in sourceView.subviews { sourceSubview.drawHierarchy(in: CGRect(origin: sourceLocalFrame.origin, size: sourceSubview.bounds.size), afterScreenUpdates: false) } - + context.resetClip() context.restoreGState() } else { @@ -7390,17 +7491,17 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI subview.drawHierarchy(in: subview.frame, afterScreenUpdates: false) } } - + let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() - + guard let image else { return nil } - + return (image, self.backgroundNode.frame) } - + public func isServiceLikeMessage() -> Bool { for contentNode in self.contentNodes { if contentNode is ChatMessageActionBubbleContentNode { @@ -7409,24 +7510,24 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } return false } - + override public func updateStickerSettings(forceStopAnimations: Bool) { self.forceStopAnimations = forceStopAnimations self.updateVisibility(isScroll: false) } - + private func toggleSummarization() { guard let item = self.item else { return } - + if item.controllerInteraction.summarizedMessageIds.contains(item.message.id) { item.controllerInteraction.summarizedMessageIds.remove(item.message.id) let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) } else { item.controllerInteraction.summarizedMessageIds.insert(item.message.id) let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) - + let translateToLanguage = item.associatedData.translateToLanguage var requestSummary = true for attribute in item.message.attributes { @@ -7453,32 +7554,32 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + private func updateVisibility(isScroll: Bool) { guard let item = self.item else { return } - + let effectiveMediaVisibility = self.visibility - + var isPlaying = true if !item.controllerInteraction.canReadHistory { isPlaying = false } - + if self.forceStopAnimations { isPlaying = false } - + if !isPlaying { self.removeEffectAnimations() } - + var effectiveVisibility = self.visibility if !isPlaying { effectiveVisibility = .none } - + for contentNode in self.contentNodes { if contentNode is ChatMessageMediaBubbleContentNode || contentNode is ChatMessageGiftBubbleContentNode || contentNode is ChatMessageWebpageBubbleContentNode || contentNode is ChatMessageInvoiceBubbleContentNode || contentNode is ChatMessageGameBubbleContentNode || contentNode is ChatMessageInstantVideoBubbleContentNode || contentNode is ChatMessageRichDataBubbleContentNode { contentNode.visibility = mapVisibility(effectiveMediaVisibility, boundsSize: self.bounds.size, insets: self.insets, to: contentNode) @@ -7486,7 +7587,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI contentNode.visibility = mapVisibility(effectiveVisibility, boundsSize: self.bounds.size, insets: self.insets, to: contentNode) } } - + if case let .visible(_, subRect) = self.visibility { if subRect.minY > 32.0 { isPlaying = false @@ -7494,19 +7595,19 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { isPlaying = false } - + if let threadInfoNode = self.threadInfoNode { threadInfoNode.visibility = effectiveVisibility != .none } - + if let replyInfoNode = self.replyInfoNode { replyInfoNode.visibility = effectiveVisibility != .none } - + if let unlockButtonNode = self.unlockButtonNode { unlockButtonNode.visibility = effectiveVisibility != .none } - + if isPlaying { var alreadySeen = true if item.message.flags.contains(.Incoming) { @@ -7524,14 +7625,14 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + if !alreadySeen { item.controllerInteraction.seenOneTimeAnimatedMedia.insert(item.message.id) - + self.playMessageEffect(force: false) } } - + if item.message.adAttribute != nil { let transition: ContainedViewLayoutTransition = isScroll ? .animated(duration: 0.25, curve: .easeInOut) : .immediate if case let .visible(_, rect) = self.visibility, rect.height >= 1.0 { @@ -7541,7 +7642,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } - + override public func messageEffectTargetView() -> UIView? { for contentNode in self.contentNodes { if let result = contentNode.messageEffectTargetView() { @@ -7551,7 +7652,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if let mosaicStatusNode = self.mosaicStatusNode, let result = mosaicStatusNode.messageEffectTargetView() { return result } - + return nil } } @@ -7567,9 +7668,9 @@ private func generateNameNavigateButtonBackgroundImage() -> UIImage { private func generateNameNavigateButtonIconImage() -> UIImage { return generateImage(CGSize(width: 26.0, height: 26.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) - + let arrowRect = CGSize(width: 4.0, height: 8.0).centered(in: CGRect(origin: CGPoint(), size: size)).offsetBy(dx: 1.0, dy: 0.0) - + context.setStrokeColor(UIColor.white.cgColor) context.setLineWidth(1.33) context.setLineCap(.round) @@ -7579,49 +7680,49 @@ private func generateNameNavigateButtonIconImage() -> UIImage { context.addLine(to: CGPoint(x: arrowRect.maxX, y: arrowRect.midY)) context.addLine(to: CGPoint(x: arrowRect.minX, y: arrowRect.maxY)) context.strokePath() - + })!.withRenderingMode(.alwaysTemplate) } public final class NameNavigateButton: HighlightableButton { private static let sharedBackgroundImage: UIImage = generateNameNavigateButtonBackgroundImage() private static let sharedIconImage: UIImage = generateNameNavigateButtonIconImage() - + private let backgroundView: UIImageView private let iconView: UIImageView - + private var titleTopicIconView: ComponentView? private var titleTopicIconComponent: EmojiStatusComponent? - + public var action: (() -> Void)? - + override public init(frame: CGRect) { self.backgroundView = UIImageView(image: NameNavigateButton.sharedBackgroundImage) self.iconView = UIImageView(image: NameNavigateButton.sharedIconImage) - + super.init(frame: frame) - + self.addSubview(self.backgroundView) self.addSubview(self.iconView) - + self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) } - + required public init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + @objc private func pressed() { self.action?() } - + public func update(context: AccountContext, theme: PresentationTheme, size: CGSize, incoming: Bool, color: UIColor, threadId: Int64, threadInfo: Message.AssociatedThreadInfo?) { self.backgroundView.frame = CGRect(origin: CGPoint(), size: size) self.backgroundView.tintColor = color - + self.iconView.frame = CGRect(origin: CGPoint(x: size.width - 26.0, y: 0.0), size: CGSize(width: 26.0, height: 26.0)) self.iconView.tintColor = color - + if let threadInfo { let titleTopicIconView: ComponentView if let current = self.titleTopicIconView { @@ -7630,7 +7731,7 @@ public final class NameNavigateButton: HighlightableButton { titleTopicIconView = ComponentView() self.titleTopicIconView = titleTopicIconView } - + let titleTopicIconContent: EmojiStatusComponent.Content var containerSize: CGSize = CGSize(width: 22.0, height: 22.0) if threadId == 1 { @@ -7642,10 +7743,10 @@ public final class NameNavigateButton: HighlightableButton { } else { titleTopicIconContent = .topic(title: String(threadInfo.title.prefix(1)), color: threadInfo.iconColor, size: CGSize(width: 22.0, height: 22.0)) } - + let iconX: CGFloat = floor((26.0 - containerSize.width) * 0.5) let iconY: CGFloat = floor((26.0 - containerSize.height) * 0.5) - + let titleTopicIconComponent = EmojiStatusComponent( context: context, animationCache: context.animationCache, @@ -7656,7 +7757,7 @@ public final class NameNavigateButton: HighlightableButton { action: nil ) self.titleTopicIconComponent = titleTopicIconComponent - + let iconSize = titleTopicIconView.update( transition: .immediate, component: AnyComponent(titleTopicIconComponent), diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode/Sources/ChatMessageDateAndStatusNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode/Sources/ChatMessageDateAndStatusNode.swift index 9d9387274a..935475c3b8 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode/Sources/ChatMessageDateAndStatusNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode/Sources/ChatMessageDateAndStatusNode.swift @@ -12,12 +12,13 @@ import ReactionImageComponent import AnimationCache import MultiAnimationRenderer import TelegramStringFormatting +import TelegramUIPreferences private func maybeAddRotationAnimation(_ layer: CALayer, duration: Double) { if let _ = layer.animation(forKey: "clockFrameAnimation") { return } - + let basicAnimation = CABasicAnimation(keyPath: "transform.rotation.z") basicAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) basicAnimation.duration = duration @@ -49,37 +50,37 @@ private let reactionFont = Font.regular(12.0) private final class StatusReactionNode: ASDisplayNode { let iconView: ReactionIconView - + private let iconImageDisposable = MetaDisposable() - + private var theme: PresentationTheme? private var value: MessageReaction.Reaction? private var isSelected: Bool? - + private var resolvedFile: TelegramMediaFile? private var fileDisposable: Disposable? - + private var alternativeTextView: ImmediateTextView? - + override init() { self.iconView = ReactionIconView() - + super.init() - + self.view.addSubview(self.iconView) } - + deinit { self.iconImageDisposable.dispose() self.fileDisposable?.dispose() } - + func update(context: AccountContext, type: ChatMessageDateAndStatusType, value: MessageReaction.Reaction, file: TelegramMediaFile?, fileId: Int64?, alternativeText: String, isSelected: Bool, count: Int, theme: PresentationTheme, wallpaper: TelegramWallpaper, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, animated: Bool) { if self.value != value { self.value = value - + let boundingImageSize = CGSize(width: 8.0, height: 8.0) - + let iconFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((boundingImageSize.width - boundingImageSize.width) / 2.0), y: floorToScreenPixels((boundingImageSize.height - boundingImageSize.height) / 2.0)), size: boundingImageSize) self.iconView.frame = iconFrame if let fileId = fileId ?? file?.fileId.id { @@ -89,7 +90,7 @@ private final class StatusReactionNode: ASDisplayNode { } else { animateIdle = false } - + let placeholderColor: UIColor switch type { case .BubbleIncoming: @@ -105,7 +106,7 @@ private final class StatusReactionNode: ASDisplayNode { case .FreeOutgoing: placeholderColor = UIColor(white: 0.0, alpha: 0.1) } - + self.iconView.update( size: boundingImageSize, context: context, @@ -144,22 +145,22 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { public struct TrailingReactionSettings { public var displayInline: Bool public var preferAdditionalInset: Bool - + public init(displayInline: Bool, preferAdditionalInset: Bool) { self.displayInline = displayInline self.preferAdditionalInset = preferAdditionalInset } } - + public struct StandaloneReactionSettings { public init() { } } - + public enum LayoutInput { case trailingContent(contentWidth: CGFloat?, reactionSettings: TrailingReactionSettings?) case standalone(reactionSettings: StandaloneReactionSettings?) - + public var displayInlineReactions: Bool { switch self { case let .trailingContent(_, reactionSettings): @@ -177,7 +178,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } } } - + public struct Arguments { var context: AccountContext var presentationData: ChatPresentationData @@ -203,7 +204,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { var canViewReactionList: Bool var animationCache: AnimationCache var animationRenderer: MultiAnimationRenderer - + public init( context: AccountContext, presentationData: ChatPresentationData, @@ -256,7 +257,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { self.animationRenderer = animationRenderer } } - + private var backgroundNode: ASImageNode? private var blurredBackgroundNode: NavigationBackgroundNode? private var checkSentNode: ASImageNode? @@ -277,7 +278,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { private var type: ChatMessageDateAndStatusType? private var theme: ChatPresentationThemeData? private var layoutSize: CGSize? - + private var tapGestureRecognizer: UITapGestureRecognizer? public var openReplies: (() -> Void)? @@ -297,33 +298,33 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } public var reactionSelected: ((ReactionButtonAsyncNode, MessageReaction.Reaction, ContextExtractedContentContainingView?) -> Void)? public var openReactionPreview: ((ContextGesture?, ContextExtractedContentContainingView, MessageReaction.Reaction) -> Void)? - + override public init() { self.dateNode = TextNode() self.dateNode.isUserInteractionEnabled = false self.dateNode.displaysAsynchronously = false self.dateNode.contentsScale = UIScreenScale self.dateNode.contentMode = .topLeft - + super.init() - + self.addSubnode(self.dateNode) } - + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.pressed?() } } - + public func asyncLayout() -> (_ arguments: Arguments) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void)) { let dateLayout = TextNode.asyncLayout(self.dateNode) - + var checkReadNode = self.checkReadNode var checkSentNode = self.checkSentNode var clockFrameNode = self.clockFrameNode var clockMinNode = self.clockMinNode - + var currentBackgroundNode = self.backgroundNode var currentImpressionIcon = self.impressionIcon var currentRepliesIcon = self.repliesIcon @@ -336,14 +337,14 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { let makeStarsCountLayout = TextNode.asyncLayout(self.starsCountNode) let reactionButtonsContainer = self.reactionButtonsContainer - + return { [weak self] arguments in let dateColor: UIColor var backgroundImage: UIImage? var blurredBackgroundColor: (UIColor, Bool)? var outgoingStatus: ChatMessageDateAndStatusOutgoingType? var leftInset: CGFloat - + let loadedCheckFullImage: UIImage? let loadedCheckPartialImage: UIImage? let clockFrameImage: UIImage? @@ -353,18 +354,18 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { var starsImage: UIImage? let themeUpdated = arguments.presentationData.theme != currentTheme || arguments.type != currentType - + let graphics = PresentationResourcesChat.principalGraphics(theme: arguments.presentationData.theme.theme, wallpaper: arguments.presentationData.theme.wallpaper, bubbleCorners: arguments.presentationData.chatBubbleCorners) let isDefaultWallpaper = serviceMessageColorHasDefaultWallpaper(arguments.presentationData.theme.wallpaper) let offset: CGFloat = -UIScreenPixel - + let checkSize: CGFloat = floor(floor(arguments.presentationData.fontSize.baseDisplaySize * 11.0 / 17.0)) - + let reactionColors: ReactionButtonComponent.Colors switch arguments.type { case .BubbleIncoming, .ImageIncoming, .FreeIncoming: let themeColors = bubbleColorComponents(theme: arguments.presentationData.theme.theme, incoming: true, wallpaper: !arguments.presentationData.theme.wallpaper.isEmpty) - + reactionColors = ReactionButtonComponent.Colors( deselectedBackground: themeColors.reactionInactiveBackground.argb, selectedBackground: themeColors.reactionActiveBackground.argb, @@ -384,7 +385,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { ) case .BubbleOutgoing, .ImageOutgoing, .FreeOutgoing: let themeColors = bubbleColorComponents(theme: arguments.presentationData.theme.theme, incoming: false, wallpaper: !arguments.presentationData.theme.wallpaper.isEmpty) - + reactionColors = ReactionButtonComponent.Colors( deselectedBackground: themeColors.reactionInactiveBackground.argb, selectedBackground: themeColors.reactionActiveBackground.argb, @@ -403,7 +404,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { isDark: arguments.presentationData.theme.theme.overallDarkAppearance ) } - + switch arguments.type { case .BubbleIncoming: dateColor = arguments.presentationData.theme.theme.chat.message.incoming.secondaryTextColor @@ -492,7 +493,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { case .FreeIncoming: let serviceColor = serviceMessageColorComponents(theme: arguments.presentationData.theme.theme, wallpaper: arguments.presentationData.theme.wallpaper) dateColor = serviceColor.primaryText - + blurredBackgroundColor = (selectDateFillStaticColor(theme: arguments.presentationData.theme.theme, wallpaper: arguments.presentationData.theme.wallpaper), arguments.context.sharedContext.energyUsageSettings.fullTranslucency && dateFillNeedsBlur(theme: arguments.presentationData.theme.theme, wallpaper: arguments.presentationData.theme.wallpaper)) leftInset = 0.0 loadedCheckFullImage = PresentationResourcesChat.chatFreeFullCheck(arguments.presentationData.theme.theme, size: checkSize, isDefaultWallpaper: isDefaultWallpaper) @@ -536,27 +537,27 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { starsImage = graphics.freeTonIcon } } - + var updatedDateText = arguments.dateText - if arguments.edited { + if arguments.edited && !currentWinterGramSettings.hideEditedMark { updatedDateText = "\(arguments.presentationData.strings.Conversation_MessageEditedLabel) \(updatedDateText)" } if let impressionCount = arguments.impressionCount { updatedDateText = compactNumericCountString(impressionCount, decimalSeparator: arguments.presentationData.dateTimeFormat.decimalSeparator) + " " + updatedDateText } - + let dateFont = Font.regular(floor(arguments.presentationData.fontSize.baseDisplaySize * 11.0 / 17.0)) let (date, dateApply) = dateLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: updatedDateText, font: dateFont, textColor: dateColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .middle, constrainedSize: arguments.constrainedSize, alignment: .natural, cutout: nil, insets: UIEdgeInsets())) - + let checkOffset = floor(arguments.presentationData.fontSize.baseDisplaySize * 6.0 / 17.0) - + let statusWidth: CGFloat - + var checkSentFrame: CGRect? var checkReadFrame: CGRect? - + var clockPosition = CGPoint() - + var impressionSize = CGSize() var impressionWidth: CGFloat = 0.0 if let impressionImage = impressionImage { @@ -572,7 +573,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } else { currentImpressionIcon = nil } - + var repliesIconSize = CGSize() if let repliesImage = repliesImage { if currentRepliesIcon == nil { @@ -586,7 +587,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } else { currentRepliesIcon = nil } - + var starsIconSize = CGSize() if let starsImage = starsImage { if currentStarsIcon == nil { @@ -600,26 +601,26 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } else { currentStarsIcon = nil } - + if let outgoingStatus = outgoingStatus { switch outgoingStatus { case .Sending: statusWidth = floor(floor(arguments.presentationData.fontSize.baseDisplaySize * 13.0 / 17.0)) - + if checkReadNode == nil { checkReadNode = ASImageNode() checkReadNode?.isLayerBacked = true checkReadNode?.displaysAsynchronously = false checkReadNode?.displayWithoutProcessing = true } - + if checkSentNode == nil { checkSentNode = ASImageNode() checkSentNode?.isLayerBacked = true checkSentNode?.displaysAsynchronously = false checkSentNode?.displayWithoutProcessing = true } - + if clockFrameNode == nil { clockFrameNode = ASImageNode() clockFrameNode?.isLayerBacked = true @@ -627,7 +628,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { clockFrameNode?.displayWithoutProcessing = true clockFrameNode?.frame = CGRect(origin: CGPoint(), size: clockFrameImage?.size ?? CGSize()) } - + if clockMinNode == nil { clockMinNode = ASImageNode() clockMinNode?.isLayerBacked = true @@ -644,36 +645,36 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { default: hideStatus = arguments.impressionCount != nil } - + if hideStatus { statusWidth = 0.0 - + checkReadNode = nil checkSentNode = nil clockFrameNode = nil clockMinNode = nil } else { statusWidth = floor(floor(arguments.presentationData.fontSize.baseDisplaySize * 13.0 / 17.0)) - + if checkReadNode == nil { checkReadNode = ASImageNode() checkReadNode?.isLayerBacked = true checkReadNode?.displaysAsynchronously = false checkReadNode?.displayWithoutProcessing = true } - + if checkSentNode == nil { checkSentNode = ASImageNode() checkSentNode?.isLayerBacked = true checkSentNode?.displaysAsynchronously = false checkSentNode?.displayWithoutProcessing = true } - + clockFrameNode = nil clockMinNode = nil - + let checkSize = loadedCheckFullImage!.size - + if read { checkReadFrame = CGRect(origin: CGPoint(x: leftInset + impressionWidth + date.size.width + 5.0 + statusWidth - checkSize.width, y: 3.0 + offset), size: checkSize) } @@ -681,7 +682,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } case .Failed: statusWidth = 0.0 - + checkReadNode = nil checkSentNode = nil clockFrameNode = nil @@ -689,15 +690,15 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } } else { statusWidth = 0.0 - + checkReadNode = nil checkSentNode = nil clockFrameNode = nil clockMinNode = nil } - + var backgroundInsets = UIEdgeInsets() - + if let _ = backgroundImage { if currentBackgroundNode == nil { let backgroundNode = ASImageNode() @@ -719,7 +720,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { let reactionTrailingSpacing: CGFloat = 6.0 var reactionInset: CGFloat = 0.0 - + if arguments.replyCount > 0 { let countString: String if arguments.replyCount > 1000000 { @@ -729,7 +730,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } else { countString = "\(arguments.replyCount)" } - + let layoutAndApply = makeReplyCountLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: countString, font: dateFont, textColor: dateColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 100.0, height: 100.0))) reactionInset += 14.0 + layoutAndApply.0.size.width + 4.0 if arguments.starsCount != nil || arguments.tonAmount != nil { @@ -739,7 +740,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } else if arguments.isPinned { reactionInset += 12.0 } - + if let starsCount = arguments.starsCount, starsCount > 0 { let countString: String if starsCount > 1000000 { @@ -749,7 +750,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } else { countString = "\(starsCount)" } - + let layoutAndApply = makeStarsCountLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: countString, font: dateFont, textColor: dateColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 100.0, height: 100.0))) reactionInset += 14.0 + layoutAndApply.0.size.width + 4.0 starsCountLayoutAndApply = layoutAndApply @@ -759,20 +760,20 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { reactionInset += 14.0 + layoutAndApply.0.size.width + 4.0 starsCountLayoutAndApply = layoutAndApply } - + if arguments.messageEffect != nil { reactionInset += 13.0 } - + leftInset += reactionInset - + let layoutSize = CGSize(width: leftInset + impressionWidth + date.size.width + statusWidth + backgroundInsets.left + backgroundInsets.right, height: date.size.height + backgroundInsets.top + backgroundInsets.bottom) - + let verticalReactionsInset: CGFloat let verticalInset: CGFloat let resultingWidth: CGFloat let resultingHeight: CGFloat - + let reactionButtonsResult: ReactionButtonsAsyncLayoutContainer.Result switch arguments.layoutInput { case .standalone: @@ -799,16 +800,16 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { for reaction in arguments.reactions { totalReactionCount += Int(reaction.count) } - + var hadStars = false var mappedReactions = arguments.reactions.map { reaction in var centerAnimation: TelegramMediaFile? var animationFileId: Int64? - + if case .stars = reaction.value { hadStars = true } - + switch reaction.value { case .builtin, .stars: if let availableReactions = arguments.availableReactions { @@ -822,7 +823,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { case let .custom(fileId): animationFileId = fileId } - + var peers: [EnginePeer] = [] for (value, peer) in arguments.reactionPeers { if value == reaction.value { @@ -836,7 +837,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { peers.removeAll() } } - + var title: String? if arguments.areReactionsTags, let savedMessageTags = arguments.savedMessageTags { for tag in savedMessageTags.tags { @@ -845,7 +846,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } } } - + return ReactionButtonsAsyncLayoutContainer.Reaction( reaction: ReactionButtonComponent.Reaction( value: reaction.value, @@ -858,11 +859,11 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { chosenOrder: reaction.chosenOrder ) } - + if arguments.areStarReactionsEnabled && !hadStars && !mappedReactions.isEmpty { var centerAnimation: TelegramMediaFile? let animationFileId: Int64? = nil - + if let availableReactions = arguments.availableReactions { for availableReaction in availableReactions.reactions { if availableReaction.value == .stars { @@ -871,7 +872,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } } } - + mappedReactions.insert(ReactionButtonsAsyncLayoutContainer.Reaction( reaction: ReactionButtonComponent.Reaction( value: .stars, @@ -884,7 +885,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { chosenOrder: nil ), at: 0) } - + reactionButtonsResult = reactionButtonsContainer.update( context: arguments.context, action: { itemNode, value, sourceView in @@ -913,7 +914,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { constrainedWidth: arguments.constrainedSize.width ) } - + var reactionButtonsSize = CGSize() var currentRowWidth: CGFloat = 0.0 for item in reactionButtonsResult.items { @@ -925,7 +926,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { reactionButtonsSize.height += item.size.height currentRowWidth = 0.0 } - + if !currentRowWidth.isZero { currentRowWidth += 6.0 } @@ -938,7 +939,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } reactionButtonsSize.height += reactionButtonsResult.items[0].size.height } - + if reactionButtonsSize.width.isZero { verticalReactionsInset = 0.0 if let contentWidth { @@ -968,7 +969,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } else { verticalReactionsInset = 0.0 } - + if currentRowWidth + layoutSize.width > arguments.constrainedSize.width { resultingWidth = max(layoutSize.width, reactionButtonsSize.width) resultingHeight = verticalReactionsInset + reactionButtonsSize.height + 1.0 + layoutSize.height @@ -980,16 +981,16 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } } } - + return (resultingWidth, { boundingWidth in return (CGSize(width: boundingWidth, height: resultingHeight), { animation in if let strongSelf = self { let leftOffset = boundingWidth - layoutSize.width - + strongSelf.theme = arguments.presentationData.theme strongSelf.type = arguments.type strongSelf.layoutSize = layoutSize - + let reactionButtons = reactionButtonsResult.apply( animation, ReactionButtonsAsyncLayoutContainer.Arguments( @@ -997,19 +998,19 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { animationRenderer: arguments.animationRenderer ) ) - + var reactionButtonPosition = CGPoint(x: -1.0, y: verticalReactionsInset) for item in reactionButtons.items { if reactionButtonPosition.x + item.size.width > boundingWidth { reactionButtonPosition.x = -1.0 reactionButtonPosition.y += item.size.height + 6.0 } - + if item.node.view.superview != strongSelf.view { assert(item.node.view.superview == nil) strongSelf.view.addSubview(item.node.view) item.node.view.frame = CGRect(origin: reactionButtonPosition, size: item.size) - + if animation.isAnimated { item.node.view.layer.animateScale(from: 0.01, to: 1.0, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) item.node.view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) @@ -1017,7 +1018,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } else { animation.animator.updateFrame(layer: item.node.view.layer, frame: CGRect(origin: reactionButtonPosition, size: item.size), completion: nil) } - + let itemValue = item.value let itemNode = item.node item.node.view.isGestureEnabled = true @@ -1030,17 +1031,17 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { guard let itemNode = itemNode else { return } - + if let openReactionPreview = strongSelf.openReactionPreview { openReactionPreview(gesture, itemNode.view.containerView, itemValue) } else { gesture.cancel() } } - + reactionButtonPosition.x += item.size.width + 6.0 } - + for node in reactionButtons.removedNodes { if animation.isAnimated { node.view.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) @@ -1051,7 +1052,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { node.view.removeFromSuperview() } } - + if backgroundImage != nil { if let currentBackgroundNode = currentBackgroundNode { if currentBackgroundNode.supernode == nil { @@ -1088,9 +1089,9 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { strongSelf.blurredBackgroundNode = nil blurredBackgroundNode.removeFromSupernode() } - + let _ = dateApply() - + if let currentImpressionIcon = currentImpressionIcon { let impressionIconFrame = CGRect(origin: CGPoint(x: leftOffset + leftInset + backgroundInsets.left, y: backgroundInsets.top + 1.0 + offset + verticalInset + floor((date.size.height - impressionSize.height) / 2.0)), size: impressionSize) currentImpressionIcon.displaysAsynchronously = false @@ -1108,16 +1109,16 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { impressionIcon.removeFromSupernode() strongSelf.impressionIcon = nil } - + animation.animator.updateFrame(layer: strongSelf.dateNode.layer, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset + backgroundInsets.left + impressionWidth, y: backgroundInsets.top + 1.0 + offset + verticalInset), size: date.size), completion: nil) - + if let clockFrameNode = clockFrameNode { let clockPosition = CGPoint(x: leftOffset + backgroundInsets.left + clockPosition.x + reactionInset, y: backgroundInsets.top + clockPosition.y + verticalInset) if strongSelf.clockFrameNode == nil { strongSelf.clockFrameNode = clockFrameNode clockFrameNode.image = clockFrameImage strongSelf.addSubnode(clockFrameNode) - + clockFrameNode.position = clockPosition } else { if themeUpdated { @@ -1132,14 +1133,14 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { clockFrameNode.removeFromSupernode() strongSelf.clockFrameNode = nil } - + if let clockMinNode = clockMinNode { let clockMinPosition = CGPoint(x: leftOffset + backgroundInsets.left + clockPosition.x + reactionInset, y: backgroundInsets.top + clockPosition.y + verticalInset) if strongSelf.clockMinNode == nil { strongSelf.clockMinNode = clockMinNode clockMinNode.image = clockMinImage strongSelf.addSubnode(clockMinNode) - + clockMinNode.position = clockMinPosition } else { if themeUpdated { @@ -1154,7 +1155,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { clockMinNode.removeFromSupernode() strongSelf.clockMinNode = nil } - + if let checkSentNode = checkSentNode, let checkReadNode = checkReadNode { var animateSentNode = false if strongSelf.checkSentNode == nil { @@ -1165,10 +1166,10 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } else if themeUpdated { checkSentNode.image = loadedCheckFullImage } - + if let checkSentFrame = checkSentFrame { let actualCheckSentFrame = checkSentFrame.offsetBy(dx: leftOffset + backgroundInsets.left + reactionInset, dy: backgroundInsets.top + verticalInset) - + if checkSentNode.isHidden { animateSentNode = animation.isAnimated checkSentNode.isHidden = false @@ -1179,7 +1180,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } else { checkSentNode.isHidden = true } - + var animateReadNode = false if strongSelf.checkReadNode == nil { animateReadNode = animation.isAnimated @@ -1189,7 +1190,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } else if themeUpdated { checkReadNode.image = loadedCheckPartialImage } - + if let checkReadFrame = checkReadFrame { if checkReadNode.isHidden { animateReadNode = animation.isAnimated @@ -1201,7 +1202,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } else { checkReadNode.isHidden = true } - + if animateSentNode { strongSelf.checkSentNode?.layer.animateScale(from: 1.3, to: 1.0, duration: 0.1) } @@ -1214,9 +1215,9 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { strongSelf.checkSentNode = nil strongSelf.checkReadNode = nil } - + var reactionOffset: CGFloat = leftOffset + leftInset - reactionInset + backgroundInsets.left - + if let messageEffect = arguments.messageEffect { var validReactions = Set() do { @@ -1230,11 +1231,11 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { strongSelf.reactionNodes[.custom(messageEffect.id)] = node } validReactions.insert(.custom(messageEffect.id)) - + var centerAnimation: TelegramMediaFile? - + centerAnimation = messageEffect.staticIcon?._parse() - + node.update( context: arguments.context, type: arguments.type, @@ -1267,7 +1268,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { if !arguments.reactions.isEmpty { reactionOffset += reactionTrailingSpacing } - + var removeIds: [MessageReaction.Reaction] = [] for (id, node) in strongSelf.reactionNodes { if !validReactions.contains(id) { @@ -1304,7 +1305,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { strongSelf.reactionNodes.removeValue(forKey: id) } } - + if let currentRepliesIcon = currentRepliesIcon { currentRepliesIcon.displaysAsynchronously = false if currentRepliesIcon.image !== repliesImage { @@ -1330,7 +1331,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { repliesIcon.removeFromSupernode() } } - + if let (layout, apply) = replyCountLayoutAndApply { let node = apply() if strongSelf.replyCountNode !== node { @@ -1357,7 +1358,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { replyCountNode.removeFromSupernode() } } - + if let currentStarsIcon = currentStarsIcon { currentStarsIcon.displaysAsynchronously = false if currentStarsIcon.image !== starsImage { @@ -1383,7 +1384,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { starsIcon.removeFromSupernode() } } - + if let (layout, apply) = starsCountLayoutAndApply { let node = apply() if strongSelf.starsCountNode !== node { @@ -1412,7 +1413,7 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { }) } } - + public static func asyncLayout(_ node: ChatMessageDateAndStatusNode?) -> (_ arguments: Arguments) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode)) { let currentLayout = node?.asyncLayout() return { arguments in @@ -1425,18 +1426,18 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { resultNode = ChatMessageDateAndStatusNode() resultSuggestedWidthAndContinue = resultNode.asyncLayout()(arguments) } - + return (resultSuggestedWidthAndContinue.0, { boundingWidth in let (size, apply) = resultSuggestedWidthAndContinue.1(boundingWidth) return (size, { animation in apply(animation) - + return resultNode }) }) } } - + public func reactionView(value: MessageReaction.Reaction) -> UIView? { for (key, button) in self.reactionButtonsContainer.buttons { if key == value { @@ -1445,14 +1446,14 @@ public class ChatMessageDateAndStatusNode: ASDisplayNode { } return nil } - + public func messageEffectTargetView() -> UIView? { for (_, node) in self.reactionNodes { return node.iconView } return nil } - + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { for (_, button) in self.reactionButtonsContainer.buttons { if button.view.frame.contains(point) { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode/Sources/StringForMessageTimestampStatus.swift b/submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode/Sources/StringForMessageTimestampStatus.swift index 7e252d709c..1a92091e3a 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode/Sources/StringForMessageTimestampStatus.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode/Sources/StringForMessageTimestampStatus.swift @@ -17,12 +17,12 @@ private func dateStringForDay(strings: PresentationStrings, dateTimeFormat: Pres var t: time_t = time_t(timestamp) var timeinfo: tm = tm() localtime_r(&t, &timeinfo) - + let timestampNow = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) var now: time_t = time_t(timestampNow) var timeinfoNow: tm = tm() localtime_r(&now, &timeinfoNow) - + if timeinfo.tm_year != timeinfoNow.tm_year { return "\(stringForTimestamp(day: timeinfo.tm_mday, month: timeinfo.tm_mon + 1, year: timeinfo.tm_year, dateTimeFormat: dateTimeFormat))" } else { @@ -61,6 +61,35 @@ private func monthAtIndex(_ index: Int, strings: PresentationStrings) -> String } } +private func winterGramStringForMessageTimestamp(timestamp: Int32, dateTimeFormat: PresentationDateTimeFormat) -> String { + if !currentWinterGramSettings.showMessageSeconds { + return stringForMessageTimestamp(timestamp: timestamp, dateTimeFormat: dateTimeFormat) + } + + var t: time_t = time_t(timestamp) + var timeinfo: tm = tm() + localtime_r(&t, &timeinfo) + + let hour: Int32 + let suffix: String + switch dateTimeFormat.timeFormat { + case .military: + hour = Int32(timeinfo.tm_hour) + suffix = "" + case .regular: + if timeinfo.tm_hour == 0 { + hour = 12 + } else if timeinfo.tm_hour > 12 { + hour = Int32(timeinfo.tm_hour - 12) + } else { + hour = Int32(timeinfo.tm_hour) + } + suffix = timeinfo.tm_hour >= 12 ? " PM" : " AM" + } + + return String(format: "%02d:%02d:%02d%@", hour, Int32(timeinfo.tm_min), Int32(timeinfo.tm_sec), suffix) +} + public func stringForMessageTimestampStatus(accountPeerId: EnginePeer.Id, message: EngineMessage, dateTimeFormat: PresentationDateTimeFormat, nameDisplayOrder: PresentationPersonNameOrder, strings: PresentationStrings, format: MessageTimestampStatusFormat = .regular, associatedData: ChatMessageItemAssociatedData, ignoreAuthor: Bool = false) -> String { if let adAttribute = message.adAttribute { switch adAttribute.messageType { @@ -70,14 +99,14 @@ public func stringForMessageTimestampStatus(accountPeerId: EnginePeer.Id, messag return strings.Message_RecommendedLabel } } - + var timestamp: Int32 if let scheduleTime = message.scheduleTime { timestamp = scheduleTime } else { timestamp = message.timestamp } - + var displayFullDate = false if case .full = format, timestamp > 100000 { displayFullDate = true @@ -85,16 +114,16 @@ public func stringForMessageTimestampStatus(accountPeerId: EnginePeer.Id, messag displayFullDate = true timestamp = forwardInfo.date } - + if let sourceAuthorInfo = message.sourceAuthorInfo, let orignalDate = sourceAuthorInfo.orignalDate { timestamp = orignalDate } - - var dateText = stringForMessageTimestamp(timestamp: timestamp, dateTimeFormat: dateTimeFormat) + + var dateText = winterGramStringForMessageTimestamp(timestamp: timestamp, dateTimeFormat: dateTimeFormat) if timestamp == scheduleWhenOnlineTimestamp { dateText = " " } - + if let repeatPeriod = message.scheduleRepeatPeriod { let repeatString: String switch repeatPeriod { @@ -121,24 +150,24 @@ public func stringForMessageTimestampStatus(accountPeerId: EnginePeer.Id, messag } dateText = strings.Message_RepeatAt(repeatString, dateText).string } - + if message.id.namespace == Namespaces.Message.ScheduledCloud, let _ = message.pendingProcessingAttribute { return strings.Message_Approximate(dateText).string } - + if displayFullDate { let dayText: String - + let nowTimestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) - + var t: time_t = time_t(timestamp) var timeinfo: tm = tm() localtime_r(&t, &timeinfo) - + var now: time_t = time_t(nowTimestamp) var timeinfoNow: tm = tm() localtime_r(&now, &timeinfoNow) - + if timeinfo.tm_year == timeinfoNow.tm_year { if format != .full, timeinfo.tm_yday == timeinfoNow.tm_yday { dayText = strings.Weekday_Today @@ -148,11 +177,11 @@ public func stringForMessageTimestampStatus(accountPeerId: EnginePeer.Id, messag } else { dayText = strings.Date_ChatDateHeaderYear(monthAtIndex(Int(timeinfo.tm_mon), strings: strings), "\(timeinfo.tm_mday)", "\(1900 + timeinfo.tm_year)").string } - dateText = strings.Message_FullDateFormat(dayText, stringForMessageTimestamp(timestamp: timestamp, dateTimeFormat: dateTimeFormat)).string + dateText = strings.Message_FullDateFormat(dayText, winterGramStringForMessageTimestamp(timestamp: timestamp, dateTimeFormat: dateTimeFormat)).string } else if let forwardInfo = message.forwardInfo, forwardInfo.flags.contains(.isImported) { - dateText = strings.Message_ImportedDateFormat(dateStringForDay(strings: strings, dateTimeFormat: dateTimeFormat, timestamp: forwardInfo.date), stringForMessageTimestamp(timestamp: forwardInfo.date, dateTimeFormat: dateTimeFormat), dateText).string + dateText = strings.Message_ImportedDateFormat(dateStringForDay(strings: strings, dateTimeFormat: dateTimeFormat, timestamp: forwardInfo.date), winterGramStringForMessageTimestamp(timestamp: forwardInfo.date, dateTimeFormat: dateTimeFormat), dateText).string } - + var authorTitle: String? if let author = message.author, case .user = author { if let peer = message.peers[message.id.peerId] as? TelegramChannel, case .broadcast = peer.info { @@ -175,7 +204,7 @@ public func stringForMessageTimestampStatus(accountPeerId: EnginePeer.Id, messag } } } - + if message.id.peerId != accountPeerId { for attribute in message.attributes { if let attribute = attribute as? SourceReferenceMessageAttribute { @@ -191,7 +220,7 @@ public func stringForMessageTimestampStatus(accountPeerId: EnginePeer.Id, messag } } } - + if authorTitle == nil { for attribute in message.attributes { if let attribute = attribute as? InlineBusinessBotMessageAttribute { @@ -203,21 +232,21 @@ public func stringForMessageTimestampStatus(accountPeerId: EnginePeer.Id, messag } } } - + if let subject = associatedData.subject, case let .messageOptions(_, _, info) = subject, case .forward = info { authorTitle = nil } if ignoreAuthor { authorTitle = nil } - + if case .minimal = format { - + } else { if let authorTitle = authorTitle, !authorTitle.isEmpty { dateText = "\(authorTitle), \(dateText)" } } - + return dateText } diff --git a/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleComponent.swift b/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleComponent.swift index db51166224..24f92aa51b 100644 --- a/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleComponent.swift +++ b/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleComponent.swift @@ -13,6 +13,7 @@ import PhoneNumberFormat import TelegramStringFormatting import EmojiStatusComponent import GlassBackgroundComponent +import AppBundle public final class ChatNavigationBarTitleView: UIView, NavigationBarTitleView { private final class ContentData: Equatable { @@ -24,7 +25,7 @@ public final class ChatNavigationBarTitleView: UIView, NavigationBarTitleView { let dateTimeFormat: PresentationDateTimeFormat let nameDisplayOrder: PresentationPersonNameOrder let content: ChatTitleContent - + init(context: AccountContext, theme: PresentationTheme, preferClearGlass: Bool, wallpaper: TelegramWallpaper, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, nameDisplayOrder: PresentationPersonNameOrder, content: ChatTitleContent) { self.context = context self.theme = theme @@ -35,7 +36,7 @@ public final class ChatNavigationBarTitleView: UIView, NavigationBarTitleView { self.nameDisplayOrder = nameDisplayOrder self.content = content } - + static func ==(lhs: ContentData, rhs: ContentData) -> Bool { if lhs.context !== rhs.context { return false @@ -64,36 +65,36 @@ public final class ChatNavigationBarTitleView: UIView, NavigationBarTitleView { return true } } - + private let parentTitleState = ComponentState() private let title = ComponentView() - + private var contentData: ContentData? private var activities: ChatTitleComponent.Activities? private var networkState: AccountNetworkState? - + private var ignoreParentTransitionRequests: Bool = false public var requestUpdate: ((ContainedViewLayoutTransition) -> Void)? - + public var tapAction: (() -> Void)? public var longTapAction: (() -> Void)? - + override public init(frame: CGRect) { super.init(frame: frame) } - + required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + public func animateLayoutTransition() { } - + public func prepareSnapshotState() -> ChatTitleView.SnapshotState? { //return titleView.contentView?.snapshotView(afterScreenUpdates: false) return nil } - + public func animateFromSnapshot(_ snapshotState: ChatTitleView.SnapshotState, direction: ChatTitleView.AnimateFromSnapshotDirection) { guard let titleView = self.title.view as? ChatTitleComponent.View else { return @@ -101,7 +102,7 @@ public final class ChatNavigationBarTitleView: UIView, NavigationBarTitleView { //titleView.contentView?.animateFromSnapshot(snapshotState, direction: direction) titleView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } - + public func update( context: AccountContext, theme: PresentationTheme, @@ -129,36 +130,36 @@ public final class ChatNavigationBarTitleView: UIView, NavigationBarTitleView { self.contentData = contentData self.update(transition: transition) self.ignoreParentTransitionRequests = false - + return isUpdated } - + public func updateActivities(activities: ChatTitleComponent.Activities?, transition: ComponentTransition) { if self.activities != activities { self.activities = activities self.update(transition: transition) } } - + public func updateNetworkState(networkState: AccountNetworkState, transition: ComponentTransition) { if self.networkState != networkState { self.networkState = networkState self.update(transition: transition) } } - + private func update(transition: ComponentTransition) { if !self.ignoreParentTransitionRequests { self.requestUpdate?(transition.containedViewLayoutTransition) } } - + public func updateLayout(availableSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { let transition = ComponentTransition(transition) - + if let contentData = self.contentData { let displayBackground: Bool = true - + let titleSize = self.title.update( transition: transition, component: AnyComponent(ChatTitleComponent( @@ -213,22 +214,22 @@ public final class ChatTitleComponent: Component { public struct Item: Equatable { public let peer: EnginePeer public let activity: PeerInputActivity - + public init(peer: EnginePeer, activity: PeerInputActivity) { self.peer = peer self.activity = activity } } - + public let peerId: EnginePeer.Id public let items: [Item] - + public init(peerId: EnginePeer.Id, items: [Item]) { self.peerId = peerId self.items = items } } - + public let context: AccountContext public let theme: PresentationTheme public let preferClearGlass: Bool @@ -241,7 +242,7 @@ public final class ChatTitleComponent: Component { public let networkState: AccountNetworkState? public let tapped: () -> Void public let longTapped: () -> Void - + public init( context: AccountContext, theme: PresentationTheme, @@ -269,7 +270,7 @@ public final class ChatTitleComponent: Component { self.tapped = tapped self.longTapped = longTapped } - + public static func ==(lhs: ChatTitleComponent, rhs: ChatTitleComponent) -> Bool { if lhs.context !== rhs.context { return false @@ -303,7 +304,7 @@ public final class ChatTitleComponent: Component { } return true } - + public final class View: UIView { private var backgroundView: GlassBackgroundView? private let contentContainer: UIView @@ -315,36 +316,37 @@ public final class ChatTitleComponent: Component { private var credibilityIcon: ComponentView? private var verifiedIcon: ComponentView? private var statusIcon: ComponentView? - + private var winterGramIcon: ComponentView? + private var presenceManager: PeerPresenceStatusManager? - + private var component: ChatTitleComponent? private weak var state: EmptyComponentState? - + override init(frame: CGRect) { self.contentContainer = UIView() self.contentContainer.clipsToBounds = true - + super.init(frame: frame) - + self.presenceManager = PeerPresenceStatusManager(update: { [weak self] in guard let self else { return } self.state?.updated(transition: .spring(duration: 0.4)) }) - + let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:))) recognizer.tapActionAtPoint = { _ in return .waitForSingleTap } self.contentContainer.addGestureRecognizer(recognizer) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + @objc private func onTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { if let (gesture, _) = recognizer.lastRecognizedGestureAndLocation { switch gesture { @@ -357,22 +359,23 @@ public final class ChatTitleComponent: Component { } } } - + func update(component: ChatTitleComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let statusIconsSpacing: CGFloat = 4.0 let leftTitleIconSpacing: CGFloat = 3.0 let rightTitleIconSpacing: CGFloat = 3.0 let containerSideInset: CGFloat = 14.0 - + self.component = component self.state = state - + var titleSegments: [AnimatedTextComponent.Item] = [] var titleLeftIcon: TitleIconComponent.Kind? var titleRightIcon: TitleIconComponent.Kind? var titleCredibilityIcon: ChatTitleCredibilityIcon = .none var titleVerifiedIcon: ChatTitleCredibilityIcon = .none var titleStatusIcon: ChatTitleCredibilityIcon = .none + var titleWinterGramIcon: Bool = false var isEnabled = true switch component.content { case let .peer(peerView, customTitle, _, _, isScheduledMessages, isMuted, _, hidePeerStatus, isEnabledValue): @@ -449,16 +452,19 @@ public final class ChatTitleComponent: Component { titleCredibilityIcon = .scam } else if !hidePeerStatus, let emojiStatus = peer.emojiStatus { titleStatusIcon = .emojiStatus(emojiStatus) - } else if peer.isPremium && !premiumConfiguration.isPremiumDisabled { + } else if peer.isPremium && !currentWinterGramSettings.hidePremiumStatuses && !premiumConfiguration.isPremiumDisabled { titleCredibilityIcon = .premium } - + if peer.isVerified { titleCredibilityIcon = .verified } if let verificationIconFileId = peer.verificationIconFileId { titleVerifiedIcon = .emojiStatus(PeerEmojiStatus(content: .emoji(fileId: verificationIconFileId), expirationDate: nil)) } + if isWinterGramOfficialPeer(EnginePeer(peer)) { + titleWinterGramIcon = true + } } } if peerView.peerId.namespace == Namespaces.Peer.SecretChat { @@ -492,7 +498,7 @@ public final class ChatTitleComponent: Component { case .replies: commentsPart = component.strings.Conversation_TitleReplies(Int32(count)) } - + if commentsPart.contains("[") && commentsPart.contains("]") { if let startIndex = commentsPart.firstIndex(of: "["), let endIndex = commentsPart.firstIndex(of: "]") { commentsPart.removeSubrange(startIndex ... endIndex) @@ -500,7 +506,7 @@ public final class ChatTitleComponent: Component { } else { commentsPart = commentsPart.trimmingCharacters(in: CharacterSet(charactersIn: "0123456789-,.")) } - + let rawTextAndRanges: PresentationStrings.FormattedString switch type { case .comments: @@ -508,22 +514,22 @@ public final class ChatTitleComponent: Component { case .replies: rawTextAndRanges = component.strings.Conversation_TitleRepliesFormat("\(count)", commentsPart) } - + let rawText = rawTextAndRanges.string - + var textIndex = 0 var latestIndex = 0 for indexAndRange in rawTextAndRanges.ranges { let index = indexAndRange.index let range = indexAndRange.range - + var lowerSegmentIndex = range.lowerBound if index != 0 { lowerSegmentIndex = min(lowerSegmentIndex, latestIndex) } else { if latestIndex < range.lowerBound { let part = String(rawText[rawText.index(rawText.startIndex, offsetBy: latestIndex) ..< rawText.index(rawText.startIndex, offsetBy: range.lowerBound)]) - + titleSegments.append(AnimatedTextComponent.Item( id: AnyHashable(textIndex), isUnbreakable: true, @@ -533,7 +539,7 @@ public final class ChatTitleComponent: Component { } } latestIndex = range.upperBound - + let part = String(rawText[rawText.index(rawText.startIndex, offsetBy: lowerSegmentIndex) ..< rawText.index(rawText.startIndex, offsetBy: min(rawText.count, range.upperBound))]) if index == 0 { titleSegments.append(AnimatedTextComponent.Item( @@ -576,7 +582,7 @@ public final class ChatTitleComponent: Component { )] } } - + isEnabled = false case let .custom(textItems, _, enabled): titleSegments = textItems.map { item -> AnimatedTextComponent.Item in @@ -595,7 +601,7 @@ public final class ChatTitleComponent: Component { } isEnabled = enabled } - + var accessibilityText = "" for segment in titleSegments { switch segment.content { @@ -608,7 +614,7 @@ public final class ChatTitleComponent: Component { } } self.accessibilityLabel = accessibilityText - + var inputActivitiesAllowed = true switch component.content { case let .peer(peerView, _, _, _, isScheduledMessages, _, _, _, _): @@ -622,7 +628,7 @@ public final class ChatTitleComponent: Component { default: inputActivitiesAllowed = false } - + let subtitleFont = Font.regular(12.0) var state: ChatTitleActivityNodeState = .none switch component.networkState { @@ -738,7 +744,7 @@ public final class ChatTitleComponent: Component { } else { statusText = component.strings.Bot_GenericBotStatus } - + let string = NSAttributedString(string: statusText, font: subtitleFont, textColor: component.theme.chat.inputPanel.inputControlColor) state = .info(string, .generic) } else if let peer = peerView.peer { @@ -775,7 +781,7 @@ public final class ChatTitleComponent: Component { } if onlineCount > 1 { let string = NSMutableAttributedString() - + string.append(NSAttributedString(string: "\(component.strings.Conversation_StatusMembers(Int32(group.participantCount))), ", font: subtitleFont, textColor: component.theme.chat.inputPanel.inputControlColor)) string.append(NSAttributedString(string: component.strings.Conversation_StatusOnline(Int32(onlineCount)), font: subtitleFont, textColor: component.theme.chat.inputPanel.inputControlColor)) state = .info(string, .generic) @@ -799,7 +805,7 @@ public final class ChatTitleComponent: Component { } else { if case .group = channel.info, let onlineMemberCount = onlineMemberCount.recent, onlineMemberCount > 1 { let string = NSMutableAttributedString() - + string.append(NSAttributedString(string: "\(component.strings.Conversation_StatusMembers(Int32(memberCount))), ", font: subtitleFont, textColor: component.theme.chat.inputPanel.inputControlColor)) string.append(NSAttributedString(string: component.strings.Conversation_StatusOnline(Int32(onlineMemberCount)), font: subtitleFont, textColor: component.theme.chat.inputPanel.inputControlColor)) state = .info(string, .generic) @@ -832,11 +838,11 @@ public final class ChatTitleComponent: Component { default: break } - + self.accessibilityValue = state.string } } - + var rightIconSize: CGSize? if let titleRightIcon { let rightIcon: ComponentView @@ -866,7 +872,7 @@ public final class ChatTitleComponent: Component { }) } } - + var leftIconSize: CGSize? if let titleLeftIcon { let leftIcon: ComponentView @@ -896,7 +902,7 @@ public final class ChatTitleComponent: Component { }) } } - + let mapTitleIcon: (ChatTitleCredibilityIcon) -> EmojiStatusComponent.Content? = { value in switch value { case .none: @@ -913,7 +919,7 @@ public final class ChatTitleComponent: Component { return .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 32.0, height: 32.0), placeholderColor: component.theme.list.mediaPlaceholderColor, themeColor: component.theme.list.itemAccentColor, loopMode: .count(2)) } } - + var credibilityIconSize: CGSize? if let titleCredibilityIcon = mapTitleIcon(titleCredibilityIcon) { let credibilityIcon: ComponentView @@ -945,7 +951,7 @@ public final class ChatTitleComponent: Component { }) } } - + var statusIconSize: CGSize? if let titleStatusIcon = mapTitleIcon(titleStatusIcon) { let statusIcon: ComponentView @@ -977,7 +983,39 @@ public final class ChatTitleComponent: Component { }) } } - + + var winterGramIconSize: CGSize? + if titleWinterGramIcon { + let winterGramIcon: ComponentView + if let current = self.winterGramIcon { + winterGramIcon = current + } else { + winterGramIcon = ComponentView() + self.winterGramIcon = winterGramIcon + } + winterGramIconSize = winterGramIcon.update( + transition: .immediate, + component: AnyComponent(EmojiStatusComponent( + context: component.context, + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + content: .winterGramBadge(backplateColor: winterGramBadgeBackplateColor(theme: component.theme)), + isVisibleForAnimations: true, + action: nil + )), + environment: {}, + containerSize: CGSize(width: 20.0, height: 20.0) + ) + } else if let winterGramIcon = self.winterGramIcon { + self.winterGramIcon = nil + if let winterGramIconView = winterGramIcon.view { + transition.setScale(view: winterGramIconView, scale: 0.001) + transition.setAlpha(view: winterGramIconView, alpha: 0.0, completion: { [weak winterGramIconView] _ in + winterGramIconView?.removeFromSuperview() + }) + } + } + var verifiedIconSize: CGSize? if let titleVerifiedIcon = mapTitleIcon(titleVerifiedIcon) { let verifiedIcon: ComponentView @@ -1009,7 +1047,7 @@ public final class ChatTitleComponent: Component { }) } } - + let subtitleNode: ChatTitleActivityNode if let current = self.subtitleNode { subtitleNode = current @@ -1019,7 +1057,7 @@ public final class ChatTitleComponent: Component { subtitleNode.isUserInteractionEnabled = false self.contentContainer.addSubview(subtitleNode.view) } - + var titleLeftIconsWidth: CGFloat = 0.0 if let leftIconSize { titleLeftIconsWidth += leftIconSize.width + leftTitleIconSpacing @@ -1027,7 +1065,7 @@ public final class ChatTitleComponent: Component { if let verifiedIconSize { titleLeftIconsWidth += verifiedIconSize.width + statusIconsSpacing } - + var titleRightIconsWidth: CGFloat = 0.0 if let rightIconSize { titleRightIconsWidth += rightIconSize.width + rightTitleIconSpacing @@ -1038,9 +1076,12 @@ public final class ChatTitleComponent: Component { if let statusIconSize { titleRightIconsWidth += statusIconSize.width + statusIconsSpacing } - + if let winterGramIconSize { + titleRightIconsWidth += winterGramIconSize.width + statusIconsSpacing + } + let maxTitleWidth = availableSize.width - titleLeftIconsWidth - titleRightIconsWidth - containerSideInset * 2.0 - + let titleSize = self.title.update( transition: transition, component: AnyComponent(AnimatedTextComponent( @@ -1055,10 +1096,10 @@ public final class ChatTitleComponent: Component { environment: {}, containerSize: CGSize(width: maxTitleWidth, height: 100.0) ) - + let _ = subtitleNode.transitionToState(state, animation: transition.animation.isImmediate ? .none : .slide) let subtitleSize = subtitleNode.updateLayout(CGSize(width: availableSize.width - containerSideInset * 2.0, height: 100.0), alignment: .center) - + var minSubtitleWidth: CGFloat? let activityMeasureSubtitleNode: ChatTitleActivityNode if let current = self.activityMeasureSubtitleNode { @@ -1071,7 +1112,7 @@ public final class ChatTitleComponent: Component { let _ = activityMeasureSubtitleNode.transitionToState(.typingText(measureTypingTextString, .black), animation: .none) let activityMeasureSubtitleSize = activityMeasureSubtitleNode.updateLayout(CGSize(width: availableSize.width - containerSideInset * 2.0, height: 100.0), alignment: .center) minSubtitleWidth = activityMeasureSubtitleSize.width - + var contentSize = titleSize contentSize.width += titleLeftIconsWidth + titleRightIconsWidth contentSize.width = max(contentSize.width, subtitleSize.width) @@ -1080,10 +1121,10 @@ public final class ChatTitleComponent: Component { } contentSize.width = max(min(150.0, availableSize.width - containerSideInset * 2.0), contentSize.width) contentSize.height += subtitleSize.height - + let containerSize = CGSize(width: contentSize.width + containerSideInset * 2.0, height: 44.0) let containerFrame = CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((availableSize.height - containerSize.height) * 0.5)), size: containerSize) - + let titleFrame = CGRect(origin: CGPoint(x: titleLeftIconsWidth + floor((containerFrame.width - titleSize.width - titleLeftIconsWidth - titleRightIconsWidth) * 0.5), y: floor((containerFrame.height - contentSize.height) * 0.5)), size: titleSize) if let titleView = self.title.view { if titleView.superview == nil { @@ -1092,13 +1133,13 @@ public final class ChatTitleComponent: Component { } transition.setFrame(view: titleView, frame: titleFrame) } - + let subtitleFrame = CGRect(origin: CGPoint(x: floor((containerFrame.width - subtitleSize.width) * 0.5), y: titleFrame.maxY), size: subtitleSize) // Internally, the status view has zero width transition.setFrame(view: subtitleNode.view, frame: CGRect(origin: CGPoint(x: subtitleFrame.midX, y: subtitleFrame.minY), size: CGSize(width: 0.0, height: subtitleFrame.height))) - + var nextLeftIconX: CGFloat = titleFrame.minX - + if let leftIconSize, let leftIconView = self.leftIcon?.view { let leftIconFrame = CGRect(origin: CGPoint(x: nextLeftIconX - leftTitleIconSpacing - leftIconSize.width, y: titleFrame.minY + leftTitleIconSpacing), size: leftIconSize) if leftIconView.superview == nil { @@ -1113,7 +1154,7 @@ public final class ChatTitleComponent: Component { transition.setAlpha(view: leftIconView, alpha: 1.0) transition.setScale(view: leftIconView, scale: 1.0) } - + if let verifiedIconSize, let verifiedIconView = self.verifiedIcon?.view { let verifiedIconFrame = CGRect(origin: CGPoint(x: nextLeftIconX - statusIconsSpacing - verifiedIconSize.width, y: titleFrame.minY), size: verifiedIconSize) if verifiedIconView.superview == nil { @@ -1129,9 +1170,9 @@ public final class ChatTitleComponent: Component { transition.setScale(view: verifiedIconView, scale: 1.0) nextLeftIconX -= statusIconsSpacing + verifiedIconSize.width } - + var nextRightIconX: CGFloat = titleFrame.maxX - + if let credibilityIconSize, let credibilityIconView = self.credibilityIcon?.view { let credibilityIconFrame = CGRect(origin: CGPoint(x: nextRightIconX + statusIconsSpacing, y: titleFrame.minY), size: credibilityIconSize) if credibilityIconView.superview == nil { @@ -1147,7 +1188,7 @@ public final class ChatTitleComponent: Component { transition.setScale(view: credibilityIconView, scale: 1.0) nextRightIconX += statusIconsSpacing + credibilityIconSize.width } - + if let statusIconSize, let statusIconView = self.statusIcon?.view { let statusIconFrame = CGRect(origin: CGPoint(x: nextRightIconX + statusIconsSpacing, y: titleFrame.minY), size: statusIconSize) if statusIconView.superview == nil { @@ -1163,7 +1204,23 @@ public final class ChatTitleComponent: Component { transition.setScale(view: statusIconView, scale: 1.0) nextRightIconX += statusIconsSpacing + statusIconSize.width } - + + if let winterGramIconSize, let winterGramIconView = self.winterGramIcon?.view { + let winterGramIconFrame = CGRect(origin: CGPoint(x: nextRightIconX + statusIconsSpacing, y: titleFrame.minY), size: winterGramIconSize) + if winterGramIconView.superview == nil { + winterGramIconView.isUserInteractionEnabled = false + self.contentContainer.addSubview(winterGramIconView) + winterGramIconView.frame = winterGramIconFrame + ComponentTransition.immediate.setScale(view: winterGramIconView, scale: 0.001) + winterGramIconView.alpha = 0.0 + } + transition.setPosition(view: winterGramIconView, position: winterGramIconFrame.center) + transition.setBounds(view: winterGramIconView, bounds: CGRect(origin: CGPoint(), size: winterGramIconFrame.size)) + transition.setAlpha(view: winterGramIconView, alpha: 1.0) + transition.setScale(view: winterGramIconView, scale: 1.0) + nextRightIconX += statusIconsSpacing + winterGramIconSize.width + } + if let rightIconSize, let rightIconView = self.rightIcon?.view { let rightIconFrame = CGRect(origin: CGPoint(x: nextRightIconX + rightTitleIconSpacing, y: titleFrame.minY + 5.0), size: rightIconSize) if rightIconView.superview == nil { @@ -1179,7 +1236,7 @@ public final class ChatTitleComponent: Component { transition.setScale(view: rightIconView, scale: 1.0) nextRightIconX += rightTitleIconSpacing + rightIconSize.width } - + if component.displayBackground { let backgroundView: GlassBackgroundView if let current = self.backgroundView { @@ -1205,15 +1262,15 @@ public final class ChatTitleComponent: Component { transition.setFrame(view: self.contentContainer, frame: containerFrame) self.contentContainer.layer.cornerRadius = 0.0 } - + return CGSize(width: containerSize.width, height: availableSize.height) } } - + public func makeView() -> View { return View(frame: CGRect()) } - + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } diff --git a/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift b/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift index f45f442812..aadced9284 100644 --- a/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift +++ b/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift @@ -22,6 +22,7 @@ import MultiAnimationRenderer import ComponentDisplayAdapters import GlassBackgroundComponent import AnimatedTextComponent +import AppBundle private let titleFont = Font.with(size: 17.0, design: .regular, weight: .semibold, traits: [.monospacedNumbers]) private let subtitleFont = Font.regular(13.0) @@ -45,11 +46,11 @@ public enum ChatTitleContent: Equatable { self.peerPresences = peerPresences self.cachedData = cachedData } - + public init(peerView: EngineRawPeerView) { self.init(peerId: peerView.peerId, peer: peerViewMainPeer(peerView), isContact: peerView.peerIsContact, isSavedMessages: false, notificationSettings: peerView.notificationSettings as? TelegramPeerNotificationSettings, peerPresences: peerView.peerPresences, cachedData: peerView.cachedData) } - + public static func ==(lhs: PeerData, rhs: PeerData) -> Bool { if let lhsPeer = lhs.peer, let rhsPeer = rhs.peer { if !lhsPeer.isEqual(rhsPeer) { @@ -86,33 +87,33 @@ public enum ChatTitleContent: Equatable { return true } } - + public enum ReplyThreadType { case comments case replies } - + public struct TitleTextItem: Equatable { public enum Content: Equatable { case text(String) case number(Int, minDigits: Int) } - + public var id: AnyHashable public var isUnbreakable: Bool public var content: Content - + public init(id: AnyHashable, isUnbreakable: Bool = true, content: Content) { self.id = id self.isUnbreakable = isUnbreakable self.content = content } } - + case peer(peerView: PeerData, customTitle: String?, customSubtitle: String?, onlineMemberCount: (total: Int32?, recent: Int32?), isScheduledMessages: Bool, isMuted: Bool?, customMessageCount: Int?, hidePeerStatus: Bool, isEnabled: Bool) case replyThread(type: ReplyThreadType, count: Int) case custom(title: [TitleTextItem], subtitle: String?, isEnabled: Bool) - + public static func ==(lhs: ChatTitleContent, rhs: ChatTitleContent) -> Bool { switch lhs { case let .peer(peerView, customTitle, customSubtitle, onlineMemberCount, isScheduledMessages, isMuted, customMessageCount, hidePeerStatus, isEnabled): @@ -186,16 +187,16 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { case left case right } - + private let context: AccountContext - + private var theme: PresentationTheme private var strings: PresentationStrings private var dateTimeFormat: PresentationDateTimeFormat private var nameDisplayOrder: PresentationPersonNameOrder private let animationCache: AnimationCache private let animationRenderer: MultiAnimationRenderer - + private let contentContainer: ASDisplayNode private let backgroundView: GlassBackgroundView public let titleContainerView: PortalSourceView @@ -205,39 +206,41 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { public let titleCredibilityIconView: ComponentHostView public let titleVerifiedIconView: ComponentHostView public let titleStatusIconView: ComponentHostView + public let titleWinterGramIconView: ComponentHostView public let activityNode: ChatTitleActivityNode - + private let button: HighlightTrackingButtonNode - + public var disableAnimations: Bool = false - + var manualLayout: Bool = false private var validLayout: CGSize? - + public var requestUpdate: ((ContainedViewLayoutTransition) -> Void)? - + private var titleLeftIcon: ChatTitleIcon = .none private var titleRightIcon: ChatTitleIcon = .none private var titleCredibilityIcon: ChatTitleCredibilityIcon = .none private var titleVerifiedIcon: ChatTitleCredibilityIcon = .none private var titleStatusIcon: ChatTitleCredibilityIcon = .none - + private var titleWinterGramIcon: Bool = false + private var presenceManager: PeerPresenceStatusManager? - + private var pointerInteraction: PointerInteraction? - + public var inputActivities: ChatTitleComponent.Activities? { didSet { let _ = self.updateStatus() } } - + private func updateNetworkStatusNode(networkState: AccountNetworkState, layout: ContainerViewLayout?) { if self.manualLayout { self.setNeedsLayout() } } - + public var networkState: AccountNetworkState = .online(proxy: nil) { didSet { if self.networkState != oldValue { @@ -246,7 +249,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } } } - + public var layout: ContainerViewLayout? { didSet { if self.layout != oldValue { @@ -254,21 +257,22 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } } } - + public var pressed: (() -> Void)? public var longPressed: (() -> Void)? - + public var titleContent: ChatTitleContent? { didSet { if let titleContent = self.titleContent { let titleTheme = self.theme - + var segments: [AnimatedCountLabelNode.Segment] = [] var titleLeftIcon: ChatTitleIcon = .none var titleRightIcon: ChatTitleIcon = .none var titleCredibilityIcon: ChatTitleCredibilityIcon = .none var titleVerifiedIcon: ChatTitleCredibilityIcon = .none var titleStatusIcon: ChatTitleCredibilityIcon = .none + var titleWinterGramIcon: Bool = false var isEnabled = true switch titleContent { case let .peer(peerView, customTitle, _, _, isScheduledMessages, isMuted, _, hidePeerStatus, isEnabledValue): @@ -310,16 +314,19 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { titleCredibilityIcon = .scam } else if !hidePeerStatus, let emojiStatus = peer.emojiStatus { titleStatusIcon = .emojiStatus(emojiStatus) - } else if peer.isPremium && !premiumConfiguration.isPremiumDisabled { + } else if peer.isPremium && !currentWinterGramSettings.hidePremiumStatuses && !premiumConfiguration.isPremiumDisabled { titleCredibilityIcon = .premium } - + if peer.isVerified { titleCredibilityIcon = .verified } if let verificationIconFileId = peer.verificationIconFileId { titleVerifiedIcon = .emojiStatus(PeerEmojiStatus(content: .emoji(fileId: verificationIconFileId), expirationDate: nil)) } + if isWinterGramOfficialPeer(EnginePeer(peer)) { + titleWinterGramIcon = true + } } } if peerView.peerId.namespace == Namespaces.Peer.SecretChat { @@ -347,7 +354,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { case let .replyThread(type, count): let textFont = titleFont let textColor = titleTheme.rootController.navigationBar.primaryTextColor - + if count > 0 { var commentsPart: String switch type { @@ -356,7 +363,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { case .replies: commentsPart = self.strings.Conversation_TitleReplies(Int32(count)) } - + if commentsPart.contains("[") && commentsPart.contains("]") { if let startIndex = commentsPart.firstIndex(of: "["), let endIndex = commentsPart.firstIndex(of: "]") { commentsPart.removeSubrange(startIndex ... endIndex) @@ -364,7 +371,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } else { commentsPart = commentsPart.trimmingCharacters(in: CharacterSet(charactersIn: "0123456789-,.")) } - + let rawTextAndRanges: PresentationStrings.FormattedString switch type { case .comments: @@ -392,7 +399,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } } latestIndex = range.upperBound - + let part = String(rawText[rawText.index(rawText.startIndex, offsetBy: lowerSegmentIndex) ..< rawText.index(rawText.startIndex, offsetBy: min(rawText.count, range.upperBound))]) if index == 0 { segments.append(.number(count, NSAttributedString(string: part, font: textFont, textColor: textColor))) @@ -414,7 +421,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { segments = [.text(0, NSAttributedString(string: strings.Conversation_TitleRepliesEmpty, font: textFont, textColor: textColor))] } } - + isEnabled = false case let .custom(textItems, _, enabled): var nextId = -1 @@ -429,14 +436,14 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } isEnabled = enabled } - + var updated = false - + if self.titleTextNode.segments != segments { self.titleTextNode.segments = segments updated = true } - + if titleLeftIcon != self.titleLeftIcon { self.titleLeftIcon = titleLeftIcon switch titleLeftIcon { @@ -447,22 +454,27 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } updated = true } - + if titleCredibilityIcon != self.titleCredibilityIcon { self.titleCredibilityIcon = titleCredibilityIcon updated = true } - + if titleVerifiedIcon != self.titleVerifiedIcon { self.titleVerifiedIcon = titleVerifiedIcon updated = true } - + if titleStatusIcon != self.titleStatusIcon { self.titleStatusIcon = titleStatusIcon updated = true } - + + if titleWinterGramIcon != self.titleWinterGramIcon { + self.titleWinterGramIcon = titleWinterGramIcon + updated = true + } + if titleRightIcon != self.titleRightIcon { self.titleRightIcon = titleRightIcon switch titleRightIcon { @@ -475,7 +487,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } self.isUserInteractionEnabled = isEnabled self.button.isUserInteractionEnabled = isEnabled - + var enableAnimation = false switch titleContent { case let .peer(_, customTitle, _, _, _, _, _, _, _): @@ -489,7 +501,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { default: break } - + if !self.updateStatus(enableAnimation: enableAnimation) { if updated { if !self.manualLayout, let size = self.validLayout { @@ -500,7 +512,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } } } - + private func updateStatus(enableAnimation: Bool = true) -> Bool { var inputActivitiesAllowed = true if let titleContent = self.titleContent { @@ -517,9 +529,9 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { inputActivitiesAllowed = false } } - + let titleTheme = self.theme - + var state = ChatTitleActivityNodeState.none switch self.networkState { case .waitingForNetwork, .connecting, .updating: @@ -626,7 +638,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { state = .info(string, .generic) } else if user.flags.contains(.isSupport) { let statusText = self.strings.Bot_GenericSupportStatus - + let string = NSAttributedString(string: statusText, font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(string, .generic) } else if let _ = user.botInfo { @@ -636,7 +648,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } else { statusText = self.strings.Bot_GenericBotStatus } - + let string = NSAttributedString(string: statusText, font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(string, .generic) } else if let peer = peerView.peer { @@ -673,7 +685,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } if onlineCount > 1 { let string = NSMutableAttributedString() - + string.append(NSAttributedString(string: "\(strings.Conversation_StatusMembers(Int32(group.participantCount))), ", font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor)) string.append(NSAttributedString(string: strings.Conversation_StatusOnline(Int32(onlineCount)), font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor)) state = .info(string, .generic) @@ -697,7 +709,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } else { if case .group = channel.info, let onlineMemberCount = onlineMemberCount.recent, onlineMemberCount > 1 { let string = NSMutableAttributedString() - + string.append(NSAttributedString(string: "\(strings.Conversation_StatusMembers(Int32(memberCount))), ", font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor)) string.append(NSAttributedString(string: strings.Conversation_StatusOnline(Int32(onlineMemberCount)), font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor)) state = .info(string, .generic) @@ -730,7 +742,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { default: break } - + var accessibilityText = "" for segment in self.titleTextNode.segments { switch segment { @@ -740,7 +752,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { accessibilityText.append(string.string) } } - + self.accessibilityLabel = accessibilityText self.accessibilityValue = state.string } else { @@ -748,7 +760,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } } } - + if self.activityNode.transitionToState(state, animation: enableAnimation ? .slide : .none) { if !self.manualLayout, let size = self.validLayout { let _ = self.updateLayout(availableSize: size, transition: enableAnimation ? .animated(duration: 0.3, curve: .spring) : .immediate) @@ -758,7 +770,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { return false } } - + public init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, nameDisplayOrder: PresentationPersonNameOrder, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer) { self.context = context self.theme = theme @@ -767,52 +779,55 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { self.nameDisplayOrder = nameDisplayOrder self.animationCache = animationCache self.animationRenderer = animationRenderer - + self.contentContainer = ASDisplayNode() - + self.backgroundView = GlassBackgroundView() - + self.titleContainerView = PortalSourceView() self.titleTextNode = ImmediateAnimatedCountLabelNode() - + self.titleLeftIconNode = ASImageNode() self.titleLeftIconNode.isLayerBacked = true self.titleLeftIconNode.displayWithoutProcessing = true self.titleLeftIconNode.displaysAsynchronously = false - + self.titleRightIconNode = ASImageNode() self.titleRightIconNode.isLayerBacked = true self.titleRightIconNode.displayWithoutProcessing = true self.titleRightIconNode.displaysAsynchronously = false - + self.titleCredibilityIconView = ComponentHostView() self.titleCredibilityIconView.isUserInteractionEnabled = false - + self.titleVerifiedIconView = ComponentHostView() self.titleVerifiedIconView.isUserInteractionEnabled = false - + self.titleStatusIconView = ComponentHostView() self.titleStatusIconView.isUserInteractionEnabled = false - + + self.titleWinterGramIconView = ComponentHostView() + self.titleWinterGramIconView.isUserInteractionEnabled = false + self.activityNode = ChatTitleActivityNode() self.button = HighlightTrackingButtonNode() - + super.init(frame: CGRect()) - + self.isAccessibilityElement = true self.accessibilityTraits = .header - + self.addSubnode(self.contentContainer) self.contentContainer.view.addSubview(self.backgroundView) self.titleContainerView.addSubnode(self.titleTextNode) self.contentContainer.view.addSubview(self.titleContainerView) self.contentContainer.addSubnode(self.activityNode) self.addSubnode(self.button) - + self.presenceManager = PeerPresenceStatusManager(update: { [weak self] in let _ = self?.updateStatus() }) - + self.button.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: [.touchUpInside]) self.button.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { @@ -839,50 +854,51 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } self.button.view.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(self.longPressGesture(_:)))) } - + required public init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override public func layoutSubviews() { super.layoutSubviews() - + if !self.manualLayout, let size = self.validLayout { let _ = self.updateLayout(availableSize: size, transition: .immediate) } } - + public func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) { if self.theme !== theme || self.strings !== strings { self.theme = theme self.strings = strings - + let titleContent = self.titleContent self.titleCredibilityIcon = .none self.titleVerifiedIcon = .none self.titleContent = titleContent let _ = self.updateStatus() - + if !self.manualLayout, let size = self.validLayout { let _ = self.updateLayout(availableSize: size, transition: .immediate) } } } - + public func updateLayout(availableSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { let size = availableSize - + self.validLayout = size - + self.button.frame = CGRect(origin: CGPoint(), size: size) self.contentContainer.frame = CGRect(origin: CGPoint(), size: size) - + var leftIconWidth: CGFloat = 0.0 var rightIconWidth: CGFloat = 0.0 var credibilityIconWidth: CGFloat = 0.0 var verifiedIconWidth: CGFloat = 0.0 var statusIconWidth: CGFloat = 0.0 - + var winterGramIconWidth: CGFloat = 0.0 + if let image = self.titleLeftIconNode.image { if self.titleLeftIconNode.supernode == nil { self.titleTextNode.addSubnode(self.titleLeftIconNode) @@ -891,7 +907,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } else if self.titleLeftIconNode.supernode != nil { self.titleLeftIconNode.removeFromSupernode() } - + let titleCredibilityContent: EmojiStatusComponent.Content switch self.titleCredibilityIcon { case .none: @@ -907,7 +923,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { case let .emojiStatus(emojiStatus): titleCredibilityContent = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 32.0, height: 32.0), placeholderColor: self.theme.list.mediaPlaceholderColor, themeColor: self.theme.list.itemAccentColor, loopMode: .count(2)) } - + let titleVerifiedContent: EmojiStatusComponent.Content switch self.titleVerifiedIcon { case .none: @@ -923,7 +939,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { case let .emojiStatus(emojiStatus): titleVerifiedContent = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 32.0, height: 32.0), placeholderColor: self.theme.list.mediaPlaceholderColor, themeColor: self.theme.list.itemAccentColor, loopMode: .count(2)) } - + let titleStatusContent: EmojiStatusComponent.Content var titleStatusParticleColor: UIColor? switch self.titleStatusIcon { @@ -935,7 +951,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { default: titleStatusContent = .none } - + let titleCredibilitySize = self.titleCredibilityIconView.update( transition: .immediate, component: AnyComponent(EmojiStatusComponent( @@ -949,7 +965,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { environment: {}, containerSize: CGSize(width: 20.0, height: 20.0) ) - + let titleVerifiedSize = self.titleVerifiedIconView.update( transition: .immediate, component: AnyComponent(EmojiStatusComponent( @@ -963,7 +979,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { environment: {}, containerSize: CGSize(width: 20.0, height: 20.0) ) - + let titleStatusSize = self.titleStatusIconView.update( transition: .immediate, component: AnyComponent(EmojiStatusComponent( @@ -978,7 +994,21 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { environment: {}, containerSize: CGSize(width: 20.0, height: 20.0) ) - + + let titleWinterGramSize = self.titleWinterGramIconView.update( + transition: .immediate, + component: AnyComponent(EmojiStatusComponent( + context: self.context, + animationCache: self.animationCache, + animationRenderer: self.animationRenderer, + content: self.titleWinterGramIcon ? .winterGramBadge(backplateColor: winterGramBadgeBackplateColor(theme: self.theme)) : EmojiStatusComponent.Content.none, + isVisibleForAnimations: true, + action: nil + )), + environment: {}, + containerSize: CGSize(width: 20.0, height: 20.0) + ) + if self.titleCredibilityIcon != .none { self.titleTextNode.view.addSubview(self.titleCredibilityIconView) credibilityIconWidth = titleCredibilitySize.width + 3.0 @@ -987,7 +1017,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { self.titleCredibilityIconView.removeFromSuperview() } } - + if self.titleVerifiedIcon != .none { self.titleTextNode.view.addSubview(self.titleVerifiedIconView) verifiedIconWidth = titleVerifiedSize.width + 3.0 @@ -996,7 +1026,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { self.titleVerifiedIconView.removeFromSuperview() } } - + if self.titleStatusIcon != .none { self.titleTextNode.view.addSubview(self.titleStatusIconView) statusIconWidth = titleStatusSize.width + 3.0 @@ -1005,7 +1035,16 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { self.titleStatusIconView.removeFromSuperview() } } - + + if self.titleWinterGramIcon { + self.titleTextNode.view.addSubview(self.titleWinterGramIconView) + winterGramIconWidth = titleWinterGramSize.width + 3.0 + } else { + if self.titleWinterGramIconView.superview != nil { + self.titleWinterGramIconView.removeFromSuperview() + } + } + if let image = self.titleRightIconNode.image { if self.titleRightIconNode.supernode == nil { self.titleTextNode.addSubnode(self.titleRightIconNode) @@ -1014,22 +1053,22 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } else if self.titleRightIconNode.supernode != nil { self.titleRightIconNode.removeFromSupernode() } - + var titleTransition = transition if self.titleContainerView.bounds.width.isZero { titleTransition = .immediate } - + let statusSpacing: CGFloat = 3.0 let titleSideInset: CGFloat = 12.0 + 8.0 var titleFrame: CGRect - + var titleInsets: UIEdgeInsets = .zero if case .emojiStatus = self.titleVerifiedIcon, verifiedIconWidth > 0.0 { titleInsets.left = verifiedIconWidth } - - var titleSize = self.titleTextNode.updateLayout(size: CGSize(width: size.width - leftIconWidth - credibilityIconWidth - verifiedIconWidth - statusIconWidth - rightIconWidth - titleSideInset * 2.0, height: size.height), insets: titleInsets, animated: titleTransition.isAnimated) + + var titleSize = self.titleTextNode.updateLayout(size: CGSize(width: size.width - leftIconWidth - credibilityIconWidth - verifiedIconWidth - statusIconWidth - winterGramIconWidth - rightIconWidth - titleSideInset * 2.0, height: size.height), insets: titleInsets, animated: titleTransition.isAnimated) titleSize.width += credibilityIconWidth titleSize.width += verifiedIconWidth if statusIconWidth > 0.0 { @@ -1038,12 +1077,15 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { titleSize.width += statusSpacing } } - + // WinterGram: reserve room for the badge so it sits to the right of the premium status + // instead of overlapping the title text's right edge. + titleSize.width += winterGramIconWidth + let activitySize = self.activityNode.updateLayout(CGSize(width: size.width - titleSideInset * 2.0, height: size.height), alignment: .center) let titleInfoSpacing: CGFloat = 0.0 - + var activityFrame = CGRect() - + if activitySize.height.isZero { titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: floor((size.height - titleSize.height) / 2.0)), size: titleSize) if titleFrame.size.width < size.width { @@ -1053,44 +1095,52 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { titleTransition.updateFrameAdditive(node: self.titleTextNode, frame: CGRect(origin: CGPoint(), size: titleFrame.size)) } else { let combinedHeight = titleSize.height + activitySize.height + titleInfoSpacing - + let contentWidth = max(titleSize.width + rightIconWidth, activitySize.width) var contentX = floor((size.width - contentWidth) / 2.0) contentX = max(contentX, 20.0) - + titleFrame = CGRect(origin: CGPoint(x: contentX + floor((contentWidth - titleSize.width) / 2.0), y: floor((size.height - combinedHeight) / 2.0)), size: titleSize) - + titleFrame.origin.x = max(titleFrame.origin.x, leftIconWidth) titleTransition.updateFrameAdditive(view: self.titleContainerView, frame: titleFrame) titleTransition.updateFrameAdditive(node: self.titleTextNode, frame: CGRect(origin: CGPoint(), size: titleFrame.size)) - + activityFrame = CGRect(origin: CGPoint(x: titleFrame.minX + floor((titleFrame.width - activitySize.width) / 2.0), y: floor((size.height - combinedHeight) / 2.0) + titleSize.height + titleInfoSpacing), size: activitySize) titleTransition.updateFrameAdditiveToCenter(node: self.activityNode, frame: activityFrame.offsetBy(dx: activitySize.width * 0.5, dy: 0.0)) } - + if let image = self.titleLeftIconNode.image { titleTransition.updateFrame(node: self.titleLeftIconNode, frame: CGRect(origin: CGPoint(x: -image.size.width - 3.0 - UIScreenPixel, y: 4.0), size: image.size)) } - + var nextIconX: CGFloat = titleFrame.width - + titleTransition.updateFrame(view: self.titleVerifiedIconView, frame: CGRect(origin: CGPoint(x: 0.0, y: floor((titleFrame.height - titleVerifiedSize.height) / 2.0)), size: titleVerifiedSize)) - + + if self.titleWinterGramIcon { + self.titleWinterGramIconView.frame = CGRect(origin: CGPoint(x: nextIconX - titleWinterGramSize.width, y: floor((titleFrame.height - titleWinterGramSize.height) / 2.0)), size: titleWinterGramSize) + nextIconX -= titleWinterGramSize.width + if winterGramIconWidth > 0.0 { + nextIconX -= statusSpacing + } + } + self.titleCredibilityIconView.frame = CGRect(origin: CGPoint(x: nextIconX - titleCredibilitySize.width, y: floor((titleFrame.height - titleCredibilitySize.height) / 2.0)), size: titleCredibilitySize) nextIconX -= titleCredibilitySize.width if credibilityIconWidth > 0.0 { nextIconX -= statusSpacing } - + self.titleStatusIconView.frame = CGRect(origin: CGPoint(x: nextIconX - titleStatusSize.width, y: floor((titleFrame.height - titleStatusSize.height) / 2.0)), size: titleStatusSize) nextIconX -= titleStatusSize.width - + if let image = self.titleRightIconNode.image { self.titleRightIconNode.frame = CGRect(origin: CGPoint(x: titleFrame.width + 3.0 + UIScreenPixel, y: 6.0), size: image.size) } - + self.pointerInteraction = PointerInteraction(view: self, style: .rectangle(CGSize(width: titleFrame.width + 16.0, height: 40.0))) - + var backgroundFrame = CGRect(origin: CGPoint(x: titleFrame.minX, y: 6.0), size: CGSize(width: titleFrame.width, height: 44.0)) if !activityFrame.isEmpty { backgroundFrame.origin.x = min(backgroundFrame.minX, activityFrame.minX) @@ -1100,14 +1150,14 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { let componentTransition = ComponentTransition(transition) componentTransition.setFrame(view: self.backgroundView, frame: backgroundFrame) self.backgroundView.update(size: backgroundFrame.size, cornerRadius: backgroundFrame.height * 0.5, isDark: self.theme.overallDarkAppearance, tintColor: .init(kind: .panel), isInteractive: false, transition: componentTransition) - + return availableSize } - + @objc private func buttonPressed() { self.pressed?() } - + @objc private func longPressGesture(_ gesture: UILongPressGestureRecognizer) { switch gesture.state { case .began: @@ -1116,12 +1166,12 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { break } } - + public func animateLayoutTransition() { UIView.transition(with: self, duration: 0.25, options: [.transitionCrossDissolve], animations: { }, completion: nil) } - + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if !self.isUserInteractionEnabled { return nil @@ -1151,7 +1201,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { public func animateFromSnapshot(_ snapshotState: SnapshotState, direction: AnimateFromSnapshotDirection = .up) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) - + var offset = CGPoint() switch direction { case .up: @@ -1163,7 +1213,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { case .right: offset.x = 20.0 } - + self.layer.animatePosition(from: offset, to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: true, additive: true) snapshotState.snapshotView.frame = self.frame diff --git a/submodules/TelegramUI/Components/EmojiStatusComponent/Sources/EmojiStatusComponent.swift b/submodules/TelegramUI/Components/EmojiStatusComponent/Sources/EmojiStatusComponent.swift index 5537ee1d8f..10509a6085 100644 --- a/submodules/TelegramUI/Components/EmojiStatusComponent/Sources/EmojiStatusComponent.swift +++ b/submodules/TelegramUI/Components/EmojiStatusComponent/Sources/EmojiStatusComponent.swift @@ -16,13 +16,38 @@ 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). +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 + color.getRed(&r, green: &g, blue: &b, alpha: &a) + let key = "\(name)|\(Int(r * 255.0))|\(Int(g * 255.0))|\(Int(b * 255.0))" + if let cached = winterGramTintedShapeCache[key] { + return cached + } + guard let shape = UIImage(bundleImageName: name) else { + return nil + } + let renderer = UIGraphicsImageRenderer(size: shape.size) + let baked = renderer.image { _ in + shape.withTintColor(color, renderingMode: .alwaysOriginal).draw(in: CGRect(origin: .zero, size: shape.size)) + } + if let cgImage = baked.cgImage { + winterGramTintedShapeCache[key] = cgImage + return cgImage + } + return nil +} + public final class EmojiStatusComponent: Component { public typealias EnvironmentType = Empty - + public enum AnimationContent: Equatable { case file(file: TelegramMediaFile) case customEmoji(fileId: Int64) - + public var fileId: MediaId { switch self { case let .file(file): @@ -32,18 +57,18 @@ public final class EmojiStatusComponent: Component { } } } - + public enum LoopMode: Equatable { case forever case count(Int) } - + public enum SizeType { case compact case large case smaller } - + public enum Content: Equatable { case none case premium(color: UIColor) @@ -52,8 +77,9 @@ public final class EmojiStatusComponent: Component { case animation(content: AnimationContent, size: CGSize, placeholderColor: UIColor, themeColor: UIColor?, loopMode: LoopMode) case topic(title: String, color: Int32, size: CGSize) case image(image: UIImage?, tintColor: UIColor?) + case winterGramBadge(backplateColor: UIColor) } - + public let postbox: Postbox public let energyUsageSettings: EnergyUsageSettings public let resolveInlineStickers: ([Int64]) -> Signal<[Int64: TelegramMediaFile], NoError> @@ -68,7 +94,7 @@ public final class EmojiStatusComponent: Component { public let action: (() -> Void)? public let emojiFileUpdated: ((TelegramMediaFile?) -> Void)? public let tag: AnyObject? - + public convenience init( context: AccountContext, animationCache: AnimationCache, @@ -102,7 +128,7 @@ public final class EmojiStatusComponent: Component { tag: tag ) } - + public init( postbox: Postbox, energyUsageSettings: EnergyUsageSettings, @@ -134,7 +160,7 @@ public final class EmojiStatusComponent: Component { self.emojiFileUpdated = emojiFileUpdated self.tag = tag } - + public func withVisibleForAnimations(_ isVisibleForAnimations: Bool) -> EmojiStatusComponent { return EmojiStatusComponent( postbox: self.postbox, @@ -153,7 +179,7 @@ public final class EmojiStatusComponent: Component { tag: self.tag ) } - + public static func ==(lhs: EmojiStatusComponent, rhs: EmojiStatusComponent) -> Bool { if lhs.postbox !== rhs.postbox { return false @@ -201,16 +227,16 @@ public final class EmojiStatusComponent: Component { } return false } - + private final class AnimationFileProperties { let path: String let coloredComposition: Animation? - + init(path: String, coloredComposition: Animation?) { self.path = path self.coloredComposition = coloredComposition } - + static func load(from path: String) -> AnimationFileProperties { guard let size = fileSize(path), size < 1024 * 1024 else { return AnimationFileProperties(path: path, coloredComposition: nil) @@ -221,41 +247,48 @@ public final class EmojiStatusComponent: Component { guard let unzippedData = TGGUnzipData(data, 1024 * 1024) else { return AnimationFileProperties(path: path, coloredComposition: nil) } - + var coloredComposition: Animation? if let composition = try? Animation.from(data: unzippedData) { coloredComposition = composition } - + return AnimationFileProperties(path: path, coloredComposition: coloredComposition) } } - + private weak var state: EmptyComponentState? private var component: EmojiStatusComponent? private var starsLayer: StarsEffectLayer? - + private var iconLayer: SimpleLayer? private var iconLayerImage: UIImage? - + + 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). + private var winterGramBadgeAnimating: Bool = false + private var animationLayer: InlineStickerItemLayer? private var lottieAnimationView: AnimationView? private let hierarchyTrackingLayer: HierarchyTrackingLayer - + private var emojiFile: TelegramMediaFile? private var emojiFileDataProperties: AnimationFileProperties? private var emojiFileDisposable: Disposable? private var emojiFileDataPathDisposable: Disposable? - + override init(frame: CGRect) { self.hierarchyTrackingLayer = HierarchyTrackingLayer() - + super.init(frame: frame) - + self.layer.addSublayer(self.hierarchyTrackingLayer) - + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) - + self.hierarchyTrackingLayer.didEnterHierarchy = { [weak self] in guard let strongSelf = self else { return @@ -263,44 +296,66 @@ public final class EmojiStatusComponent: Component { if let lottieAnimationView = strongSelf.lottieAnimationView { lottieAnimationView.play() } + strongSelf.applyWinterGramBadgeRotation() } } - + + // (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. + private func applyWinterGramBadgeRotation() { + guard let backplateLayer = self.winterGramBackplateLayer else { + return + } + backplateLayer.removeAnimation(forKey: "winterGramBackplateRotation") + guard self.winterGramBadgeAnimating else { + return + } + let animation = CABasicAnimation(keyPath: "transform.rotation.z") + animation.fromValue = 0.0 + animation.toValue = Double.pi * 2.0 + animation.duration = 8.0 + animation.repeatCount = .infinity + animation.timingFunction = CAMediaTimingFunction(name: .linear) + animation.isRemovedOnCompletion = false + backplateLayer.add(animation, forKey: "winterGramBackplateRotation") + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + deinit { self.emojiFileDisposable?.dispose() self.emojiFileDataPathDisposable?.dispose() } - + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.component?.action?() } } - + public func playOnce() { self.animationLayer?.playOnce() } - + func update(component: EmojiStatusComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let availableSize = component.size ?? availableSize - + self.state = state - + var iconImage: UIImage? var emojiFileId: Int64? var emojiPlaceholderColor: UIColor? var emojiThemeColor: UIColor? var emojiLoopMode: LoopMode? var emojiSize = CGSize() - + var iconTintColor: UIColor? - + self.isUserInteractionEnabled = component.action != nil - + if let particleColor = component.particleColor { let starsLayer: StarsEffectLayer if let current = self.starsLayer { @@ -318,7 +373,7 @@ public final class EmojiStatusComponent: Component { self.starsLayer = nil starsLayer.removeFromSuperlayer() } - + //let previousContent = self.component?.content if self.component?.content != component.content { switch component.content { @@ -326,7 +381,7 @@ public final class EmojiStatusComponent: Component { iconImage = nil case let .premium(color): iconTintColor = color - + if case .premium = self.component?.content, let image = self.iconLayerImage { iconImage = image } else { @@ -336,7 +391,7 @@ public final class EmojiStatusComponent: Component { context.clear(CGRect(origin: CGPoint(), size: size)) let imageSize = CGSize(width: sourceImage.size.width - 8.0, height: sourceImage.size.height - 8.0) context.clip(to: CGRect(origin: CGPoint(x: floor((size.width - imageSize.width) / 2.0), y: floor((size.height - imageSize.height) / 2.0)), size: imageSize), mask: cgImage) - + context.setFillColor(UIColor.white.cgColor) context.fill(CGRect(origin: CGPoint(), size: size)) } @@ -363,7 +418,7 @@ public final class EmojiStatusComponent: Component { case .large: imageNamePrefix = "Peer Info/VerifiedIcon" } - + if let backgroundImage = UIImage(bundleImageName: "\(imageNamePrefix)Background"), let foregroundImage = UIImage(bundleImageName: "\(imageNamePrefix)Foreground") { iconImage = generateImage(backgroundImage.size, contextGenerator: { size, context in if let backgroundCgImage = backgroundImage.cgImage, let foregroundCgImage = foregroundImage.cgImage { @@ -374,7 +429,7 @@ public final class EmojiStatusComponent: Component { context.setFillColor(fillColor.cgColor) context.fill(CGRect(origin: CGPoint(), size: size)) context.restoreGState() - + context.setBlendMode(.copy) context.clip(to: CGRect(origin: .zero, size: size), mask: foregroundCgImage) context.setFillColor(foregroundColor.cgColor) @@ -387,18 +442,18 @@ public final class EmojiStatusComponent: Component { case let .text(color, string): let titleString = NSAttributedString(string: string, font: Font.bold(10.0), textColor: color, paragraphAlignment: .center) let stringRect = titleString.boundingRect(with: CGSize(width: 100.0, height: 16.0), options: .usesLineFragmentOrigin, context: nil) - + iconImage = generateImage(CGSize(width: floor(stringRect.width) + 11.0, height: 16.0), contextGenerator: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) - + context.setFillColor(color.cgColor) context.setStrokeColor(color.cgColor) context.setLineWidth(1.0) - + context.addPath(UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 2.0).cgPath) context.strokePath() - + let titlePath = CGMutablePath() titlePath.addRect(bounds.offsetBy(dx: 0.0, dy: -2.0 + UIScreenPixel)) let titleFramesetter = CTFramesetterCreateWithAttributedString(titleString as CFAttributedString) @@ -412,20 +467,20 @@ public final class EmojiStatusComponent: Component { emojiThemeColor = themeColor emojiSize = size emojiLoopMode = loopMode - + if case let .animation(previousAnimationContent, _, _, _, _) = self.component?.content { if previousAnimationContent.fileId != animationContent.fileId { self.emojiFileDisposable?.dispose() self.emojiFileDisposable = nil self.emojiFileDataPathDisposable?.dispose() self.emojiFileDataPathDisposable = nil - + self.emojiFile = nil self.emojiFileDataProperties = nil - + if let animationLayer = self.animationLayer { self.animationLayer = nil - + if !transition.animation.isImmediate { animationLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak animationLayer] _ in animationLayer?.removeFromSuperlayer() @@ -437,7 +492,7 @@ public final class EmojiStatusComponent: Component { } if let lottieAnimationView = self.lottieAnimationView { self.lottieAnimationView = nil - + if !transition.animation.isImmediate { lottieAnimationView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak lottieAnimationView] _ in lottieAnimationView?.removeFromSuperview() @@ -449,13 +504,30 @@ public final class EmojiStatusComponent: Component { } } } - + switch animationContent { case let .file(file): self.emojiFile = file case .customEmoji: break } + case .winterGramBadge(_): + iconImage = nil + + if let animationLayer = self.animationLayer { + self.animationLayer = nil + animationLayer.removeFromSuperlayer() + } + if let lottieAnimationView = self.lottieAnimationView { + self.lottieAnimationView = nil + lottieAnimationView.removeFromSuperview() + } + self.emojiFile = nil + self.emojiFileDataProperties = nil + self.emojiFileDisposable?.dispose() + self.emojiFileDisposable = nil + self.emojiFileDataPathDisposable?.dispose() + self.emojiFileDataPathDisposable = nil } } else { iconImage = self.iconLayerImage @@ -469,11 +541,11 @@ public final class EmojiStatusComponent: Component { iconTintColor = color } } - + self.component = component - + var size = CGSize() - + if let iconImage = iconImage { let iconLayer: SimpleLayer if let current = self.iconLayer { @@ -482,7 +554,7 @@ public final class EmojiStatusComponent: Component { iconLayer = SimpleLayer() self.iconLayer = iconLayer self.layer.addSublayer(iconLayer) - + if !transition.animation.isImmediate { iconLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) iconLayer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5) @@ -492,13 +564,13 @@ public final class EmojiStatusComponent: Component { self.iconLayerImage = iconImage iconLayer.contents = iconImage.cgImage } - + if let iconTintColor { transition.setTintColor(layer: iconLayer, color: iconTintColor) } else { iconLayer.layerTintColor = nil } - + var useFit = false switch component.content { case .text: @@ -518,7 +590,7 @@ public final class EmojiStatusComponent: Component { } else { if let iconLayer = self.iconLayer { self.iconLayer = nil - + if !transition.animation.isImmediate { iconLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak iconLayer] _ in iconLayer?.removeFromSuperlayer() @@ -530,17 +602,66 @@ public final class EmojiStatusComponent: Component { } self.iconLayerImage = nil } - + + 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. + let backplateImage = winterGramBakeTintedShape("WntGramBackplateShape", color: backplateColor) + let snowflakeImage = winterGramBakeTintedShape("WntGramSnowflakeShape", color: .white) + + let backplateLayer: SimpleLayer + if let current = self.winterGramBackplateLayer { + backplateLayer = current + } else { + backplateLayer = SimpleLayer() + self.winterGramBackplateLayer = backplateLayer + self.layer.addSublayer(backplateLayer) + } + let snowflakeLayer: SimpleLayer + if let current = self.winterGramSnowflakeLayer { + snowflakeLayer = current + } else { + snowflakeLayer = SimpleLayer() + self.winterGramSnowflakeLayer = snowflakeLayer + self.layer.addSublayer(snowflakeLayer) + } + + backplateLayer.contents = backplateImage + snowflakeLayer.contents = snowflakeImage + + backplateLayer.frame = CGRect(origin: .zero, size: availableSize) + // Snowflake: 756/1024 of the canvas, centred at the 134/1024 inset (per the badge spec), + // so the rotating backplate spins around the static, centred snowflake. + let snowflakeInset = availableSize.width * 134.0 / 1024.0 + let snowflakeSide = availableSize.width * 756.0 / 1024.0 + snowflakeLayer.frame = CGRect(x: snowflakeInset, y: snowflakeInset, width: snowflakeSide, height: snowflakeSide) + + // WinterGram badge always spins (independent of the GIF/energy autoplay setting). + self.winterGramBadgeAnimating = true + self.applyWinterGramBadgeRotation() + } else { + self.winterGramBadgeAnimating = false + if let backplateLayer = self.winterGramBackplateLayer { + self.winterGramBackplateLayer = nil + backplateLayer.removeFromSuperlayer() + } + if let snowflakeLayer = self.winterGramSnowflakeLayer { + self.winterGramSnowflakeLayer = nil + snowflakeLayer.removeFromSuperlayer() + } + } + let emojiFileUpdated = component.emojiFileUpdated if let emojiFileId = emojiFileId, let emojiPlaceholderColor = emojiPlaceholderColor, let emojiLoopMode = emojiLoopMode { size = availableSize - + if let emojiFile = self.emojiFile { self.emojiFileDisposable?.dispose() self.emojiFileDisposable = nil self.emojiFileDataPathDisposable?.dispose() self.emojiFileDataPathDisposable = nil - + let animationLayer: InlineStickerItemLayer if let current = self.animationLayer { animationLayer = current @@ -575,13 +696,13 @@ public final class EmojiStatusComponent: Component { ) self.animationLayer = animationLayer self.layer.addSublayer(animationLayer) - + if !transition.animation.isImmediate { animationLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) animationLayer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5) } } - + var accentTint = false if let _ = emojiThemeColor { if emojiFile.isCustomTemplateEmoji { @@ -600,13 +721,13 @@ public final class EmojiStatusComponent: Component { } } } - + if accentTint { animationLayer.updateTintColor(contentTintColor: emojiThemeColor, dynamicColor: emojiThemeColor, transition: transition) } else { animationLayer.updateTintColor(contentTintColor: nil, dynamicColor: nil, transition: transition) } - + animationLayer.frame = CGRect(origin: CGPoint(), size: size) animationLayer.isVisibleForAnimations = component.isVisibleForAnimations } else { @@ -619,7 +740,7 @@ public final class EmojiStatusComponent: Component { strongSelf.emojiFile = result[emojiFileId] strongSelf.emojiFileDataProperties = nil strongSelf.state?.updated(transition: transition) - + emojiFileUpdated?(result[emojiFileId]) }) } @@ -630,15 +751,15 @@ public final class EmojiStatusComponent: Component { self.emojiFileDataProperties = nil emojiFileUpdated?(nil) } - + self.emojiFileDisposable?.dispose() self.emojiFileDisposable = nil self.emojiFileDataPathDisposable?.dispose() self.emojiFileDataPathDisposable = nil - + if let animationLayer = self.animationLayer { self.animationLayer = nil - + if !transition.animation.isImmediate { animationLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak animationLayer] _ in animationLayer?.removeFromSuperlayer() @@ -650,7 +771,7 @@ public final class EmojiStatusComponent: Component { } if let lottieAnimationView = self.lottieAnimationView { self.lottieAnimationView = nil - + if !transition.animation.isImmediate { lottieAnimationView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak lottieAnimationView] _ in lottieAnimationView?.removeFromSuperview() @@ -661,7 +782,7 @@ public final class EmojiStatusComponent: Component { } } } - + return size } } @@ -669,7 +790,7 @@ public final class EmojiStatusComponent: Component { public func makeView() -> View { return View(frame: CGRect()) } - + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } @@ -684,27 +805,27 @@ public func topicIconColors(for color: Int32) -> ([UInt32], [UInt32]) { 0xFF93B2: ([0xFF93B2, 0xE23264], [0xFC447A, 0xC80C46]), 0xFB6F5F: ([0xFB6F5F, 0xD72615], [0xDC1908, 0xB61506]) ] - + return topicColors[color] ?? ([0x6FB9F0, 0x0261E4], [0x026CB5, 0x064BB7]) } public final class StarsEffectLayer: SimpleLayer { private let emitterLayer = CAEmitterLayer() - + public override init() { super.init() - + self.addSublayer(self.emitterLayer) } - + override init(layer: Any) { super.init(layer: layer) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private func setup(color: UIColor, size: CGSize) { let emitter = CAEmitterCell() emitter.name = "emitter" @@ -716,7 +837,7 @@ public final class StarsEffectLayer: SimpleLayer { emitter.scaleRange = 0.02 emitter.alphaRange = 0.1 emitter.emissionRange = .pi * 2.0 - + let staticColors: [Any] = [ color.withAlphaComponent(0.0).cgColor, color.withAlphaComponent(0.58).cgColor, @@ -728,7 +849,7 @@ public final class StarsEffectLayer: SimpleLayer { emitter.setValue([staticColorBehavior], forKey: "emitterBehaviors") self.emitterLayer.emitterCells = [emitter] } - + public func update(color: UIColor, size: CGSize) { if self.emitterLayer.emitterCells == nil { self.setup(color: color, size: size) diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentSignals.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentSignals.swift index c7b08f6fa2..ab6c9a12fc 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentSignals.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentSignals.swift @@ -4,13 +4,14 @@ import AccountContext import TelegramCore import Postbox import SwiftSignalKit +import TelegramUIPreferences import AnimationCache import MultiAnimationRenderer import TelegramNotices import FlatBuffers import FlatSerialization -public extension EmojiPagerContentComponent { +public extension EmojiPagerContentComponent { private static func hasPremium(context: AccountContext, chatPeerId: EnginePeer.Id?, premiumIfSavedMessages: Bool) -> Signal { let hasPremium: Signal if premiumIfSavedMessages, let chatPeerId = chatPeerId, chatPeerId == context.account.peerId { @@ -27,7 +28,7 @@ public extension EmojiPagerContentComponent { } return hasPremium } - + enum Subject: Equatable { case generic case status @@ -43,7 +44,7 @@ public extension EmojiPagerContentComponent { case reactionList case stickerAlt } - + static func emojiInputData( context: AccountContext, animationCache: AnimationCache, @@ -70,14 +71,14 @@ public extension EmojiPagerContentComponent { ) -> Signal { let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) let isPremiumDisabled = premiumConfiguration.isPremiumDisabled - + let strings = context.sharedContext.currentPresentationData.with({ $0 }).strings - + struct PeerSpecificPackData: Equatable { var info: StickerPackCollectionInfo.Accessor var items: [StickerPackItem] var peer: EnginePeer - + static func ==(lhs: PeerSpecificPackData, rhs: PeerSpecificPackData) -> Bool { if lhs.info.id != rhs.info.id { return false @@ -88,11 +89,11 @@ public extension EmojiPagerContentComponent { if lhs.peer != rhs.peer { return false } - + return true } } - + let peerSpecificPack: Signal if let chatPeerId = chatPeerId { peerSpecificPack = combineLatest( @@ -103,34 +104,34 @@ public extension EmojiPagerContentComponent { guard let peer = peer else { return nil } - + guard let (info, items) = packData.packInfo else { return nil } - + return PeerSpecificPackData(info: info, items: items.compactMap { $0 as? StickerPackItem }, peer: peer) } |> distinctUntilChanged } else { peerSpecificPack = .single(nil) } - + var orderedItemListCollectionIds: [Int32] = [] - + switch subject { case .backgroundIcon, .reactionList: break default: orderedItemListCollectionIds.append(Namespaces.OrderedItemList.LocalRecentEmoji) } - + var iconStatusEmoji: Signal<[TelegramMediaFile], NoError> = .single([]) - + if case .status = subject { orderedItemListCollectionIds.append(Namespaces.OrderedItemList.CloudFeaturedStatusEmoji) orderedItemListCollectionIds.append(Namespaces.OrderedItemList.CloudRecentStatusEmoji) orderedItemListCollectionIds.append(Namespaces.OrderedItemList.CloudUniqueStarGifts) - + iconStatusEmoji = context.engine.stickers.loadedStickerPack(reference: .iconStatusEmoji, forceActualized: false) |> map { result -> [TelegramMediaFile] in switch result { @@ -144,7 +145,7 @@ public extension EmojiPagerContentComponent { } else if case .channelStatus = subject { orderedItemListCollectionIds.append(Namespaces.OrderedItemList.CloudFeaturedChannelStatusEmoji) orderedItemListCollectionIds.append(Namespaces.OrderedItemList.CloudDisabledChannelStatusEmoji) - + iconStatusEmoji = context.engine.stickers.loadedStickerPack(reference: .iconChannelStatusEmoji, forceActualized: false) |> map { result -> [TelegramMediaFile] in switch result { @@ -178,14 +179,14 @@ public extension EmojiPagerContentComponent { } else if case .backgroundIcon = subject { orderedItemListCollectionIds.append(Namespaces.OrderedItemList.CloudFeaturedBackgroundIconEmoji) } - + let availableReactions: Signal if [.reaction(onlyTop: false), .quickReaction, .reactionList].contains(subject) { availableReactions = context.engine.stickers.availableReactions() } else { availableReactions = .single(nil) } - + let searchCategories: Signal if [.emoji, .reaction(onlyTop: false), .reactionList, .messageTag].contains(subject) { searchCategories = context.engine.stickers.emojiSearchCategories(kind: .emoji) @@ -198,7 +199,7 @@ public extension EmojiPagerContentComponent { } else { searchCategories = .single(nil) } - + let emojiItems: Signal = combineLatest( context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: orderedItemListCollectionIds, namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000), forceHasPremium ? .single(true) : hasPremium(context: context, chatPeerId: chatPeerId, premiumIfSavedMessages: premiumIfSavedMessages), @@ -225,14 +226,14 @@ public extension EmojiPagerContentComponent { } var itemGroups: [ItemGroup] = [] var itemGroupIndexById: [AnyHashable: Int] = [:] - + let maybeAppendUnicodeEmoji = { let groupId: AnyHashable = "static" - + if itemGroupIndexById[groupId] != nil { return } - + if areUnicodeEmojiEnabled { for (subgroupId, list) in staticEmojiMapping { for emojiString in list { @@ -244,12 +245,12 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: .none ) - + if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) } else { itemGroupIndexById[groupId] = itemGroups.count - + let title: String? if case .stickerAlt = subject { title = nil @@ -262,27 +263,27 @@ public extension EmojiPagerContentComponent { } } } - + var installedCollectionIds = Set() for (id, _, _) in view.collectionInfos { installedCollectionIds.insert(id) } - + let dismissedTrendingEmojiPacksSet = Set(dismissedTrendingEmojiPacks ?? []) let featuredEmojiPacksSet = Set(featuredEmojiPacks.map(\.info.id.id)) - - if dismissedTrendingEmojiPacksSet != featuredEmojiPacksSet && hasTrending { + + if dismissedTrendingEmojiPacksSet != featuredEmojiPacksSet && hasTrending && !currentWinterGramSettings.showOnlyAddedEmojisAndStickers { for featuredEmojiPack in featuredEmojiPacks { if installedCollectionIds.contains(featuredEmojiPack.info.id) { continue } - + guard let item = featuredEmojiPack.topItems.first else { continue } - + let animationData: EntityKeyboardAnimationData - + if let thumbnailDimensions = featuredEmojiPack.info.thumbnailDimensions { let type: EntityKeyboardAnimationData.ItemType if item.file.isAnimatedSticker { @@ -294,7 +295,7 @@ public extension EmojiPagerContentComponent { } else { type = .still } - + animationData = EntityKeyboardAnimationData( id: .stickerPackThumbnail(featuredEmojiPack.info.id), type: type, @@ -307,12 +308,12 @@ public extension EmojiPagerContentComponent { } else { animationData = EntityKeyboardAnimationData(file: item.file) } - + var tintMode: Item.TintMode = .none if item.file.isCustomTemplateEmoji { tintMode = .primary } - + let resultItem = EmojiPagerContentComponent.Item( animationData: animationData, content: .animation(animationData), @@ -321,7 +322,7 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: tintMode ) - + let supergroupId = "featuredTop" let groupId: AnyHashable = supergroupId let isPremiumLocked: Bool = item.file.isPremiumSticker && !hasPremium @@ -332,13 +333,13 @@ public extension EmojiPagerContentComponent { itemGroups[groupIndex].items.append(resultItem) } else { itemGroupIndexById[groupId] = itemGroups.count - + let title = strings.EmojiInput_TrendingEmoji itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: title, subtitle: nil, badge: nil, isPremiumLocked: false, isFeatured: false, collapsedLineCount: 0, isClearable: false, headerItem: nil, items: [resultItem])) } } } - + var recentEmoji: OrderedItemListView? var featuredStatusEmoji: OrderedItemListView? var featuredChannelStatusEmoji: OrderedItemListView? @@ -377,7 +378,7 @@ public extension EmojiPagerContentComponent { defaultTagReactions = orderedView } } - + if case .stickerAlt = subject { for emoji in topEmojiItems { let resultItem = EmojiPagerContentComponent.Item( @@ -388,7 +389,7 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: .none ) - + let groupId = "recent" if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) @@ -397,7 +398,7 @@ public extension EmojiPagerContentComponent { itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: nil, subtitle: nil, badge: nil, isPremiumLocked: false, isFeatured: false, collapsedLineCount: 5, isClearable: false, headerItem: nil, items: [resultItem])) } } - + } else if case .topicIcon = subject { let resultItem = EmojiPagerContentComponent.Item( animationData: nil, @@ -407,7 +408,7 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: .none ) - + let groupId = "recent" if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) @@ -415,15 +416,15 @@ public extension EmojiPagerContentComponent { itemGroupIndexById[groupId] = itemGroups.count itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: nil, subtitle: nil, badge: nil, isPremiumLocked: false, isFeatured: false, collapsedLineCount: 5, isClearable: false, headerItem: nil, items: [resultItem])) } - + var existingIds = Set() - + for file in iconStatusEmoji { if existingIds.contains(file.fileId) { continue } existingIds.insert(file.fileId) - + var tintMode: Item.TintMode = .none if file.isCustomTemplateEmoji { tintMode = .accent @@ -440,9 +441,9 @@ public extension EmojiPagerContentComponent { } } } - + let resultItem: EmojiPagerContentComponent.Item - + let animationData = EntityKeyboardAnimationData(file: TelegramMediaFile.Accessor(file)) resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -452,7 +453,7 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: tintMode ) - + if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) } @@ -466,7 +467,7 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: .none ) - + let groupId = "recent" if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) @@ -474,15 +475,15 @@ public extension EmojiPagerContentComponent { itemGroupIndexById[groupId] = itemGroups.count itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: topStatusTitle?.uppercased(), subtitle: nil, badge: nil, isPremiumLocked: false, isFeatured: false, collapsedLineCount: 5, isClearable: false, headerItem: nil, items: [resultItem])) } - + var existingIds = Set() - + for file in iconStatusEmoji.prefix(7) { if existingIds.contains(file.fileId) { continue } existingIds.insert(file.fileId) - + var tintMode: Item.TintMode = .none if file.isCustomTemplateEmoji { tintMode = .accent @@ -499,9 +500,9 @@ public extension EmojiPagerContentComponent { } } } - + let resultItem: EmojiPagerContentComponent.Item - + let animationData = EntityKeyboardAnimationData(file: TelegramMediaFile.Accessor(file)) resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -511,24 +512,24 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: tintMode ) - + if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) } } - + if let recentStatusEmoji = recentStatusEmoji { for item in recentStatusEmoji.items { guard let item = item.contents.get(RecentMediaItem.self) else { continue } - + let file = item.media if existingIds.contains(file.fileId) { continue } existingIds.insert(file.fileId) - + var tintMode: Item.TintMode = .none if file.isCustomTemplateEmoji { tintMode = .accent @@ -536,9 +537,9 @@ public extension EmojiPagerContentComponent { if file.internal_isHardcodedTemplateEmoji { tintMode = .accent } - + let resultItem: EmojiPagerContentComponent.Item - + let animationData = EntityKeyboardAnimationData(file: file) resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -548,12 +549,12 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: tintMode ) - + if let groupIndex = itemGroupIndexById[groupId] { if itemGroups[groupIndex].items.count >= (5 + 8) * 8 { break } - + itemGroups[groupIndex].items.append(resultItem) } } @@ -563,15 +564,15 @@ public extension EmojiPagerContentComponent { guard let item = item.contents.get(RecentMediaItem.self) else { continue } - + let file = item.media if existingIds.contains(file.fileId) { continue } existingIds.insert(file.fileId) - + let resultItem: EmojiPagerContentComponent.Item - + var tintMode: Item.TintMode = .none if file.isCustomTemplateEmoji { tintMode = .accent @@ -579,7 +580,7 @@ public extension EmojiPagerContentComponent { if file.internal_isHardcodedTemplateEmoji { tintMode = .accent } - + let animationData = EntityKeyboardAnimationData(file: file) resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -589,17 +590,17 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: tintMode ) - + if let groupIndex = itemGroupIndexById[groupId] { if itemGroups[groupIndex].items.count >= (5 + 8) * 8 { break } - + itemGroups[groupIndex].items.append(resultItem) } } } - + if let uniqueGifts, !uniqueGifts.items.isEmpty { let groupId = "collectible" let groupIndex: Int @@ -610,7 +611,7 @@ public extension EmojiPagerContentComponent { itemGroupIndexById[groupId] = groupIndex itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: strings.EmojiInput_SectionTitleCollectibles.uppercased(), subtitle: nil, badge: nil, isPremiumLocked: false, isFeatured: false, collapsedLineCount: 2, isClearable: false, headerItem: nil, items: [])) } - + for item in uniqueGifts.items { guard let item = item.contents.get(RecentStarGiftItem.self) else { continue @@ -639,7 +640,7 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: .accent ) - + let groupId = "recent" if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) @@ -647,9 +648,9 @@ public extension EmojiPagerContentComponent { itemGroupIndexById[groupId] = itemGroups.count itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: topStatusTitle?.uppercased(), subtitle: nil, badge: nil, isPremiumLocked: false, isFeatured: false, collapsedLineCount: 5, isClearable: false, headerItem: nil, items: [resultItem])) } - + var existingIds = Set() - + if let disabledChannelStatusEmoji { for item in disabledChannelStatusEmoji.items { guard let item = item.contents.get(RecentMediaItem.self) else { @@ -659,13 +660,13 @@ public extension EmojiPagerContentComponent { existingIds.insert(file.fileId) } } - + for file in iconStatusEmoji.prefix(7) { if existingIds.contains(file.fileId) { continue } existingIds.insert(file.fileId) - + var tintMode: Item.TintMode = .none if file.isCustomTemplateEmoji { tintMode = .accent @@ -682,9 +683,9 @@ public extension EmojiPagerContentComponent { } } } - + let resultItem: EmojiPagerContentComponent.Item - + let animationData = EntityKeyboardAnimationData(file: TelegramMediaFile.Accessor(file)) resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -694,26 +695,26 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: tintMode ) - + if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) } } - + if let featuredChannelStatusEmoji { for item in featuredChannelStatusEmoji.items { guard let item = item.contents.get(RecentMediaItem.self) else { continue } - + let file = item.media if existingIds.contains(file.fileId) { continue } existingIds.insert(file.fileId) - + let resultItem: EmojiPagerContentComponent.Item - + var tintMode: Item.TintMode = .none if file.isCustomTemplateEmoji { tintMode = .accent @@ -721,7 +722,7 @@ public extension EmojiPagerContentComponent { if file.internal_isHardcodedTemplateEmoji { tintMode = .accent } - + let animationData = EntityKeyboardAnimationData(file: file) resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -731,7 +732,7 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: tintMode ) - + if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) } @@ -739,7 +740,7 @@ public extension EmojiPagerContentComponent { } } else if case .reactionList = subject { var existingIds = Set() - + if let availableReactions = availableReactions { for reactionItem in availableReactions.reactions { if !reactionItem.isEnabled { @@ -749,19 +750,19 @@ public extension EmojiPagerContentComponent { continue } existingIds.insert(reactionItem.value) - + let icon: EmojiPagerContentComponent.Item.Icon if !hasPremium, case .custom = reactionItem.value { icon = .locked } else { icon = .none } - + var tintMode: Item.TintMode = .none if reactionItem.selectAnimation.isCustomTemplateEmoji { tintMode = .primary } - + let animationFile = reactionItem.selectAnimation let animationData = EntityKeyboardAnimationData(file: animationFile, isReaction: true) let resultItem = EmojiPagerContentComponent.Item( @@ -772,7 +773,7 @@ public extension EmojiPagerContentComponent { icon: icon, tintMode: tintMode ) - + let groupId = "liked" if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) @@ -784,7 +785,7 @@ public extension EmojiPagerContentComponent { } } else if [.reaction(onlyTop: true), .reaction(onlyTop: false), .quickReaction].contains(subject) { var existingIds = Set() - + var topReactionItems = topReactionItems if topReactionItems.isEmpty { if let topReactions = topReactions { @@ -792,7 +793,7 @@ public extension EmojiPagerContentComponent { guard let topReaction = item.contents.get(RecentReactionItem.self) else { continue } - + switch topReaction.content { case let .builtin(value): if let reaction = availableReactions?.reactions.first(where: { $0.value == .builtin(value) }) { @@ -810,7 +811,7 @@ public extension EmojiPagerContentComponent { } } } - + let maxTopLineCount: Int if case .reaction(onlyTop: true) = subject { maxTopLineCount = 1000 @@ -819,13 +820,13 @@ public extension EmojiPagerContentComponent { } else { maxTopLineCount = 6 } - + for reactionItem in topReactionItems { if existingIds.contains(reactionItem.reaction) { continue } existingIds.insert(reactionItem.reaction) - + let icon: EmojiPagerContentComponent.Item.Icon if case .reaction(onlyTop: true) = subject { icon = .none @@ -834,12 +835,12 @@ public extension EmojiPagerContentComponent { } else { icon = .none } - + var tintMode: Item.TintMode = .none if reactionItem.file.isCustomTemplateEmoji { tintMode = .primary } - + let animationFile = reactionItem.file let animationData = EntityKeyboardAnimationData(file: animationFile, isReaction: true) let resultItem = EmojiPagerContentComponent.Item( @@ -850,11 +851,11 @@ public extension EmojiPagerContentComponent { icon: icon, tintMode: tintMode ) - + let groupId = "recent" if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) - + if itemGroups[groupIndex].items.count >= 8 * maxTopLineCount { break } @@ -863,22 +864,22 @@ public extension EmojiPagerContentComponent { itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: nil, subtitle: nil, badge: nil, isPremiumLocked: false, isFeatured: false, collapsedLineCount: nil, isClearable: false, headerItem: nil, items: [resultItem])) } } - + if case .reaction(onlyTop: false) = subject { var hasRecent = false if let recentReactions = recentReactions, !recentReactions.items.isEmpty { hasRecent = true } - + let maxRecentLineCount: Int if hasPremium { maxRecentLineCount = 10 } else { maxRecentLineCount = 10 } - + let popularTitle = hasRecent ? strings.Chat_ReactionSection_Recent : strings.Chat_ReactionSection_Popular - + if let availableReactions = availableReactions { for reactionItem in availableReactions.reactions { if !reactionItem.isEnabled { @@ -888,19 +889,19 @@ public extension EmojiPagerContentComponent { continue } existingIds.insert(reactionItem.value) - + let icon: EmojiPagerContentComponent.Item.Icon if !hasPremium, case .custom = reactionItem.value { icon = .locked } else { icon = .none } - + var tintMode: Item.TintMode = .none if reactionItem.selectAnimation.isCustomTemplateEmoji { tintMode = .primary } - + let animationFile = reactionItem.selectAnimation let animationData = EntityKeyboardAnimationData(file: animationFile, isReaction: true) let resultItem = EmojiPagerContentComponent.Item( @@ -911,7 +912,7 @@ public extension EmojiPagerContentComponent { icon: icon, tintMode: tintMode ) - + if hasPremium { let groupId = "popular" if let groupIndex = itemGroupIndexById[groupId] { @@ -924,7 +925,7 @@ public extension EmojiPagerContentComponent { let groupId = "recent" if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) - + if itemGroups[groupIndex].items.count >= maxRecentLineCount * 8 { break } @@ -935,17 +936,17 @@ public extension EmojiPagerContentComponent { } } } - + if let recentReactions = recentReactions { var popularInsertIndex = 0 for item in recentReactions.items { guard let item = item.contents.get(RecentReactionItem.self) else { continue } - + let animationFile: TelegramMediaFile.Accessor let icon: EmojiPagerContentComponent.Item.Icon - + switch item.content { case let .builtin(value): if existingIds.contains(.builtin(value)) { @@ -961,7 +962,7 @@ public extension EmojiPagerContentComponent { } else { continue } - + icon = .none case let .custom(file): if existingIds.contains(.custom(file.fileId.id)) { @@ -969,7 +970,7 @@ public extension EmojiPagerContentComponent { } existingIds.insert(.custom(file.fileId.id)) animationFile = file - + if !hasPremium { icon = .locked } else { @@ -989,15 +990,15 @@ public extension EmojiPagerContentComponent { } else { continue } - + icon = .none } - + var tintMode: Item.TintMode = .none if animationFile.isCustomTemplateEmoji { tintMode = .primary } - + let animationData = EntityKeyboardAnimationData(file: animationFile, isReaction: true) let resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -1007,13 +1008,13 @@ public extension EmojiPagerContentComponent { icon: icon, tintMode: tintMode ) - + let groupId = "popular" if let groupIndex = itemGroupIndexById[groupId] { if itemGroups[groupIndex].items.count + 1 >= maxRecentLineCount * 8 { break } - + itemGroups[groupIndex].items.insert(resultItem, at: popularInsertIndex) popularInsertIndex += 1 } else { @@ -1025,7 +1026,7 @@ public extension EmojiPagerContentComponent { } } else if case .messageTag = subject { var existingIds = Set() - + var topReactionItems = topReactionItems if topReactionItems.isEmpty { if let defaultTagReactions { @@ -1033,7 +1034,7 @@ public extension EmojiPagerContentComponent { guard let topReaction = item.contents.get(RecentReactionItem.self) else { continue } - + switch topReaction.content { case let .builtin(value): if let reaction = availableReactions?.reactions.first(where: { $0.value == .builtin(value) }) { @@ -1053,22 +1054,22 @@ public extension EmojiPagerContentComponent { } } } - + let maxTopLineCount: Int = 1000 - + for reactionItem in topReactionItems { if existingIds.contains(reactionItem.reaction) { continue } existingIds.insert(reactionItem.reaction) - + let icon: EmojiPagerContentComponent.Item.Icon = .none - + var tintMode: Item.TintMode = .none if reactionItem.file.isCustomTemplateEmoji { tintMode = .primary } - + let animationFile = reactionItem.file let animationData = EntityKeyboardAnimationData(file: animationFile, isReaction: true) let resultItem = EmojiPagerContentComponent.Item( @@ -1079,11 +1080,11 @@ public extension EmojiPagerContentComponent { icon: icon, tintMode: tintMode ) - + let groupId = "recent" if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) - + if itemGroups[groupIndex].items.count >= 8 * maxTopLineCount { break } @@ -1094,25 +1095,25 @@ public extension EmojiPagerContentComponent { } } else if [.profilePhoto, .groupPhoto].contains(subject) { var existingIds = Set() - + let groupId = "recent" itemGroupIndexById[groupId] = itemGroups.count itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: topStatusTitle?.uppercased(), subtitle: nil, badge: nil, isPremiumLocked: false, isFeatured: false, collapsedLineCount: 5, isClearable: false, headerItem: nil, items: [])) - + if let featuredAvatarEmoji = featuredAvatarEmoji { for item in featuredAvatarEmoji.items { guard let item = item.contents.get(RecentMediaItem.self) else { continue } - + let file = item.media if existingIds.contains(file.fileId) { continue } existingIds.insert(file.fileId) - + let resultItem: EmojiPagerContentComponent.Item - + var tintMode: Item.TintMode = .none if file.isCustomTemplateEmoji { tintMode = .accent @@ -1120,7 +1121,7 @@ public extension EmojiPagerContentComponent { if file.internal_isHardcodedTemplateEmoji { tintMode = .accent } - + let animationData = EntityKeyboardAnimationData(file: file) resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -1130,19 +1131,19 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: tintMode ) - + if let groupIndex = itemGroupIndexById[groupId] { if itemGroups[groupIndex].items.count >= (5 + 8) * 8 { break } - + itemGroups[groupIndex].items.append(resultItem) } } } } else if case .backgroundIcon = subject { var existingIds = Set() - + let resultItem = EmojiPagerContentComponent.Item( animationData: nil, content: .icon(.stop), @@ -1151,7 +1152,7 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: .accent ) - + let groupId = "recent" if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) @@ -1159,21 +1160,21 @@ public extension EmojiPagerContentComponent { itemGroupIndexById[groupId] = itemGroups.count itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: nil, subtitle: nil, badge: nil, isPremiumLocked: false, isFeatured: false, collapsedLineCount: 5, isClearable: false, headerItem: nil, items: [resultItem])) } - + if let featuredBackgroundIconEmoji { for item in featuredBackgroundIconEmoji.items { guard let item = item.contents.get(RecentMediaItem.self) else { continue } - + let file = item.media if existingIds.contains(file.fileId) { continue } existingIds.insert(file.fileId) - + let resultItem: EmojiPagerContentComponent.Item - + var tintMode: Item.TintMode = .none if file.isCustomTemplateEmoji { if let backgroundIconColor { @@ -1185,7 +1186,7 @@ public extension EmojiPagerContentComponent { if file.internal_isHardcodedTemplateEmoji { tintMode = .accent } - + let animationData = EntityKeyboardAnimationData(file: file) resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -1195,34 +1196,34 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: tintMode ) - + if let groupIndex = itemGroupIndexById[groupId] { if itemGroups[groupIndex].items.count >= (5 + 8) * 8 { break } - + itemGroups[groupIndex].items.append(resultItem) } } } } - + let hasRecentEmoji = ![.reaction(onlyTop: true), .reaction(onlyTop: false), .quickReaction, .status, .profilePhoto, .groupPhoto, .topicIcon, .backgroundIcon, .reactionList, .messageTag, .stickerAlt].contains(subject) - + if let recentEmoji = recentEmoji, hasRecentEmoji { for item in recentEmoji.items { guard let item = item.contents.get(RecentEmojiItem.self) else { continue } - + if case let .file(file) = item.content, isPremiumDisabled, file.isPremiumEmoji { continue } - + if !areCustomEmojiEnabled, case .file = item.content { continue } - + let resultItem: EmojiPagerContentComponent.Item switch item.content { case let .file(file): @@ -1230,7 +1231,7 @@ public extension EmojiPagerContentComponent { if file.isCustomTemplateEmoji { tintMode = .primary } - + let animationData = EntityKeyboardAnimationData(file: TelegramMediaFile.Accessor(file)) resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -1253,7 +1254,7 @@ public extension EmojiPagerContentComponent { tintMode: .none ) } - + let groupId = "recent" if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) @@ -1263,20 +1264,20 @@ public extension EmojiPagerContentComponent { } } } - + var itemCollectionMapping: [ItemCollectionId: StickerPackCollectionInfo] = [:] for (id, info, _) in view.collectionInfos { if let info = info as? StickerPackCollectionInfo { itemCollectionMapping[id] = info } } - + var skippedCollectionIds = Set() - + var avatarPeer: EnginePeer? if let peerSpecificPack = peerSpecificPack { avatarPeer = peerSpecificPack.peer - + var processedIds = Set() for item in peerSpecificPack.items { if isPremiumDisabled && item.file.isPremiumSticker { @@ -1286,12 +1287,12 @@ public extension EmojiPagerContentComponent { continue } processedIds.insert(item.file.fileId) - + var tintMode: Item.TintMode = .none if item.file.isCustomTemplateEmoji { tintMode = .primary } - + let animationData = EntityKeyboardAnimationData(file: item.file) let resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -1301,7 +1302,7 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: tintMode ) - + let groupId = "peerSpecific" if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) @@ -1310,33 +1311,33 @@ public extension EmojiPagerContentComponent { itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: peerSpecificPack.info.title, subtitle: nil, badge: strings.Emoji_GroupEmoji, isPremiumLocked: false, isFeatured: false, collapsedLineCount: nil, isClearable: false, headerItem: nil, items: [resultItem])) } } - + let supergroupId: AnyHashable = peerSpecificPack.info.id skippedCollectionIds.insert(supergroupId) } - + if !hasPremium { maybeAppendUnicodeEmoji() } - + if areCustomEmojiEnabled { for entry in view.entries { guard let item = entry.item as? StickerPackItem else { continue } - + var icon: EmojiPagerContentComponent.Item.Icon = .none if [.reaction(onlyTop: false), .quickReaction].contains(subject), !hasPremium { icon = .locked } - + let supergroupId = entry.index.collectionId let groupId: AnyHashable = supergroupId - + if skippedCollectionIds.contains(groupId) { continue } - + if case .channelStatus = subject { guard let collection = itemCollectionMapping[entry.index.collectionId] else { continue @@ -1345,7 +1346,7 @@ public extension EmojiPagerContentComponent { continue } } - + var isTemplate = false var tintMode: Item.TintMode = .none if item.file.isCustomTemplateEmoji { @@ -1365,7 +1366,7 @@ public extension EmojiPagerContentComponent { skippedCollectionIds.insert(groupId) continue } - + let animationData = EntityKeyboardAnimationData(file: item.file) let resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -1375,7 +1376,7 @@ public extension EmojiPagerContentComponent { icon: icon, tintMode: tintMode ) - + let isPremiumLocked: Bool = item.file.isPremiumEmoji && !hasPremium if isPremiumLocked && isPremiumDisabled { continue @@ -1384,13 +1385,13 @@ public extension EmojiPagerContentComponent { itemGroups[groupIndex].items.append(resultItem) } else { itemGroupIndexById[groupId] = itemGroups.count - + var title = "" var headerItem: EntityKeyboardAnimationData? inner: for (id, info, _) in view.collectionInfos { if id == entry.index.collectionId, let info = info as? StickerPackCollectionInfo { title = info.title - + if let thumbnail = info.thumbnail { let type: EntityKeyboardAnimationData.ItemType if item.file.isAnimatedSticker { @@ -1402,7 +1403,7 @@ public extension EmojiPagerContentComponent { } else { type = .still } - + headerItem = EntityKeyboardAnimationData( id: .stickerPackThumbnail(info.id), type: type, @@ -1413,33 +1414,33 @@ public extension EmojiPagerContentComponent { isTemplate: isTemplate ) } - + break inner } } itemGroups.append(ItemGroup(supergroupId: supergroupId, id: groupId, title: title, subtitle: nil, badge: nil, isPremiumLocked: isPremiumLocked, isFeatured: false, collapsedLineCount: nil, isClearable: false, headerItem: headerItem, items: [resultItem])) } } - + if !isStandalone { for featuredEmojiPack in featuredEmojiPacks { if installedCollectionIds.contains(featuredEmojiPack.info.id) { continue } - + let supergroupId = featuredEmojiPack.info.id let groupId: AnyHashable = supergroupId - + if skippedCollectionIds.contains(groupId) { continue } - + if case .channelStatus = subject { if !featuredEmojiPack.info.flags.contains(.isAvailableAsChannelStatus) { continue } } - + for item in featuredEmojiPack.topItems { var tintMode: Item.TintMode = .none if item.file.isCustomTemplateEmoji { @@ -1456,7 +1457,7 @@ public extension EmojiPagerContentComponent { skippedCollectionIds.insert(groupId) continue } - + let animationData = EntityKeyboardAnimationData(file: item.file) let resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -1466,7 +1467,7 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: tintMode ) - + let isPremiumLocked: Bool = item.file.isPremiumEmoji && !hasPremium if isPremiumLocked && isPremiumDisabled { continue @@ -1475,7 +1476,7 @@ public extension EmojiPagerContentComponent { itemGroups[groupIndex].items.append(resultItem) } else { itemGroupIndexById[groupId] = itemGroups.count - + var headerItem: EntityKeyboardAnimationData? if let thumbnailFileId = featuredEmojiPack.info.thumbnailFileId, let file = featuredEmojiPack.topItems.first(where: { $0.file.fileId.id == thumbnailFileId }) { headerItem = EntityKeyboardAnimationData(file: file.file) @@ -1491,7 +1492,7 @@ public extension EmojiPagerContentComponent { } else { type = .still } - + headerItem = EntityKeyboardAnimationData( id: .stickerPackThumbnail(info.id), type: type, @@ -1502,23 +1503,23 @@ public extension EmojiPagerContentComponent { isTemplate: false ) } - + var isFeatured = true if case .reactionList = subject { isFeatured = false } - + itemGroups.append(ItemGroup(supergroupId: supergroupId, id: groupId, title: featuredEmojiPack.info.title, subtitle: nil, badge: nil, isPremiumLocked: isPremiumLocked, isFeatured: isFeatured, collapsedLineCount: 3, isClearable: false, headerItem: headerItem, items: [resultItem])) } } } } } - + if hasPremium { maybeAppendUnicodeEmoji() } - + var displaySearchWithPlaceholder: String? let searchInitiallyHidden = true if hasSearch { @@ -1536,7 +1537,7 @@ public extension EmojiPagerContentComponent { displaySearchWithPlaceholder = strings.Common_Search } } - + let allItemGroups = itemGroups.map { group -> EmojiPagerContentComponent.ItemGroup in var hasClear = group.isClearable var isEmbedded = false @@ -1544,9 +1545,9 @@ public extension EmojiPagerContentComponent { hasClear = true isEmbedded = true } - + var headerItem = group.headerItem - + if let groupId = group.id.base as? ItemCollectionId { outer: for (id, info, _) in view.collectionInfos { if id == groupId, let info = info as? StickerPackCollectionInfo { @@ -1561,7 +1562,7 @@ public extension EmojiPagerContentComponent { } } } - + return EmojiPagerContentComponent.ItemGroup( supergroupId: group.supergroupId, groupId: group.id, @@ -1582,10 +1583,10 @@ public extension EmojiPagerContentComponent { items: group.items ) } - + let warpContentsOnEdges = [.reaction(onlyTop: true), .reaction(onlyTop: false), .quickReaction, .status, .channelStatus, .profilePhoto, .groupPhoto, .backgroundIcon, .messageTag].contains(subject) let enableLongPress = [.reaction(onlyTop: true), .reaction(onlyTop: false), .status, .channelStatus].contains(subject) - + return EmojiPagerContentComponent( id: "emoji", context: context, @@ -1615,14 +1616,14 @@ public extension EmojiPagerContentComponent { } return emojiItems } - + enum StickersSubject { case profilePhotoEmojiSelection case groupPhotoEmojiSelection case chatStickers case greetingStickers } - + static func stickerInputData( context: AccountContext, animationCache: AnimationCache, @@ -1642,12 +1643,12 @@ public extension EmojiPagerContentComponent { ) -> Signal { let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) let isPremiumDisabled = premiumConfiguration.isPremiumDisabled - + struct PeerSpecificPackData: Equatable { var info: StickerPackCollectionInfo.Accessor var items: [StickerPackItem] var peer: EnginePeer - + static func ==(lhs: PeerSpecificPackData, rhs: PeerSpecificPackData) -> Bool { if lhs.info.id != rhs.info.id { return false @@ -1658,11 +1659,11 @@ public extension EmojiPagerContentComponent { if lhs.peer != rhs.peer { return false } - + return true } } - + let peerSpecificPack: Signal if let chatPeerId = chatPeerId { peerSpecificPack = combineLatest( @@ -1673,20 +1674,20 @@ public extension EmojiPagerContentComponent { guard let peer = peer else { return nil } - + guard let (info, items) = packData.packInfo else { return nil } - + return PeerSpecificPackData(info: info, items: items.compactMap { $0 as? StickerPackItem }, peer: peer) } |> distinctUntilChanged } else { peerSpecificPack = .single(nil) } - + let strings = context.sharedContext.currentPresentationData.with({ $0 }).strings - + let searchCategories: Signal switch subject { case .groupPhotoEmojiSelection, .profilePhotoEmojiSelection: @@ -1697,7 +1698,7 @@ public extension EmojiPagerContentComponent { guard let result else { return nil } - + var groups: [EmojiSearchCategories.Group] = [] groups = result.groups if case .greetingStickers = subject { @@ -1715,14 +1716,14 @@ public extension EmojiPagerContentComponent { groups.append(group) } } - + return EmojiSearchCategories( hash: result.hash, groups: groups ) } } - + return combineLatest( context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: stickerOrderedItemListCollectionIds, namespaces: stickerNamespaces, aroundIndex: nil, count: 10000000), hasPremium(context: context, chatPeerId: chatPeerId, premiumIfSavedMessages: false), @@ -1749,7 +1750,7 @@ public extension EmojiPagerContentComponent { } var itemGroups: [ItemGroup] = [] var itemGroupIndexById: [AnyHashable: Int] = [:] - + var savedStickers: OrderedItemListView? var recentStickers: OrderedItemListView? for orderedView in view.orderedItemListsViews { @@ -1759,28 +1760,28 @@ public extension EmojiPagerContentComponent { savedStickers = orderedView } } - + var installedCollectionIds = Set() for (id, _, _) in view.collectionInfos { installedCollectionIds.insert(id) } - + let dismissedTrendingStickerPacksSet = Set(dismissedTrendingStickerPacks ?? []) let featuredStickerPacksSet = Set(featuredStickerPacks.map(\.info.id.id)) - + if dismissedTrendingStickerPacksSet != featuredStickerPacksSet { let featuredStickersConfiguration = featuredStickersConfiguration?.get(FeaturedStickersConfiguration.self) for featuredStickerPack in featuredStickerPacks { if installedCollectionIds.contains(featuredStickerPack.info.id) { continue } - + guard let item = featuredStickerPack.topItems.first else { continue } - + let animationData: EntityKeyboardAnimationData - + if let thumbnailDimensions = featuredStickerPack.info.thumbnailDimensions { let type: EntityKeyboardAnimationData.ItemType if item.file.isAnimatedSticker { @@ -1792,7 +1793,7 @@ public extension EmojiPagerContentComponent { } else { type = .still } - + animationData = EntityKeyboardAnimationData( id: .stickerPackThumbnail(featuredStickerPack.info.id), type: type, @@ -1805,12 +1806,12 @@ public extension EmojiPagerContentComponent { } else { animationData = EntityKeyboardAnimationData(file: item.file) } - + var tintMode: Item.TintMode = .none if item.file.isCustomTemplateEmoji { tintMode = .primary } - + let resultItem = EmojiPagerContentComponent.Item( animationData: animationData, content: .animation(animationData), @@ -1819,7 +1820,7 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: tintMode ) - + let supergroupId = "featuredTop" let groupId: AnyHashable = supergroupId let isPremiumLocked: Bool = item.file.isPremiumSticker && !hasPremium @@ -1830,10 +1831,10 @@ public extension EmojiPagerContentComponent { itemGroups[groupIndex].items.append(resultItem) } else { itemGroupIndexById[groupId] = itemGroups.count - + let trendingIsPremium = featuredStickersConfiguration?.isPremium ?? false let title = trendingIsPremium ? strings.Stickers_TrendingPremiumStickers : strings.StickerPacksSettings_FeaturedPacks - + itemGroups.append( ItemGroup( supergroupId: groupId, @@ -1852,7 +1853,7 @@ public extension EmojiPagerContentComponent { } } } - + if let savedStickers = savedStickers { let groupId = "saved" for item in savedStickers.items { @@ -1862,12 +1863,12 @@ public extension EmojiPagerContentComponent { if isPremiumDisabled && item.file.isPremiumSticker { continue } - + var tintMode: Item.TintMode = .none if item.file.isCustomTemplateEmoji { tintMode = .primary } - + let animationData = EntityKeyboardAnimationData(file: item.file, partialReference: .savedSticker) let resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -1877,7 +1878,7 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: tintMode ) - + if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) } else { @@ -1886,7 +1887,7 @@ public extension EmojiPagerContentComponent { } } } - + var addedCreateStickerButton = false if let recentStickers = recentStickers { let groupId = "recent" @@ -1897,12 +1898,12 @@ public extension EmojiPagerContentComponent { if isPremiumDisabled && item.media.isPremiumSticker { continue } - + var tintMode: Item.TintMode = .none if item.media.isCustomTemplateEmoji { tintMode = .primary } - + let animationData = EntityKeyboardAnimationData(file: item.media, partialReference: .recentSticker) let resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -1912,7 +1913,7 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: tintMode ) - + if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) } else { @@ -1920,7 +1921,7 @@ public extension EmojiPagerContentComponent { itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: strings.Stickers_FrequentlyUsed, subtitle: nil, actionButtonTitle: nil, isPremiumLocked: false, isFeatured: false, displayPremiumBadges: false, hasEdit: false, headerItem: nil, items: [resultItem])) } } - + if hasAdd && !addedCreateStickerButton, let groupIndex = itemGroupIndexById[groupId] { let resultItem = EmojiPagerContentComponent.Item( animationData: nil, @@ -1934,11 +1935,11 @@ public extension EmojiPagerContentComponent { addedCreateStickerButton = true } } - + var avatarPeer: EnginePeer? if let peerSpecificPack = peerSpecificPack { avatarPeer = peerSpecificPack.peer - + var processedIds = Set() let groupId = "peerSpecific" for item in peerSpecificPack.items { @@ -1949,12 +1950,12 @@ public extension EmojiPagerContentComponent { continue } processedIds.insert(item.file.fileId) - + var tintMode: Item.TintMode = .none if item.file.isCustomTemplateEmoji { tintMode = .primary } - + let animationData = EntityKeyboardAnimationData(file: item.file) let resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -1964,7 +1965,7 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: tintMode ) - + if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) } else { @@ -1972,7 +1973,7 @@ public extension EmojiPagerContentComponent { itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: peerSpecificPack.peer.compactDisplayTitle, subtitle: nil, actionButtonTitle: nil, isPremiumLocked: false, isFeatured: false, displayPremiumBadges: false, hasEdit: false, headerItem: nil, items: [resultItem])) } } - + if hasEdit && !addedCreateStickerButton, let groupIndex = itemGroupIndexById[groupId] { let resultItem = EmojiPagerContentComponent.Item( animationData: nil, @@ -1986,17 +1987,17 @@ public extension EmojiPagerContentComponent { addedCreateStickerButton = true } } - + for entry in view.entries { guard let item = entry.item as? StickerPackItem else { continue } - + var tintMode: Item.TintMode = .none if item.file.isCustomTemplateEmoji { tintMode = .primary } - + let animationData = EntityKeyboardAnimationData(file: item.file) let resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -2011,7 +2012,7 @@ public extension EmojiPagerContentComponent { itemGroups[groupIndex].items.append(resultItem) } else { itemGroupIndexById[groupId] = itemGroups.count - + var title = "" var headerItem: EntityKeyboardAnimationData? var groupHasEdit = false @@ -2019,7 +2020,7 @@ public extension EmojiPagerContentComponent { if id == groupId, let info = info as? StickerPackCollectionInfo { title = info.title groupHasEdit = info.flags.contains(.isCreator) - + if let thumbnail = info.thumbnail { let type: EntityKeyboardAnimationData.ItemType if item.file.isAnimatedSticker { @@ -2031,7 +2032,7 @@ public extension EmojiPagerContentComponent { } else { type = .still } - + headerItem = EntityKeyboardAnimationData( id: .stickerPackThumbnail(info.id), type: type, @@ -2042,12 +2043,12 @@ public extension EmojiPagerContentComponent { isTemplate: false ) } - + break inner } } itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: title, subtitle: nil, actionButtonTitle: nil, isPremiumLocked: false, isFeatured: false, displayPremiumBadges: true, hasEdit: hasEdit && groupHasEdit, headerItem: headerItem, items: [resultItem])) - + if hasEdit && !addedCreateStickerButton, let groupIndex = itemGroupIndexById[groupId] { let resultItem = EmojiPagerContentComponent.Item( animationData: nil, @@ -2062,18 +2063,18 @@ public extension EmojiPagerContentComponent { } } } - - for featuredStickerPack in featuredStickerPacks { + + for featuredStickerPack in (currentWinterGramSettings.showOnlyAddedEmojisAndStickers ? [] : featuredStickerPacks) { if installedCollectionIds.contains(featuredStickerPack.info.id) { continue } - + for item in featuredStickerPack.topItems { var tintMode: Item.TintMode = .none if item.file.isCustomTemplateEmoji { tintMode = .primary } - + let animationData = EntityKeyboardAnimationData(file: item.file) let resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -2083,7 +2084,7 @@ public extension EmojiPagerContentComponent { icon: .none, tintMode: tintMode ) - + let supergroupId = featuredStickerPack.info.id let groupId: AnyHashable = supergroupId let isPremiumLocked: Bool = item.file.isPremiumSticker && !hasPremium @@ -2094,10 +2095,10 @@ public extension EmojiPagerContentComponent { itemGroups[groupIndex].items.append(resultItem) } else { itemGroupIndexById[groupId] = itemGroups.count - + let subtitle: String = strings.StickerPack_StickerCount(Int32(featuredStickerPack.info.count)) var headerItem: EntityKeyboardAnimationData? - + if let thumbnailFileId = featuredStickerPack.info.thumbnailFileId, let file = featuredStickerPack.topItems.first(where: { $0.file.fileId.id == thumbnailFileId }) { headerItem = EntityKeyboardAnimationData(file: file.file) } else if let thumbnailDimensions = featuredStickerPack.info.thumbnailDimensions { @@ -2112,7 +2113,7 @@ public extension EmojiPagerContentComponent { } else { type = .still } - + headerItem = EntityKeyboardAnimationData( id: .stickerPackThumbnail(info.id), type: type, @@ -2123,14 +2124,14 @@ public extension EmojiPagerContentComponent { isTemplate: false ) } - + itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: featuredStickerPack.info.title, subtitle: subtitle, actionButtonTitle: strings.Stickers_Install, isPremiumLocked: isPremiumLocked, isFeatured: true, displayPremiumBadges: false, hasEdit: false, headerItem: headerItem, items: [resultItem])) } } } - + let isMasks = stickerNamespaces.contains(Namespaces.ItemCollection.CloudMaskPacks) - + let allItemGroups = itemGroups.map { group -> EmojiPagerContentComponent.ItemGroup in var hasClear = false var isEmbedded = false @@ -2141,7 +2142,7 @@ public extension EmojiPagerContentComponent { isEmbedded = true } else if group.id == AnyHashable("saved") { } - + return EmojiPagerContentComponent.ItemGroup( supergroupId: group.supergroupId, groupId: group.id, @@ -2161,7 +2162,7 @@ public extension EmojiPagerContentComponent { items: group.items ) } - + let warpContentsOnEdges: Bool switch subject { case .profilePhotoEmojiSelection, .groupPhotoEmojiSelection: @@ -2169,7 +2170,7 @@ public extension EmojiPagerContentComponent { default: warpContentsOnEdges = false } - + return EmojiPagerContentComponent( id: isMasks ? "masks" : "stickers", context: context, @@ -2198,7 +2199,7 @@ public extension EmojiPagerContentComponent { ) } } - + static func messageEffectsInputData( context: AccountContext, animationCache: AnimationCache, @@ -2209,11 +2210,11 @@ public extension EmojiPagerContentComponent { ) -> Signal { let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) let isPremiumDisabled = premiumConfiguration.isPremiumDisabled - + let strings = context.sharedContext.currentPresentationData.with({ $0 }).strings - + let searchCategories: Signal = context.engine.stickers.emojiSearchCategories(kind: .emoji) - + return combineLatest( hasPremium(context: context, chatPeerId: nil, premiumIfSavedMessages: false), context.engine.stickers.availableMessageEffects(), @@ -2235,7 +2236,7 @@ public extension EmojiPagerContentComponent { } var itemGroups: [ItemGroup] = [] var itemGroupIndexById: [AnyHashable: Int] = [:] - + if let availableMessageEffects { var reactionEffects: [AvailableMessageEffects.MessageEffect] = [] var stickerEffects: [AvailableMessageEffects.MessageEffect] = [] @@ -2246,21 +2247,21 @@ public extension EmojiPagerContentComponent { stickerEffects.append(messageEffect) } } - + for i in 0 ..< 2 { let groupId = i == 0 ? "reactions" : "stickers" for item in i == 0 ? reactionEffects : stickerEffects { if item.isPremium && isPremiumDisabled { continue } - + let itemFile = item.effectSticker - + var tintMode: Item.TintMode = .none if itemFile.isCustomTemplateEmoji { tintMode = .primary } - + let icon: EmojiPagerContentComponent.Item.Icon if i == 0 { if !hasPremium && item.isPremium { @@ -2277,7 +2278,7 @@ public extension EmojiPagerContentComponent { icon = .text(item.emoticon) } } - + let animationData = EntityKeyboardAnimationData(file: itemFile, partialReference: .none) let resultItem = EmojiPagerContentComponent.Item( animationData: animationData, @@ -2287,7 +2288,7 @@ public extension EmojiPagerContentComponent { icon: icon, tintMode: tintMode ) - + if let groupIndex = itemGroupIndexById[groupId] { itemGroups[groupIndex].items.append(resultItem) } else { @@ -2297,13 +2298,13 @@ public extension EmojiPagerContentComponent { } } } - + let warpContentsOnEdges: Bool = true - + let allItemGroups = itemGroups.map { group -> EmojiPagerContentComponent.ItemGroup in let hasClear = false let isEmbedded = false - + return EmojiPagerContentComponent.ItemGroup( supergroupId: group.supergroupId, groupId: group.id, @@ -2323,7 +2324,7 @@ public extension EmojiPagerContentComponent { items: group.items ) } - + return EmojiPagerContentComponent( id: "stickers", context: context, diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent/Sources/PeerInfoGiftsCoverComponent.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent/Sources/PeerInfoGiftsCoverComponent.swift index 02aa6d2b18..b3dc98d22a 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent/Sources/PeerInfoGiftsCoverComponent.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent/Sources/PeerInfoGiftsCoverComponent.swift @@ -18,6 +18,7 @@ public final class PeerInfoGiftsCoverComponent: Component { public let context: AccountContext public let peerId: EnginePeer.Id public let giftsContext: ProfileGiftsContext + public let additionalGifts: [ProfileGiftsContext.State.StarGift] public let hasBackground: Bool public let avatarCenter: CGPoint public let avatarSize: CGSize @@ -29,11 +30,12 @@ public final class PeerInfoGiftsCoverComponent: Component { public let titleWidth: CGFloat public let bottomHeight: CGFloat public let action: (ProfileGiftsContext.State.StarGift) -> Void - + public init( context: AccountContext, peerId: EnginePeer.Id, giftsContext: ProfileGiftsContext, + additionalGifts: [ProfileGiftsContext.State.StarGift] = [], hasBackground: Bool, avatarCenter: CGPoint, avatarSize: CGSize, @@ -49,6 +51,7 @@ public final class PeerInfoGiftsCoverComponent: Component { self.context = context self.peerId = peerId self.giftsContext = giftsContext + self.additionalGifts = additionalGifts self.hasBackground = hasBackground self.avatarCenter = avatarCenter self.avatarSize = avatarSize @@ -61,7 +64,7 @@ public final class PeerInfoGiftsCoverComponent: Component { self.bottomHeight = bottomHeight self.action = action } - + public static func ==(lhs: PeerInfoGiftsCoverComponent, rhs: PeerInfoGiftsCoverComponent) -> Bool { if lhs.context !== rhs.context { return false @@ -99,34 +102,40 @@ public final class PeerInfoGiftsCoverComponent: Component { if lhs.bottomHeight != rhs.bottomHeight { return false } + if lhs.additionalGifts != rhs.additionalGifts { + return false + } return true } - + public final class View: UIView { private var currentSize: CGSize? private var component: PeerInfoGiftsCoverComponent? private var state: EmptyComponentState? - + private var giftsDisposable: Disposable? private var gifts: [ProfileGiftsContext.State.StarGift] = [] private var appliedGiftIds: [Int64] = [] - + private var iconLayers: [AnyHashable: GiftIconLayer] = [:] private var iconPositions: [PositionGenerator.Position] = [] private let seed = UInt(Date().timeIntervalSince1970) - + private let trackingLayer = HierarchyTrackingLayer() private var isCurrentlyInHierarchy = false - + private var isUpdating = false - + + private var currentGiftsState: ProfileGiftsContext.State? + private var currentGiftStatusId: Int64? + override public init(frame: CGRect) { super.init(frame: frame) - + self.clipsToBounds = true - + self.layer.addSublayer(self.trackingLayer) - + self.trackingLayer.didEnterHierarchy = { [weak self] in guard let self else { return @@ -134,25 +143,25 @@ public final class PeerInfoGiftsCoverComponent: Component { self.isCurrentlyInHierarchy = true self.updateAnimations() } - + self.trackingLayer.didExitHierarchy = { [weak self] in guard let self else { return } self.isCurrentlyInHierarchy = false } - + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapped(_:)))) } - + required public init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + deinit { self.giftsDisposable?.dispose() } - + @objc private func tapped(_ gestureRecognizer: UITapGestureRecognizer) { guard let component = self.component else { return @@ -165,7 +174,7 @@ public final class PeerInfoGiftsCoverComponent: Component { } } } - + public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { for (_, iconLayer) in self.iconLayers { if iconLayer.frame.contains(point) { @@ -174,7 +183,7 @@ public final class PeerInfoGiftsCoverComponent: Component { } return false } - + func updateAnimations() { var index = 0 for (_, iconLayer) in self.iconLayers { @@ -184,7 +193,32 @@ public final class PeerInfoGiftsCoverComponent: Component { index += 1 } } - + + 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. + let giftStatusId = self.currentGiftStatusId + let pinnedGifts = (self.currentGiftsState?.gifts ?? []).filter { gift in + if gift.pinnedToTop { + if case let .unique(uniqueGift) = gift.gift { + return uniqueGift.id != giftStatusId + } + } + return false + } + var mergedGifts = pinnedGifts + for gift in self.component?.additionalGifts ?? [] { + if !mergedGifts.contains(where: { $0.reference == gift.reference }) { + mergedGifts.append(gift) + } + } + self.gifts = mergedGifts + if !self.isUpdating { + self.state?.updated(transition: .spring(duration: 0.4)) + } + } + private var scheduledAnimateIn = false public func willAnimateIn() { self.scheduledAnimateIn = true @@ -192,13 +226,13 @@ public final class PeerInfoGiftsCoverComponent: Component { layer.opacity = 0.0 } } - + public func animateIn() { guard let _ = self.currentSize, let component = self.component else { return } self.scheduledAnimateIn = false - + for (_, layer) in self.iconLayers { layer.opacity = 1.0 layer.animatePosition( @@ -209,22 +243,26 @@ public final class PeerInfoGiftsCoverComponent: Component { ) } } - + func update(component: PeerInfoGiftsCoverComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } - + let previousComponent = self.component self.component = component self.state = state - + + if previousComponent?.additionalGifts != component.additionalGifts { + self.recomputeGifts() + } + let previousCurrentSize = self.currentSize self.currentSize = availableSize - + let iconSize = CGSize(width: 32.0, height: 32.0) - + let giftIds = self.gifts.map { gift in if case let .unique(gift) = gift.gift { return gift.id @@ -232,13 +270,13 @@ public final class PeerInfoGiftsCoverComponent: Component { return 0 } } - + if !giftIds.isEmpty && (self.iconPositions.isEmpty || previousCurrentSize?.width != availableSize.width || (previousComponent != nil && previousComponent?.hasBackground != component.hasBackground) || self.appliedGiftIds != giftIds) { var avatarCenter = component.avatarCenter if avatarCenter.y < 0.0 { avatarCenter.y = component.statusBarHeight + 75.0 } - + var excludeRects: [CGRect] = [] if component.statusBarHeight > 0.0 { excludeRects.append(CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: component.statusBarHeight + 4.0))) @@ -249,7 +287,7 @@ public final class PeerInfoGiftsCoverComponent: Component { if component.bottomHeight > 0.0 { excludeRects.append(CGRect(origin: CGPoint(x: 0.0, y: component.defaultHeight - component.bottomHeight), size: CGSize(width: availableSize.width, height: component.bottomHeight))) } - + let positionGenerator = PositionGenerator( containerSize: CGSize(width: availableSize.width, height: component.defaultHeight), centerFrame: component.avatarSize.centered(around: avatarCenter), @@ -258,11 +296,11 @@ public final class PeerInfoGiftsCoverComponent: Component { edgePadding: 5.0, seed: self.seed ) - + self.iconPositions = positionGenerator.generatePositions(count: 12, itemSize: iconSize) } self.appliedGiftIds = giftIds - + if self.giftsDisposable == nil { self.giftsDisposable = combineLatest( queue: Queue.mainQueue(), @@ -279,23 +317,12 @@ public final class PeerInfoGiftsCoverComponent: Component { guard let self else { return } - - let pinnedGifts = state.gifts.filter { gift in - if gift.pinnedToTop { - if case let .unique(uniqueGift) = gift.gift { - return uniqueGift.id != giftStatusId - } - } - return false - } - self.gifts = pinnedGifts - - if !self.isUpdating { - self.state?.updated(transition: .spring(duration: 0.4)) - } + self.currentGiftsState = state + self.currentGiftStatusId = giftStatusId + self.recomputeGifts() }) } - + var validIds = Set() var index = 0 for gift in self.gifts.prefix(12) { @@ -309,7 +336,7 @@ public final class PeerInfoGiftsCoverComponent: Component { id = index } validIds.insert(id) - + var iconTransition = transition let iconPosition = self.iconPositions[index] let iconLayer: GiftIconLayer @@ -320,49 +347,49 @@ public final class PeerInfoGiftsCoverComponent: Component { iconLayer = GiftIconLayer(context: component.context, gift: gift, size: iconSize, glowing: component.hasBackground) self.iconLayers[id] = iconLayer self.layer.addSublayer(iconLayer) - + if self.scheduledAnimateIn { iconLayer.opacity = 0.0 } else { iconLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) iconLayer.animateScale(from: 0.01, to: 1.0, duration: 0.2) } - + iconLayer.startAnimations(index: index) } iconLayer.glowing = component.hasBackground - + let itemDistanceFraction = max(0.0, min(0.5, (iconPosition.distance - component.avatarSize.width / 2.0) / 144.0)) let itemScaleFraction = patternScaleValueAt(fraction: min(1.0, component.avatarTransitionFraction * 1.33), t: itemDistanceFraction, reverse: false) - + func interpolatePosition(from: PositionGenerator.Position, to: PositionGenerator.Position, t: CGFloat) -> PositionGenerator.Position { let clampedT = max(0, min(1, t)) - + let interpolatedDistance = from.distance + (to.distance - from.distance) * clampedT let interpolatedAngle = from.angle + (to.angle - from.angle) * clampedT - + return PositionGenerator.Position(distance: interpolatedDistance, angle: interpolatedAngle, scale: from.scale) } - + let toAngle: CGFloat = .pi * 0.18 let centerPosition = PositionGenerator.Position(distance: 0.0, angle: iconPosition.angle + toAngle, scale: iconPosition.scale) let effectivePosition = interpolatePosition(from: iconPosition, to: centerPosition, t: itemScaleFraction) let effectiveAngle = toAngle * itemScaleFraction - + let absolutePosition = getAbsolutePosition(position: effectivePosition, centerPoint: component.avatarCenter) - + iconTransition.setBounds(layer: iconLayer, bounds: CGRect(origin: .zero, size: iconSize)) iconTransition.setPosition(layer: iconLayer, position: absolutePosition) iconLayer.updateRotation(effectiveAngle, transition: iconTransition) iconTransition.setScale(layer: iconLayer, scale: iconPosition.scale * (1.0 - itemScaleFraction)) - + if !self.scheduledAnimateIn { iconTransition.setAlpha(layer: iconLayer, alpha: 1.0 - itemScaleFraction) } - + index += 1 } - + var removeIds: [AnyHashable] = [] for (id, layer) in self.iconLayers { if !validIds.contains(id) { @@ -379,11 +406,11 @@ public final class PeerInfoGiftsCoverComponent: Component { return availableSize } } - + public func makeView() -> View { return View(frame: CGRect()) } - + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } @@ -392,7 +419,7 @@ public final class PeerInfoGiftsCoverComponent: Component { private var shadowImage: UIImage? = { return generateImage(CGSize(width: 44.0, height: 44.0), rotatedContext: { size, context in context.clear(CGRect(origin: .zero, size: size)) - + var locations: [CGFloat] = [0.0, 0.3, 1.0] let colors: [CGColor] = [UIColor(rgb: 0xffffff, alpha: 0.65).cgColor, UIColor(rgb: 0xffffff, alpha: 0.65).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor] let colorSpace = CGColorSpaceCreateDeviceRGB() @@ -403,24 +430,24 @@ private var shadowImage: UIImage? = { private final class StarsEffectLayer: SimpleLayer { private let emitterLayer = CAEmitterLayer() - + override init() { super.init() - + self.addSublayer(self.emitterLayer) } - + override init(layer: Any) { super.init(layer: layer) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + func setup(color: UIColor, size: CGSize) { self.color = color - + let emitter = CAEmitterCell() emitter.name = "emitter" emitter.contents = UIImage(bundleImageName: "Premium/Stars/Particle")?.cgImage @@ -431,7 +458,7 @@ private final class StarsEffectLayer: SimpleLayer { emitter.scaleRange = 0.02 emitter.alphaRange = 0.1 emitter.emissionRange = .pi * 2.0 - + let staticColors: [Any] = [ color.withAlphaComponent(0.0).cgColor, color.withAlphaComponent(0.58).cgColor, @@ -443,9 +470,9 @@ private final class StarsEffectLayer: SimpleLayer { emitter.setValue([staticColorBehavior], forKey: "emitterBehaviors") self.emitterLayer.emitterCells = [emitter] } - + private var color: UIColor? - + func update(color: UIColor, size: CGSize) { if self.color != color { self.setup(color: color, size: size) @@ -466,7 +493,7 @@ private class GiftIconLayer: SimpleLayer { var glowing: Bool { didSet { self.shadowLayer.opacity = self.glowing ? 1.0 : 0.0 - + let color: UIColor if self.glowing { color = .white @@ -475,28 +502,28 @@ private class GiftIconLayer: SimpleLayer { } else { color = .white } - + let side = floor(self.size.width * 1.25) let starsFrame = CGSize(width: side, height: side).centered(in: CGRect(origin: .zero, size: self.size)) self.starsLayer.frame = starsFrame self.starsLayer.update(color: color, size: starsFrame.size) } } - + let shadowLayer = SimpleLayer() let starsLayer = StarsEffectLayer() let animationLayer: InlineStickerItemLayer - + override init(layer: Any) { guard let layer = layer as? GiftIconLayer else { fatalError() } - + let context = layer.context let gift = layer.gift let size = layer.size let glowing = layer.glowing - + var file: TelegramMediaFile? var color: UIColor = .white switch gift.gift { @@ -511,7 +538,7 @@ private class GiftIconLayer: SimpleLayer { } } } - + let emoji = ChatTextInputTextCustomEmojiAttribute( interactivelySelectedFromPackId: nil, fileId: file?.fileId.id ?? 0, @@ -530,28 +557,28 @@ private class GiftIconLayer: SimpleLayer { pointSize: CGSize(width: size.width * 2.0, height: size.height * 2.0), loopCount: 1 ) - + self.shadowLayer.contents = shadowImage?.cgImage self.shadowLayer.layerTintColor = color.cgColor self.shadowLayer.opacity = glowing ? 1.0 : 0.0 - + self.context = context self.gift = gift self.size = size self.glowing = glowing - + super.init() - + let side = floor(size.width * 1.25) let starsFrame = CGSize(width: side, height: side).centered(in: CGRect(origin: .zero, size: size)) self.starsLayer.frame = starsFrame self.starsLayer.update(color: glowing ? .white : color, size: starsFrame.size) - + self.addSublayer(self.shadowLayer) self.addSublayer(self.starsLayer) self.addSublayer(self.animationLayer) } - + init( context: AccountContext, gift: ProfileGiftsContext.State.StarGift, @@ -562,7 +589,7 @@ private class GiftIconLayer: SimpleLayer { self.gift = gift self.size = size self.glowing = glowing - + var file: TelegramMediaFile? var color: UIColor = .white switch gift.gift { @@ -577,7 +604,7 @@ private class GiftIconLayer: SimpleLayer { } } } - + let emoji = ChatTextInputTextCustomEmojiAttribute( interactivelySelectedFromPackId: nil, fileId: file?.fileId.id ?? 0, @@ -596,45 +623,45 @@ private class GiftIconLayer: SimpleLayer { pointSize: CGSize(width: size.width * 2.0, height: size.height * 2.0), loopCount: 1 ) - + self.shadowLayer.contents = shadowImage?.cgImage self.shadowLayer.layerTintColor = color.cgColor self.shadowLayer.opacity = glowing ? 1.0 : 0.0 - + super.init() - + let side = floor(size.width * 1.25) let starsFrame = CGSize(width: side, height: side).centered(in: CGRect(origin: .zero, size: size)) self.starsLayer.frame = starsFrame self.starsLayer.update(color: glowing ? .white : color, size: starsFrame.size) - + self.addSublayer(self.shadowLayer) self.addSublayer(self.starsLayer) self.addSublayer(self.animationLayer) } - + required init?(coder: NSCoder) { preconditionFailure() } - + override func layoutSublayers() { self.shadowLayer.frame = CGRect(origin: .zero, size: self.bounds.size).insetBy(dx: -8.0, dy: -8.0) self.animationLayer.bounds = CGRect(origin: .zero, size: self.bounds.size) self.animationLayer.position = CGPoint(x: self.bounds.width / 2.0, y: self.bounds.height / 2.0) } - + func updateRotation(_ angle: CGFloat, transition: ComponentTransition) { self.animationLayer.transform = CATransform3DMakeRotation(angle, 0.0, 0.0, 1.0) } - + func startAnimations(index: Int) { let beginTime = Double(index) * 1.5 - + if self.animation(forKey: "hover") == nil { let upDistance = CGFloat.random(in: 1.0 ..< 2.0) let downDistance = CGFloat.random(in: 1.0 ..< 2.0) let hoverDuration = TimeInterval.random(in: 3.5 ..< 4.5) - + let hoverAnimation = CABasicAnimation(keyPath: "transform.translation.y") hoverAnimation.duration = hoverDuration hoverAnimation.fromValue = -upDistance @@ -646,12 +673,12 @@ private class GiftIconLayer: SimpleLayer { hoverAnimation.isAdditive = true self.add(hoverAnimation, forKey: "hover") } - + if self.animationLayer.animation(forKey: "wiggle") == nil { let fromRotationAngle = CGFloat.random(in: 0.025 ..< 0.05) let toRotationAngle = CGFloat.random(in: 0.025 ..< 0.05) let wiggleDuration = TimeInterval.random(in: 2.0 ..< 3.0) - + let wiggleAnimation = CABasicAnimation(keyPath: "transform.rotation.z") wiggleAnimation.duration = wiggleDuration wiggleAnimation.fromValue = -fromRotationAngle @@ -663,10 +690,10 @@ private class GiftIconLayer: SimpleLayer { wiggleAnimation.isAdditive = true self.animationLayer.add(wiggleAnimation, forKey: "wiggle") } - + if self.shadowLayer.animation(forKey: "glow") == nil { let glowDuration = TimeInterval.random(in: 2.0 ..< 3.0) - + let glowAnimation = CABasicAnimation(keyPath: "transform.scale") glowAnimation.duration = glowDuration glowAnimation.fromValue = 1.0 @@ -685,7 +712,7 @@ private struct PositionGenerator { let distance: CGFloat let angle: CGFloat let scale: CGFloat - + var relativeCartesian: CGPoint { return CGPoint( x: self.distance * cos(self.angle), @@ -693,20 +720,20 @@ private struct PositionGenerator { ) } } - + let containerSize: CGSize let centerFrame: CGRect let exclusionZones: [CGRect] let minimumDistance: CGFloat let edgePadding: CGFloat let scaleRange: (min: CGFloat, max: CGFloat) - + let innerOrbitRange: (min: CGFloat, max: CGFloat) let outerOrbitRange: (min: CGFloat, max: CGFloat) let innerOrbitCount: Int - + private let lokiRng: LokiRng - + init( containerSize: CGSize, centerFrame: CGRect, @@ -730,57 +757,57 @@ private struct PositionGenerator { self.innerOrbitCount = innerOrbitCount self.lokiRng = LokiRng(seed0: seed, seed1: 0, seed2: 0) } - + func generatePositions(count: Int, itemSize: CGSize) -> [Position] { var positions: [Position] = [] - + let centerPoint = CGPoint(x: self.centerFrame.midX, y: self.centerFrame.midY) let centerRadius = min(self.centerFrame.width, self.centerFrame.height) / 2.0 - + let maxAttempts = count * 200 var attempts = 0 - + var leftPositions = 0 var rightPositions = 0 - + let innerCount = min(self.innerOrbitCount, count) - + while positions.count < innerCount && attempts < maxAttempts { attempts += 1 - + let placeOnLeftSide = rightPositions > leftPositions - + let orbitRangeSize = self.innerOrbitRange.max - self.innerOrbitRange.min let orbitDistanceFactor = self.innerOrbitRange.min + orbitRangeSize * CGFloat(self.lokiRng.next()) let distance = orbitDistanceFactor * centerRadius - + let angleRange: CGFloat = placeOnLeftSide ? .pi : .pi let angleOffset: CGFloat = placeOnLeftSide ? .pi/2 : -(.pi/2) let angle = angleOffset + angleRange * CGFloat(self.lokiRng.next()) - + let absolutePosition = getAbsolutePosition(distance: distance, angle: angle, centerPoint: centerPoint) - + if absolutePosition.x - itemSize.width/2 < self.edgePadding || absolutePosition.x + itemSize.width/2 > self.containerSize.width - self.edgePadding || absolutePosition.y - itemSize.height/2 < self.edgePadding || absolutePosition.y + itemSize.height/2 > self.containerSize.height - self.edgePadding { continue } - + let itemRect = CGRect( x: absolutePosition.x - itemSize.width/2, y: absolutePosition.y - itemSize.height/2, width: itemSize.width, height: itemSize.height ) - + if self.isValidPosition(itemRect, existingPositions: positions.map { getAbsolutePosition(distance: $0.distance, angle: $0.angle, centerPoint: centerPoint) }, itemSize: itemSize) { let scaleRangeSize = max(self.scaleRange.min + 0.1, 0.75) - self.scaleRange.max let scale = self.scaleRange.max + scaleRangeSize * CGFloat(self.lokiRng.next()) positions.append(Position(distance: distance, angle: angle, scale: scale)) - + if absolutePosition.x < centerPoint.x { leftPositions += 1 } else { @@ -788,22 +815,22 @@ private struct PositionGenerator { } } } - + let maxPossibleDistance = hypot(self.containerSize.width, self.containerSize.height) / 2 - + while positions.count < count && attempts < maxAttempts { attempts += 1 - + let placeOnLeftSide = rightPositions >= leftPositions - + let orbitRangeSize = self.outerOrbitRange.max - self.outerOrbitRange.min let orbitDistanceFactor = self.outerOrbitRange.min + orbitRangeSize * CGFloat(self.lokiRng.next()) let distance = orbitDistanceFactor * centerRadius - + let angleRange: CGFloat = placeOnLeftSide ? .pi : .pi let angleOffset: CGFloat = placeOnLeftSide ? .pi/2 : -(.pi/2) let angle = angleOffset + angleRange * CGFloat(self.lokiRng.next()) - + let absolutePosition = getAbsolutePosition(distance: distance, angle: angle, centerPoint: centerPoint) if absolutePosition.x - itemSize.width/2 < self.edgePadding || absolutePosition.x + itemSize.width/2 > self.containerSize.width - self.edgePadding || @@ -811,21 +838,21 @@ private struct PositionGenerator { absolutePosition.y + itemSize.height/2 > self.containerSize.height - self.edgePadding { continue } - + let itemRect = CGRect( x: absolutePosition.x - itemSize.width/2, y: absolutePosition.y - itemSize.height/2, width: itemSize.width, height: itemSize.height ) - + if self.isValidPosition(itemRect, existingPositions: positions.map { getAbsolutePosition(distance: $0.distance, angle: $0.angle, centerPoint: centerPoint) }, itemSize: itemSize) { let normalizedDistance = min(distance / maxPossibleDistance, 1.0) let scale = self.scaleRange.max - normalizedDistance * (self.scaleRange.max - self.scaleRange.min) positions.append(Position(distance: distance, angle: angle, scale: scale)) - + if absolutePosition.x < centerPoint.x { leftPositions += 1 } else { @@ -833,38 +860,38 @@ private struct PositionGenerator { } } } - + return positions } - + func getAbsolutePosition(distance: CGFloat, angle: CGFloat, centerPoint: CGPoint) -> CGPoint { return CGPoint( x: centerPoint.x + distance * cos(angle), y: centerPoint.y + distance * sin(angle) ) } - + private func isValidPosition(_ rect: CGRect, existingPositions: [CGPoint], itemSize: CGSize) -> Bool { if rect.minX < self.edgePadding || rect.maxX > self.containerSize.width - self.edgePadding || rect.minY < self.edgePadding || rect.maxY > self.containerSize.height - self.edgePadding { return false } - + for zone in self.exclusionZones { if rect.intersects(zone) { return false } } - + let effectiveMinDistance = existingPositions.count > 5 ? max(self.minimumDistance * 0.7, 10.0) : self.minimumDistance - + for existingPosition in existingPositions { let distance = hypot(existingPosition.x - rect.midX, existingPosition.y - rect.midY) if distance < effectiveMinDistance { return false } } - + return true } } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenLabeledValueItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenLabeledValueItem.swift index 37d162fdaa..f3245afcc4 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenLabeledValueItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenLabeledValueItem.swift @@ -33,6 +33,7 @@ enum PeerInfoScreenLabeledValueLeftIcon { enum PeerInfoScreenLabeledValueIcon { case qrCode case premiumGift + case developerBadge } private struct TextLinkItemSource: Equatable { @@ -40,11 +41,11 @@ private struct TextLinkItemSource: Equatable { case primary case additional } - + let item: TextLinkItem let target: Target let range: NSRange? - + init(item: TextLinkItem, target: Target, range: NSRange?) { self.item = item self.target = target @@ -56,13 +57,13 @@ final class PeerInfoScreenLabeledValueItem: PeerInfoScreenItem { final class Button { let title: String let action: () -> Void - + init(title: String, action: @escaping () -> Void) { self.title = title self.action = action } } - + let id: AnyHashable let context: AccountContext? let label: String @@ -82,7 +83,7 @@ final class PeerInfoScreenLabeledValueItem: PeerInfoScreenItem { let button: Button? let contextAction: ((ASDisplayNode, ContextGesture?, CGPoint?) -> Void)? let requestLayout: (Bool) -> Void - + init( id: AnyHashable, context: AccountContext? = nil, @@ -124,7 +125,7 @@ final class PeerInfoScreenLabeledValueItem: PeerInfoScreenItem { self.contextAction = contextAction self.requestLayout = requestLayout } - + func node() -> PeerInfoScreenItemNode { return PeerInfoScreenLabeledValueItemNode() } @@ -133,13 +134,13 @@ final class PeerInfoScreenLabeledValueItem: PeerInfoScreenItem { private func generateExpandBackground(size: CGSize, color: UIColor) -> UIImage? { return generateImage(size, rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) - + var locations: [CGFloat] = [0.0, 1.0] let colors: [CGColor] = [color.withAlphaComponent(0.0).cgColor, color.cgColor] - + let colorSpace = CGColorSpaceCreateDeviceRGB() let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! - + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 40.0, y: size.height), options: CGGradientDrawingOptions()) context.setFillColor(color.cgColor) context.fill(CGRect(origin: CGPoint(x: 40.0, y: 0.0), size: CGSize(width: size.width - 40.0, height: size.height))) @@ -148,15 +149,15 @@ private func generateExpandBackground(size: CGSize, color: UIColor) -> UIImage? private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { private weak var context: AccountContext? - + private let containerNode: ContextControllerSourceNode private let contextSourceNode: ContextExtractedContentContainingNode - + private let extractedBackgroundImageNode: ASImageNode - + private var extractedRect: CGRect? private var nonExtractedRect: CGRect? - + private let selectionNode: PeerInfoScreenSelectableBackgroundNode private let maskNode: ASImageNode private let labelNode: ImmediateTextNode @@ -166,119 +167,119 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { private let additionalTextNode: ImmediateTextNode private let measureTextNode: ImmediateTextNode private let bottomSeparatorNode: ASDisplayNode - + private let expandBackgroundNode: ASImageNode private let expandNode: ImmediateTextNode private let expandButonNode: HighlightTrackingButtonNode - + private let iconNode: ASImageNode private let iconButtonNode: HighlightTrackingButtonNode - + private var animatedEmojiLayer: InlineStickerItemLayer? - + private var linkHighlightingNode: LinkHighlightingNode? - + private var actionButton: ComponentView? - + private let activateArea: AccessibilityAreaNode - + private var validLayout: (width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool)? private var item: PeerInfoScreenLabeledValueItem? private var theme: PresentationTheme? - + private var linkProgressView: TextLoadingEffectView? private var linkItemWithProgress: TextLinkItemSource? private var linkItemProgressDisposable: Disposable? - + private var isExpanded: Bool = false - + override init() { var bringToFrontForHighlightImpl: (() -> Void)? - + self.contextSourceNode = ContextExtractedContentContainingNode() self.containerNode = ContextControllerSourceNode() - + self.extractedBackgroundImageNode = ASImageNode() self.extractedBackgroundImageNode.displaysAsynchronously = false self.extractedBackgroundImageNode.alpha = 0.0 - + self.selectionNode = PeerInfoScreenSelectableBackgroundNode(bringToFrontForHighlight: { bringToFrontForHighlightImpl?() }) self.selectionNode.isUserInteractionEnabled = false - + self.maskNode = ASImageNode() self.maskNode.isUserInteractionEnabled = false - + self.labelNode = ImmediateTextNode() self.labelNode.displaysAsynchronously = false self.labelNode.isUserInteractionEnabled = false - + self.rightLabelNode = ImmediateTextNode() self.rightLabelNode.displaysAsynchronously = false self.rightLabelNode.isUserInteractionEnabled = false - + self.textNode = ImmediateTextNodeWithEntities() self.textNode.displaysAsynchronously = false self.textNode.isUserInteractionEnabled = false - + self.additionalTextNode = ImmediateTextNode() self.additionalTextNode.displaysAsynchronously = false self.additionalTextNode.isUserInteractionEnabled = false - + self.measureTextNode = ImmediateTextNode() self.measureTextNode.displaysAsynchronously = false self.measureTextNode.isUserInteractionEnabled = false - + self.bottomSeparatorNode = ASDisplayNode() self.bottomSeparatorNode.isLayerBacked = true - + self.expandBackgroundNode = ASImageNode() self.expandBackgroundNode.displaysAsynchronously = false - + self.expandNode = ImmediateTextNode() self.expandNode.displaysAsynchronously = false self.expandNode.isUserInteractionEnabled = false - + self.expandButonNode = HighlightTrackingButtonNode() - + self.iconNode = ASImageNode() self.iconNode.contentMode = .center self.iconNode.displaysAsynchronously = false - + self.iconButtonNode = HighlightTrackingButtonNode() - + self.activateArea = AccessibilityAreaNode() - + super.init() - + bringToFrontForHighlightImpl = { [weak self] in self?.bringToFrontForHighlight?() } - + self.addSubnode(self.bottomSeparatorNode) self.addSubnode(self.selectionNode) - + self.containerNode.addSubnode(self.contextSourceNode) self.containerNode.targetNodeForActivationProgress = self.contextSourceNode.contentNode self.addSubnode(self.containerNode) - + self.addSubnode(self.maskNode) - + self.contextSourceNode.contentNode.addSubnode(self.extractedBackgroundImageNode) - + self.contextSourceNode.contentNode.addSubnode(self.labelNode) self.contextSourceNode.contentNode.addSubnode(self.rightLabelNode) self.contextSourceNode.contentNode.addSubnode(self.textNode) self.contextSourceNode.contentNode.addSubnode(self.additionalTextNode) - + self.contextSourceNode.contentNode.addSubnode(self.expandBackgroundNode) self.contextSourceNode.contentNode.addSubnode(self.expandNode) self.contextSourceNode.contentNode.addSubnode(self.expandButonNode) - + self.contextSourceNode.contentNode.addSubnode(self.iconNode) self.contextSourceNode.contentNode.addSubnode(self.iconButtonNode) - + self.addSubnode(self.activateArea) - + self.expandButonNode.addTarget(self, action: #selector(self.expandPressed), forControlEvents: .touchUpInside) self.expandButonNode.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { @@ -291,7 +292,7 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { } } } - + self.iconButtonNode.addTarget(self, action: #selector(self.iconPressed), forControlEvents: .touchUpInside) self.iconButtonNode.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { @@ -304,19 +305,19 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { } } } - + self.containerNode.shouldBegin = { [weak self] point in guard let self else { return false } - + if self.linkItemAtPoint(point) != nil { return false } - + return true } - + self.containerNode.activated = { [weak self] gesture, _ in guard let strongSelf = self, let item = strongSelf.item, let contextAction = item.contextAction else { gesture.cancel() @@ -324,21 +325,21 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { } contextAction(strongSelf.contextSourceNode, gesture, nil) } - + self.contextSourceNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in guard let strongSelf = self, let theme = strongSelf.theme else { return } - + if isExtracted { strongSelf.extractedBackgroundImageNode.image = generateStretchableFilledCircleImage(diameter: 52.0, color: theme.list.plainBackgroundColor) } - + if let extractedRect = strongSelf.extractedRect, let nonExtractedRect = strongSelf.nonExtractedRect { let rect = isExtracted ? extractedRect : nonExtractedRect transition.updateFrame(node: strongSelf.extractedBackgroundImageNode, frame: rect) } - + transition.updateAlpha(node: strongSelf.extractedBackgroundImageNode, alpha: isExtracted ? 1.0 : 0.0, completion: { _ in if !isExtracted { self?.extractedBackgroundImageNode.image = nil @@ -346,23 +347,23 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { }) } } - + deinit { self.linkItemProgressDisposable?.dispose() } - + @objc private func expandPressed() { self.isExpanded = true self.item?.requestLayout(true) } - + @objc private func iconPressed() { self.item?.iconAction?() } - + override func didLoad() { super.didLoad() - + let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) recognizer.tapActionAtPoint = { [weak self] point in guard let strongSelf = self, let item = strongSelf.item else { @@ -396,7 +397,7 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { } self.view.addGestureRecognizer(recognizer) } - + @objc private func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { switch recognizer.state { case .ended: @@ -419,13 +420,13 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { } if self.linkItemWithProgress != currentLinkItem { self.linkItemWithProgress = currentLinkItem - + if let validLayout = self.validLayout, let context = self.context { let _ = self.update(context: context, width: validLayout.width, safeInsets: validLayout.safeInsets, presentationData: validLayout.presentationData, item: validLayout.item, topItem: validLayout.topItem, bottomItem: validLayout.bottomItem, hasCorners: validLayout.hasCorners, transition: .immediate) } } }) - + item.linkItemAction?(gesture == .tap ? .tap : .longTap, linkItem.item, self.linkHighlightingNode ?? self, self.linkHighlightingNode?.rects.first, progressValue) } else if case .longTap = gesture { item.longTapAction?(self) @@ -448,13 +449,13 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { } if self.linkItemWithProgress != currentLinkItem { self.linkItemWithProgress = currentLinkItem - + if let validLayout = self.validLayout, let context = self.context { let _ = self.update(context: context, width: validLayout.width, safeInsets: validLayout.safeInsets, presentationData: validLayout.presentationData, item: validLayout.item, topItem: validLayout.topItem, bottomItem: validLayout.bottomItem, hasCorners: validLayout.hasCorners, transition: .immediate) } } }) - + item.action?(self.contextSourceNode, progressValue) } } @@ -466,18 +467,18 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { break } } - + override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat { guard let item = item as? PeerInfoScreenLabeledValueItem else { return 10.0 } - + self.context = context self.validLayout = (width, safeInsets, presentationData, item, topItem, bottomItem, hasCorners) - + self.item = item self.theme = presentationData.theme - + if let action = item.action { self.selectionNode.pressed = { [weak self] in if let strongSelf = self { @@ -487,11 +488,11 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { } else { self.selectionNode.pressed = nil } - + let sideInset: CGFloat = 16.0 + safeInsets.left - + self.bottomSeparatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor - + let textColorValue: UIColor switch item.textColor { case .primary: @@ -499,14 +500,14 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { case .accent: textColorValue = presentationData.theme.list.itemAccentColor } - + self.expandNode.attributedText = NSAttributedString(string: presentationData.strings.PeerInfo_BioExpand, font: Font.regular(17.0), textColor: presentationData.theme.list.itemAccentColor) let expandSize = self.expandNode.updateLayout(CGSize(width: width, height: 100.0)) - + self.labelNode.attributedText = NSAttributedString(string: item.label, font: Font.regular(14.0), textColor: presentationData.theme.list.itemPrimaryTextColor) - + self.rightLabelNode.attributedText = NSAttributedString(string: item.rightLabel ?? "", font: Font.regular(14.0), textColor: presentationData.theme.list.itemSecondaryTextColor) - + if let icon = item.icon { let iconImage: UIImage? switch icon { @@ -514,6 +515,8 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { iconImage = UIImage(bundleImageName: "Settings/QrIcon") case .premiumGift: iconImage = UIImage(bundleImageName: "Premium/Gift") + case .developerBadge: + iconImage = UIImage(bundleImageName: "Peer Info/WntGramDeveloperBadge") } self.iconNode.image = generateTintedImage(image: iconImage, color: presentationData.theme.list.itemAccentColor) self.iconNode.isHidden = false @@ -524,9 +527,9 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { self.iconButtonNode.isHidden = true self.iconButtonNode.accessibilityLabel = nil } - + let additionalSideInset: CGFloat = !self.iconNode.isHidden ? 32.0 : 0.0 - + self.textNode.arguments = TextNodeWithEntities.Arguments( context: context, cache: context.animationCache, @@ -535,7 +538,7 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { attemptSynchronous: false ) self.textNode.spoilerColor = presentationData.theme.list.itemPrimaryTextColor - + var text = item.text let maxNumberOfLines: Int switch item.textBehavior { @@ -544,20 +547,20 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { self.textNode.maximumNumberOfLines = maxNumberOfLines self.textNode.cutout = nil self.textNode.attributedText = NSAttributedString(string: item.text, font: Font.regular(17.0), textColor: textColorValue) - + let fontSize: CGFloat = 15.0 - + let baseFont = Font.regular(fontSize) let linkFont = baseFont let boldFont = Font.medium(fontSize) let italicFont = Font.italic(fontSize) let boldItalicFont = Font.semiboldItalic(fontSize) let titleFixedFont = Font.monospace(fontSize) - + if let additionalText = item.additionalText { let entities = generateTextEntities(additionalText, enabledTypes: [.mention]) let attributedAdditionalText = stringWithAppliedEntities(additionalText, entities: entities, baseColor: presentationData.theme.list.itemPrimaryTextColor, linkColor: presentationData.theme.list.itemAccentColor, baseFont: baseFont, linkFont: linkFont, boldFont: boldFont, italicFont: italicFont, boldItalicFont: boldItalicFont, fixedFont: titleFixedFont, blockQuoteFont: baseFont, underlineLinks: false, message: nil) - + self.additionalTextNode.maximumNumberOfLines = 10 self.additionalTextNode.attributedText = attributedAdditionalText } else { @@ -568,7 +571,7 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { if !self.isExpanded { text = trimToLineCount(text, lineCount: 3) } - + let fontSize: CGFloat = 17.0 let baseFont = Font.regular(fontSize) let linkFont = baseFont @@ -576,7 +579,7 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { let italicFont = Font.italic(fontSize) let boldItalicFont = Font.semiboldItalic(fontSize) let titleFixedFont = Font.monospace(fontSize) - + func createAttributedText(_ text: String) -> NSAttributedString { if enabledEntities.isEmpty { return NSAttributedString(string: text, font: baseFont, textColor: textColorValue) @@ -585,9 +588,9 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { return stringWithAppliedEntities(text, entities: entities, baseColor: textColorValue, linkColor: presentationData.theme.list.itemAccentColor, baseFont: baseFont, linkFont: linkFont, boldFont: boldFont, italicFont: italicFont, boldItalicFont: boldItalicFont, fixedFont: titleFixedFont, blockQuoteFont: baseFont, message: nil) } } - + self.measureTextNode.maximumNumberOfLines = 0 - + if !item.entities.isEmpty { self.measureTextNode.attributedText = stringWithAppliedEntities(originalText, entities: item.entities, baseColor: textColorValue, linkColor: presentationData.theme.list.itemAccentColor, baseFont: baseFont, linkFont: linkFont, boldFont: boldFont, italicFont: italicFont, boldItalicFont: boldItalicFont, fixedFont: titleFixedFont, blockQuoteFont: baseFont, message: nil) self.textNode.attributedText = stringWithAppliedEntities(text, entities: item.entities, baseColor: textColorValue, linkColor: presentationData.theme.list.itemAccentColor, baseFont: baseFont, linkFont: linkFont, boldFont: boldFont, italicFont: italicFont, boldItalicFont: boldItalicFont, fixedFont: titleFixedFont, blockQuoteFont: baseFont, message: nil) @@ -595,32 +598,32 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { self.measureTextNode.attributedText = createAttributedText(originalText) self.textNode.attributedText = createAttributedText(text) } - + let textLayout = self.measureTextNode.updateLayoutInfo(CGSize(width: width - sideInset * 2.0 - additionalSideInset, height: .greatestFiniteMagnitude)) var collapsedNumberOfLines = 3 if textLayout.numberOfLines == 4 { collapsedNumberOfLines = 4 } - + maxNumberOfLines = self.isExpanded ? maxLines : collapsedNumberOfLines self.textNode.maximumNumberOfLines = maxNumberOfLines } - + let labelSize = self.labelNode.updateLayout(CGSize(width: width - sideInset * 2.0, height: .greatestFiniteMagnitude)) let rightLabelSize = self.rightLabelNode.updateLayout(CGSize(width: width - sideInset * 2.0, height: .greatestFiniteMagnitude)) let textLayout = self.textNode.updateLayoutInfo(CGSize(width: width - sideInset * 2.0 - additionalSideInset, height: .greatestFiniteMagnitude)) let textSize = textLayout.size - + let additionalTextSize = self.additionalTextNode.updateLayout(CGSize(width: width - sideInset * 2.0, height: .greatestFiniteMagnitude)) - + var displayMore = false if !self.isExpanded { if textLayout.truncated || text.count < item.text.count { displayMore = true } } - + if case .multiLine = item.textBehavior, displayMore { self.expandBackgroundNode.isHidden = false self.expandNode.isHidden = false @@ -630,7 +633,7 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { self.expandNode.isHidden = true self.expandButonNode.isHidden = true } - + var topOffset = 15.0 var height = topOffset * 2.0 let labelFrame = CGRect(origin: CGPoint(x: sideInset, y: topOffset), size: labelSize) @@ -645,20 +648,20 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { height += textSize.height } let additionalTextFrame = CGRect(origin: CGPoint(x: sideInset, y: topOffset), size: additionalTextSize) - + if let context = item.context, let leftIcon = item.leftIcon { var file: TelegramMediaFile? switch leftIcon { case .birthday: file = context.animatedEmojiStickersValue["🎂"]?.first?.file._parse() } - + if let file { let itemSize = floorToScreenPixels(17.0) var itemFrame = CGRect(origin: CGPoint(x: textFrame.minX + itemSize / 2.0, y: textFrame.midY), size: CGSize()).insetBy(dx: -itemSize / 2.0, dy: -itemSize / 2.0) itemFrame.origin.x = floorToScreenPixels(itemFrame.origin.x) itemFrame.origin.y = floorToScreenPixels(itemFrame.origin.y) - + let itemLayer: InlineStickerItemLayer if let current = self.animatedEmojiLayer { itemLayer = current @@ -667,34 +670,34 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { itemLayer = InlineStickerItemLayer(context: context, userLocation: .other, attemptSynchronousLoad: true, emoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file, custom: nil), file: file, cache: context.animationCache, renderer: context.animationRenderer, placeholderColor: presentationData.theme.list.mediaPlaceholderColor, pointSize: CGSize(width: pointSize, height: pointSize), dynamicColor: nil) self.animatedEmojiLayer = itemLayer self.layer.addSublayer(itemLayer) - + itemLayer.isVisibleForAnimations = true } itemLayer.frame = itemFrame - + textFrame.origin.x += 20.0 } } - + let expandFrame = CGRect(origin: CGPoint(x: width - safeInsets.right - expandSize.width - 14.0 - UIScreenPixel, y: textFrame.maxY - expandSize.height), size: expandSize) self.expandNode.frame = expandFrame self.expandButonNode.frame = expandFrame.insetBy(dx: -8.0, dy: -8.0) - + var expandBackgroundFrame = expandFrame expandBackgroundFrame.origin.x -= 50.0 expandBackgroundFrame.size.width += 50.0 self.expandBackgroundNode.frame = expandBackgroundFrame self.expandBackgroundNode.image = generateExpandBackground(size: expandBackgroundFrame.size, color: presentationData.theme.list.itemBlocksBackgroundColor) - + transition.updateFrame(node: self.labelNode, frame: labelFrame) transition.updateFrame(node: self.rightLabelNode, frame: rightLabelFrame) - + var textTransition = transition if self.textNode.frame.size != textFrame.size { textTransition = .immediate } textTransition.updateFrame(node: self.textNode, frame: textFrame) - + if item.handleSpoilers { let spoilerTextNode: ImmediateTextNodeWithEntities if let current = self.spoilerTextNode { @@ -703,10 +706,10 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { spoilerTextNode = ImmediateTextNodeWithEntities() spoilerTextNode.alpha = 0.0 self.spoilerTextNode = spoilerTextNode - + self.textNode.dustNode?.textNode = spoilerTextNode } - + spoilerTextNode.displaySpoilers = true spoilerTextNode.displaySpoilerEffect = false spoilerTextNode.attributedText = self.textNode.attributedText @@ -720,40 +723,40 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { spoilerTextNode.textShadowColor = self.textNode.textShadowColor spoilerTextNode.textStroke = self.textNode.textStroke spoilerTextNode.isUserInteractionEnabled = false - + let _ = spoilerTextNode.updateLayout(CGSize(width: width - sideInset * 2.0 - additionalSideInset, height: .greatestFiniteMagnitude)) spoilerTextNode.frame = textFrame - + if spoilerTextNode.supernode == nil { self.contextSourceNode.contentNode.addSubnode(spoilerTextNode) } } else if let spoilerTextNode = self.spoilerTextNode { self.spoilerTextNode = nil spoilerTextNode.removeFromSupernode() - + self.textNode.dustNode?.textNode = nil } - - + + transition.updateFrame(node: self.additionalTextNode, frame: additionalTextFrame) - + let iconButtonFrame = CGRect(x: width - safeInsets.right - height, y: 0.0, width: height, height: height) transition.updateFrame(node: self.iconButtonNode, frame: iconButtonFrame) if let iconSize = self.iconNode.image?.size { transition.updateFrame(node: self.iconNode, frame: CGRect(origin: CGPoint(x: width - safeInsets.right - sideInset - iconSize.width + 5.0, y: floorToScreenPixels((height - iconSize.height) / 2.0)), size: iconSize)) } - + if additionalTextSize.height > 0.0 { height += additionalTextSize.height + 3.0 } - + if let button = item.button { if textSize.height > 0.0 { height += 3.0 } else { height -= 7.0 } - + let actionButton: ComponentView if let current = self.actionButton { actionButton = current @@ -761,7 +764,7 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { actionButton = ComponentView() self.actionButton = actionButton } - + let actionButtonSize = actionButton.update( transition: ComponentTransition(transition), component: AnyComponent(ButtonComponent( @@ -797,46 +800,46 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { actionButton.view?.removeFromSuperview() } } - + let highlightNodeOffset: CGFloat = topItem == nil ? 0.0 : UIScreenPixel self.selectionNode.update(size: CGSize(width: width, height: height + highlightNodeOffset), theme: presentationData.theme, transition: transition) transition.updateFrame(node: self.selectionNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -highlightNodeOffset), size: CGSize(width: width, height: height + highlightNodeOffset))) - + let separatorRightInset: CGFloat = 16.0 - + transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: sideInset, y: height - UIScreenPixel), size: CGSize(width: width - sideInset - separatorRightInset, height: UIScreenPixel))) transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0) - + let hasCorners = hasCorners && (topItem == nil || bottomItem == nil) let hasTopCorners = hasCorners && topItem == nil let hasBottomCorners = hasCorners && bottomItem == nil - + self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil self.maskNode.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height)) self.bottomSeparatorNode.isHidden = hasBottomCorners - + self.activateArea.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: height)) self.activateArea.accessibilityLabel = item.label self.activateArea.accessibilityValue = item.text - + let contentSize = CGSize(width: width, height: height) self.containerNode.frame = CGRect(origin: CGPoint(), size: contentSize) self.contextSourceNode.frame = CGRect(origin: CGPoint(), size: contentSize) self.contextSourceNode.contentNode.frame = CGRect(origin: CGPoint(), size: contentSize) self.containerNode.isGestureEnabled = item.contextAction != nil - + let nonExtractedRect = CGRect(origin: CGPoint(), size: CGSize(width: contentSize.width, height: contentSize.height)) let extractedRect = nonExtractedRect self.extractedRect = extractedRect self.nonExtractedRect = nonExtractedRect - + if self.contextSourceNode.isExtractedToContextPreview { self.extractedBackgroundImageNode.frame = extractedRect } else { self.extractedBackgroundImageNode.frame = nonExtractedRect } self.contextSourceNode.contentRect = extractedRect - + if let linkItemWithProgress = self.linkItemWithProgress, let range = linkItemWithProgress.range { let linkProgressView: TextLoadingEffectView if let current = self.linkProgressView { @@ -846,9 +849,9 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { self.linkProgressView = linkProgressView self.contextSourceNode.contentNode.view.addSubview(linkProgressView) } - + let progressColor: UIColor = presentationData.theme.list.itemAccentColor.withMultipliedAlpha(0.1) - + let targetTextNode: TextNode switch linkItemWithProgress.target { case .primary: @@ -866,10 +869,10 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { }) } } - + return height } - + private func linkItemAtPoint(_ point: CGPoint) -> TextLinkItemSource? { let textNodeFrame = self.textNode.frame if let (index, attributes) = self.textNode.attributesAtPoint(CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY)) { @@ -880,19 +883,19 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { } if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { item = .url(url: url, concealed: false) - + if let (_, _, urlRangeValue) = self.textNode.attributeSubstringWithRange(name: TelegramTextAttributes.URL, index: index) { urlRange = urlRangeValue } } else if let peerName = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String { item = .mention(peerName) - + if let (_, _, urlRangeValue) = self.textNode.attributeSubstringWithRange(name: NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention).rawValue, index: index) { urlRange = urlRangeValue } } else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag { item = .hashtag(hashtag.peerName, hashtag.hashtag) - + if let (_, _, urlRangeValue) = self.textNode.attributeSubstringWithRange(name: NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag).rawValue, index: index) { urlRange = urlRangeValue } @@ -909,29 +912,29 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { if let (index, attributes) = self.additionalTextNode.attributesAtPoint(CGPoint(x: point.x - additionalTextNodeFrame.minX, y: point.y - additionalTextNodeFrame.minY)) { var item: TextLinkItem? var urlRange: NSRange? - + if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { item = .url(url: url, concealed: false) - + if let (_, _, urlRangeValue) = self.additionalTextNode.attributeSubstringWithRange(name: TelegramTextAttributes.URL, index: index) { urlRange = urlRangeValue } } else if let peerName = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String { item = .mention(peerName) - + if let (_, _, urlRangeValue) = self.additionalTextNode.attributeSubstringWithRange(name: NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention).rawValue, index: index) { urlRange = urlRangeValue } } else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag { item = .hashtag(hashtag.peerName, hashtag.hashtag) - + if let (_, _, urlRangeValue) = self.additionalTextNode.attributeSubstringWithRange(name: NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag).rawValue, index: index) { urlRange = urlRangeValue } } else { item = nil } - + if let item { return TextLinkItemSource(item: item, target: .additional, range: urlRange) } else { @@ -940,7 +943,7 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { } return nil } - + private func updateTouchesAtPoint(_ point: CGPoint?) { guard let item = self.item, let theme = self.theme else { return @@ -967,7 +970,7 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)], let dustNode = self.textNode.dustNode, !dustNode.isRevealed { rects = nil textNode = nil - + Queue.mainQueue().justDispatch { dustNode.revealAtLocation(CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY)) } @@ -994,7 +997,7 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { } } } - + if let rects = rects, let textNode = textNode { let linkHighlightingNode: LinkHighlightingNode if let current = self.linkHighlightingNode { @@ -1012,7 +1015,7 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { linkHighlightingNode?.removeFromSupernode() }) } - + if point != nil && rects == nil && item.action != nil { self.selectionNode.updateIsHighlighted(true) } else { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift index b0d90b7108..51987bc4af 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift @@ -44,6 +44,8 @@ import PlainButtonComponent import BundleIconComponent import MarqueeComponent import EdgeEffect +import AlertUI +import PresentationDataUtils final class PeerInfoHeaderNavigationTransition { let sourceNavigationBar: NavigationBar @@ -52,7 +54,7 @@ final class PeerInfoHeaderNavigationTransition { let sourceSubtitleFrame: CGRect let previousAvatarView: UIView? let fraction: CGFloat - + init(sourceNavigationBar: NavigationBar, sourceTitleView: ChatTitleView, sourceTitleFrame: CGRect, sourceSubtitleFrame: CGRect, previousAvatarView: UIView?, fraction: CGFloat) { self.sourceNavigationBar = sourceNavigationBar self.sourceTitleView = sourceTitleView @@ -75,7 +77,7 @@ enum PeerInfoHeaderTextFieldNodeKey: Equatable { protocol PeerInfoHeaderTextFieldNode: ASDisplayNode { var text: String { get } - + func update(width: CGFloat, safeInset: CGFloat, isSettings: Bool, hasPrevious: Bool, hasNext: Bool, placeholder: String, isEnabled: Bool, presentationData: PresentationData, updateText: String?) -> CGFloat } @@ -92,21 +94,21 @@ final class PeerInfoHeaderNode: ASDisplayNode { private var threadData: MessageHistoryThreadData? private var isSearching: Bool = false private var avatarSize: CGFloat? - + private let isOpenedFromChat: Bool private let isSettings: Bool private let isMyProfile: Bool private let videoCallsEnabled: Bool private let forumTopicThreadId: Int64? private let chatLocation: ChatLocation - + private(set) var isAvatarExpanded: Bool var skipCollapseCompletion = false var ignoreCollapse = false - + let avatarClippingNode: SparseNode let avatarListNode: PeerInfoAvatarListNode - + let backgroundBannerView: UIView let backgroundCover = ComponentView() let giftsCover = ComponentView() @@ -121,24 +123,29 @@ final class PeerInfoHeaderNode: ASDisplayNode { let titleNodeRawContainer: ASDisplayNode let titleNode: MultiScaleTextNode var standardTitle: ComponentView? - + let titleCredibilityIconView: ComponentHostView var credibilityIconSize: CGSize? let titleExpandedCredibilityIconView: ComponentHostView var titleExpandedCredibilityIconSize: CGSize? - + let titleVerifiedIconView: ComponentHostView var verifiedIconSize: CGSize? let titleExpandedVerifiedIconView: ComponentHostView var titleExpandedVerifiedIconSize: CGSize? - + let titleStatusIconView: ComponentHostView var statusIconSize: CGSize? let titleExpandedStatusIconView: ComponentHostView var titleExpandedStatusIconSize: CGSize? - + + let titleWinterGramIconView: ComponentHostView + var winterGramIconSize: CGSize? + let titleExpandedWinterGramIconView: ComponentHostView + var titleExpandedWinterGramIconSize: CGSize? + var subtitleRating: ComponentView? - + let subtitleNodeContainer: ASDisplayNode let subtitleNodeRawContainer: ASDisplayNode let subtitleNode: MultiScaleTextNode @@ -160,10 +167,10 @@ final class PeerInfoHeaderNode: ASDisplayNode { var searchEdgeEffectView: EdgeEffectView? let searchBarContainer: SparseNode let editingEdgeEffectView: EdgeEffectView - + var musicBackground: UIView? var music: ComponentView? - + var performButtonAction: ((PeerInfoHeaderButtonKey, PeerInfoHeaderButtonNode?, ContextGesture?) -> Void)? var requestAvatarExpansion: ((Bool, [AvatarGalleryEntry], AvatarGalleryEntry?, (ASDisplayNode, CGRect, () -> (UIView?, UIView?))?) -> Void)? var requestOpenAvatarForEditing: ((Bool) -> Void)? @@ -171,41 +178,41 @@ final class PeerInfoHeaderNode: ASDisplayNode { var requestUpdateLayout: ((Bool) -> Void)? var animateOverlaysFadeIn: (() -> Void)? var updateUnderHeaderContentsAlpha: ((CGFloat, ContainedViewLayoutTransition) -> Void)? - + var displayAvatarContextMenu: ((ASDisplayNode, ContextGesture?) -> Void)? var displayCopyContextMenu: ((ASDisplayNode, Bool, Bool) -> Void)? var displayEmojiPackTooltip: (() -> Void)? - + var displaySavedMusic: (() -> Void)? - + var displayPremiumIntro: ((UIView, PeerEmojiStatus?, Signal<(TelegramMediaFile, LoadedStickerPack)?, NoError>, Bool) -> Void)? var displayStatusPremiumIntro: (() -> Void)? var displayUniqueGiftInfo: ((UIView, String) -> Void)? var openUniqueGift: ((UIView, String) -> Void)? - + var navigateToForum: (() -> Void)? - + var navigationTransition: PeerInfoHeaderNavigationTransition? - + var backgroundAlpha: CGFloat = 1.0 var updateHeaderAlpha: ((CGFloat, ContainedViewLayoutTransition) -> Void)? - + private(set) var contentButtonBackgroundColor: UIColor? - + let animationCache: AnimationCache let animationRenderer: MultiAnimationRenderer - + var emojiStatusPackDisposable = MetaDisposable() var emojiStatusFileAndPackTitle = Promise<(TelegramMediaFile, LoadedStickerPack)?>() - + var customNavigationContentNode: PeerInfoPanelNodeNavigationContentNode? private var appliedCustomNavigationContentNode: PeerInfoPanelNodeNavigationContentNode? - + private var validLayout: (width: CGFloat, statusBarHeight: CGFloat, deviceMetrics: DeviceMetrics)? - + private var currentStarRating: TelegramStarRating? private var currentPendingStarRating: TelegramStarPendingRating? - + init(context: AccountContext, controller: PeerInfoScreenImpl, avatarInitiallyExpanded: Bool, isOpenedFromChat: Bool, isMediaOnly: Bool, isSettings: Bool, isMyProfile: Bool, forumTopicThreadId: Int64?, chatLocation: ChatLocation) { self.context = context self.controller = controller @@ -216,39 +223,45 @@ final class PeerInfoHeaderNode: ASDisplayNode { self.videoCallsEnabled = true self.forumTopicThreadId = forumTopicThreadId self.chatLocation = chatLocation - + let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) self.isPremiumDisabled = premiumConfiguration.isPremiumDisabled - + self.avatarClippingNode = SparseNode() self.avatarClippingNode.alpha = 0.996 self.avatarClippingNode.clipsToBounds = true - + self.avatarListNode = PeerInfoAvatarListNode(context: context, readyWhenGalleryLoads: avatarInitiallyExpanded, isSettings: isSettings) - + self.titleNodeContainer = ASDisplayNode() self.titleNodeRawContainer = ASDisplayNode() self.titleNode = MultiScaleTextNode(stateKeys: [TitleNodeStateRegular, TitleNodeStateExpanded]) self.titleNode.displaysAsynchronously = false - + self.titleCredibilityIconView = ComponentHostView() self.titleNode.stateNode(forKey: TitleNodeStateRegular)?.view.addSubview(self.titleCredibilityIconView) - + self.titleExpandedCredibilityIconView = ComponentHostView() self.titleNode.stateNode(forKey: TitleNodeStateExpanded)?.view.addSubview(self.titleExpandedCredibilityIconView) - + self.titleVerifiedIconView = ComponentHostView() self.titleNode.stateNode(forKey: TitleNodeStateRegular)?.view.addSubview(self.titleVerifiedIconView) - + self.titleExpandedVerifiedIconView = ComponentHostView() self.titleNode.stateNode(forKey: TitleNodeStateExpanded)?.view.addSubview(self.titleExpandedVerifiedIconView) - + self.titleStatusIconView = ComponentHostView() self.titleNode.stateNode(forKey: TitleNodeStateRegular)?.view.addSubview(self.titleStatusIconView) - + self.titleExpandedStatusIconView = ComponentHostView() self.titleNode.stateNode(forKey: TitleNodeStateExpanded)?.view.addSubview(self.titleExpandedStatusIconView) - + + self.titleWinterGramIconView = ComponentHostView() + self.titleNode.stateNode(forKey: TitleNodeStateRegular)?.view.addSubview(self.titleWinterGramIconView) + + self.titleExpandedWinterGramIconView = ComponentHostView() + self.titleNode.stateNode(forKey: TitleNodeStateExpanded)?.view.addSubview(self.titleExpandedWinterGramIconView) + self.subtitleNodeContainer = ASDisplayNode() self.subtitleNodeRawContainer = ASDisplayNode() self.subtitleNode = MultiScaleTextNode(stateKeys: [TitleNodeStateRegular, TitleNodeStateExpanded]) @@ -256,58 +269,58 @@ final class PeerInfoHeaderNode: ASDisplayNode { self.panelSubtitleNode = MultiScaleTextNode(stateKeys: [TitleNodeStateRegular, TitleNodeStateExpanded]) self.panelSubtitleNode.displaysAsynchronously = false - + self.usernameNodeContainer = ASDisplayNode() self.usernameNodeRawContainer = ASDisplayNode() self.usernameNode = MultiScaleTextNode(stateKeys: [TitleNodeStateRegular, TitleNodeStateExpanded]) self.usernameNode.displaysAsynchronously = false - + self.backgroundBannerView = UIView() self.backgroundBannerView.clipsToBounds = true self.backgroundBannerView.isUserInteractionEnabled = false self.backgroundBannerView.layer.allowsGroupOpacity = true - + self.buttonsContainerNode = SparseNode() self.buttonsContainerNode.clipsToBounds = true - + self.buttonsBackgroundNode = NavigationBackgroundNode(color: .clear, enableBlur: true, enableSaturation: false) self.buttonsBackgroundNode.isUserInteractionEnabled = false self.buttonsContainerNode.addSubnode(self.buttonsBackgroundNode) self.buttonsMaskView = UIView() self.buttonsBackgroundNode.view.mask = self.buttonsMaskView - + self.regularContentNode = PeerInfoHeaderRegularContentNode() var requestUpdateLayoutImpl: (() -> Void)? self.editingContentNode = PeerInfoHeaderEditingContentNode(context: context, requestUpdateLayout: { requestUpdateLayoutImpl?() }) self.editingContentNode.alpha = 0.0 - + self.avatarOverlayNode = PeerInfoEditingAvatarOverlayNode(context: context) self.avatarOverlayNode.isUserInteractionEnabled = false - + self.navigationButtonContainer = PeerInfoHeaderNavigationButtonContainerNode() self.searchBarContainer = SparseNode() self.searchContainer = ASDisplayNode() - + self.headerEdgeEffectView = EdgeEffectView() self.headerEdgeEffectView.isUserInteractionEnabled = false - + self.headerEdgeEffectContainer = UIView() self.headerEdgeEffectContainer.addSubview(self.headerEdgeEffectView) - + self.editingEdgeEffectView = EdgeEffectView() self.editingEdgeEffectView.isUserInteractionEnabled = false - + self.animationCache = context.animationCache self.animationRenderer = context.animationRenderer - + super.init() - + requestUpdateLayoutImpl = { [weak self] in self?.requestUpdateLayout?(false) } - + self.view.addSubview(self.backgroundBannerView) self.titleNodeContainer.addSubnode(self.titleNode) self.subtitleNodeContainer.addSubnode(self.subtitleNode) @@ -316,28 +329,28 @@ final class PeerInfoHeaderNode: ASDisplayNode { self.regularContentNode.addSubnode(self.avatarClippingNode) self.avatarClippingNode.addSubnode(self.avatarListNode) - + self.regularContentNode.addSubnode(self.avatarListNode.listContainerNode.controlsClippingOffsetNode) self.regularContentNode.addSubnode(self.titleNodeContainer) self.regularContentNode.addSubnode(self.subtitleNodeContainer) self.regularContentNode.addSubnode(self.subtitleNodeRawContainer) self.regularContentNode.addSubnode(self.usernameNodeContainer) self.regularContentNode.addSubnode(self.usernameNodeRawContainer) - + self.addSubnode(self.regularContentNode) - + if !isMediaOnly { self.regularContentNode.addSubnode(self.buttonsContainerNode) } - + self.addSubnode(self.editingContentNode) self.addSubnode(self.avatarOverlayNode) self.view.addSubview(self.editingEdgeEffectView) self.addSubnode(self.navigationButtonContainer) - + self.addSubnode(self.searchContainer) self.addSubnode(self.searchBarContainer) - + self.avatarListNode.avatarContainerNode.tapped = { [weak self] in self?.initiateAvatarExpansion(gallery: false, first: false) } @@ -347,14 +360,14 @@ final class PeerInfoHeaderNode: ASDisplayNode { self.avatarListNode.avatarContainerNode.emojiTapped = { [weak self] in self?.displayEmojiPackTooltip?() } - + self.editingContentNode.avatarNode.tapped = { [weak self] confirm in self?.initiateAvatarExpansion(gallery: true, first: true) } self.editingContentNode.requestEditing = { [weak self] in self?.requestOpenAvatarForEditing?(true) } - + self.avatarListNode.itemsUpdated = { [weak self] items in guard let strongSelf = self, let state = strongSelf.state, let peer = strongSelf.peer, let presentationData = strongSelf.presentationData, let avatarSize = strongSelf.avatarSize else { return @@ -368,59 +381,59 @@ final class PeerInfoHeaderNode: ASDisplayNode { } strongSelf.navigationButtonContainer.layer.animateAlpha(from: 0.0, to: strongSelf.navigationButtonContainer.alpha, duration: 0.25) strongSelf.avatarListNode.listContainerNode.topShadowNode.layer.animateAlpha(from: 0.0, to: strongSelf.avatarListNode.listContainerNode.topShadowNode.alpha, duration: 0.25) - + strongSelf.avatarListNode.listContainerNode.bottomShadowNode.alpha = 1.0 strongSelf.avatarListNode.listContainerNode.bottomShadowNode.layer.animateAlpha(from: 0.0, to: strongSelf.avatarListNode.listContainerNode.bottomShadowNode.alpha, duration: 0.25) strongSelf.avatarListNode.listContainerNode.controlsContainerNode.layer.animateAlpha(from: 0.0, to: strongSelf.avatarListNode.listContainerNode.controlsContainerNode.alpha, duration: 0.25) - + strongSelf.titleNode.layer.animateAlpha(from: 0.0, to: strongSelf.titleNode.alpha, duration: 0.25) strongSelf.subtitleNode.layer.animateAlpha(from: 0.0, to: strongSelf.subtitleNode.alpha, duration: 0.25) strongSelf.animateOverlaysFadeIn?() } } - + deinit { self.emojiStatusPackDisposable.dispose() } - + override func didLoad() { super.didLoad() - + let usernameGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.handleUsernameLongPress(_:))) self.usernameNodeRawContainer.view.addGestureRecognizer(usernameGestureRecognizer) - + let phoneGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.handlePhoneLongPress(_:))) self.subtitleNodeRawContainer.view.addGestureRecognizer(phoneGestureRecognizer) } - + @objc private func handleUsernameLongPress(_ gestureRecognizer: UILongPressGestureRecognizer) { if gestureRecognizer.state == .began { self.displayCopyContextMenu?(self.usernameNodeRawContainer, !self.isAvatarExpanded, true) } } - + @objc private func handlePhoneLongPress(_ gestureRecognizer: UILongPressGestureRecognizer) { if gestureRecognizer.state == .began { self.displayCopyContextMenu?(self.subtitleNodeRawContainer, true, !self.isAvatarExpanded) } } - + @objc private func subtitleBackgroundPressed() { self.navigateToForum?() } - + func invokeDisplayPremiumIntro() { self.displayPremiumIntro?(self.isAvatarExpanded ? self.titleExpandedCredibilityIconView : self.titleCredibilityIconView, nil, .never(), self.isAvatarExpanded) } - + func invokeDisplayGiftInfo() { guard case let .emojiStatus(status) = self.currentStatusIcon, case let .starGift(_, _, title, _, _, _, _, _, _) = status.content else { return } self.displayUniqueGiftInfo?(self.isAvatarExpanded ? self.titleExpandedStatusIconView : self.titleStatusIconView, title) } - + func initiateAvatarExpansion(gallery: Bool, first: Bool) { if let peer = self.peer, peer.profileImageRepresentations.isEmpty && gallery { self.requestOpenAvatarForEditing?(false) @@ -439,7 +452,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { self.cancelUpload?() } } - + func avatarTransitionArguments(entry: AvatarGalleryEntry) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { if self.isAvatarExpanded { if let avatarNode = self.avatarListNode.listContainerNode.currentItemNode?.imageNode { @@ -458,7 +471,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { return nil } } - + func addToAvatarTransitionSurface(view: UIView) { if self.isAvatarExpanded { self.avatarListNode.listContainerNode.view.addSubview(view) @@ -466,7 +479,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { self.view.addSubview(view) } } - + func updateAvatarIsHidden(entry: AvatarGalleryEntry?) { if let entry = entry { self.avatarListNode.avatarContainerNode.containerNode.isHidden = entry == self.avatarListNode.listContainerNode.galleryEntries.first @@ -477,7 +490,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { } self.avatarListNode.listContainerNode.updateEntryIsHidden(entry: entry) } - + private enum CredibilityIcon: Equatable { case none case premium @@ -486,11 +499,12 @@ final class PeerInfoHeaderNode: ASDisplayNode { case scam case emojiStatus(PeerEmojiStatus) } - + private var currentCredibilityIcon: CredibilityIcon? private var currentVerifiedIcon: CredibilityIcon? private var currentStatusIcon: CredibilityIcon? - + private var currentWinterGramIcon: Bool = false + private var currentPanelStatusData: PeerInfoStatusData? func update(width: CGFloat, containerHeight: CGFloat, containerInset: CGFloat, statusBarHeight: CGFloat, navigationHeight: CGFloat, isModalOverlay: Bool, isMediaOnly: Bool, contentOffset: CGFloat, paneContainerY: CGFloat, presentationData: PresentationData, peer: EnginePeer?, cachedData: EngineCachedPeerData?, threadData: MessageHistoryThreadData?, peerNotificationSettings: TelegramPeerNotificationSettings?, threadNotificationSettings: TelegramPeerNotificationSettings?, globalNotificationSettings: EngineGlobalNotificationSettings?, statusData: PeerInfoStatusData?, panelStatusData: (PeerInfoStatusData?, PeerInfoStatusData?, CGFloat?), isSecretChat: Bool, isContact: Bool, isSettings: Bool, state: PeerInfoState, profileGiftsContext: ProfileGiftsContext?, screenData: PeerInfoScreenData?, isSearching: Bool, metrics: LayoutMetrics, deviceMetrics: DeviceMetrics, transition: ContainedViewLayoutTransition, additive: Bool, animateHeader: Bool) -> CGFloat { if self.appliedCustomNavigationContentNode !== self.customNavigationContentNode { @@ -499,7 +513,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { previous?.removeFromSupernode() }) } - + self.appliedCustomNavigationContentNode = self.customNavigationContentNode if let customNavigationContentNode = self.customNavigationContentNode { self.searchBarContainer.addSubnode(customNavigationContentNode) @@ -510,32 +524,32 @@ final class PeerInfoHeaderNode: ASDisplayNode { } else if let customNavigationContentNode = self.customNavigationContentNode { transition.updateFrame(node: customNavigationContentNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: navigationHeight))) } - + var threadData = threadData if case let .replyThread(replyThreadMessage) = self.chatLocation, replyThreadMessage.peerId == self.context.account.peerId { threadData = nil } - + self.state = state self.peer = peer self.threadData = threadData self.isSearching = isSearching self.avatarListNode.listContainerNode.peer = peer - + let isFirstTime = self.validLayout == nil self.validLayout = (width, statusBarHeight, deviceMetrics) - + self.searchBarContainer.isUserInteractionEnabled = isSearching self.searchContainer.isUserInteractionEnabled = isSearching - + let previousPanelStatusData = self.currentPanelStatusData self.currentPanelStatusData = panelStatusData.0 - + let avatarSize: CGFloat = isModalOverlay ? 200.0 : 100.0 self.avatarSize = avatarSize - + var contentOffset = contentOffset - + if isMediaOnly { if isModalOverlay { contentOffset = 312.0 @@ -543,10 +557,10 @@ final class PeerInfoHeaderNode: ASDisplayNode { contentOffset = 212.0 } } - + let actionButtonKeys: [PeerInfoHeaderButtonKey] = (self.isSettings || self.isMyProfile) ? [] : peerInfoHeaderActionButtons(peer: peer, isSecretChat: isSecretChat, isContact: isContact) let buttonKeys: [PeerInfoHeaderButtonKey] = (self.isSettings || self.isMyProfile) ? [] : peerInfoHeaderButtons(peer: peer, cachedData: cachedData, isOpenedFromChat: self.isOpenedFromChat, isExpanded: true, videoCallsEnabled: width > 320.0 && self.videoCallsEnabled, isSecretChat: isSecretChat, isContact: isContact, threadInfo: threadData?.info) - + let backgroundCoverSubject: PeerInfoCoverComponent.Subject? var backgroundCoverAnimateIn = false var backgroundDefaultHeight: CGFloat = 254.0 @@ -574,7 +588,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { } else { backgroundCoverSubject = nil } - + var currentSavedMusic: TelegramMediaFile? if let peer, peer.id != self.context.account.peerId || self.isMyProfile, let screenData { if let savedMusicState = screenData.savedMusicState { @@ -585,13 +599,14 @@ final class PeerInfoHeaderNode: ASDisplayNode { } let musicHeight: CGFloat = hasBackground || self.isAvatarExpanded ? 24.0 : 16.0 let bottomInset: CGFloat = currentSavedMusic != nil ? musicHeight : 0.0 - + let isLandscape = containerInset > 16.0 - + let themeUpdated = self.presentationData?.theme !== presentationData.theme self.presentationData = presentationData - + let premiumConfiguration = PremiumConfiguration.with(appConfiguration: self.context.currentAppConfiguration.with { $0 }) + let isWinterGramOfficial = peer.map { isWinterGramOfficialPeer($0) } ?? false var credibilityIcon: CredibilityIcon = .none var verifiedIcon: CredibilityIcon = .none var statusIcon: CredibilityIcon = .none @@ -604,7 +619,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { credibilityIcon = .scam } else if let emojiStatus = peer.emojiStatus { statusIcon = .emojiStatus(emojiStatus) - } else if peer.isPremium && !premiumConfiguration.isPremiumDisabled && (peer.id != self.context.account.peerId || self.isSettings || self.isMyProfile) { + } else if (peer.isPremium || (currentWinterGramSettings.localPremium && (peer.id == self.context.account.peerId && (self.isSettings || self.isMyProfile)))) && !premiumConfiguration.isPremiumDisabled && (peer.id != self.context.account.peerId || self.isSettings || self.isMyProfile) { credibilityIcon = .premium } else { credibilityIcon = .none @@ -616,60 +631,60 @@ final class PeerInfoHeaderNode: ASDisplayNode { verifiedIcon = .emojiStatus(PeerEmojiStatus(content: .emoji(fileId: verificationIconFileId), expirationDate: nil)) } } - + var isForum = false if case let .channel(channel) = peer, channel.isForumOrMonoForum { isForum = true } - + transition.updateAlpha(node: self.regularContentNode, alpha: (state.isEditing || self.customNavigationContentNode != nil) ? 0.0 : 1.0) if self.navigationTransition == nil { transition.updateAlpha(node: self.navigationButtonContainer, alpha: (self.customNavigationContentNode != nil || isSearching) ? 0.0 : 1.0) } - + self.editingContentNode.alpha = state.isEditing ? 1.0 : 0.0 - + let editingContentHeight = self.editingContentNode.update(width: width, safeInset: containerInset, statusBarHeight: statusBarHeight, navigationHeight: navigationHeight, isModalOverlay: isModalOverlay, peer: state.isEditing ? peer : nil, threadData: threadData, chatLocation: self.chatLocation, cachedData: cachedData, isContact: isContact, isSettings: isSettings || isMyProfile, presentationData: presentationData, transition: transition) transition.updateFrame(node: self.editingContentNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -contentOffset), size: CGSize(width: width, height: editingContentHeight))) - + let avatarOverlayFarme = self.editingContentNode.convert(self.editingContentNode.avatarNode.frame, to: self) transition.updateFrame(node: self.avatarOverlayNode, frame: avatarOverlayFarme) - + let transitionSourceHeight: CGFloat = 0.0 let transitionFraction: CGFloat = 0.0 let transitionSourceAvatarFrame: CGRect? = nil let transitionSourceTitleFrame = CGRect() let transitionSourceSubtitleFrame = CGRect() - + let avatarFrame = CGRect(origin: CGPoint(x: floor((width - avatarSize) / 2.0), y: statusBarHeight + 22.0), size: CGSize(width: avatarSize, height: avatarSize)) - + let regularNavigationContentsAccentColor: UIColor = peer?.effectiveProfileColor != nil ? .white : presentationData.theme.list.itemAccentColor let collapsedHeaderNavigationContentsAccentColor = presentationData.theme.list.itemAccentColor let expandedAvatarNavigationContentsAccentColor: UIColor = .white - + let regularNavigationContentsPrimaryColor: UIColor = peer?.effectiveProfileColor != nil ? .white : presentationData.theme.list.itemPrimaryTextColor let collapsedHeaderNavigationContentsPrimaryColor = presentationData.theme.list.itemPrimaryTextColor let expandedAvatarNavigationContentsPrimaryColor: UIColor = .white - + let regularContentButtonBackgroundColor: UIColor let collapsedHeaderContentButtonBackgroundColor = presentationData.theme.list.itemBlocksBackgroundColor let expandedAvatarContentButtonBackgroundColor: UIColor = UIColor(white: 1.0, alpha: 0.1) - + let regularHeaderButtonBackgroundColor: UIColor let collapsedHeaderButtonBackgroundColor: UIColor = .clear let expandedAvatarHeaderButtonBackgroundColor: UIColor = UIColor(white: 0.0, alpha: 0.5) - + let regularContentButtonForegroundColor: UIColor = peer?.effectiveProfileColor != nil ? UIColor.white : presentationData.theme.list.itemAccentColor let collapsedHeaderContentButtonForegroundColor = presentationData.theme.list.itemAccentColor let expandedAvatarContentButtonForegroundColor: UIColor = .white - + var hasCoverColor = false let regularNavigationContentsSecondaryColor: UIColor if let emojiStatus = peer?.emojiStatus, case let .starGift(_, _, _, _, _, innerColor, outerColor, _, _) = emojiStatus.content { let mainColor = UIColor(rgb: UInt32(bitPattern: innerColor)) let secondaryColor = UIColor(rgb: UInt32(bitPattern: outerColor)) regularNavigationContentsSecondaryColor = UIColor(white: 1.0, alpha: 0.6).blitOver(mainColor.withMultiplied(hue: 1.0, saturation: 2.2, brightness: 1.5), alpha: 1.0) - + let baseButtonBackgroundColor: UIColor if presentationData.theme.overallDarkAppearance { baseButtonBackgroundColor = UIColor(white: 0.0, alpha: 0.25) @@ -678,12 +693,12 @@ final class PeerInfoHeaderNode: ASDisplayNode { } regularContentButtonBackgroundColor = baseButtonBackgroundColor.blendOver(background: secondaryColor.mixedWith(mainColor, alpha: 0.1)) regularHeaderButtonBackgroundColor = baseButtonBackgroundColor.blendOver(background: secondaryColor.mixedWith(mainColor, alpha: 0.1)) - + hasCoverColor = true } else if let profileColor = peer?.effectiveProfileColor { let backgroundColors = self.context.peerNameColors.getProfile(profileColor, dark: presentationData.theme.overallDarkAppearance) regularNavigationContentsSecondaryColor = UIColor(white: 1.0, alpha: 0.6).blitOver(backgroundColors.main.withMultiplied(hue: 1.0, saturation: 2.2, brightness: 1.5), alpha: 1.0) - + let baseButtonBackgroundColor: UIColor if presentationData.theme.overallDarkAppearance { baseButtonBackgroundColor = UIColor(white: 0.0, alpha: 0.25) @@ -692,7 +707,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { } regularContentButtonBackgroundColor = baseButtonBackgroundColor.blendOver(background: backgroundColors.main) regularHeaderButtonBackgroundColor = baseButtonBackgroundColor.blendOver(background: (backgroundColors.secondary ?? backgroundColors.main).mixedWith(backgroundColors.main, alpha: 0.1)) - + hasCoverColor = true } else { regularNavigationContentsSecondaryColor = presentationData.theme.list.itemSecondaryTextColor @@ -700,34 +715,34 @@ final class PeerInfoHeaderNode: ASDisplayNode { regularHeaderButtonBackgroundColor = .clear } self.contentButtonBackgroundColor = regularNavigationContentsSecondaryColor.mixedWith(regularContentButtonBackgroundColor, alpha: 0.5) - + let collapsedHeaderNavigationContentsSecondaryColor = presentationData.theme.list.itemSecondaryTextColor let expandedAvatarNavigationContentsSecondaryColor: UIColor = .white - + let navigationContentsAccentColor: UIColor let navigationContentsPrimaryColor: UIColor let navigationContentsSecondaryColor: UIColor let navigationContentsCanBeExpanded: Bool - + let contentButtonBackgroundColor: UIColor let contentButtonForegroundColor: UIColor - + let headerButtonBackgroundColor: UIColor - + var panelWithAvatarHeight: CGFloat = 35.0 + avatarSize if threadData != nil { panelWithAvatarHeight += 10.0 } - + let innerBackgroundTransitionFraction: CGFloat - + let navigationTransition: ContainedViewLayoutTransition if transition.isAnimated { navigationTransition = transition } else { navigationTransition = animateHeader ? .animated(duration: 0.2, curve: .easeInOut) : .immediate } - + let editingEdgeEffectHeight: CGFloat = 40.0 let editingEdgeEffectFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: navigationHeight + 10.0)) transition.updateFrame(view: self.editingEdgeEffectView, frame: editingEdgeEffectFrame) @@ -739,7 +754,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { editingBackgroundAlpha = 0.0 } ComponentTransition(transition).setAlpha(view: self.editingEdgeEffectView, alpha: editingBackgroundAlpha) - + if isSearching { let searchNavigationHeight: CGFloat if isSettings { @@ -747,10 +762,10 @@ final class PeerInfoHeaderNode: ASDisplayNode { } else { searchNavigationHeight = navigationHeight + 10.0 } - + let searchEdgeEffectHeight: CGFloat = 40.0 let searchEdgeEffectFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: searchNavigationHeight)) - + let searchEdgeEffectView: EdgeEffectView var searchEdgeEffectTransition = ComponentTransition(transition) if let current = self.searchEdgeEffectView { @@ -764,7 +779,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { searchEdgeEffectView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } - + transition.updateFrame(view: searchEdgeEffectView, frame: searchEdgeEffectFrame) searchEdgeEffectView.update(content: presentationData.theme.list.plainBackgroundColor, blur: true, rect: searchEdgeEffectFrame, edge: .top, edgeSize: searchEdgeEffectHeight, transition: searchEdgeEffectTransition) } else if let searchEdgeEffectView = self.searchEdgeEffectView { @@ -773,9 +788,9 @@ final class PeerInfoHeaderNode: ASDisplayNode { searchEdgeEffectView?.removeFromSuperview() }) } - + let backgroundBannerAlpha: CGFloat - + do { let backgroundTransitionStepDistance: CGFloat = 50.0 var backgroundTransitionDistance: CGFloat = navigationHeight + panelWithAvatarHeight - backgroundTransitionStepDistance @@ -788,7 +803,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { let contentOffset = max(0.0, contentOffset - backgroundTransitionDistance) innerBackgroundTransitionFraction = max(0.0, min(1.0, contentOffset / backgroundTransitionStepDistance)) } - + if state.isEditing { backgroundBannerAlpha = 0.0 } else { @@ -799,32 +814,32 @@ final class PeerInfoHeaderNode: ASDisplayNode { } } navigationTransition.updateAlpha(layer: self.backgroundBannerView.layer, alpha: backgroundBannerAlpha) - + self.avatarClippingNode.clipsToBounds = true } - + let accentRatingBackgroundColor: UIColor if let currentStarRating = self.currentStarRating, currentStarRating.level < 0 { accentRatingBackgroundColor = UIColor(rgb: 0xFF3B30) } else { accentRatingBackgroundColor = presentationData.theme.list.itemCheckColors.fillColor } - + let ratingBackgroundColor: UIColor let ratingBorderColor: UIColor let ratingForegroundColor: UIColor - + if state.isEditing { navigationContentsAccentColor = collapsedHeaderNavigationContentsAccentColor navigationContentsPrimaryColor = collapsedHeaderNavigationContentsPrimaryColor navigationContentsSecondaryColor = collapsedHeaderNavigationContentsSecondaryColor navigationContentsCanBeExpanded = true - + contentButtonBackgroundColor = collapsedHeaderContentButtonBackgroundColor contentButtonForegroundColor = collapsedHeaderContentButtonForegroundColor - + headerButtonBackgroundColor = collapsedHeaderButtonBackgroundColor - + ratingBackgroundColor = accentRatingBackgroundColor ratingBorderColor = .clear ratingForegroundColor = presentationData.theme.list.itemCheckColors.foregroundColor @@ -834,36 +849,36 @@ final class PeerInfoHeaderNode: ASDisplayNode { navigationContentsSecondaryColor = expandedAvatarNavigationContentsSecondaryColor contentButtonBackgroundColor = expandedAvatarContentButtonBackgroundColor contentButtonForegroundColor = expandedAvatarContentButtonForegroundColor - + navigationContentsCanBeExpanded = false - + headerButtonBackgroundColor = expandedAvatarHeaderButtonBackgroundColor - + ratingBackgroundColor = .white ratingBorderColor = .clear ratingForegroundColor = .clear } else { let effectiveTransitionFraction: CGFloat = innerBackgroundTransitionFraction < 0.5 ? 0.0 : 1.0 - + navigationContentsAccentColor = regularNavigationContentsAccentColor.mixedWith(collapsedHeaderNavigationContentsAccentColor, alpha: effectiveTransitionFraction) navigationContentsPrimaryColor = regularNavigationContentsPrimaryColor.mixedWith(collapsedHeaderNavigationContentsPrimaryColor, alpha: effectiveTransitionFraction) navigationContentsSecondaryColor = regularNavigationContentsSecondaryColor.mixedWith(collapsedHeaderNavigationContentsSecondaryColor, alpha: effectiveTransitionFraction) - + if hasCoverColor { navigationContentsCanBeExpanded = effectiveTransitionFraction == 1.0 } else { navigationContentsCanBeExpanded = true } - + contentButtonBackgroundColor = regularContentButtonBackgroundColor contentButtonForegroundColor = regularContentButtonForegroundColor - + headerButtonBackgroundColor = regularHeaderButtonBackgroundColor.mixedWith(collapsedHeaderButtonBackgroundColor, alpha: effectiveTransitionFraction) - + if let status = peer?.emojiStatus, case let .starGift(_, _, _, _, _, innerColor, outerColor, patternColorValue, _) = status.content { let _ = innerColor ratingBackgroundColor = UIColor(white: 1.0, alpha: 1.0).mixedWith(accentRatingBackgroundColor, alpha: effectiveTransitionFraction) - + let innerColor = UIColor(rgb: UInt32(bitPattern: innerColor)) let outerColor = UIColor(rgb: UInt32(bitPattern: outerColor)) let backgroundColor = innerColor.mixedWith(outerColor, alpha: 0.8) @@ -873,9 +888,9 @@ final class PeerInfoHeaderNode: ASDisplayNode { ratingForegroundColor = ratingBorderColor.mixedWith(presentationData.theme.list.itemCheckColors.foregroundColor, alpha: effectiveTransitionFraction) } else if let profileColor = peer?.effectiveProfileColor { ratingBackgroundColor = UIColor(white: 1.0, alpha: 1.0).mixedWith(presentationData.theme.list.itemCheckColors.fillColor, alpha: effectiveTransitionFraction) - + let backgroundColors = self.context.peerNameColors.getProfile(profileColor, dark: presentationData.theme.overallDarkAppearance) - + let innerColor = backgroundColors.main let outerColor = backgroundColors.secondary ?? backgroundColors.main let backgroundColor = innerColor.mixedWith(outerColor, alpha: 0.8) @@ -889,10 +904,10 @@ final class PeerInfoHeaderNode: ASDisplayNode { ratingForegroundColor = presentationData.theme.list.itemCheckColors.foregroundColor } } - + do { self.currentCredibilityIcon = credibilityIcon - + var emojiStatusSize: CGSize? var currentEmojiStatus: PeerEmojiStatus? let emojiRegularStatusContent: EmojiStatusComponent.Content @@ -919,7 +934,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { emojiRegularStatusContent = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 80.0, height: 80.0), placeholderColor: presentationData.theme.list.mediaPlaceholderColor, themeColor: navigationContentsAccentColor, loopMode: .forever) emojiExpandedStatusContent = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 80.0, height: 80.0), placeholderColor: navigationContentsAccentColor, themeColor: navigationContentsAccentColor, loopMode: .forever) } - + let iconSize = self.titleCredibilityIconView.update( transition: ComponentTransition(navigationTransition), component: AnyComponent(EmojiStatusComponent( @@ -964,18 +979,18 @@ final class PeerInfoHeaderNode: ASDisplayNode { environment: {}, containerSize: CGSize(width: 26.0, height: 26.0) ) - + self.credibilityIconSize = iconSize self.titleExpandedCredibilityIconSize = expandedIconSize } - + do { self.currentStatusIcon = statusIcon - + var currentEmojiStatus: PeerEmojiStatus? var particleColor: UIColor? var uniqueGiftSlug: String? - + let emojiRegularStatusContent: EmojiStatusComponent.Content let emojiExpandedStatusContent: EmojiStatusComponent.Content switch statusIcon { @@ -991,7 +1006,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { emojiRegularStatusContent = .none emojiExpandedStatusContent = .none } - + let iconSize = self.titleStatusIconView.update( transition: ComponentTransition(navigationTransition), component: AnyComponent(EmojiStatusComponent( @@ -1016,10 +1031,10 @@ final class PeerInfoHeaderNode: ASDisplayNode { guard let strongSelf = self else { return } - + if let emojiFile = emojiFile { strongSelf.emojiStatusFileAndPackTitle.set(.never()) - + for attribute in emojiFile.attributes { if case let .CustomEmoji(_, _, _, packReference) = attribute, let packReference = packReference { strongSelf.emojiStatusPackDisposable.set((strongSelf.context.engine.stickers.loadedStickerPack(reference: packReference, forceActualized: false) @@ -1077,14 +1092,14 @@ final class PeerInfoHeaderNode: ASDisplayNode { environment: {}, containerSize: CGSize(width: 26.0, height: 26.0) ) - + self.statusIconSize = iconSize self.titleExpandedStatusIconSize = expandedIconSize } - + do { self.currentVerifiedIcon = verifiedIcon - + let emojiRegularStatusContent: EmojiStatusComponent.Content let emojiExpandedStatusContent: EmojiStatusComponent.Content switch verifiedIcon { @@ -1098,7 +1113,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { emojiRegularStatusContent = .none emojiExpandedStatusContent = .none } - + let iconSize = self.titleVerifiedIconView.update( transition: ComponentTransition(navigationTransition), component: AnyComponent(EmojiStatusComponent( @@ -1128,16 +1143,58 @@ final class PeerInfoHeaderNode: ASDisplayNode { environment: {}, containerSize: CGSize(width: 26.0, height: 26.0) ) - + self.verifiedIconSize = iconSize self.titleExpandedVerifiedIconSize = expandedIconSize } - + + do { + self.currentWinterGramIcon = isWinterGramOfficial + + let badgeContent: EmojiStatusComponent.Content = isWinterGramOfficial ? .winterGramBadge(backplateColor: winterGramBadgeBackplateColor(theme: presentationData.theme)) : EmojiStatusComponent.Content.none + + let iconSize = self.titleWinterGramIconView.update( + transition: ComponentTransition(navigationTransition), + component: AnyComponent(EmojiStatusComponent( + context: self.context, + animationCache: self.animationCache, + animationRenderer: self.animationRenderer, + content: badgeContent, + isVisibleForAnimations: true, + useSharedAnimation: true, + action: { [weak self] in + self?.presentWinterGramBadgeInfo() + } + )), + environment: {}, + containerSize: CGSize(width: 26.0, height: 26.0) + ) + let expandedIconSize = self.titleExpandedWinterGramIconView.update( + transition: ComponentTransition(navigationTransition), + component: AnyComponent(EmojiStatusComponent( + context: self.context, + animationCache: self.animationCache, + animationRenderer: self.animationRenderer, + content: badgeContent, + isVisibleForAnimations: true, + useSharedAnimation: true, + action: { [weak self] in + self?.presentWinterGramBadgeInfo() + } + )), + environment: {}, + containerSize: CGSize(width: 26.0, height: 26.0) + ) + + self.winterGramIconSize = iconSize + self.titleExpandedWinterGramIconSize = expandedIconSize + } + var actualNavigationContentsColor = navigationContentsAccentColor actualNavigationContentsColor = presentationData.theme.chat.inputPanel.panelControlColor - + self.navigationButtonContainer.updateContentsColor(backgroundContentColor: headerButtonBackgroundColor, contentsColor: actualNavigationContentsColor, isOverColoredContents: !navigationContentsCanBeExpanded, transition: navigationTransition) - + self.titleNode.updateTintColor(color: navigationContentsPrimaryColor, transition: navigationTransition) self.subtitleNode.updateTintColor(color: navigationContentsSecondaryColor, transition: navigationTransition) self.panelSubtitleNode.updateTintColor(color: navigationContentsSecondaryColor, transition: navigationTransition) @@ -1146,16 +1203,16 @@ final class PeerInfoHeaderNode: ASDisplayNode { navigationTransition.updateTintColor(layer: mainContentNode.layer, color: navigationContentsAccentColor) } navigationTransition.updateTintColor(layer: navigationBar.backButtonArrow.layer, color: navigationContentsAccentColor) - + if let mainContentNode = navigationBar.leftButtonNode.mainContentNode { navigationTransition.updateTintColor(layer: mainContentNode.layer, color: navigationContentsAccentColor) } - + navigationBar.rightButtonNode.contentsColor = navigationContentsAccentColor navigationBar.leftButtonNode.contentsColor = navigationContentsAccentColor navigationBar.backButtonNode.contentsColor = navigationContentsAccentColor } - + var titleBrightness: CGFloat = 0.0 navigationContentsPrimaryColor.getHue(nil, saturation: nil, brightness: &titleBrightness, alpha: nil) if isSearching { @@ -1163,11 +1220,11 @@ final class PeerInfoHeaderNode: ASDisplayNode { } else { self.controller?.setStatusBarStyle(titleBrightness > 0.5 ? .White : .Black, animated: !isFirstTime && animateHeader) } - + self.avatarListNode.avatarContainerNode.updateTransitionFraction(transitionFraction, transition: transition) self.avatarListNode.listContainerNode.currentItemNode?.updateTransitionFraction(transitionFraction, transition: transition) self.avatarOverlayNode.updateTransitionFraction(transitionFraction, transition: transition) - + let expandedAvatarControlsHeight: CGFloat = 61.0 var expandedAvatarListHeight = min(width, containerHeight - expandedAvatarControlsHeight) if self.isSettings || self.isMyProfile { @@ -1175,9 +1232,9 @@ final class PeerInfoHeaderNode: ASDisplayNode { } else { expandedAvatarListHeight = expandedAvatarListHeight + 98.0 } - + let expandedAvatarListSize = CGSize(width: width, height: expandedAvatarListHeight) - + var isPremium = false var isVerified = false var isFake = false @@ -1195,11 +1252,11 @@ final class PeerInfoHeaderNode: ASDisplayNode { isVerified = peer.isVerified isFake = peer.isFake || peer.isScam } - + let titleShadowColor: UIColor? = nil - + var displayStandardTitle = false - + if let peer = peer { var title: String if peer.id == self.context.account.peerId && !self.isSettings && !self.isMyProfile { @@ -1233,26 +1290,26 @@ final class PeerInfoHeaderNode: ASDisplayNode { titleStringText = title titleAttributes = MultiScaleTextState.Attributes(font: Font.medium(28.0), color: .white) smallTitleAttributes = MultiScaleTextState.Attributes(font: Font.medium(28.0), color: .white, shadowColor: titleShadowColor) - + if self.isSettings, case let .user(user) = peer { var subtitle = formatPhoneNumber(context: self.context, number: user.phone ?? "") - + if let mainUsername = user.addressName, !mainUsername.isEmpty { subtitle = "\(subtitle) • @\(mainUsername)" } subtitleStringText = subtitle subtitleAttributes = MultiScaleTextState.Attributes(font: Font.regular(17.0), color: .white) smallSubtitleAttributes = MultiScaleTextState.Attributes(font: Font.regular(16.0), color: .white, shadowColor: titleShadowColor) - + usernameString = ("", MultiScaleTextState.Attributes(font: Font.regular(16.0), color: .white)) } else if self.isMyProfile { let subtitleColor: UIColor subtitleColor = .white - + subtitleStringText = presentationData.strings.Presence_online subtitleAttributes = MultiScaleTextState.Attributes(font: Font.regular(17.0), color: subtitleColor) smallSubtitleAttributes = MultiScaleTextState.Attributes(font: Font.regular(16.0), color: .white, shadowColor: titleShadowColor) - + usernameString = ("", MultiScaleTextState.Attributes(font: Font.regular(16.0), color: .white)) let (maybePanelStatusData, _, _) = panelStatusData @@ -1267,7 +1324,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { } } else if let _ = threadData { let subtitleColor: UIColor = .white - + let statusText: String if case let .user(user) = peer, user.isForum { statusText = " " @@ -1275,11 +1332,11 @@ final class PeerInfoHeaderNode: ASDisplayNode { statusText = peer.debugDisplayTitle subtitleIsButton = true } - + subtitleStringText = statusText subtitleAttributes = MultiScaleTextState.Attributes(font: Font.semibold(16.0), color: subtitleColor) smallSubtitleAttributes = MultiScaleTextState.Attributes(font: Font.regular(16.0), color: .white, shadowColor: titleShadowColor) - + usernameString = ("", MultiScaleTextState.Attributes(font: Font.regular(16.0), color: .white)) let (maybePanelStatusData, _, _) = panelStatusData @@ -1299,11 +1356,11 @@ final class PeerInfoHeaderNode: ASDisplayNode { } else { subtitleColor = UIColor.white } - + subtitleStringText = statusData.text subtitleAttributes = MultiScaleTextState.Attributes(font: Font.regular(17.0), color: subtitleColor) smallSubtitleAttributes = MultiScaleTextState.Attributes(font: Font.regular(16.0), color: .white, shadowColor: titleShadowColor) - + usernameString = ("", MultiScaleTextState.Attributes(font: Font.regular(16.0), color: .white)) let (maybePanelStatusData, _, _) = panelStatusData @@ -1320,9 +1377,9 @@ final class PeerInfoHeaderNode: ASDisplayNode { subtitleStringText = " " subtitleAttributes = MultiScaleTextState.Attributes(font: Font.regular(16.0), color: .white) smallSubtitleAttributes = MultiScaleTextState.Attributes(font: Font.regular(16.0), color: .white, shadowColor: titleShadowColor) - + usernameString = ("", MultiScaleTextState.Attributes(font: Font.regular(16.0), color: .white)) - + let (maybePanelStatusData, _, _) = panelStatusData if let panelStatusData = maybePanelStatusData { let subtitleColor: UIColor @@ -1338,33 +1395,33 @@ final class PeerInfoHeaderNode: ASDisplayNode { titleStringText = " " titleAttributes = MultiScaleTextState.Attributes(font: Font.regular(24.0), color: .white) smallTitleAttributes = MultiScaleTextState.Attributes(font: Font.regular(24.0), color: .white, shadowColor: titleShadowColor) - + subtitleStringText = " " subtitleAttributes = MultiScaleTextState.Attributes(font: Font.regular(16.0), color: .white) smallSubtitleAttributes = MultiScaleTextState.Attributes(font: Font.regular(16.0), color: .white, shadowColor: titleShadowColor) - + usernameString = ("", MultiScaleTextState.Attributes(font: Font.regular(16.0), color: .white)) } - + let textSideInset: CGFloat = 36.0 let expandedAvatarHeight: CGFloat = expandedAvatarListSize.height - + var titleConstrainedSize = CGSize(width: width - textSideInset * 2.0 - (isPremium || isVerified || isFake ? 20.0 : 0.0), height: .greatestFiniteMagnitude) if self.navigationButtonContainer.rightButtonNodes.count > 1 { titleConstrainedSize.width -= 60.0 } - + let titleNodeLayout = self.titleNode.updateLayout(text: titleStringText, states: [ TitleNodeStateRegular: MultiScaleTextState(attributes: titleAttributes, constrainedSize: titleConstrainedSize), TitleNodeStateExpanded: MultiScaleTextState(attributes: smallTitleAttributes, constrainedSize: titleConstrainedSize) ], mainState: TitleNodeStateRegular) - + let subtitleNodeLayout = self.subtitleNode.updateLayout(text: subtitleStringText, states: [ TitleNodeStateRegular: MultiScaleTextState(attributes: subtitleAttributes, constrainedSize: titleConstrainedSize), TitleNodeStateExpanded: MultiScaleTextState(attributes: smallSubtitleAttributes, constrainedSize: titleConstrainedSize) ], mainState: TitleNodeStateRegular) self.subtitleNode.accessibilityLabel = subtitleStringText - + var subtitleButtonHorizontalOffset: CGFloat = 0.0 if subtitleIsButton { let subtitleBackgroundNode: ASDisplayNode @@ -1375,7 +1432,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { self.subtitleBackgroundNode = subtitleBackgroundNode self.subtitleNode.insertSubnode(subtitleBackgroundNode, at: 0) } - + let subtitleBackgroundButton: HighlightTrackingButtonNode if let current = self.subtitleBackgroundButton { subtitleBackgroundButton = current @@ -1383,7 +1440,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { subtitleBackgroundButton = HighlightTrackingButtonNode() self.subtitleBackgroundButton = subtitleBackgroundButton self.subtitleNode.addSubnode(subtitleBackgroundButton) - + subtitleBackgroundButton.addTarget(self, action: #selector(self.subtitleBackgroundPressed), forControlEvents: .touchUpInside) subtitleBackgroundButton.highligthedChanged = { [weak self] highlighted in guard let self else { @@ -1398,7 +1455,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { } } } - + let subtitleArrowNode: ASImageNode if let current = self.subtitleArrowNode { subtitleArrowNode = current @@ -1411,7 +1468,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { subtitleArrowNode.image = generateTintedImage(image: UIImage(bundleImageName: "Item List/DisclosureArrow"), color: presentationData.theme.list.itemSecondaryTextColor) } self.subtitleNode.updateTintColor(color: presentationData.theme.list.itemSecondaryTextColor, transition: navigationTransition) - + transition.updateBackgroundColor(node: subtitleBackgroundNode, color: contentButtonBackgroundColor) let subtitleSize = subtitleNodeLayout[TitleNodeStateRegular]!.size var subtitleBackgroundFrame = CGRect(origin: CGPoint(), size: subtitleSize).offsetBy(dx: -subtitleSize.width * 0.5, dy: -subtitleSize.height * 0.5).insetBy(dx: -8.0, dy: -4.0) @@ -1419,9 +1476,9 @@ final class PeerInfoHeaderNode: ASDisplayNode { subtitleButtonHorizontalOffset = subtitleBackgroundFrame.midX transition.updateFrame(node: subtitleBackgroundNode, frame: subtitleBackgroundFrame) transition.updateCornerRadius(node: subtitleBackgroundNode, cornerRadius: subtitleBackgroundFrame.height * 0.5) - + transition.updateFrame(node: subtitleBackgroundButton, frame: subtitleBackgroundFrame) - + if let arrowImage = subtitleArrowNode.image { let scaleFactor: CGFloat = 0.8 let arrowSize = CGSize(width: floorToScreenPixels(arrowImage.size.width * scaleFactor), height: floorToScreenPixels(arrowImage.size.height * scaleFactor)) @@ -1441,57 +1498,57 @@ final class PeerInfoHeaderNode: ASDisplayNode { subtitleBackgroundButton.removeFromSupernode() } } - + if let previousPanelStatusData = previousPanelStatusData, let currentPanelStatusData = panelStatusData.0, let previousPanelStatusDataKey = previousPanelStatusData.key, let currentPanelStatusDataKey = currentPanelStatusData.key, previousPanelStatusDataKey != currentPanelStatusDataKey { if let snapshotView = self.panelSubtitleNode.view.snapshotContentTree() { let previousIndex = screenData?.availablePanes.firstIndex(of: previousPanelStatusDataKey) let currentIndex = screenData?.availablePanes.firstIndex(of: currentPanelStatusDataKey) - + let direction: CGFloat if let previousIndex, let currentIndex { direction = previousIndex > currentIndex ? 1.0 : -1.0 } else { direction = previousPanelStatusDataKey.rawValue > currentPanelStatusDataKey.rawValue ? 1.0 : -1.0 } - + self.panelSubtitleNode.view.superview?.addSubview(snapshotView) snapshotView.frame = self.panelSubtitleNode.frame snapshotView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 100.0 * direction, y: 0.0), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() }) snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) - + self.panelSubtitleNode.layer.animatePosition(from: CGPoint(x: 100.0 * direction * -1.0, y: 0.0), to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.panelSubtitleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) } } - + let panelSubtitleNodeLayout = self.panelSubtitleNode.updateLayout(text: panelSubtitleString?.text ?? subtitleStringText, states: [ TitleNodeStateRegular: MultiScaleTextState(attributes: panelSubtitleString?.attributes ?? subtitleAttributes, constrainedSize: titleConstrainedSize), TitleNodeStateExpanded: MultiScaleTextState(attributes: panelSubtitleString?.attributes ?? subtitleAttributes, constrainedSize: titleConstrainedSize) ], mainState: TitleNodeStateRegular) self.panelSubtitleNode.accessibilityLabel = panelSubtitleString?.text ?? subtitleStringText - + let usernameNodeLayout = self.usernameNode.updateLayout(text: usernameString.text, states: [ TitleNodeStateRegular: MultiScaleTextState(attributes: usernameString.attributes, constrainedSize: CGSize(width: titleConstrainedSize.width, height: titleConstrainedSize.height)), TitleNodeStateExpanded: MultiScaleTextState(attributes: usernameString.attributes, constrainedSize: CGSize(width: width - titleNodeLayout[TitleNodeStateExpanded]!.size.width - 8.0, height: titleConstrainedSize.height)) ], mainState: TitleNodeStateRegular) self.usernameNode.accessibilityLabel = usernameString.text - + let avatarCenter: CGPoint if let transitionSourceAvatarFrame = transitionSourceAvatarFrame { avatarCenter = CGPoint(x: (1.0 - transitionFraction) * avatarFrame.midX + transitionFraction * transitionSourceAvatarFrame.midX, y: (1.0 - transitionFraction) * avatarFrame.midY + transitionFraction * transitionSourceAvatarFrame.midY) } else { avatarCenter = avatarFrame.center } - + let titleSize = titleNodeLayout[TitleNodeStateRegular]!.size let titleExpandedSize = titleNodeLayout[TitleNodeStateExpanded]!.size let subtitleSize = subtitleNodeLayout[TitleNodeStateRegular]!.size var subtitleBadgeSize: CGSize? let _ = panelSubtitleNodeLayout[TitleNodeStateRegular]!.size let usernameSize = usernameNodeLayout[TitleNodeStateRegular]!.size - + if let statusData, statusData.isHiddenStatus, !self.isPremiumDisabled { let subtitleBadgeView: PeerInfoSubtitleBadgeView if let current = self.subtitleBadgeView { @@ -1506,92 +1563,116 @@ final class PeerInfoHeaderNode: ASDisplayNode { self.subtitleBadgeView = subtitleBadgeView self.subtitleNodeContainer.view.addSubview(subtitleBadgeView) } - + subtitleBadgeSize = subtitleBadgeView.update(title: presentationData.strings.PeerInfo_HiddenStatusBadge, fillColor: contentButtonBackgroundColor, foregroundColor: contentButtonForegroundColor) } else if let subtitleBadgeView = self.subtitleBadgeView { subtitleBadgeView.removeFromSuperview() } - + var titleHorizontalOffset: CGFloat = 0.0 var titleExpandedHorizontalOffset: CGFloat = 0.0 var nextIconX: CGFloat = titleSize.width var nextExpandedIconX: CGFloat = titleExpandedSize.width - + if let statusIconSize = self.statusIconSize, let titleExpandedStatusIconSize = self.titleExpandedStatusIconSize, statusIconSize.width > 0.0 { let offset = (statusIconSize.width + 4.0) / 2.0 - + let leftOffset: CGFloat = nextIconX + 4.0 let leftExpandedOffset: CGFloat = nextExpandedIconX + 4.0 titleHorizontalOffset -= offset - + var collapsedTransitionOffset: CGFloat = 0.0 if let navigationTransition = self.navigationTransition { collapsedTransitionOffset = -10.0 * navigationTransition.fraction } - + transition.updateFrame(view: self.titleStatusIconView, frame: CGRect(origin: CGPoint(x: leftOffset + collapsedTransitionOffset, y: floor((titleSize.height - statusIconSize.height) / 2.0)), size: statusIconSize)) transition.updateFrame(view: self.titleExpandedStatusIconView, frame: CGRect(origin: CGPoint(x: leftExpandedOffset, y: floor((titleExpandedSize.height - titleExpandedStatusIconSize.height) / 2.0) + 1.0), size: titleExpandedStatusIconSize)) - + nextIconX += 4.0 + statusIconSize.width nextExpandedIconX += 4.0 + titleExpandedStatusIconSize.width } - + if let credibilityIconSize = self.credibilityIconSize, let titleExpandedCredibilityIconSize = self.titleExpandedCredibilityIconSize, credibilityIconSize.width > 0.0 { let offset = (credibilityIconSize.width + 4.0) / 2.0 - + let leftOffset: CGFloat = nextIconX + 4.0 let leftExpandedOffset: CGFloat = nextExpandedIconX + 4.0 titleHorizontalOffset -= offset - + var collapsedTransitionOffset: CGFloat = 0.0 if let navigationTransition = self.navigationTransition { collapsedTransitionOffset = -10.0 * navigationTransition.fraction } - + transition.updateFrame(view: self.titleCredibilityIconView, frame: CGRect(origin: CGPoint(x: leftOffset + collapsedTransitionOffset, y: floor((titleSize.height - credibilityIconSize.height) / 2.0)), size: credibilityIconSize)) transition.updateFrame(view: self.titleExpandedCredibilityIconView, frame: CGRect(origin: CGPoint(x: leftExpandedOffset, y: floor((titleExpandedSize.height - titleExpandedCredibilityIconSize.height) / 2.0) + 1.0), size: titleExpandedCredibilityIconSize)) - + nextIconX += 4.0 + credibilityIconSize.width nextExpandedIconX += 4.0 + titleExpandedCredibilityIconSize.width } - + if let verifiedIconSize = self.verifiedIconSize, let titleExpandedVerifiedIconSize = self.titleExpandedVerifiedIconSize, verifiedIconSize.width > 0.0 { let leftOffset: CGFloat let leftExpandedOffset: CGFloat if case .verified = verifiedIcon { titleHorizontalOffset -= (verifiedIconSize.width + 4.0) / 2.0 - + leftOffset = nextIconX + 4.0 leftExpandedOffset = nextExpandedIconX + 4.0 } else { titleHorizontalOffset += (verifiedIconSize.width + 4.0) / 2.0 titleExpandedHorizontalOffset += titleExpandedVerifiedIconSize.width - 2.0 - + leftOffset = -verifiedIconSize.width - 4.0 leftExpandedOffset = -titleExpandedVerifiedIconSize.width - 4.0 } - + var collapsedTransitionOffset: CGFloat = 0.0 if let navigationTransition = self.navigationTransition { collapsedTransitionOffset = -10.0 * navigationTransition.fraction } - + transition.updateFrame(view: self.titleVerifiedIconView, frame: CGRect(origin: CGPoint(x: leftOffset + collapsedTransitionOffset, y: floor((titleSize.height - verifiedIconSize.height) / 2.0)), size: verifiedIconSize)) transition.updateFrame(view: self.titleExpandedVerifiedIconView, frame: CGRect(origin: CGPoint(x: leftExpandedOffset, y: floor((titleExpandedSize.height - titleExpandedVerifiedIconSize.height) / 2.0) + 1.0), size: titleExpandedVerifiedIconSize)) - + if case .verified = verifiedIcon { nextIconX += 4.0 + verifiedIconSize.width nextExpandedIconX += 4.0 + titleExpandedVerifiedIconSize.width } } - + + // 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. + 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. + let winterGramLeadingGap: CGFloat = 10.0 + let offset = (winterGramIconSize.width + winterGramLeadingGap) / 2.0 + + let leftOffset: CGFloat = nextIconX + winterGramLeadingGap + let leftExpandedOffset: CGFloat = nextExpandedIconX + winterGramLeadingGap + titleHorizontalOffset -= offset + + var collapsedTransitionOffset: CGFloat = 0.0 + if let navigationTransition = self.navigationTransition { + collapsedTransitionOffset = -10.0 * navigationTransition.fraction + } + + transition.updateFrame(view: self.titleWinterGramIconView, frame: CGRect(origin: CGPoint(x: leftOffset + collapsedTransitionOffset, y: floor((titleSize.height - winterGramIconSize.height) / 2.0)), size: winterGramIconSize)) + transition.updateFrame(view: self.titleExpandedWinterGramIconView, frame: CGRect(origin: CGPoint(x: leftExpandedOffset, y: floor((titleExpandedSize.height - titleExpandedWinterGramIconSize.height) / 2.0) + 1.0), size: titleExpandedWinterGramIconSize)) + + nextIconX += winterGramLeadingGap + winterGramIconSize.width + nextExpandedIconX += winterGramLeadingGap + titleExpandedWinterGramIconSize.width + } + var titleFrame: CGRect var subtitleFrame: CGRect let usernameFrame: CGRect let usernameSpacing: CGFloat = 4.0 - + let expandedTitleScale: CGFloat = 0.8 - + var bottomShadowHeight: CGFloat = 88.0 if !self.isSettings && !self.isMyProfile { bottomShadowHeight += 100.0 @@ -1599,14 +1680,14 @@ final class PeerInfoHeaderNode: ASDisplayNode { let bottomShadowFrame = CGRect(origin: CGPoint(x: 0.0, y: expandedAvatarHeight - bottomShadowHeight), size: CGSize(width: width, height: bottomShadowHeight)) transition.updateFrame(node: self.avatarListNode.listContainerNode.bottomShadowNode, frame: bottomShadowFrame, beginWithCurrentState: true) self.avatarListNode.listContainerNode.bottomShadowNode.update(size: bottomShadowFrame.size, transition: transition) - + let singleTitleLockOffset: CGFloat = ((peer?.id == self.context.account.peerId && !self.isMyProfile) || subtitleSize.height.isZero) ? 8.0 : 0.0 - + let titleLockOffset: CGFloat = 16.0 + singleTitleLockOffset let titleMaxLockOffset: CGFloat = 7.0 let titleOffset: CGFloat let titleCollapseFraction: CGFloat - + if self.isAvatarExpanded { let minTitleSize = CGSize(width: titleSize.width * expandedTitleScale, height: titleSize.height * expandedTitleScale) var minTitleFrame = CGRect(origin: CGPoint(x: 16.0, y: expandedAvatarHeight - bottomInset - 58.0 - UIScreenPixel + (subtitleSize.height.isZero ? 10.0 : 0.0)), size: minTitleSize) @@ -1615,14 +1696,14 @@ final class PeerInfoHeaderNode: ASDisplayNode { } titleFrame = CGRect(origin: CGPoint(x: minTitleFrame.midX - titleSize.width / 2.0, y: minTitleFrame.midY - titleSize.height / 2.0), size: titleSize) - + var titleCollapseOffset = titleFrame.midY - statusBarHeight - titleLockOffset if case .regular = metrics.widthClass, !isSettings, !isMyProfile { titleCollapseOffset -= 7.0 } titleOffset = -min(titleCollapseOffset, contentOffset) titleCollapseFraction = max(0.0, min(1.0, contentOffset / titleCollapseOffset)) - + subtitleFrame = CGRect(origin: CGPoint(x: 16.0 - subtitleButtonHorizontalOffset * (1.0 - titleCollapseFraction), y: minTitleFrame.maxY + 2.0), size: subtitleSize) if self.subtitleRating != nil { subtitleFrame.origin.x += 22.0 @@ -1630,19 +1711,19 @@ final class PeerInfoHeaderNode: ASDisplayNode { usernameFrame = CGRect(origin: CGPoint(x: width - usernameSize.width - 16.0, y: minTitleFrame.midY - usernameSize.height / 2.0), size: usernameSize) } else { titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((width - titleSize.width) / 2.0), y: avatarFrame.maxY + 9.0 + (subtitleSize.height.isZero ? 11.0 : 0.0)), size: titleSize) - + var titleCollapseOffset = titleFrame.midY - statusBarHeight - titleLockOffset if case .regular = metrics.widthClass, !isSettings, !isMyProfile { titleCollapseOffset -= 7.0 } titleOffset = -min(titleCollapseOffset, contentOffset) titleCollapseFraction = max(0.0, min(1.0, contentOffset / titleCollapseOffset)) - + var effectiveSubtitleWidth = subtitleSize.width if let subtitleBadgeSize { effectiveSubtitleWidth += (subtitleBadgeSize.width + 7.0) * (1.0 - titleCollapseFraction) } - + let totalSubtitleWidth = effectiveSubtitleWidth + usernameSpacing + usernameSize.width if usernameSize.width == 0.0 { subtitleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((width - effectiveSubtitleWidth) / 2.0) - subtitleButtonHorizontalOffset * (1.0 - titleCollapseFraction), y: titleFrame.maxY + 1.0), size: subtitleSize) @@ -1652,11 +1733,11 @@ final class PeerInfoHeaderNode: ASDisplayNode { usernameFrame = CGRect(origin: CGPoint(x: subtitleFrame.maxX + usernameSpacing, y: titleFrame.maxY + 1.0), size: usernameSize) } } - + let titleMinScale: CGFloat = 0.6 let subtitleMinScale: CGFloat = 0.8 let avatarMinScale: CGFloat = 0.55 - + let apparentTitleLockOffset = (1.0 - titleCollapseFraction) * 0.0 + titleCollapseFraction * titleMaxLockOffset let paneAreaExpansionDistance: CGFloat = 32.0 @@ -1669,7 +1750,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { var paneAreaExpansionDelta = (self.frame.maxY - navigationHeight) - contentOffset paneAreaExpansionDelta = max(0.0, min(paneAreaExpansionDelta, paneAreaExpansionDistance)) effectiveAreaExpansionFraction = 1.0 - paneAreaExpansionDelta / paneAreaExpansionDistance - + do { var paneAreaExpansionDelta = (paneContainerY - navigationHeight) - contentOffset paneAreaExpansionDelta = max(0.0, min(paneAreaExpansionDelta, paneAreaExpansionDistance)) @@ -1681,14 +1762,14 @@ final class PeerInfoHeaderNode: ASDisplayNode { effectiveAreaExpansionFraction = 1.0 - paneAreaExpansionDelta / paneAreaExpansionDistance realAreaExpansionFraction = effectiveAreaExpansionFraction } - + self.titleNode.update(stateFractions: [ TitleNodeStateRegular: self.isAvatarExpanded ? 0.0 : 1.0, TitleNodeStateExpanded: self.isAvatarExpanded ? 1.0 : 0.0 ], transition: transition) - + transition.updateAlpha(node: self.titleNode, alpha: isSearching ? 0.0 : 1.0) - + var subtitleAlpha: CGFloat var subtitleOffset: CGFloat = 0.0 var panelSubtitleAlpha: CGFloat @@ -1700,7 +1781,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { if (panelSubtitleString?.text ?? subtitleStringText) != subtitleStringText { subtitleAlpha = 1.0 - effectiveAreaExpansionFraction panelSubtitleAlpha = effectiveAreaExpansionFraction - + subtitleOffset = -effectiveAreaExpansionFraction * 5.0 panelSubtitleOffset = (1.0 - effectiveAreaExpansionFraction) * 5.0 } else { @@ -1718,12 +1799,12 @@ final class PeerInfoHeaderNode: ASDisplayNode { } } } - + if isSearching { subtitleAlpha = 0.0 panelSubtitleAlpha = 0.0 } - + self.subtitleNode.update(stateFractions: [ TitleNodeStateRegular: self.isAvatarExpanded ? 0.0 : 1.0, TitleNodeStateExpanded: self.isAvatarExpanded ? 1.0 : 0.0 @@ -1733,12 +1814,12 @@ final class PeerInfoHeaderNode: ASDisplayNode { TitleNodeStateRegular: self.isAvatarExpanded ? 0.0 : 1.0, TitleNodeStateExpanded: self.isAvatarExpanded ? 1.0 : 0.0 ], alpha: panelSubtitleAlpha, transition: transition) - + self.usernameNode.update(stateFractions: [ TitleNodeStateRegular: self.isAvatarExpanded ? 0.0 : 1.0, TitleNodeStateExpanded: self.isAvatarExpanded ? 1.0 : 0.0 ], alpha: subtitleAlpha, transition: transition) - + let avatarScale: CGFloat let avatarOffset: CGFloat if self.navigationTransition != nil { @@ -1748,7 +1829,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { trueAvatarSize.width -= 1.33 * 4.0 trueAvatarSize.height -= 1.33 * 4.0 } - + avatarScale = ((1.0 - transitionFraction) * avatarFrame.width + transitionFraction * trueAvatarSize.width) / avatarFrame.width } else { avatarScale = 1.0 @@ -1759,15 +1840,15 @@ final class PeerInfoHeaderNode: ASDisplayNode { avatarScale = 1.0 * (1.0 - titleCollapseFraction) + avatarMinScale * titleCollapseFraction avatarOffset = apparentTitleLockOffset + 0.0 * (1.0 - titleCollapseFraction) + 10.0 * titleCollapseFraction } - + if let previousAvatarView = self.navigationTransition?.previousAvatarView, let transitionSourceAvatarFrame { let previousScale = ((1.0 - transitionFraction) * avatarFrame.width + transitionFraction * transitionSourceAvatarFrame.width) / transitionSourceAvatarFrame.width - + transition.updateAlpha(layer: previousAvatarView.layer, alpha: transitionFraction) transition.updateTransformScale(layer: previousAvatarView.layer, scale: previousScale) transition.updatePosition(layer: previousAvatarView.layer, position: self.view.convert(CGPoint(x: avatarCenter.x - (27.0 * (1.0 - transitionFraction) + 10 * transitionFraction), y: avatarCenter.y - (2.66 * (1.0 - transitionFraction) + 1.0 * transitionFraction)), to: previousAvatarView.superview)) } - + if subtitleIsButton { subtitleFrame.origin.y += 11.0 * (1.0 - titleCollapseFraction) if let subtitleBackgroundButton = self.subtitleBackgroundButton { @@ -1780,9 +1861,9 @@ final class PeerInfoHeaderNode: ASDisplayNode { transition.updateAlpha(node: subtitleArrowNode, alpha: (1.0 - titleCollapseFraction)) } } - + let avatarCornerRadius: CGFloat = isForum ? floor(avatarSize * 0.25) : avatarSize / 2.0 - + if self.isAvatarExpanded { self.avatarListNode.listContainerNode.isHidden = false if let transitionSourceAvatarFrame = transitionSourceAvatarFrame { @@ -1791,7 +1872,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { trueAvatarSize.width -= 1.33 * 4.0 trueAvatarSize.height -= 1.33 * 4.0 } - + transition.updateCornerRadius(node: self.avatarListNode.listContainerNode, cornerRadius: transitionFraction * trueAvatarSize.width / 2.0) transition.updateCornerRadius(node: self.avatarListNode.listContainerNode.controlsClippingNode, cornerRadius: transitionFraction * trueAvatarSize.width / 2.0) } else { @@ -1813,7 +1894,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { } }) } - + self.avatarListNode.update(size: CGSize(), avatarSize: avatarSize, isExpanded: self.isAvatarExpanded, peer: peer, isForum: isForum, threadId: self.forumTopicThreadId, threadInfo: threadData?.info, theme: presentationData.theme, transition: transition) self.editingContentNode.avatarNode.update(peer: peer, threadData: threadData, chatLocation: self.chatLocation, item: self.avatarListNode.item, updatingAvatar: state.updatingAvatar, uploadProgress: state.avatarUploadProgress, theme: presentationData.theme, avatarSize: avatarSize, isEditing: state.isEditing) self.avatarOverlayNode.update(peer: peer, threadData: threadData, chatLocation: self.chatLocation, item: self.avatarListNode.item, updatingAvatar: state.updatingAvatar, uploadProgress: state.avatarUploadProgress, theme: presentationData.theme, avatarSize: avatarSize, isEditing: state.isEditing) @@ -1824,21 +1905,21 @@ final class PeerInfoHeaderNode: ASDisplayNode { transition.updateSublayerTransformScale(node: self.avatarListNode.avatarContainerNode, scale: avatarScale) transition.updateSublayerTransformScale(node: self.avatarOverlayNode, scale: avatarScale) } - + if let avatarStoryView = self.avatarListNode.avatarContainerNode.avatarStoryView?.view { transition.updateAlpha(layer: avatarStoryView.layer, alpha: 1.0 - transitionFraction) } - + var apparentAvatarFrame: CGRect var apparentAvatarListFrame: CGRect let controlsClippingFrame: CGRect if self.isAvatarExpanded { let expandedAvatarCenter = CGPoint(x: expandedAvatarListSize.width / 2.0, y: expandedAvatarListSize.width / 2.0 - contentOffset / 2.0) apparentAvatarFrame = CGRect(origin: CGPoint(x: expandedAvatarCenter.x * (1.0 - transitionFraction) + transitionFraction * avatarCenter.x, y: expandedAvatarCenter.y * (1.0 - transitionFraction) + transitionFraction * avatarCenter.y), size: CGSize()) - + let expandedAvatarListCenter = CGPoint(x: expandedAvatarListSize.width / 2.0, y: expandedAvatarListSize.height / 2.0 - contentOffset / 2.0) apparentAvatarListFrame = CGRect(origin: CGPoint(x: expandedAvatarListCenter.x * (1.0 - transitionFraction) + transitionFraction * avatarCenter.x, y: expandedAvatarListCenter.y * (1.0 - transitionFraction) + transitionFraction * avatarCenter.y), size: CGSize()) - + if let transitionSourceAvatarFrame = transitionSourceAvatarFrame { var trueAvatarSize = transitionSourceAvatarFrame.size if let storyStats = self.avatarListNode.avatarContainerNode.avatarNode.storyStats, storyStats.unseenCount != 0 { @@ -1846,7 +1927,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { trueAvatarSize.height -= 1.33 * 4.0 } let trueAvatarFrame = trueAvatarSize.centered(around: transitionSourceAvatarFrame.center) - + let expandedFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: expandedAvatarListSize) controlsClippingFrame = CGRect(origin: CGPoint(x: transitionFraction * trueAvatarFrame.minX + (1.0 - transitionFraction) * expandedFrame.minX, y: transitionFraction * trueAvatarFrame.minY + (1.0 - transitionFraction) * expandedFrame.minY), size: CGSize(width: transitionFraction * trueAvatarFrame.width + (1.0 - transitionFraction) * expandedFrame.width, height: transitionFraction * trueAvatarFrame.height + (1.0 - transitionFraction) * expandedFrame.height)) } else { @@ -1862,11 +1943,11 @@ final class PeerInfoHeaderNode: ASDisplayNode { apparentAvatarListFrame = apparentAvatarFrame controlsClippingFrame = apparentAvatarFrame } - + let _ = apparentAvatarListFrame transition.updateFrameAdditive(node: self.avatarListNode, frame: CGRect(origin: apparentAvatarFrame.center, size: CGSize())) transition.updateFrameAdditive(node: self.avatarOverlayNode, frame: CGRect(origin: apparentAvatarFrame.center, size: CGSize())) - + var avatarListContainerFrame: CGRect let avatarListContainerScale: CGFloat var avatarListVerticalOffset: CGFloat = 0.0 @@ -1874,12 +1955,12 @@ final class PeerInfoHeaderNode: ASDisplayNode { if let transitionSourceAvatarFrame = transitionSourceAvatarFrame { let neutralAvatarListContainerSize = expandedAvatarListSize var avatarListContainerSize = CGSize(width: neutralAvatarListContainerSize.width * (1.0 - transitionFraction) + transitionSourceAvatarFrame.width * transitionFraction, height: neutralAvatarListContainerSize.height * (1.0 - transitionFraction) + transitionSourceAvatarFrame.height * transitionFraction) - + if let storyStats = self.avatarListNode.avatarContainerNode.avatarNode.storyStats, storyStats.unseenCount != 0 { avatarListContainerSize.width -= 1.33 * 5.0 avatarListContainerSize.height -= 1.33 * 5.0 } - + avatarListContainerFrame = CGRect(origin: CGPoint(x: -avatarListContainerSize.width / 2.0, y: -avatarListContainerSize.width / 2.0), size: avatarListContainerSize) } else { avatarListContainerFrame = CGRect(origin: CGPoint(x: -expandedAvatarListSize.width / 2.0, y: -expandedAvatarListSize.width / 2.0), size: expandedAvatarListSize) @@ -1893,14 +1974,14 @@ final class PeerInfoHeaderNode: ASDisplayNode { avatarListContainerScale = avatarScale } transition.updateFrame(node: self.avatarListNode.listContainerNode, frame: avatarListContainerFrame) - + let avatarClipOffset: CGFloat = !self.isAvatarExpanded && deviceMetrics.hasDynamicIsland && statusBarHeight > 0.0 && self.avatarClippingNode.clipsToBounds && !isLandscape ? 47.0 : 0.0 let clippingNodeTransition = ContainedViewLayoutTransition.immediate clippingNodeTransition.updateFrame(layer: self.avatarClippingNode.layer, frame: CGRect(origin: CGPoint(x: 0.0, y: avatarClipOffset + avatarListVerticalOffset), size: CGSize(width: width, height: 1000.0))) clippingNodeTransition.updateSublayerTransformOffset(layer: self.avatarClippingNode.layer, offset: CGPoint(x: 0.0, y: -avatarClipOffset)) let clippingNodeRadiusTransition = ContainedViewLayoutTransition.animated(duration: 0.15, curve: .easeInOut) clippingNodeRadiusTransition.updateCornerRadius(node: self.avatarClippingNode, cornerRadius: avatarClipOffset > 0.0 ? width / 2.5 : 0.0) - + let innerScale = avatarListContainerFrame.width / expandedAvatarListSize.width let innerDeltaX = (avatarListContainerFrame.width - expandedAvatarListSize.width) / 2.0 var innerDeltaY = (avatarListContainerFrame.height - expandedAvatarListSize.height) / 2.0 @@ -1910,22 +1991,22 @@ final class PeerInfoHeaderNode: ASDisplayNode { transition.updateSublayerTransformScale(node: self.avatarListNode.listContainerNode, scale: innerScale) transition.updateFrameAdditive(node: self.avatarListNode.listContainerNode.contentNode, frame: CGRect(origin: CGPoint(x: innerDeltaX + expandedAvatarListSize.width / 2.0, y: innerDeltaY + expandedAvatarListSize.height / 2.0), size: CGSize())) self.avatarListNode.listContainerNode.contentNode.update(size: expandedAvatarListSize) - + transition.updateFrameAdditive(node: self.avatarListNode.listContainerNode.controlsClippingOffsetNode, frame: CGRect(origin: controlsClippingFrame.center, size: CGSize())) transition.updateFrame(node: self.avatarListNode.listContainerNode.controlsClippingNode, frame: CGRect(origin: CGPoint(x: -controlsClippingFrame.width / 2.0, y: -controlsClippingFrame.height / 2.0), size: controlsClippingFrame.size)) transition.updateFrameAdditive(node: self.avatarListNode.listContainerNode.controlsContainerNode, frame: CGRect(origin: CGPoint(x: -controlsClippingFrame.minX, y: -controlsClippingFrame.minY), size: CGSize(width: expandedAvatarListSize.width, height: expandedAvatarListSize.height))) - + transition.updateFrame(node: self.avatarListNode.listContainerNode.topShadowNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: expandedAvatarListSize.width, height: navigationHeight + 20.0))) transition.updateFrame(node: self.avatarListNode.listContainerNode.stripContainerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: statusBarHeight < 25.0 ? (statusBarHeight + 2.0) : (statusBarHeight - 3.0)), size: CGSize(width: expandedAvatarListSize.width, height: 2.0))) transition.updateFrame(node: self.avatarListNode.listContainerNode.highlightContainerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: expandedAvatarListSize.width, height: expandedAvatarListSize.height))) transition.updateAlpha(node: self.avatarListNode.listContainerNode.controlsContainerNode, alpha: self.isAvatarExpanded ? (1.0 - transitionFraction) : 0.0) - + if additive { transition.updateSublayerTransformScaleAdditive(node: self.avatarListNode.listContainerTransformNode, scale: avatarListContainerScale) } else { transition.updateSublayerTransformScale(node: self.avatarListNode.listContainerTransformNode, scale: avatarListContainerScale) } - + if deviceMetrics.hasDynamicIsland && statusBarHeight > 0.0 && self.forumTopicThreadId == nil && self.navigationTransition == nil && !isLandscape { let maskValue = max(0.0, min(1.0, contentOffset / 120.0)) self.avatarListNode.containerNode.view.mask = self.avatarListNode.maskNode.view @@ -1941,20 +2022,20 @@ final class PeerInfoHeaderNode: ASDisplayNode { self.avatarListNode.topCoverNode.update(maskValue) self.avatarListNode.maskNode.update(maskValue) self.avatarListNode.bottomCoverNode.backgroundColor = UIColor(white: 0.0, alpha: maskValue) - + self.avatarListNode.listContainerNode.topShadowNode.isHidden = !self.isAvatarExpanded - + var avatarMaskOffset: CGFloat = 0.0 if contentOffset < 0.0 { avatarMaskOffset -= contentOffset } - + self.avatarListNode.maskNode.position = CGPoint(x: 0.0, y: -self.avatarListNode.frame.minY + 48.0 + 85.0 + avatarMaskOffset) self.avatarListNode.maskNode.bounds = CGRect(origin: .zero, size: CGSize(width: 171.0, height: 171.0)) - + self.avatarListNode.bottomCoverNode.position = self.avatarListNode.maskNode.position self.avatarListNode.bottomCoverNode.bounds = self.avatarListNode.maskNode.bounds - + self.avatarListNode.topCoverNode.position = self.avatarListNode.maskNode.position self.avatarListNode.topCoverNode.bounds = self.avatarListNode.maskNode.bounds } else { @@ -1962,12 +2043,12 @@ final class PeerInfoHeaderNode: ASDisplayNode { self.avatarListNode.topCoverNode.isHidden = true self.avatarListNode.containerNode.view.mask = nil } - + self.avatarListNode.listContainerNode.update(size: expandedAvatarListSize, peer: peer, isExpanded: self.isAvatarExpanded, transition: transition) if self.avatarListNode.listContainerNode.isCollapsing && !self.ignoreCollapse { self.avatarListNode.avatarContainerNode.canAttachVideo = false } - + let rawHeight: CGFloat let height: CGFloat let maxY: CGFloat @@ -1997,12 +2078,12 @@ final class PeerInfoHeaderNode: ASDisplayNode { backgroundHeight = height } let _ = maxY - + let apparentHeight = (1.0 - transitionFraction) * backgroundHeight + transitionFraction * transitionSourceHeight let apparentBackgroundHeight = (1.0 - transitionFraction) * backgroundHeight + transitionFraction * transitionSourceHeight - + var subtitleRatingSize: CGSize? - + if let cachedData = cachedData as? CachedUserData, let starRating = cachedData.starRating { self.currentStarRating = starRating self.currentPendingStarRating = cachedData.pendingStarRating @@ -2010,25 +2091,25 @@ final class PeerInfoHeaderNode: ASDisplayNode { self.currentStarRating = nil self.currentPendingStarRating = nil } - + #if DEBUG && false if "".isEmpty { let starRating: TelegramStarRating - + if self.context.account.peerId.id._internalGetInt64Value() == 654152421 { starRating = TelegramStarRating(level: -1, currentLevelStars: -1, stars: -100, nextLevelStars: 0) } else { starRating = TelegramStarRating(level: 2, currentLevelStars: 1000, stars: 2000, nextLevelStars: 3000) } self.currentStarRating = starRating - + if let _ = starRating.nextLevelStars { //self.currentPendingStarRating = TelegramStarPendingRating(rating: TelegramStarRating(level: starRating.level, currentLevelStars: starRating.currentLevelStars, stars: starRating.stars + 234, nextLevelStars: starRating.nextLevelStars), timestamp: Int32(Date().timeIntervalSince1970) + 60 * 60 * 24 * 3) self.currentPendingStarRating = TelegramStarPendingRating(rating: TelegramStarRating(level: starRating.level + 2, currentLevelStars: starRating.nextLevelStars!, stars: max(500, starRating.nextLevelStars! + starRating.nextLevelStars! / 2 - starRating.nextLevelStars! / 4), nextLevelStars: max(1000, starRating.nextLevelStars! * 2)), timestamp: Int32(Date().timeIntervalSince1970) + 60 * 60 * 24 * 3) } } #endif - + if let starRating = self.currentStarRating { let subtitleRating: ComponentView var subtitleRatingTransition = ComponentTransition(transition) @@ -2039,7 +2120,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { subtitleRating = ComponentView() self.subtitleRating = subtitleRating } - + subtitleRatingSize = subtitleRating.update( transition: subtitleRatingTransition, component: AnyComponent(PeerInfoRatingComponent( @@ -2075,7 +2156,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { subtitleRating.view?.removeFromSuperview() } } - + if !titleSize.width.isZero && !titleSize.height.isZero { if self.navigationTransition != nil { var neutralTitleScale: CGFloat = 1.0 @@ -2084,20 +2165,20 @@ final class PeerInfoHeaderNode: ASDisplayNode { neutralTitleScale = expandedTitleScale neutralSubtitleScale = 1.0 } - + let titleScale = (transitionFraction * transitionSourceTitleFrame.height + (1.0 - transitionFraction) * titleFrame.height * neutralTitleScale) / (titleFrame.height) let subtitleScale = max(0.01, min(10.0, (transitionFraction * transitionSourceSubtitleFrame.height + (1.0 - transitionFraction) * subtitleFrame.height * neutralSubtitleScale) / (subtitleFrame.height))) - + var titleFrame = titleFrame if !self.isAvatarExpanded { titleFrame = titleFrame.offsetBy(dx: titleHorizontalOffset * titleScale, dy: 0.0) } else { titleFrame = titleFrame.offsetBy(dx: titleExpandedHorizontalOffset, dy: 0.0) } - + let titleCenter = CGPoint(x: transitionFraction * transitionSourceTitleFrame.midX + (1.0 - transitionFraction) * titleFrame.midX, y: transitionFraction * transitionSourceTitleFrame.midY + (1.0 - transitionFraction) * titleFrame.midY) let subtitleCenter = CGPoint(x: transitionFraction * transitionSourceSubtitleFrame.midX + (1.0 - transitionFraction) * subtitleFrame.midX, y: transitionFraction * transitionSourceSubtitleFrame.midY + (1.0 - transitionFraction) * subtitleFrame.midY) - + let rawTitleFrame = CGRect(origin: CGPoint(x: titleCenter.x - titleFrame.size.width * neutralTitleScale / 2.0, y: titleCenter.y - titleFrame.size.height * neutralTitleScale / 2.0), size: CGSize(width: titleFrame.size.width * neutralTitleScale, height: titleFrame.size.height * neutralTitleScale)) self.titleNodeRawContainer.frame = rawTitleFrame transition.updateFrameAdditiveToCenter(node: self.titleNodeContainer, frame: CGRect(origin: rawTitleFrame.center, size: CGSize())) @@ -2111,13 +2192,13 @@ final class PeerInfoHeaderNode: ASDisplayNode { transition.updateSublayerTransformScale(node: self.titleNodeContainer, scale: titleScale) transition.updateSublayerTransformScale(node: self.subtitleNodeContainer, scale: subtitleScale) transition.updateSublayerTransformScale(node: self.usernameNodeContainer, scale: subtitleScale) - + if let subtitleBadgeView = self.subtitleBadgeView, let subtitleBadgeSize { let subtitleBadgeFrame = CGRect(origin: CGPoint(x: (subtitleSize.width + 8.0) * 0.5, y: floor((-subtitleBadgeSize.height) * 0.5)), size: subtitleBadgeSize) transition.updateFrameAdditive(view: subtitleBadgeView, frame: subtitleBadgeFrame) transition.updateAlpha(layer: subtitleBadgeView.layer, alpha: (1.0 - transitionFraction)) } - + if let subtitleRatingView = self.subtitleRating?.view, let subtitleRatingSize { let subtitleBadgeFrame: CGRect subtitleBadgeFrame = CGRect(origin: CGPoint(x: (-subtitleSize.width) * 0.5 - subtitleRatingSize.width + 1.0, y: subtitleOffset + floor((-subtitleRatingSize.height) * 0.5)), size: subtitleRatingSize) @@ -2139,7 +2220,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { subtitleOffset = titleCollapseFraction * -1.0 subtitleBadgeFraction = (1.0 - titleCollapseFraction) } - + let rawTitleFrame = titleFrame.offsetBy(dx: self.isAvatarExpanded ? titleExpandedHorizontalOffset : titleHorizontalOffset * titleScale, dy: 0.0) self.titleNodeRawContainer.frame = rawTitleFrame transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(), size: CGSize())) @@ -2153,12 +2234,12 @@ final class PeerInfoHeaderNode: ASDisplayNode { transition.updateFrameAdditive(node: self.usernameNodeContainer, frame: CGRect(origin: rawUsernameFrame.center, size: CGSize()).offsetBy(dx: 0.0, dy: titleOffset)) } else { transition.updateFrameAdditiveToCenter(node: self.titleNodeContainer, frame: CGRect(origin: rawTitleFrame.center, size: CGSize()).offsetBy(dx: 0.0, dy: titleOffset + apparentTitleLockOffset)) - + var subtitleCenter = rawSubtitleFrame.center subtitleCenter.x = rawTitleFrame.center.x + (subtitleCenter.x - rawTitleFrame.center.x) * subtitleScale subtitleCenter.y += subtitleOffset transition.updateFrameAdditiveToCenter(node: self.subtitleNodeContainer, frame: CGRect(origin: subtitleCenter, size: CGSize()).offsetBy(dx: 0.0, dy: titleOffset)) - + var usernameCenter = rawUsernameFrame.center usernameCenter.x = rawTitleFrame.center.x + (usernameCenter.x - rawTitleFrame.center.x) * subtitleScale transition.updateFrameAdditiveToCenter(node: self.usernameNodeContainer, frame: CGRect(origin: usernameCenter, size: CGSize()).offsetBy(dx: 0.0, dy: titleOffset)) @@ -2169,16 +2250,16 @@ final class PeerInfoHeaderNode: ASDisplayNode { transition.updateSublayerTransformScaleAdditive(node: self.titleNodeContainer, scale: titleScale) transition.updateSublayerTransformScaleAdditive(node: self.subtitleNodeContainer, scale: subtitleScale) transition.updateSublayerTransformScaleAdditive(node: self.usernameNodeContainer, scale: subtitleScale) - + if let subtitleBadgeView = self.subtitleBadgeView, let subtitleBadgeSize { let subtitleBadgeFrame = CGRect(origin: CGPoint(x: (subtitleSize.width + 8.0) * 0.5, y: floor((-subtitleBadgeSize.height) * 0.5)), size: subtitleBadgeSize) transition.updateFrameAdditive(view: subtitleBadgeView, frame: subtitleBadgeFrame) transition.updateAlpha(layer: subtitleBadgeView.layer, alpha: (1.0 - transitionFraction) * subtitleBadgeFraction) } - + if let subtitleRatingView = self.subtitleRating?.view, let subtitleRatingSize { let subtitleBadgeFrame = CGRect(origin: CGPoint(x: (-subtitleSize.width) * 0.5 - subtitleRatingSize.width + 1.0, y: floor((-subtitleRatingSize.height) * 0.5)), size: subtitleRatingSize) - + if subtitleRatingView.frame.isEmpty { subtitleRatingView.frame = subtitleBadgeFrame subtitleRatingView.alpha = subtitleAlpha @@ -2189,10 +2270,10 @@ final class PeerInfoHeaderNode: ASDisplayNode { } } } - + if displayStandardTitle { self.titleNode.isHidden = true - + let standardTitle: ComponentView if let current = self.standardTitle { standardTitle = current @@ -2200,7 +2281,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { standardTitle = ComponentView() self.standardTitle = standardTitle } - + let titleSize = standardTitle.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( @@ -2220,32 +2301,32 @@ final class PeerInfoHeaderNode: ASDisplayNode { if let standardTitle = self.standardTitle { self.standardTitle = nil standardTitle.view?.removeFromSuperview() - + self.titleNode.isHidden = false } } - + let buttonsTransitionDistance: CGFloat = -min(0.0, apparentBackgroundHeight - backgroundHeight) let buttonsTransitionDistanceNorm: CGFloat = 40.0 - + let innerContentOffset = max(0.0, contentOffset - 140.0) let backgroundTransitionFraction: CGFloat = 1.0 - max(0.0, min(1.0, innerContentOffset / 30.0)) - + let innerButtonsTransitionStepDistance: CGFloat = 58.0 let innerButtonsTransitionStepInset: CGFloat = 28.0 let innerButtonsTransitionDistance: CGFloat = navigationHeight + panelWithAvatarHeight - innerButtonsTransitionStepDistance - innerButtonsTransitionStepInset let innerButtonsContentOffset = max(0.0, contentOffset - innerButtonsTransitionDistance) let innerButtonsTransitionFraction = max(0.0, min(1.0, innerButtonsContentOffset / innerButtonsTransitionStepDistance)) - + let buttonsTransitionFraction: CGFloat = 1.0 - max(0.0, min(1.0, buttonsTransitionDistance / buttonsTransitionDistanceNorm)) - + let buttonSpacing: CGFloat = 8.0 let buttonSideInset = max(16.0, containerInset) - + let actionButtonWidth = (width - buttonSideInset * 2.0 + buttonSpacing) / CGFloat(actionButtonKeys.count) - buttonSpacing let actionButtonSize = CGSize(width: actionButtonWidth, height: 40.0) var actionButtonRightOrigin = CGPoint(x: width - buttonSideInset, y: backgroundHeight - 16.0 - actionButtonSize.height) - + for buttonKey in actionButtonKeys.reversed() { let buttonNode: PeerInfoHeaderActionButtonNode var wasAdded = false @@ -2259,10 +2340,10 @@ final class PeerInfoHeaderNode: ASDisplayNode { self.actionButtonNodes[buttonKey] = buttonNode self.buttonsContainerNode.addSubnode(buttonNode) } - + let buttonFrame = CGRect(origin: CGPoint(x: actionButtonRightOrigin.x - actionButtonSize.width, y: actionButtonRightOrigin.y), size: actionButtonSize) let buttonTransition: ContainedViewLayoutTransition = wasAdded ? .immediate : transition - + if additive { buttonTransition.updateFrameAdditiveToCenter(node: buttonNode, frame: buttonFrame) } else { @@ -2277,16 +2358,16 @@ final class PeerInfoHeaderNode: ASDisplayNode { default: fatalError() } - + buttonNode.update(size: buttonFrame.size, text: buttonText, presentationData: presentationData, transition: buttonTransition) - + if wasAdded { buttonNode.alpha = 0.0 } transition.updateAlpha(node: buttonNode, alpha: 1.0) actionButtonRightOrigin.x -= actionButtonSize.width + buttonSpacing } - + for key in self.actionButtonNodes.keys { if !actionButtonKeys.contains(key) { if let buttonNode = self.actionButtonNodes[key] { @@ -2297,21 +2378,21 @@ final class PeerInfoHeaderNode: ASDisplayNode { } } } - + let buttonWidth = (width - buttonSideInset * 2.0 + buttonSpacing) / CGFloat(buttonKeys.count) - buttonSpacing let buttonSize = CGSize(width: buttonWidth, height: 58.0) var buttonRightOrigin = CGPoint(x: width - buttonSideInset, y: backgroundHeight - bottomInset - 16.0 - buttonSize.height) if !actionButtonKeys.isEmpty { buttonRightOrigin.y += actionButtonSize.height + 24.0 } - + transition.updateFrameAdditive(node: self.buttonsBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: buttonRightOrigin.y), size: CGSize(width: width, height: buttonSize.height + 40.0))) self.buttonsBackgroundNode.update(size: self.buttonsBackgroundNode.bounds.size, transition: transition) self.buttonsBackgroundNode.updateColor(color: contentButtonBackgroundColor, enableBlur: true, transition: transition) if isReduceTransparencyEnabled() { self.buttonsBackgroundNode.alpha = 0.1 } - + for buttonKey in buttonKeys.reversed() { let buttonNode: PeerInfoHeaderButtonNode var wasAdded = false @@ -2326,17 +2407,17 @@ final class PeerInfoHeaderNode: ASDisplayNode { self.buttonsContainerNode.addSubnode(buttonNode) self.buttonsMaskView.addSubview(buttonNode.backgroundContainerView) } - + let buttonFrame = CGRect(origin: CGPoint(x: buttonRightOrigin.x - buttonSize.width, y: buttonRightOrigin.y), size: buttonSize) let buttonTransition: ContainedViewLayoutTransition = wasAdded ? .immediate : transition - + if additive { buttonTransition.updateFrameAdditiveToCenter(node: buttonNode, frame: buttonFrame) } else { buttonTransition.updateFrame(node: buttonNode, frame: buttonFrame) } buttonTransition.updateFrame(view: buttonNode.backgroundContainerView, frame: buttonFrame.offsetBy(dx: 0.0, dy: -buttonFrame.minY)) - + let buttonText: String let buttonIcon: PeerInfoHeaderButtonIcon switch buttonKey { @@ -2386,21 +2467,21 @@ final class PeerInfoHeaderNode: ASDisplayNode { case .addContact: fatalError() } - + var isActive = true if let highlightedButton = state.highlightedButton { isActive = buttonKey == highlightedButton } - + buttonNode.update(size: buttonFrame.size, text: buttonText, icon: buttonIcon, isActive: isActive, presentationData: presentationData, backgroundColor: contentButtonBackgroundColor, foregroundColor: contentButtonForegroundColor, fraction: 1.0 - innerButtonsTransitionFraction, transition: buttonTransition) - + if wasAdded { buttonNode.alpha = 0.0 buttonNode.backgroundContainerView.alpha = 0.0 } transition.updateAlpha(node: buttonNode, alpha: buttonsTransitionFraction) transition.updateAlpha(layer: buttonNode.backgroundContainerView.layer, alpha: buttonsTransitionFraction) - + if case .mute = buttonKey, buttonNode.containerNode.alpha.isZero, additive { if case let .animated(duration, curve) = transition { ContainedViewLayoutTransition.animated(duration: duration * 0.3, curve: curve).updateAlpha(node: buttonNode.containerNode, alpha: 1.0) @@ -2412,7 +2493,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { } buttonRightOrigin.x -= buttonSize.width + buttonSpacing } - + for key in self.buttonNodes.keys { if !buttonKeys.contains(key) { if let buttonNode = self.buttonNodes[key] { @@ -2425,18 +2506,18 @@ final class PeerInfoHeaderNode: ASDisplayNode { } } } - + let resolvedRegularHeight: CGFloat if self.isAvatarExpanded { resolvedRegularHeight = expandedAvatarListSize.height } else { resolvedRegularHeight = panelWithAvatarHeight + navigationHeight + bottomInset } - + let backgroundFrame: CGRect - + var resolvedHeight: CGFloat - + if state.isEditing { resolvedHeight = editingContentHeight backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: -2000.0 + max(navigationHeight, resolvedHeight - contentOffset)), size: CGSize(width: width, height: 2000.0)) @@ -2444,22 +2525,22 @@ final class PeerInfoHeaderNode: ASDisplayNode { resolvedHeight = resolvedRegularHeight backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: -2000.0 + apparentHeight), size: CGSize(width: width, height: 2000.0)) } - + transition.updateFrame(node: self.regularContentNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: width, height: resolvedHeight))) - + transition.updateFrameAdditive(node: self.buttonsContainerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: apparentBackgroundHeight - backgroundHeight), size: CGSize(width: width, height: 1000.0))) - + navigationTransition.updateAlpha(node: self.buttonsContainerNode, alpha: backgroundBannerAlpha) - + let bannerInset: CGFloat = 3.0 let bannerFrame = CGRect(origin: CGPoint(x: -bannerInset, y: -2000.0 + apparentBackgroundHeight), size: CGSize(width: width + bannerInset * 2.0, height: 2000.0)) - + if additive { transition.updateFrameAdditive(view: self.backgroundBannerView, frame: bannerFrame) } else { transition.updateFrame(view: self.backgroundBannerView, frame: bannerFrame) } - + let backgroundCoverSize = self.backgroundCover.update( transition: ComponentTransition(transition), component: AnyComponent(PeerInfoCoverComponent( @@ -2499,14 +2580,18 @@ final class PeerInfoHeaderNode: ASDisplayNode { } } } - + if let profileGiftsContext, let peer { + // WinterGram: visual gifts are listed in the Gifts pane only; they are NOT auto-pinned onto + // the profile cover. (Pinning to the cover would be a manual per-gift action.) + let additionalGifts: [ProfileGiftsContext.State.StarGift] = [] let giftsCoverSize = self.giftsCover.update( transition: ComponentTransition(transition), component: AnyComponent(PeerInfoGiftsCoverComponent( context: self.context, peerId: peer.id, giftsContext: profileGiftsContext, + additionalGifts: additionalGifts, hasBackground: hasBackground, avatarCenter: apparentAvatarFrame.center, avatarSize: apparentAvatarFrame.size, @@ -2547,32 +2632,32 @@ final class PeerInfoHeaderNode: ASDisplayNode { } } } - + let edgeEffectHeight: CGFloat = 60.0 var edgeEffectFrame = CGRect(origin: CGPoint(x: 0.0, y: -50.0), size: CGSize(width: backgroundFrame.width, height: navigationHeight + 18.0 + 50.0)) edgeEffectFrame.origin.y += floorToScreenPixels(realAreaExpansionFraction * 50.0) - + if additive { transition.updateFrameAdditive(layer: self.headerEdgeEffectView.layer, frame: edgeEffectFrame) } else { transition.updateFrame(view: self.headerEdgeEffectView, frame: edgeEffectFrame) } - + if !isSettings { self.updateUnderHeaderContentsAlpha?(1.0 - realAreaExpansionFraction, transition) } - + self.headerEdgeEffectView.update(content: presentationData.theme.list.plainBackgroundColor, blur: true, rect: edgeEffectFrame, edge: .top, edgeSize: edgeEffectHeight, transition: ComponentTransition(transition)) - + navigationTransition.updateAlpha(layer: self.headerEdgeEffectView.layer, alpha: state.isEditing ? 0.0 : 1.0) - + if !state.isEditing { if !isSettings && !isMyProfile { if self.isAvatarExpanded { resolvedHeight -= 21.0 } else { resolvedHeight += 79.0 - + if !actionButtonKeys.isEmpty { resolvedHeight += 64.0 } @@ -2583,7 +2668,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { } } } - + if let currentSavedMusic { var musicTransition = transition var artist = presentationData.strings.MediaPlayer_UnknownArtist @@ -2602,7 +2687,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { track = presentationData.strings.MediaPlayer_UnknownTrack } } - + if hasBackground || self.isAvatarExpanded { if self.musicBackground == nil { musicTransition = .immediate @@ -2618,7 +2703,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { return musicBackground }() musicTransition.updateFrame(view: musicBackground, frame: CGRect(origin: CGPoint(x: 0.0, y: backgroundHeight - 24.0 - buttonRightOrigin.y), size: CGSize(width: backgroundFrame.width, height: 24.0))) - + if let _ = self.navigationTransition { transition.updateAlpha(layer: musicBackground.layer, alpha: 1.0 - transitionFraction) } else { @@ -2634,18 +2719,18 @@ final class PeerInfoHeaderNode: ASDisplayNode { musicBackground.removeFromSuperview() } } - + let music = self.music ?? { let componentView = ComponentView() self.music = componentView return componentView }() - + let musicString = NSMutableAttributedString() let isOverlay = self.isAvatarExpanded || hasBackground musicString.append(NSAttributedString(string: track ?? "", font: Font.semibold(12.0), textColor: isOverlay ? .white : presentationData.theme.list.itemAccentColor)) musicString.append(NSAttributedString(string: " - \(artist)", font: Font.regular(12.0), textColor: isOverlay ? UIColor.white.withAlphaComponent(0.7) : presentationData.theme.list.itemSecondaryTextColor)) - + let musicSize = music.update( transition: .immediate, component: AnyComponent( @@ -2688,7 +2773,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { } else { musicTransition.updateFrame(view: musicView, frame: musicFrame) } - + if let _ = self.navigationTransition { transition.updateAlpha(layer: musicView.layer, alpha: 1.0 - transitionFraction) } else { @@ -2717,22 +2802,22 @@ final class PeerInfoHeaderNode: ASDisplayNode { } } } - + if isFirstTime { self.updateAvatarMask(transition: .immediate) } - + return resolvedHeight } - + private func buttonPressed(_ buttonNode: PeerInfoHeaderButtonNode, gesture: ContextGesture?) { self.performButtonAction?(buttonNode.key, buttonNode, gesture) } - + private func actionButtonPressed(_ buttonNode: PeerInfoHeaderActionButtonNode, gesture: ContextGesture?) { self.performButtonAction?(buttonNode.key, nil, gesture) } - + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { var result = super.point(inside: point, with: event) if let musicView = self.music?.view, musicView.frame.contains(point) { @@ -2740,31 +2825,65 @@ final class PeerInfoHeaderNode: ASDisplayNode { } return result } - + + // WinterGram: tapping the badge shows a supporter-style tooltip with a "Learn more" action, + // mirroring the AyuGram/exteraGram unique-badge popup. + private func presentWinterGramBadgeInfo() { + guard let peer = self.peer else { + return + } + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + let strings = presentationData.strings + let name = peer.compactDisplayTitle + let text: String + if case .channel = peer { + text = "\(name) \(strings.WinterGram_BadgeOfficialChannelSuffix)" + } else if isWinterGramDeveloperPeer(peer) { + // Actual developers are labelled as developers, not as supporters. + text = "\(name) \(strings.WinterGram_BadgeDeveloperRole)" + } else { + text = "\(name) \(strings.WinterGram_BadgeDeveloperSuffix)" + } + let badgeImage = winterGramBadgeImage(for: peer, theme: presentationData.theme) ?? UIImage(bundleImageName: "WinterGramSnowflake") + let content: UndoOverlayContent + if let badgeImage { + content = .universalImage(image: badgeImage, size: CGSize(width: 32.0, height: 32.0), title: nil, text: text, customUndoText: strings.WinterGram_LearnMore, timeout: nil) + } else { + content = .info(title: nil, text: text, timeout: nil, customUndoText: strings.WinterGram_LearnMore) + } + let overlayController = UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: false, action: { [weak self] action in + if case .undo = action, let self { + self.context.sharedContext.applicationBindings.openUrl("https://github.com/reekeer/WinterGram") + } + return true + }) + self.controller?.present(overlayController, in: .current) + } + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { guard let result = super.hitTest(point, with: event) else { return nil } - + if self.isSearching { if !result.isDescendant(of: self.searchBarContainer.view) && !result.isDescendant(of: self.searchContainer.view) { return self.view } - + return result } - + if let customNavigationContentNode = self.customNavigationContentNode { if let result = customNavigationContentNode.view.hitTest(self.view.convert(point, to: customNavigationContentNode.view), with: event) { return result } } - + let setByFrame = self.avatarListNode.listContainerNode.setByYouNode.view.convert(self.avatarListNode.listContainerNode.setByYouNode.bounds, to: self.view).insetBy(dx: -44.0, dy: 0.0) if self.avatarListNode.listContainerNode.setByYouNode.alpha > 0.0, setByFrame.contains(point) { return self.avatarListNode.listContainerNode.setByYouNode.view } - + if !(self.state?.isEditing ?? false) { switch self.currentCredibilityIcon { case .premium: @@ -2778,6 +2897,15 @@ final class PeerInfoHeaderNode: ASDisplayNode { default: break } + if self.currentWinterGramIcon { + let iconFrame = self.titleWinterGramIconView.convert(self.titleWinterGramIconView.bounds, to: self.view) + let expandedIconFrame = self.titleExpandedWinterGramIconView.convert(self.titleExpandedWinterGramIconView.bounds, to: self.view) + if expandedIconFrame.contains(point) && self.isAvatarExpanded { + return self.titleExpandedWinterGramIconView.hitTest(self.view.convert(point, to: self.titleExpandedWinterGramIconView), with: event) + } else if iconFrame.contains(point) { + return self.titleWinterGramIconView.hitTest(self.view.convert(point, to: self.titleWinterGramIconView), with: event) + } + } switch self.currentStatusIcon { case .emojiStatus: let iconFrame = self.titleStatusIconView.convert(self.titleStatusIconView.bounds, to: self.view) @@ -2791,50 +2919,50 @@ final class PeerInfoHeaderNode: ASDisplayNode { break } } - + if let subtitleBackgroundButton = self.subtitleBackgroundButton, subtitleBackgroundButton.view.convert(subtitleBackgroundButton.bounds, to: self.view).contains(point) { if let result = subtitleBackgroundButton.view.hitTest(self.view.convert(point, to: subtitleBackgroundButton.view), with: event) { return result } } - + if let subtitleBadgeView = self.subtitleBadgeView, let result = subtitleBadgeView.hitTest(self.view.convert(point, to: subtitleBadgeView), with: event) { return result } - + if let subtitleRatingView = self.subtitleRating?.view, let result = subtitleRatingView.hitTest(self.view.convert(point, to: subtitleRatingView), with: event) { return result } - + if result.isDescendant(of: self.navigationButtonContainer.view) { return result } - + if self.isSettings && self.buttonsContainerNode.alpha != 0.0 { if self.subtitleNodeRawContainer.bounds.contains(self.view.convert(point, to: self.subtitleNodeRawContainer.view)) { return self.subtitleNodeRawContainer.view } } - + if let result = self.buttonsContainerNode.view.hitTest(self.view.convert(point, to: self.buttonsContainerNode.view), with: event) { return result } - + if let giftsCoverView = self.giftsCover.view, giftsCoverView.alpha > 0.0, giftsCoverView.point(inside: self.view.convert(point, to: giftsCoverView), with: event) { return giftsCoverView } - + if let musicView = self.music?.view, let result = musicView.hitTest(self.view.convert(point, to: musicView), with: event) { return result } - + if result == self.view || result == self.regularContentNode.view || result == self.editingContentNode.view { return nil } - + return result } - + func updateIsAvatarExpanded(_ isAvatarExpanded: Bool, transition: ContainedViewLayoutTransition) { if self.isAvatarExpanded != isAvatarExpanded { self.isAvatarExpanded = isAvatarExpanded @@ -2844,11 +2972,11 @@ final class PeerInfoHeaderNode: ASDisplayNode { if case .animated = transition, !isAvatarExpanded { self.avatarListNode.animateAvatarCollapse(transition: transition) } - + self.updateAvatarMask(transition: transition) } } - + private func updateAvatarMask(transition: ContainedViewLayoutTransition) { guard let (width, statusBarHeight, deviceMetrics) = self.validLayout, deviceMetrics.hasDynamicIsland && statusBarHeight > 0.0 else { return @@ -2857,7 +2985,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { transition.updateTransformScale(layer: self.avatarListNode.maskNode.layer, scale: maskScale) transition.updateTransformScale(layer: self.avatarListNode.bottomCoverNode.layer, scale: maskScale) transition.updateTransformScale(layer: self.avatarListNode.topCoverNode.layer, scale: maskScale) - + let maskAnchorPoint = CGPoint(x: 0.5, y: self.isAvatarExpanded ? 0.37 : 0.5) transition.updateAnchorPoint(layer: self.avatarListNode.maskNode.layer, anchorPoint: maskAnchorPoint) } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift index 1ccdf29d21..70c02362ca 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift @@ -4,9 +4,11 @@ import Display import AccountContext import TelegramPresentationData import TelegramCore +import TelegramUIPreferences import PeerInfoUI import TextFormat import PhoneNumberFormat +import UndoUI import SwiftSignalKit import TelegramStringFormatting import AsyncDisplayKit @@ -22,6 +24,7 @@ private let enabledPrivateBioEntities: EnabledEntityTypes = [.internalUrl, .ment enum InfoSection: Int, CaseIterable { case unofficial + case winterGramInfo case groupLocation case calls case personalChannel @@ -50,14 +53,14 @@ func infoItems( guard let data = data else { return [] } - + var currentPeerInfoSection: InfoSection = .peerInfo - + var items: [InfoSection: [PeerInfoScreenItem]] = [:] for section in InfoSection.allCases { items[section] = [] } - + let bioContextAction: (ASDisplayNode, ContextGesture?, CGPoint?) -> Void = { node, gesture, _ in interaction.openBioContextMenu(node, gesture) } @@ -76,7 +79,7 @@ func infoItems( let birthdayContextAction: (ASDisplayNode, ContextGesture?, CGPoint?) -> Void = { node, gesture, _ in interaction.openBirthdayContextMenu(node, gesture) } - + if case let .user(user) = data.peer { let ItemCallList = 1000 let ItemPersonalChannelHeader = 2000 @@ -108,15 +111,15 @@ func infoItems( let ItemBotAddToChat = 9002 let ItemBotAddToChatInfo = 9003 let ItemVerification = 9004 - + if let cachedUserData = data.cachedData as? CachedUserData, cachedUserData.flags.contains(.unofficialSecurityRisk) { items[.unofficial]!.append(PeerInfoScreenInfoItem(id: 0, title: "", text: .markdown(presentationData.strings.PeerInfo_UnofficialSecurityRisk(EnginePeer(user).compactDisplayTitle).string), style: .compact, linkAction: nil)) } - + if !callMessages.isEmpty { items[.calls]!.append(PeerInfoScreenCallListItem(id: ItemCallList, messages: callMessages)) } - + if let personalChannel = data.personalChannel { let peerId = personalChannel.peer.peerId var label: String? @@ -136,7 +139,7 @@ func infoItems( interaction.openChat(peerId) })) } - + if let phone = user.phone { let formattedPhone = formatPhoneNumber(context: context, number: phone) let label: String @@ -153,13 +156,67 @@ func infoItems( interaction.requestLayout(animated) })) } + // The WinterGram badge is now rendered next to the name (like the premium emoji status), + // so the old "WinterGram Developer" profile row has been removed. + + if currentWinterGramSettings.showPeerId != .hidden { + let rawId = user.id.id._internalGetInt64Value() + // Data center is derived from the peer's profile-photo resource (CloudPeerPhotoSizeMediaResource). + var winterGramDcId: Int? + for representation in user.photo { + if let resource = representation.resource as? CloudPeerPhotoSizeMediaResource { + winterGramDcId = resource.datacenterId + break + } + } + var displayId = "\(rawId)" + if let winterGramDcId = winterGramDcId { + displayId += " (DC: \(winterGramDcId))" + } + let copyId: () -> Void = { [weak interaction] in + UIPasteboard.general.string = "\(rawId)" + if let controller = interaction?.getController() { + 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 + copyId() + }, longTapAction: nil, contextAction: { _, gesture, _ in + copyId() + gesture?.cancel() + }, requestLayout: { animated in + interaction.requestLayout(animated) + })) + } + + 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 + UIPasteboard.general.string = dateText + gesture?.cancel() + }, requestLayout: { animated in + interaction.requestLayout(animated) + })) + } + + if currentWinterGramSettings.showPeerId != .hidden, !user.isDeleted, user.id != context.account.peerId { + let isMutual = user.flags.contains(.mutualContact) + let mutualText = isMutual ? presentationData.strings.WinterGram_MutualContact : presentationData.strings.WinterGram_NotMutualContact + items[.winterGramInfo]!.append(PeerInfoScreenLabeledValueItem(id: 3095, label: "", text: mutualText, textColor: .primary, action: nil, requestLayout: { animated in + interaction.requestLayout(animated) + })) + } + if let mainUsername = user.addressName { var additionalUsernames: String? let usernames = user.usernames.filter { $0.isActive && $0.username != mainUsername } if !usernames.isEmpty { additionalUsernames = presentationData.strings.Profile_AdditionalUsernames(String(usernames.map { "@\($0.username)" }.joined(separator: ", "))).string } - + items[currentPeerInfoSection]!.append( PeerInfoScreenLabeledValueItem( id: ItemUsername, @@ -186,7 +243,7 @@ func infoItems( ) ) } - + if let cachedData = data.cachedData as? CachedUserData { if let birthday = cachedData.birthday { var hasBirthdayToday = false @@ -194,7 +251,7 @@ func infoItems( if today.day == Int(birthday.day) && today.month == Int(birthday.month) { hasBirthdayToday = true } - + var birthdayAction: ((ASDisplayNode, Promise?) -> Void)? if isMyProfile { birthdayAction = { node, _ in @@ -205,13 +262,13 @@ func infoItems( interaction.openPremiumGift() } } - + items[currentPeerInfoSection]!.append(PeerInfoScreenLabeledValueItem(id: ItemBirthdate, context: context, label: hasBirthdayToday ? presentationData.strings.UserInfo_BirthdayToday : presentationData.strings.UserInfo_Birthday, text: stringForCompactBirthday(birthday, strings: presentationData.strings, showAge: true), textColor: .primary, leftIcon: hasBirthdayToday ? .birthday : nil, icon: hasBirthdayToday ? .premiumGift : nil, action: birthdayAction, longTapAction: nil, iconAction: { interaction.openPremiumGift() }, contextAction: birthdayContextAction, requestLayout: { _ in })) } - + var hasAbout = false if let about = cachedData.about, !about.isEmpty { hasAbout = true @@ -220,12 +277,12 @@ func infoItems( if let note = cachedData.note, !note.text.isEmpty { hasNote = true } - + var hasWebApp = false if let botInfo = user.botInfo, botInfo.flags.contains(.hasWebApp) { hasWebApp = true } - + if user.isFake { items[currentPeerInfoSection]!.append(PeerInfoScreenLabeledValueItem(id: ItemAbout, label: "", text: user.botInfo != nil ? presentationData.strings.UserInfo_FakeBotWarning : presentationData.strings.UserInfo_FakeUserWarning, textColor: .primary, textBehavior: .multiLine(maxLines: 100, enabledEntities: user.botInfo != nil ? enabledPrivateBioEntities : []), action: nil, requestLayout: { animated in interaction.requestLayout(animated) @@ -241,7 +298,7 @@ func infoItems( guard let parentController = interaction.getController() else { return } - + if let navigationController = parentController.navigationController as? NavigationController, let minimizedContainer = navigationController.minimizedContainer { for controller in minimizedContainer.controllers { if let controller = controller as? AttachmentController, let mainController = controller.mainController as? WebAppController, mainController.botId == user.id && mainController.source == .generic { @@ -250,7 +307,7 @@ func infoItems( } } } - + context.sharedContext.openWebApp( context: context, parentController: parentController, @@ -268,7 +325,7 @@ func infoItems( ) }) } - + if hasAbout || hasWebApp { var label: String = "" if let about = cachedData.about, !about.isEmpty { @@ -280,7 +337,7 @@ func infoItems( interaction.requestLayout(animated) })) } - + if let note = cachedData.note, !note.text.isEmpty { var entities = note.entities if context.isPremium { @@ -290,14 +347,14 @@ func infoItems( interaction.requestLayout(animated) })) } - + if let botInfo = user.botInfo, botInfo.flags.contains(.canEdit) { items[currentPeerInfoSection]!.append(PeerInfoScreenCommentItem(id: ItemAppFooter, text: presentationData.strings.PeerInfo_AppFooterAdmin, linkAction: { action in if case let .tap(url) = action { context.sharedContext.applicationBindings.openUrl(url) } })) - + currentPeerInfoSection = .peerInfoTrailing } else if actionButton != nil { items[currentPeerInfoSection]!.append(PeerInfoScreenCommentItem(id: ItemAppFooter, text: presentationData.strings.PeerInfo_AppFooter, linkAction: { action in @@ -305,10 +362,10 @@ func infoItems( context.sharedContext.applicationBindings.openUrl(url) } })) - + currentPeerInfoSection = .peerInfoTrailing } - + if let botInfo = user.botInfo, botInfo.flags.contains(.canEdit) { } else { if let starRefProgram = cachedData.starRefProgram, starRefProgram.endDate == nil { @@ -320,7 +377,7 @@ func infoItems( canJoinRefProgram = value } } - + if canJoinRefProgram { if items[.botAffiliateProgram] == nil { items[.botAffiliateProgram] = [] @@ -335,13 +392,13 @@ func infoItems( } } } - + if let businessHours = cachedData.businessHours { items[currentPeerInfoSection]!.append(PeerInfoScreenBusinessHoursItem(id: ItemBusinessHours, label: presentationData.strings.PeerInfo_BusinessHours_Label, businessHours: businessHours, requestLayout: { animated in interaction.requestLayout(animated) }, longTapAction: nil, contextAction: workingHoursContextAction)) } - + if let businessLocation = cachedData.businessLocation { if let coordinates = businessLocation.coordinates { let imageSignal = chatMapSnapshotImage(engine: context.engine, resource: MapSnapshotMediaResource(latitude: coordinates.latitude, longitude: coordinates.longitude, width: 90, height: 90)) @@ -367,7 +424,7 @@ func infoItems( } } } - + if !isMyProfile { if !data.isContact, user.botInfo == nil { items[currentPeerInfoSection]!.append(PeerInfoScreenActionItem(id: ItemAddToContacts, text: presentationData.strings.PeerInfo_AddToContacts, action: { @@ -389,7 +446,7 @@ func infoItems( if let cachedData = data.cachedData as? CachedUserData, cachedData.isBlocked { isBlocked = true } - + if isBlocked { items[currentPeerInfoSection]!.append(PeerInfoScreenActionItem(id: ItemBlock, text: user.botInfo != nil ? presentationData.strings.Bot_Unblock : presentationData.strings.Conversation_Unblock, action: { interaction.updateBlocked(false) @@ -404,19 +461,19 @@ func infoItems( } } } - + if let encryptionKeyFingerprint = data.encryptionKeyFingerprint { items[currentPeerInfoSection]!.append(PeerInfoScreenDisclosureEncryptionKeyItem(id: ItemEncryptionKey, text: presentationData.strings.Profile_EncryptionKey, fingerprint: encryptionKeyFingerprint, action: { interaction.openEncryptionKey() })) } - + let revenueBalance = data.revenueStatsState?.balances.currentBalance.amount.value ?? 0 let overallRevenueBalance = data.revenueStatsState?.balances.overallRevenue.amount.value ?? 0 - + let starsBalance = data.starsRevenueStatsState?.balances.currentBalance.amount ?? StarsAmount.zero let overallStarsBalance = data.starsRevenueStatsState?.balances.overallRevenue.amount ?? StarsAmount.zero - + if overallRevenueBalance > 0 || overallStarsBalance > StarsAmount.zero { items[.balances]!.append(PeerInfoScreenHeaderItem(id: ItemBalanceHeader, text: presentationData.strings.PeerInfo_BotBalance_Title)) if overallRevenueBalance > 0 { @@ -438,7 +495,7 @@ func infoItems( let labelColor = presentationData.theme.list.itemSecondaryTextColor let attributedString = tonAmountAttributedString(formattedLabel, integralFont: labelFont, fractionalFont: smallLabelFont, color: labelColor, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator).mutableCopy() as! NSMutableAttributedString attributedString.insert(NSAttributedString(string: "*", font: labelFont, textColor: labelColor), at: 0) - + if let range = attributedString.string.range(of: "*") { attributedString.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: false)), range: NSRange(range, in: attributedString.string)) attributedString.addAttribute(.baselineOffset, value: 1.5, range: NSRange(range, in: attributedString.string)) @@ -448,7 +505,7 @@ func infoItems( })) } } - + if let _ = user.botInfo { var canManageEmojiStatus = false if let cachedData = data.cachedData as? CachedUserData, cachedData.flags.contains(.botCanManageEmojiStatus) { @@ -458,7 +515,7 @@ func infoItems( items[.permissions]!.append(PeerInfoScreenSwitchItem(id: ItemBotPermissionsEmojiStatus, text: presentationData.strings.PeerInfo_Permissions_EmojiStatus, value: canManageEmojiStatus, icon: PresentationResourcesSettings.emojiStatus, isLocked: false, toggled: { value in let _ = (context.engine.peers.toggleBotEmojiStatusAccess(peerId: user.id, enabled: value) |> deliverOnMainQueue).startStandalone() - + let _ = updateWebAppPermissionsStateInteractively(context: context, peerId: user.id) { current in return WebAppPermissionsState(location: current?.location, emojiStatus: WebAppPermissionsState.EmojiStatus(isRequested: true)) }.startStandalone() @@ -473,27 +530,27 @@ func infoItems( } if !"".isEmpty { items[.permissions]!.append(PeerInfoScreenSwitchItem(id: ItemBotPermissionsBiometry, text: presentationData.strings.PeerInfo_Permissions_Biometry, value: true, icon: renderAttachAppIcon(iconImage: UIImage(bundleImageName: "Settings/Menu/TouchId")), isLocked: false, toggled: { value in - + })) } - + if !items[.permissions]!.isEmpty { items[.permissions]!.insert(PeerInfoScreenHeaderItem(id: ItemBotPermissionsHeader, text: presentationData.strings.PeerInfo_Permissions_Title), at: 0) } } - + if let botInfo = user.botInfo, botInfo.flags.contains(.canEdit) { items[currentPeerInfoSection]!.append(PeerInfoScreenDisclosureItem(id: ItemBotSettings, label: .none, text: presentationData.strings.Bot_Settings, icon: PresentationResourcesSettings.settings, action: { interaction.openEditing() })) } - + if let botInfo = user.botInfo, !botInfo.flags.contains(.canEdit) { items[currentPeerInfoSection]!.append(PeerInfoScreenActionItem(id: ItemBotReport, text: presentationData.strings.ReportPeer_Report, action: { interaction.openReport(.default) })) } - + if let verification = (data.cachedData as? CachedUserData)?.verification { let description: String let descriptionString = verification.description @@ -507,7 +564,7 @@ func infoItems( } let attributedPrefix = NSMutableAttributedString(string: " ") attributedPrefix.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: verification.iconFileId, file: nil), range: NSMakeRange(0, 1)) - + items[currentPeerInfoSection]!.append(PeerInfoScreenCommentItem(id: ItemVerification, text: description, attributedPrefix: attributedPrefix, useAccentLinkColor: false, linkAction: { action in if case let .tap(url) = action, let navigationController = interaction.getController()?.navigationController as? NavigationController { context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: url, forceExternal: false, presentationData: presentationData, navigationController: navigationController, dismissInput: {}) @@ -517,7 +574,7 @@ func infoItems( items[currentPeerInfoSection]!.append(PeerInfoScreenActionItem(id: ItemBotAddToChat, text: presentationData.strings.Bot_AddToChat, color: .accent, action: { interaction.openAddBotToGroup() })) - + if let managedByBot = data.managedByBot { items[currentPeerInfoSection]!.append(PeerInfoScreenCommentItem(id: ItemBotAddToChatInfo, icon: .managedBot, text: presentationData.strings.PeerInfo_ManagedBotFooter(managedByBot.compactDisplayTitle).string, linkAction: { _ in interaction.openPeerInfo(managedByBot, false) @@ -540,7 +597,7 @@ func infoItems( let ItemBalance = 9 let ItemEdit = 10 let ItemPeerPersonalChannel = 11 - + if let _ = data.threadData { let mainUsername: String if let addressName = channel.addressName { @@ -548,14 +605,14 @@ func infoItems( } else { mainUsername = "c/\(channel.id.id._internalGetInt64Value())" } - + var threadId: Int64 = 0 if case let .replyThread(message) = chatLocation { threadId = message.threadId } - + let linkText = "https://t.me/\(mainUsername)/\(threadId)" - + items[currentPeerInfoSection]!.append( PeerInfoScreenLabeledValueItem( id: ItemUsername, @@ -581,14 +638,14 @@ func infoItems( ) ) if let _ = channel.addressName { - + } else { items[currentPeerInfoSection]!.append(PeerInfoScreenCommentItem(id: ItemUsernameInfo, text: presentationData.strings.PeerInfo_PrivateShareLinkInfo)) } } else { if let location = (data.cachedData as? CachedChannelData)?.peerGeoLocation { items[.groupLocation]!.append(PeerInfoScreenHeaderItem(id: ItemLocationHeader, text: presentationData.strings.GroupInfo_Location.uppercased())) - + let imageSignal = chatMapSnapshotImage(engine: context.engine, resource: MapSnapshotMediaResource(latitude: location.latitude, longitude: location.longitude, width: 90, height: 90)) items[.groupLocation]!.append(PeerInfoScreenAddressItem( id: ItemLocation, @@ -600,14 +657,14 @@ func infoItems( } )) } - + if let mainUsername = channel.addressName { var additionalUsernames: String? let usernames = channel.usernames.filter { $0.isActive && $0.username != mainUsername } if !usernames.isEmpty { additionalUsernames = presentationData.strings.Profile_AdditionalUsernames(String(usernames.map { "@\($0.username)" }.joined(separator: ", "))).string } - + items[currentPeerInfoSection]!.append( PeerInfoScreenLabeledValueItem( id: ItemUsername, @@ -638,6 +695,22 @@ func infoItems( ) ) } + if currentWinterGramSettings.showPeerId != .hidden { + let rawId = channel.id.id._internalGetInt64Value() + let displayId: String + switch currentWinterGramSettings.showPeerId { + case .hidden, .telegramApi: + displayId = "\(rawId)" + case .botApi: + displayId = "-100\(rawId)" + } + items[currentPeerInfoSection]!.append(PeerInfoScreenLabeledValueItem(id: 99, label: "ID", text: displayId, textColor: .primary, action: nil, longTapAction: nil, contextAction: { _, gesture, _ in + UIPasteboard.general.string = displayId + gesture?.cancel() + }, requestLayout: { animated in + interaction.requestLayout(animated) + })) + } if let cachedData = data.cachedData as? CachedChannelData { let aboutText: String? if channel.isFake { @@ -657,7 +730,7 @@ func infoItems( } else { aboutText = nil } - + if let aboutText = aboutText { var enabledEntities = enabledPublicBioEntities if case .group = channel.info { @@ -669,7 +742,7 @@ func infoItems( interaction.requestLayout(animated) })) } - + if let verification = (data.cachedData as? CachedChannelData)?.verification { let description: String let descriptionString = verification.description @@ -681,17 +754,17 @@ func infoItems( } else { description = descriptionString } - + let attributedPrefix = NSMutableAttributedString(string: " ") attributedPrefix.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: verification.iconFileId, file: nil), range: NSMakeRange(0, 1)) - + items[currentPeerInfoSection]!.append(PeerInfoScreenCommentItem(id: 800, text: description, attributedPrefix: attributedPrefix, useAccentLinkColor: false, linkAction: { action in if case let .tap(url) = action, let navigationController = interaction.getController()?.navigationController as? NavigationController { context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: url, forceExternal: false, presentationData: presentationData, navigationController: navigationController, dismissInput: {}) } })) } - + if case .broadcast = channel.info { var canEditMembers = false if channel.hasPermission(.banMembers) { @@ -701,14 +774,14 @@ func infoItems( if channel.adminRights != nil || channel.flags.contains(.isCreator) { let adminCount = cachedData.participantsSummary.adminCount ?? 0 let memberCount = cachedData.participantsSummary.memberCount ?? 0 - + items[.peerMembers]!.append(PeerInfoScreenDisclosureItem(id: ItemAdmins, label: .text("\(adminCount == 0 ? "" : "\(presentationStringsFormattedNumber(adminCount, presentationData.dateTimeFormat.groupingSeparator))")"), text: presentationData.strings.GroupInfo_Administrators, icon: PresentationResourcesSettings.admins, action: { interaction.openParticipantsSection(.admins) })) items[.peerMembers]!.append(PeerInfoScreenDisclosureItem(id: ItemMembers, label: .text("\(memberCount == 0 ? "" : "\(presentationStringsFormattedNumber(memberCount, presentationData.dateTimeFormat.groupingSeparator))")"), text: presentationData.strings.Channel_Info_Subscribers, icon: PresentationResourcesSettings.subscribers, action: { interaction.openParticipantsSection(.members) })) - + if let count = data.requests?.count, count > 0 { items[.peerMembers]!.append(PeerInfoScreenDisclosureItem(id: ItemMemberRequests, label: .badge(presentationStringsFormattedNumber(count, presentationData.dateTimeFormat.groupingSeparator), presentationData.theme.list.itemAccentColor), text: presentationData.strings.GroupInfo_MemberRequests, icon: PresentationResourcesSettings.groupRequests, action: { interaction.openParticipantsSection(.memberRequests) @@ -717,7 +790,7 @@ func infoItems( } } } - + if channel.adminRights != nil || channel.flags.contains(.isCreator) { let section: InfoSection if case .group = channel.info { @@ -728,15 +801,15 @@ func infoItems( if cachedData.flags.contains(.canViewRevenue) || cachedData.flags.contains(.canViewStarsRevenue) { let revenueBalance = data.revenueStatsState?.balances.currentBalance.amount.value ?? 0 let starsBalance = data.starsRevenueStatsState?.balances.currentBalance.amount ?? StarsAmount.zero - + let overallRevenueBalance = data.revenueStatsState?.balances.overallRevenue.amount.value ?? 0 let overallStarsBalance = data.starsRevenueStatsState?.balances.overallRevenue.amount ?? StarsAmount.zero - + if overallRevenueBalance > 0 || overallStarsBalance > StarsAmount.zero { let smallLabelFont = Font.regular(floor(presentationData.listsFontSize.itemListBaseFontSize / 17.0 * 13.0)) let labelFont = Font.regular(presentationData.listsFontSize.itemListBaseFontSize) let labelColor = presentationData.theme.list.itemSecondaryTextColor - + let attributedString = NSMutableAttributedString() if overallRevenueBalance > 0 { attributedString.append(NSAttributedString(string: "#\(formatTonAmountText(revenueBalance, dateTimeFormat: presentationData.dateTimeFormat))", font: labelFont, textColor: labelColor)) @@ -746,7 +819,7 @@ func infoItems( attributedString.append(NSAttributedString(string: " ", font: labelFont, textColor: labelColor)) } attributedString.append(NSAttributedString(string: "*", font: labelFont, textColor: labelColor)) - + let formattedLabel = formatStarsAmountText(starsBalance, dateTimeFormat: presentationData.dateTimeFormat) let starsAttributedString = tonAmountAttributedString(formattedLabel, integralFont: labelFont, fractionalFont: smallLabelFont, color: labelColor, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator).mutableCopy() as! NSMutableAttributedString attributedString.append(starsAttributedString) @@ -759,13 +832,13 @@ func infoItems( attributedString.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 1, file: nil, custom: .stars(tinted: false)), range: NSRange(range, in: attributedString.string)) attributedString.addAttribute(.baselineOffset, value: 1.5, range: NSRange(range, in: attributedString.string)) } - + items[section]!.append(PeerInfoScreenDisclosureItem(id: ItemBalance, label: .attributedText(attributedString), text: presentationData.strings.PeerInfo_Bot_Balance, icon: PresentationResourcesSettings.balance, action: { interaction.openStats(.monetization) })) } } - + let settingsTitle: String switch channel.info { case .broadcast: @@ -777,7 +850,7 @@ func infoItems( interaction.openEditing() })) } - + if channel.hasPermission(.manageDirect), let personalChannel = data.personalChannel { let peerId = personalChannel.peer.peerId items[.channelMonoforum]?.append(PeerInfoScreenPersonalChannelItem(id: ItemPeerPersonalChannel, context: context, data: personalChannel, controller: { [weak interaction] in @@ -806,7 +879,7 @@ func infoItems( } else { aboutText = nil } - + if let aboutText = aboutText { items[currentPeerInfoSection]!.append(PeerInfoScreenLabeledValueItem(id: 0, label: presentationData.strings.Channel_Info_Description, text: aboutText, textColor: .primary, textBehavior: .multiLine(maxLines: 100, enabledEntities: enabledPrivateBioEntities), action: isMyProfile ? { node, _ in bioContextAction(node, nil, nil) @@ -816,7 +889,7 @@ func infoItems( } } } - + if let peer = data.peer, let members = data.members, case let .shortList(_, memberList) = members { var canAddMembers = false if case let .legacyGroup(group) = data.peer { @@ -839,13 +912,13 @@ func infoItems( } } } - + if canAddMembers { items[.peerMembers]!.append(PeerInfoScreenActionItem(id: 0, text: presentationData.strings.GroupInfo_AddParticipant, color: .accent, icon: UIImage(bundleImageName: "Contact List/AddMemberIcon"), alignment: .peerList, action: { interaction.openAddMember() })) } - + for member in memberList { let isAccountPeer = member.id == context.account.peerId items[.peerMembers]!.append(PeerInfoScreenMemberItem(id: member.id, context: .account(context), enclosingPeer: peer, member: member, isAccount: false, action: isAccountPeer ? { _ in @@ -871,7 +944,7 @@ func infoItems( })) } } - + var result: [(AnyHashable, [PeerInfoScreenItem])] = [] for section in InfoSection.allCases { if let sectionItems = items[section], !sectionItems.isEmpty { @@ -895,17 +968,17 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s case peerAdditionalSettings case peerActions } - + var items: [Section: [PeerInfoScreenItem]] = [:] for section in Section.allCases { items[section] = [] } - + if let data = data { if case let .user(user) = data.peer { let ItemNote: AnyHashable = AnyHashable("note_edit") let ItemNoteInfo = 1 - + let ItemSuggestBirthdate = 2 let ItemSuggestPhoto = 3 let ItemCustomPhoto = 4 @@ -914,19 +987,19 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s let ItemDelete = 7 let ItemUsername = 8 let ItemAffiliateProgram = 9 - + let ItemVerify = 10 - + let ItemIntro = 11 let ItemCommands = 12 let ItemBotSettings = 13 let ItemBotInfo = 14 - + if let botInfo = user.botInfo, botInfo.flags.contains(.canEdit) { items[.peerDataSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemUsername, label: .text("@\(user.addressName ?? "")"), text: presentationData.strings.PeerInfo_Bot_Username, icon: PresentationResourcesSettings.bot, action: { interaction.editingOpenPublicLinkSetup() })) - + var canSetupRefProgram = false if let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["starref_program_allowed"] { if let value = value as? Double { @@ -935,7 +1008,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s canSetupRefProgram = value } } - + if canSetupRefProgram { let programTitleValue: PeerInfoScreenDisclosureItem.Label if let cachedData = data.cachedData as? CachedUserData, let starRefProgram = cachedData.starRefProgram, starRefProgram.endDate == nil { @@ -947,13 +1020,13 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s interaction.editingOpenAffiliateProgram() })) } - + if let cachedUserData = data.cachedData as? CachedUserData, let _ = cachedUserData.botInfo?.verifierSettings { items[.peerVerifySettings]!.append(PeerInfoScreenActionItem(id: ItemVerify, text: presentationData.strings.PeerInfo_VerifyAccounts, icon: UIImage(bundleImageName: "Peer Info/BotVerify"), action: { interaction.editingOpenVerifyAccounts() })) } - + items[.peerSettings]!.append(PeerInfoScreenActionItem(id: ItemIntro, text: presentationData.strings.PeerInfo_Bot_EditIntro, icon: UIImage(bundleImageName: "Peer Info/BotIntro"), action: { interaction.openPeerMention("botfather", .withBotStartPayload(ChatControllerInitialBotStart(payload: "\(user.addressName ?? "")-intro", behavior: .interactive))) })) @@ -968,7 +1041,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s })) } else if !user.flags.contains(.isSupport) { let compactName = EnginePeer(user).compactDisplayTitle - + if let cachedData = data.cachedData as? CachedUserData { items[.peerNote]!.append(PeerInfoScreenNoteListItem( id: ItemNote, @@ -980,35 +1053,35 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s interaction.requestLayout(animated) } )) - + items[.peerNote]!.append(PeerInfoScreenCommentItem(id: ItemNoteInfo, text: presentationData.strings.PeerInfo_AddNotesInfo)) - + if let _ = cachedData.sendPaidMessageStars { - + } else { if cachedData.birthday == nil { items[.peerDataSettings]!.append(PeerInfoScreenActionItem(id: ItemSuggestBirthdate, text: presentationData.strings.UserInfo_SuggestBirthdate, color: .accent, icon: UIImage(bundleImageName: "Contact List/AddBirthdayIcon"), action: { interaction.suggestBirthdate() })) } - + items[.peerDataSettings]!.append(PeerInfoScreenActionItem(id: ItemSuggestPhoto, text: presentationData.strings.UserInfo_SuggestPhoto(compactName).string, color: .accent, icon: UIImage(bundleImageName: "Peer Info/SuggestAvatar"), action: { interaction.suggestPhoto() })) } } - + let setText: String if user.photo.first?.isPersonal == true || state.updatingAvatar != nil { setText = presentationData.strings.UserInfo_ChangeCustomPhoto(compactName).string } else { setText = presentationData.strings.UserInfo_SetCustomPhoto(compactName).string } - + items[.peerDataSettings]!.append(PeerInfoScreenActionItem(id: ItemCustomPhoto, text: setText, color: .accent, icon: UIImage(bundleImageName: "Settings/SetAvatar"), action: { interaction.setCustomPhoto() })) - + if user.photo.first?.isPersonal == true || state.updatingAvatar != nil { var representation: TelegramMediaImageRepresentation? var originalIsVideo: Bool? @@ -1016,14 +1089,14 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s representation = photo?.representationForDisplayAtSize(PixelDimensions(width: 28, height: 28)) originalIsVideo = !(photo?.videoRepresentations.isEmpty ?? true) } - + let removeText: String if let originalIsVideo { removeText = originalIsVideo ? presentationData.strings.UserInfo_ResetCustomVideo : presentationData.strings.UserInfo_ResetCustomPhoto } else { removeText = user.photo.first?.hasVideo == true ? presentationData.strings.UserInfo_RemoveCustomVideo : presentationData.strings.UserInfo_RemoveCustomPhoto } - + let imageSignal: Signal if let representation, let signal = peerAvatarImage(account: context.account, peerReference: PeerReference(user), authorOfMessage: nil, representation: representation, displayDimensions: CGSize(width: 28.0, height: 28.0)) { imageSignal = signal @@ -1033,14 +1106,14 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s } else { imageSignal = peerAvatarCompleteImage(account: context.account, peer: EnginePeer(user), forceProvidedRepresentation: true, representation: representation, size: CGSize(width: 28.0, height: 28.0)) } - + items[.peerDataSettings]!.append(PeerInfoScreenActionItem(id: ItemReset, text: removeText, color: .accent, icon: nil, iconSignal: imageSignal, action: { interaction.resetCustomPhoto() })) } items[.peerDataSettings]!.append(PeerInfoScreenCommentItem(id: ItemInfo, text: presentationData.strings.UserInfo_CustomPhotoInfo(compactName).string)) } - + if data.isContact { items[.peerSettings]!.append(PeerInfoScreenActionItem(id: ItemDelete, text: presentationData.strings.UserInfo_DeleteContact, color: .destructive, action: { interaction.requestDeleteContact() @@ -1064,9 +1137,9 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s let ItemAffiliatePrograms = 13 let ItemPostSuggestionsSettings = 14 let ItemPeerAutoTranslate = 15 - + let isCreator = channel.flags.contains(.isCreator) - + if isCreator { let linkText: String if let _ = channel.addressName { @@ -1090,7 +1163,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s interaction.editingOpenInviteLinksSetup() })) } - + if isCreator || (channel.adminRights?.rights.contains(.canChangeInfo) == true) { let discussionGroupTitle: String if let _ = data.cachedData as? CachedChannelData { @@ -1106,12 +1179,12 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s } else { discussionGroupTitle = "..." } - + items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemDiscussionGroup, label: .text(discussionGroupTitle), text: presentationData.strings.Channel_DiscussionGroup, icon: PresentationResourcesSettings.chatHistory, action: { interaction.editingOpenDiscussionGroupSetup() })) } - + if isCreator || (channel.adminRights?.rights.contains(.canChangeInfo) == true) { let label: String if let cachedData = data.cachedData as? CachedChannelData, case let .known(reactionSettings) = cachedData.reactionSettings { @@ -1139,7 +1212,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s interaction.editingOpenReactionsSetup() })) } - + if isCreator || (channel.adminRights?.rights.contains(.canChangeInfo) == true) { var colors: [PeerNameColors.Colors] = [] if let nameColor = channel.nameColor.flatMap({ context.peerNameColors.get($0, dark: presentationData.theme.overallDarkAppearance) }) { @@ -1149,7 +1222,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s colors.append(profileColor) } let colorImage = generateSettingsMenuPeerColorsLabelIcon(colors: colors) - + var boostIcon: UIImage? if let approximateBoostLevel = channel.approximateBoostLevel, approximateBoostLevel < 1 { boostIcon = generateDisclosureActionBoostLevelBadgeImage(text: presentationData.strings.Channel_Info_BoostLevelPlusBadge("1").string) @@ -1157,7 +1230,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemPeerColor, label: .image(colorImage, colorImage.size), additionalBadgeIcon: boostIcon, text: presentationData.strings.Channel_Info_AppearanceItem, icon: PresentationResourcesSettings.chatAppearance, action: { interaction.editingOpenNameColorSetup() })) - + let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) var isLocked = true if let boostLevel = boostStatus?.level, boostLevel >= BoostSubject.autoTranslate.requiredLevel(group: false, context: context, configuration: premiumConfiguration) { @@ -1171,7 +1244,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s } })) } - + if isCreator || (channel.adminRights?.rights.contains(.canChangeInfo) == true) { let labelString: NSAttributedString if channel.linkedMonoforumId != nil { @@ -1183,7 +1256,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s let labelColor = presentationData.theme.list.itemSecondaryTextColor let attributedString = tonAmountAttributedString(formattedLabel, integralFont: labelFont, fractionalFont: smallLabelFont, color: labelColor, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator).mutableCopy() as! NSMutableAttributedString attributedString.insert(NSAttributedString(string: "*", font: labelFont, textColor: labelColor), at: 0) - + if let range = attributedString.string.range(of: "*") { attributedString.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: false)), range: NSRange(range, in: attributedString.string)) attributedString.addAttribute(.baselineOffset, value: 1.5, range: NSRange(range, in: attributedString.string)) @@ -1192,26 +1265,26 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s } else { let labelFont = Font.regular(presentationData.listsFontSize.itemListBaseFontSize) let labelColor = presentationData.theme.list.itemSecondaryTextColor - + labelString = NSAttributedString(string: presentationData.strings.PeerInfo_AllowChannelMessages_Free, font: labelFont, textColor: labelColor) } } else { let labelFont = Font.regular(presentationData.listsFontSize.itemListBaseFontSize) let labelColor = presentationData.theme.list.itemSecondaryTextColor - + labelString = NSAttributedString(string: " ", font: labelFont, textColor: labelColor) } } else { let labelFont = Font.regular(presentationData.listsFontSize.itemListBaseFontSize) let labelColor = presentationData.theme.list.itemSecondaryTextColor - + labelString = NSAttributedString(string: presentationData.strings.PeerInfo_AllowChannelMessages_Off, font: labelFont, textColor: labelColor) } - + items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemPostSuggestionsSettings, label: .attributedText(labelString), text: presentationData.strings.PeerInfo_AllowChannelMessages, icon: PresentationResourcesSettings.channelMessages, action: { interaction.editingOpenPostSuggestionsSetup() })) - + if let personalChannel = data.personalChannel { let peerId = personalChannel.peer.peerId items[.linkedMonoforum]?.append(PeerInfoScreenPersonalChannelItem(id: 1, context: context, data: personalChannel, controller: { [weak interaction] in @@ -1227,7 +1300,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s })) } } - + var canEditMembers = false if channel.hasPermission(.banMembers) && (channel.adminRights != nil || channel.flags.contains(.isCreator)) { canEditMembers = true @@ -1242,27 +1315,27 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s adminCount = 0 memberCount = 0 } - + items[.peerAdditionalSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemAdmins, label: .text("\(adminCount == 0 ? "" : "\(presentationStringsFormattedNumber(adminCount, presentationData.dateTimeFormat.groupingSeparator))")"), text: presentationData.strings.GroupInfo_Administrators, icon: PresentationResourcesSettings.admins, action: { interaction.openParticipantsSection(.admins) })) items[.peerAdditionalSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemMembers, label: .text("\(memberCount == 0 ? "" : "\(presentationStringsFormattedNumber(memberCount, presentationData.dateTimeFormat.groupingSeparator))")"), text: presentationData.strings.Channel_Info_Subscribers, icon: PresentationResourcesSettings.subscribers, action: { interaction.openParticipantsSection(.members) })) - + if let count = data.requests?.count, count > 0 { items[.peerAdditionalSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemMemberRequests, label: .badge(presentationStringsFormattedNumber(count, presentationData.dateTimeFormat.groupingSeparator), presentationData.theme.list.itemAccentColor), text: presentationData.strings.GroupInfo_MemberRequests, icon: PresentationResourcesSettings.groupRequests, action: { interaction.openParticipantsSection(.memberRequests) })) } } - + if let cachedData = data.cachedData as? CachedChannelData, cachedData.flags.contains(.canViewStats) { items[.peerAdditionalSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemStats, label: .none, text: presentationData.strings.Channel_Info_Stats, icon: PresentationResourcesSettings.stats, action: { interaction.openStats(.stats) })) } - + if canEditMembers { let bannedCount: Int32 if let cachedData = data.cachedData as? CachedChannelData { @@ -1273,12 +1346,12 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s items[.peerAdditionalSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemBanned, label: .text("\(bannedCount == 0 ? "" : "\(presentationStringsFormattedNumber(bannedCount, presentationData.dateTimeFormat.groupingSeparator))")"), text: presentationData.strings.GroupInfo_Permissions_Removed, icon: PresentationResourcesSettings.block, action: { interaction.openParticipantsSection(.banned) })) - + items[.peerAdditionalSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemRecentActions, label: .none, text: presentationData.strings.Group_Info_AdminLog, icon: PresentationResourcesSettings.recentActions, action: { interaction.openRecentActions() })) } - + if channel.hasPermission(.changeInfo) { var canJoinRefProgram = false if let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["starref_connect_allowed"] { @@ -1288,14 +1361,14 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s canJoinRefProgram = value } } - + if canJoinRefProgram { items[.peerAdditionalSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemAffiliatePrograms, label: .text(""), additionalBadgeLabel: nil, text: presentationData.strings.PeerInfo_ItemAffiliatePrograms_Title, icon: PresentationResourcesSettings.affiliateProgram, action: { interaction.editingOpenAffiliateProgram() })) } } - + if isCreator { //if let cachedData = data.cachedData as? CachedChannelData, cachedData.flags.contains(.canDeleteHistory) { items[.peerActions]!.append(PeerInfoScreenActionItem(id: ItemDeleteChannel, text: presentationData.strings.ChannelInfo_DeleteChannel, color: .destructive, icon: nil, alignment: .natural, action: { interaction.openDeletePeer() @@ -1319,14 +1392,14 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s let ItemTopics = 117 let ItemTopicsText = 118 let ItemAppearance = 119 - + let isCreator = channel.flags.contains(.isCreator) let isPublic = channel.addressName != nil - + if let cachedData = data.cachedData as? CachedChannelData { if isCreator, let location = cachedData.peerGeoLocation { items[.groupLocation]!.append(PeerInfoScreenHeaderItem(id: ItemLocationHeader, text: presentationData.strings.GroupInfo_Location.uppercased())) - + let imageSignal = chatMapSnapshotImage(engine: context.engine, resource: MapSnapshotMediaResource(latitude: location.latitude, longitude: location.longitude, width: 90, height: 90)) items[.groupLocation]!.append(PeerInfoScreenAddressItem( id: ItemLocation, @@ -1338,7 +1411,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s } )) } - + if isCreator || (channel.adminRights != nil && channel.hasPermission(.pinMessages)) { if cachedData.peerGeoLocation != nil { if isCreator { @@ -1354,14 +1427,14 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s } } else { if cachedData.flags.contains(.canChangeUsername) { - + items[.peerPublicSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemUsername, label: .text(isPublic ? presentationData.strings.Group_Setup_TypePublic : presentationData.strings.Group_Setup_TypePrivate), text: presentationData.strings.GroupInfo_GroupType, icon: PresentationResourcesSettings.groupType, action: { interaction.editingOpenPublicLinkSetup() })) } } } - + if (isCreator && (channel.addressName?.isEmpty ?? true) && cachedData.peerGeoLocation == nil) || (!isCreator && channel.adminRights?.rights.contains(.canInviteUsers) == true) { let invitesText: String if let count = data.invitations?.count, count > 0 { @@ -1369,12 +1442,12 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s } else { invitesText = "" } - + items[.peerDataSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemInviteLinks, label: .text(invitesText), text: presentationData.strings.GroupInfo_InviteLinks, icon: PresentationResourcesSettings.links, action: { interaction.editingOpenInviteLinksSetup() })) } - + if (isCreator || (channel.adminRights != nil && channel.hasPermission(.pinMessages))) && cachedData.peerGeoLocation == nil { if let linkedDiscussionPeer = data.linkedDiscussionPeer { let peerTitle: String @@ -1383,12 +1456,12 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s } else { peerTitle = linkedDiscussionPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) } - + items[.peerDataSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemLinkedChannel, label: .text(peerTitle), text: presentationData.strings.Group_LinkedChannel, icon: PresentationResourcesSettings.channels, action: { interaction.editingOpenDiscussionGroupSetup() })) } - + if isCreator || (channel.adminRights?.rights.contains(.canChangeInfo) == true) { let label: String if let cachedData = data.cachedData as? CachedChannelData, case let .known(reactionSettings) = cachedData.reactionSettings { @@ -1427,7 +1500,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s })) } } - + if isCreator || channel.adminRights?.rights.contains(.canChangeInfo) == true { var colors: [PeerNameColors.Colors] = [] if let nameColor = channel.nameColor.flatMap({ context.peerNameColors.get($0, dark: presentationData.theme.overallDarkAppearance) }) { @@ -1437,7 +1510,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s colors.append(profileColor) } let colorImage = generateSettingsMenuPeerColorsLabelIcon(colors: colors) - + var boostIcon: UIImage? if let approximateBoostLevel = channel.approximateBoostLevel, approximateBoostLevel < 1 { boostIcon = generateDisclosureActionBoostLevelBadgeImage(text: presentationData.strings.Channel_Info_BoostLevelPlusBadge("1").string) @@ -1448,19 +1521,19 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s interaction.editingOpenNameColorSetup() })) } - + if (isCreator || (channel.adminRights != nil && channel.hasPermission(.banMembers))) && cachedData.peerGeoLocation == nil, !isPublic, case .known(nil) = cachedData.linkedDiscussionPeerId, !channel.isForumOrMonoForum { items[.peerPublicSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemPreHistory, label: .text(cachedData.flags.contains(.preHistoryEnabled) ? presentationData.strings.GroupInfo_GroupHistoryVisible : presentationData.strings.GroupInfo_GroupHistoryHidden), text: presentationData.strings.GroupInfo_GroupHistoryShort, icon: PresentationResourcesSettings.chatHistory, action: { interaction.editingOpenPreHistorySetup() })) } - + if isCreator, let appConfiguration = data.appConfiguration { var minParticipants = 200 if let data = appConfiguration.data, let value = data["forum_upgrade_participants_min"] as? Double { minParticipants = Int(value) } - + var canSetupTopics = false var topicsLimitedReason: TopicsLimitedReason? if channel.flags.contains(.isForum) { @@ -1474,7 +1547,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s topicsLimitedReason = .participants(minParticipants) } } - + if canSetupTopics { let label = channel.flags.contains(.isForum) ? presentationData.strings.PeerInfo_OptionTopics_Enabled : presentationData.strings.PeerInfo_OptionTopics_Disabled items[.peerDataSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemTopics, label: .text(label), text: presentationData.strings.PeerInfo_OptionTopics, icon: PresentationResourcesSettings.topics, action: { @@ -1484,18 +1557,18 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s interaction.openForumSettings() } })) - + items[.peerDataSettings]!.append(PeerInfoScreenCommentItem(id: ItemTopicsText, text: presentationData.strings.PeerInfo_OptionTopicsText)) } } - + var canViewAdminsAndBanned = false if let _ = channel.adminRights { canViewAdminsAndBanned = true } else if channel.flags.contains(.isCreator) { canViewAdminsAndBanned = true } - + if canViewAdminsAndBanned { var activePermissionCount: Int? if let defaultBannedRights = channel.defaultBannedRights { @@ -1513,7 +1586,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s } activePermissionCount = count } - + items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemMembers, label: .text(cachedData.participantsSummary.memberCount.flatMap { "\(presentationStringsFormattedNumber($0, presentationData.dateTimeFormat.groupingSeparator))" } ?? ""), text: presentationData.strings.Group_Info_Members, icon: PresentationResourcesSettings.subscribers, action: { interaction.openParticipantsSection(.members) })) @@ -1522,26 +1595,26 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s interaction.openPermissions() })) } - + items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemAdmins, label: .text(cachedData.participantsSummary.adminCount.flatMap { "\(presentationStringsFormattedNumber($0, presentationData.dateTimeFormat.groupingSeparator))" } ?? ""), text: presentationData.strings.GroupInfo_Administrators, icon: PresentationResourcesSettings.admins, action: { interaction.openParticipantsSection(.admins) })) - + if let count = data.requests?.count, count > 0 { items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemMemberRequests, label: .badge(presentationStringsFormattedNumber(count, presentationData.dateTimeFormat.groupingSeparator), presentationData.theme.list.itemAccentColor), text: presentationData.strings.GroupInfo_MemberRequests, icon: PresentationResourcesSettings.groupRequests, action: { interaction.openParticipantsSection(.memberRequests) })) } - + items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemRemovedUsers, label: .text(cachedData.participantsSummary.kickedCount.flatMap { $0 > 0 ? "\(presentationStringsFormattedNumber($0, presentationData.dateTimeFormat.groupingSeparator))" : "" } ?? ""), text: presentationData.strings.GroupInfo_Permissions_Removed, icon: PresentationResourcesSettings.block, action: { interaction.openParticipantsSection(.banned) })) - + items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemRecentActions, label: .none, text: presentationData.strings.Group_Info_AdminLog, icon: PresentationResourcesSettings.recentActions, action: { interaction.openRecentActions() })) } - + if isCreator { items[.peerActions]!.append(PeerInfoScreenActionItem(id: ItemDeleteGroup, text: presentationData.strings.Group_DeleteGroup, color: .destructive, icon: nil, alignment: .natural, action: { interaction.openDeletePeer() @@ -1559,9 +1632,9 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s let ItemReactions = 107 let ItemTopics = 108 let ItemTopicsText = 109 - + var canViewAdminsAndBanned = false - + if case .creator = group.role { if let cachedData = data.cachedData as? CachedGroupData { if cachedData.flags.contains(.canChangeUsername) { @@ -1570,7 +1643,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s })) } } - + if (group.addressName?.isEmpty ?? true) { let invitesText: String if let count = data.invitations?.count, count > 0 { @@ -1578,16 +1651,16 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s } else { invitesText = "" } - + items[.peerPublicSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemInviteLinks, label: .text(invitesText), text: presentationData.strings.GroupInfo_InviteLinks, icon: PresentationResourcesSettings.links, action: { interaction.editingOpenInviteLinksSetup() })) } - + items[.peerPublicSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemPreHistory, label: .text(presentationData.strings.GroupInfo_GroupHistoryHidden), text: presentationData.strings.GroupInfo_GroupHistoryShort, icon: PresentationResourcesSettings.chatHistory, action: { interaction.editingOpenPreHistorySetup() })) - + var canSetupTopics = false if case .creator = group.role { canSetupTopics = true @@ -1602,7 +1675,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s topicsLimitedReason = .participants(minParticipants) } } - + if canSetupTopics { items[.peerPublicSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemTopics, label: .text(presentationData.strings.PeerInfo_OptionTopics_Disabled), text: presentationData.strings.PeerInfo_OptionTopics, icon: PresentationResourcesSettings.topics, action: { if let topicsLimitedReason = topicsLimitedReason { @@ -1611,10 +1684,10 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s interaction.openForumSettings() } })) - + items[.peerPublicSettings]!.append(PeerInfoScreenCommentItem(id: ItemTopicsText, text: presentationData.strings.PeerInfo_OptionTopicsText)) } - + let label: String if let cachedData = data.cachedData as? CachedGroupData, case let .known(reactionSettings) = cachedData.reactionSettings { switch reactionSettings.allowedReactions { @@ -1631,7 +1704,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemReactions, label: .text(label), text: presentationData.strings.PeerInfo_Reactions, icon: PresentationResourcesSettings.reactions, action: { interaction.editingOpenReactionsSetup() })) - + canViewAdminsAndBanned = true } else if case let .admin(rights, _) = group.role { let label: String @@ -1650,7 +1723,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemReactions, label: .text(label), text: presentationData.strings.PeerInfo_Reactions, icon: PresentationResourcesSettings.reactions, action: { interaction.editingOpenReactionsSetup() })) - + if rights.rights.contains(.canInviteUsers) { let invitesText: String if let count = data.invitations?.count, count > 0 { @@ -1658,15 +1731,15 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s } else { invitesText = "" } - + items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemInviteLinks, label: .text(invitesText), text: presentationData.strings.GroupInfo_InviteLinks, icon: PresentationResourcesSettings.links, action: { interaction.editingOpenInviteLinksSetup() })) } - + canViewAdminsAndBanned = true } - + if canViewAdminsAndBanned { var activePermissionCount: Int? if let defaultBannedRights = group.defaultBannedRights { @@ -1684,15 +1757,15 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s } activePermissionCount = count } - + items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemPermissions, label: .text(activePermissionCount.flatMap({ "\($0)/\(allGroupPermissionList(peer: .legacyGroup(group), expandMedia: true).count)" }) ?? ""), text: presentationData.strings.GroupInfo_Permissions, icon: PresentationResourcesSettings.permissions, action: { interaction.openPermissions() })) - + items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemAdmins, text: presentationData.strings.GroupInfo_Administrators, icon: PresentationResourcesSettings.admins, action: { interaction.openParticipantsSection(.admins) })) - + if let count = data.requests?.count, count > 0 { items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemMemberRequests, label: .badge(presentationStringsFormattedNumber(count, presentationData.dateTimeFormat.groupingSeparator), presentationData.theme.list.itemAccentColor), text: presentationData.strings.GroupInfo_MemberRequests, icon: PresentationResourcesSettings.groupRequests, action: { interaction.openParticipantsSection(.memberRequests) @@ -1701,7 +1774,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s } } } - + var result: [(AnyHashable, [PeerInfoScreenItem])] = [] for section in Section.allCases { if let sectionItems = items[section], !sectionItems.isEmpty { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenSettingsActions.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenSettingsActions.swift index 39f95aea3e..a2bda11712 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenSettingsActions.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenSettingsActions.swift @@ -22,7 +22,7 @@ extension PeerInfoScreenNode { guard let strongSelf = self, let navigationController = strongSelf.controller?.navigationController as? NavigationController else { return } - + if strongSelf.isMyProfile { navigationController.pushViewController(c) } else { @@ -35,7 +35,7 @@ extension PeerInfoScreenNode { } } updatedControllers.append(c) - + var animated = true if let validLayout = strongSelf.validLayout?.0, case .regular = validLayout.metrics.widthClass { animated = false @@ -136,7 +136,7 @@ extension PeerInfoScreenNode { } let _ = self.context.engine.notices.dismissServerProvidedSuggestion(suggestion: ServerProvidedSuggestion.setupPassword.id).startStandalone() }) - + let controller = self.context.sharedContext.makeSetupTwoFactorAuthController(context: self.context) push(controller) case .dataAndStorage: @@ -174,7 +174,7 @@ extension PeerInfoScreenNode { case .support: let supportPeer = Promise() supportPeer.set(context.engine.peers.supportPeerId()) - + self.controller?.present(textAlertController(context: self.context, updatedPresentationData: self.controller?.updatedPresentationData, title: nil, text: self.presentationData.strings.Settings_FAQ_Intro, actions: [ TextAlertAction(type: .genericAction, title: presentationData.strings.Settings_FAQ_Button, action: { [weak self] in self?.openFaq() @@ -230,7 +230,7 @@ extension PeerInfoScreenNode { count += 1 } } - + if count >= maximumAvailableAccounts { var replaceImpl: ((ViewController) -> Void)? let controller = PremiumLimitScreen(context: strongSelf.context, subject: .accounts, count: Int32(count), action: { @@ -272,7 +272,7 @@ extension PeerInfoScreenNode { case .powerSaving: push(energySavingSettingsScreen(context: self.context)) case .winterGram: - push(winterGramSettingsController(context: self.context)) + push(winterGramMainSettingsController(context: self.context)) case .businessSetup: guard let controller = self.controller, !controller.presentAccountFrozenInfoIfNeeded() else { return @@ -305,10 +305,10 @@ extension PeerInfoScreenNode { self.didSetCachedFaq = true } } - + func openFaq(anchor: String? = nil) { self.setupFaqIfNeeded() - + let presentationData = self.presentationData let progressSignal = Signal { [weak self] subscriber in let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil)) @@ -322,7 +322,7 @@ extension PeerInfoScreenNode { |> runOn(Queue.mainQueue()) |> delay(0.15, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() - + let _ = (self.cachedFaq.get() |> filter { $0 != nil } |> take(1) @@ -341,11 +341,11 @@ extension PeerInfoScreenNode { } }) } - + private func openTips() { let controller = OverlayStatusController(theme: self.presentationData.theme, type: .loading(cancelled: nil)) self.controller?.present(controller, in: .window(.root)) - + let context = self.context let navigationController = self.controller?.navigationController as? NavigationController self.tipsPeerDisposable.set((self.context.engine.peers.resolvePeerByName(name: self.presentationData.strings.Settings_TipsUsername, referrer: nil) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift index e1c98a1ac3..c3a1f5504c 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift @@ -32,30 +32,30 @@ final class GiftsListView: UIView { private let peerId: EnginePeer.Id let profileGifts: ProfileGiftsContext private let giftsCollections: ProfileGiftsCollectionsContext? - + private let canSelect: Bool private let ignoreCollection: Int32? private let remainingSelectionCount: Int32 - + private var dataDisposable: Disposable? - + weak var parentController: ViewController? - + private var footerText: ComponentView? - + private let emptyResultsClippingView = UIView() private let emptyResultsAnimation = ComponentView() private let emptyResultsTitle = ComponentView() private let emptyResultsText = ComponentView() private let emptyResultsAction = ComponentView() - + private var currentParams: (size: CGSize, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, presentationData: PresentationData)? private var visibleBounds: CGRect? private var topInset: CGFloat? - + private var theme: PresentationTheme? private let presentationDataPromise = Promise() - + private let ready = Promise() private var didSetReady: Bool = false var isReady: Signal { @@ -66,21 +66,21 @@ final class GiftsListView: UIView { var status: Signal { self.statusPromise.get() } - + private var starsProducts: [ProfileGiftsContext.State.StarGift]? private var starsItems: [AnyHashable: (StarGiftReference?, ComponentView)] = [:] private(set) var resultsAreEmpty = false private var filteredResultsAreEmpty = false - + var onContentUpdated: () -> Void = { } - + private(set) var selectedItemIds = Set() private var selectedItemsMap: [AnyHashable: ProfileGiftsContext.State.StarGift] = [:] var selectionUpdated: () -> Void = { } - + var displayUnpinScreen: ((ProfileGiftsContext.State.StarGift, (() -> Void)?) -> Void)? - + var selectedItems: [ProfileGiftsContext.State.StarGift] { var gifts: [ProfileGiftsContext.State.StarGift] = [] var existingIds = Set() @@ -101,7 +101,7 @@ final class GiftsListView: UIView { } return gifts } - + private(set) var pinnedReferences: [StarGiftReference] = [] private var isReordering: Bool = false private var reorderingItem: (id: AnyHashable, initialPosition: CGPoint, position: CGPoint)? @@ -111,21 +111,21 @@ final class GiftsListView: UIView { } } private var reorderedReferencesPromise = ValuePromise<[StarGiftReference]?>(nil) - + private var reorderedPinnedReferences: Set? { didSet { self.reorderedPinnedReferencesPromise.set(self.reorderedPinnedReferences) } } private var reorderedPinnedReferencesPromise = ValuePromise?>(nil) - + private var reorderRecognizer: ReorderGestureRecognizer? - + let maxPinnedCount: Int - + var contextAction: ((ProfileGiftsContext.State.StarGift, UIView, ContextGesture) -> Void)? var addToCollection: (() -> Void)? - + init(context: AccountContext, peerId: EnginePeer.Id, profileGifts: ProfileGiftsContext, giftsCollections: ProfileGiftsCollectionsContext?, canSelect: Bool, ignoreCollection: Int32? = nil, remainingSelectionCount: Int32 = 0) { self.context = context self.peerId = peerId @@ -134,32 +134,67 @@ final class GiftsListView: UIView { self.canSelect = canSelect self.ignoreCollection = ignoreCollection self.remainingSelectionCount = remainingSelectionCount - + if let value = context.currentAppConfiguration.with({ $0 }).data?["stargifts_pinned_to_top_limit"] as? Double { self.maxPinnedCount = Int(value) } else { self.maxPinnedCount = 6 } - + super.init(frame: .zero) - + self.dataDisposable = combineLatest( queue: Queue.mainQueue(), profileGifts.state, - self.reorderedReferencesPromise.get() - ).startStrict(next: { [weak self] state, reorderedReferences in + self.reorderedReferencesPromise.get(), + winterGramSettings(accountManager: context.sharedContext.accountManager) + ).startStrict(next: { [weak self] state, reorderedReferences, winterGramSettings in guard let self else { return } let isFirstTime = self.starsProducts == nil let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } - self.statusPromise.set(.single(PeerInfoStatusData(text: presentationData.strings.SharedMedia_GiftCount(state.count ?? 0), isActivity: true, key: .gifts))) - + + var stateItems: [ProfileGiftsContext.State.StarGift] = state.gifts + if self.peerId == self.context.account.peerId { + for visualGift in winterGramSettings.visualGifts { + let gift = ProfileGiftsContext.State.StarGift( + gift: visualGift.gift, + reference: .slug(slug: "visual_\(visualGift.id)"), + fromPeer: visualGift.fromPeer, + date: 0, + text: nil, + entities: nil, + nameHidden: false, + savedToProfile: true, + pinnedToTop: false, + convertStars: nil, + canUpgrade: false, + canExportDate: nil, + upgradeStars: nil, + transferStars: nil, + canTransferDate: nil, + canResaleDate: nil, + collectionIds: nil, + prepaidUpgradeHash: nil, + upgradeSeparate: false, + dropOriginalDetailsStars: nil, + number: nil, + isRefunded: false, + canCraftAt: nil + ) + if !stateItems.contains(where: { $0.reference == gift.reference }) { + stateItems.append(gift) + } + } + } + + self.statusPromise.set(.single(PeerInfoStatusData(text: presentationData.strings.SharedMedia_GiftCount(Int32(stateItems.count)), isActivity: true, key: .gifts))) + if self.isReordering { - var stateItems: [ProfileGiftsContext.State.StarGift] = state.gifts if let reorderedReferences { var fixedStateItems: [ProfileGiftsContext.State.StarGift] = [] - + var seenIds = Set() for reference in reorderedReferences { if let index = stateItems.firstIndex(where: { $0.reference == reference }) { @@ -171,7 +206,7 @@ final class GiftsListView: UIView { fixedStateItems.append(item) } } - + for item in stateItems { if let reference = item.reference, !seenIds.contains(reference) { var item = item @@ -186,29 +221,29 @@ final class GiftsListView: UIView { self.starsProducts = stateItems self.pinnedReferences = Array(stateItems.filter { $0.pinnedToTop }.compactMap { $0.reference }) } else { - self.starsProducts = state.filteredGifts - self.pinnedReferences = Array(state.gifts.filter { $0.pinnedToTop }.compactMap { $0.reference }) + self.starsProducts = stateItems + self.pinnedReferences = Array(stateItems.filter { $0.pinnedToTop }.compactMap { $0.reference }) } - - self.resultsAreEmpty = state.filter == .All && state.gifts.isEmpty && state.dataState != .loading - self.filteredResultsAreEmpty = state.filter != .All && state.filteredGifts.isEmpty - + + self.resultsAreEmpty = state.filter == .All && stateItems.isEmpty && state.dataState != .loading + self.filteredResultsAreEmpty = state.filter != .All && stateItems.isEmpty + if !self.didSetReady { self.didSetReady = true self.ready.set(.single(true)) } - + let _ = self.updateScrolling(transition: isFirstTime ? .immediate : .easeInOut(duration: 0.25)) - + Queue.mainQueue().justDispatch { self.onContentUpdated() } }) - + self.emptyResultsClippingView.clipsToBounds = true self.emptyResultsClippingView.isHidden = true self.addSubview(self.emptyResultsClippingView) - + let reorderRecognizer = ReorderGestureRecognizer( shouldBegin: { [weak self] point in guard let self, let (id, item) = self.item(at: point) else { @@ -243,15 +278,15 @@ final class GiftsListView: UIView { self.addGestureRecognizer(reorderRecognizer) reorderRecognizer.isEnabled = false } - + required init?(coder: NSCoder) { preconditionFailure() } - + deinit { self.dataDisposable?.dispose() } - + func item(at point: CGPoint) -> (AnyHashable, ComponentView)? { for (id, visibleItem) in self.starsItems { if let view = visibleItem.1.view, view.frame.contains(point), let reference = visibleItem.0, self.isCollection || self.pinnedReferences.contains(reference) { @@ -260,18 +295,18 @@ final class GiftsListView: UIView { } return nil } - + func beginReordering() { self.profileGifts.updateFilter(.All) self.profileGifts.updateSorting(.date) - + if let parentController = self.parentController as? PeerInfoScreen { parentController.togglePaneIsReordering(isReordering: true) } else { self.updateIsReordering(isReordering: true, animated: true) } } - + func endReordering() { if let parentController = self.parentController as? PeerInfoScreen { parentController.togglePaneIsReordering(isReordering: false) @@ -279,13 +314,13 @@ final class GiftsListView: UIView { self.updateIsReordering(isReordering: false, animated: true) } } - + func updateIsReordering(isReordering: Bool, animated: Bool) { if self.isReordering != isReordering { self.isReordering = isReordering - + self.reorderRecognizer?.isEnabled = isReordering - + if !isReordering, let _ = self.reorderedReferences, let starsProducts = self.starsProducts { if let collectionId = self.profileGifts.collectionId { var orderedReferences: [StarGiftReference] = [] @@ -304,17 +339,17 @@ final class GiftsListView: UIView { } self.profileGifts.updatePinnedToTopStarGifts(references: pinnedReferences) } - + Queue.mainQueue().after(1.0) { self.reorderedReferences = nil self.reorderedPinnedReferences = nil } } - + self.updateScrolling(transition: animated ? .spring(duration: 0.4) : .immediate) } } - + func setReorderingItem(item: AnyHashable?) { var mappedItem: (AnyHashable, ComponentView)? for (id, visibleItem) in self.starsItems { @@ -323,7 +358,7 @@ final class GiftsListView: UIView { break } } - + if self.reorderingItem?.id != mappedItem?.0 { if let (id, visibleItem) = mappedItem, let view = visibleItem.view { self.addSubview(view) @@ -334,13 +369,13 @@ final class GiftsListView: UIView { self.updateScrolling(transition: item == nil ? .spring(duration: 0.3) : .immediate) } } - + func moveReorderingItem(distance: CGPoint) { if let (id, initialPosition, _) = self.reorderingItem { let targetPosition = CGPoint(x: initialPosition.x + distance.x, y: initialPosition.y + distance.y) self.reorderingItem = (id, initialPosition, targetPosition) self.updateScrolling(transition: .immediate) - + if let starsProducts = self.starsProducts, let visibleReorderingItem = self.starsItems[id] { for (_, visibleItem) in self.starsItems { if visibleItem.1 === visibleReorderingItem.1 { @@ -356,15 +391,15 @@ final class GiftsListView: UIView { } } } - + private var isCollection: Bool { return self.profileGifts.collectionId != nil } - + private func reorderIfPossible(reference: StarGiftReference, toIndex: Int) { if let items = self.starsProducts { var toIndex = toIndex - + let maxPinnedIndex: Int? if self.isCollection { maxPinnedIndex = items.count - 1 @@ -376,11 +411,11 @@ final class GiftsListView: UIView { } else { return } - + var ids = items.compactMap { item -> StarGiftReference? in return item.reference } - + if let fromIndex = ids.firstIndex(of: reference) { if fromIndex < toIndex { ids.insert(reference, at: toIndex + 1) @@ -392,16 +427,16 @@ final class GiftsListView: UIView { } if self.reorderedReferences != ids { self.reorderedReferences = ids - + HapticFeedback().tap() } } } - + func loadMore() { self.profileGifts.loadMore() } - + @discardableResult private func updateScrolling(interactive: Bool = false, transition: ComponentTransition) -> CGFloat { guard let topInset = self.topInset, let visibleBounds = self.visibleBounds else { @@ -409,25 +444,25 @@ final class GiftsListView: UIView { } return self.updateScrolling(interactive: interactive, topInset: topInset, visibleBounds: visibleBounds, transition: transition) } - + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { if let topInset = self.topInset, point.y < topInset { return false } return super.point(inside: point, with: event) } - + func updateScrolling(interactive: Bool = false, topInset: CGFloat, visibleBounds: CGRect, transition: ComponentTransition) -> CGFloat { self.topInset = topInset self.visibleBounds = visibleBounds - + guard let starsProducts = self.starsProducts, let params = self.currentParams else { return 0.0 } - + let optionSpacing: CGFloat = 10.0 let itemsSideInset = params.sideInset + 16.0 - + let defaultItemsInRow: Int if params.size.width > params.size.height || params.size.width > 480.0 { if case .tablet = params.deviceMetrics.type { @@ -441,19 +476,19 @@ final class GiftsListView: UIView { let itemsInRow = max(1, min(starsProducts.count, defaultItemsInRow)) let defaultOptionWidth = (params.size.width - itemsSideInset * 2.0 - optionSpacing * CGFloat(defaultItemsInRow - 1)) / CGFloat(defaultItemsInRow) let optionWidth = (params.size.width - itemsSideInset * 2.0 - optionSpacing * CGFloat(itemsInRow - 1)) / CGFloat(itemsInRow) - + let starsOptionSize = CGSize(width: optionWidth, height: defaultOptionWidth) - + var validIds: [AnyHashable] = [] var itemFrame = CGRect(origin: CGPoint(x: itemsSideInset, y: topInset), size: starsOptionSize) - + var index: Int32 = 0 for product in starsProducts { var isVisible = false if visibleBounds.intersects(itemFrame) { isVisible = true } - + if isVisible { let info: String switch product.gift { @@ -466,7 +501,7 @@ final class GiftsListView: UIView { let id = "\(stableId)_\(info)" let itemId = AnyHashable(id) validIds.append(itemId) - + var itemTransition = transition let visibleItem: ComponentView if let (_, current) = self.starsItems[itemId] { @@ -476,21 +511,21 @@ final class GiftsListView: UIView { self.starsItems[itemId] = (product.reference, visibleItem) itemTransition = .immediate } - + var ribbonText: String? var ribbonColor: GiftItemComponent.Ribbon.Color = .blue var ribbonFont: GiftItemComponent.Ribbon.Font = .generic var ribbonOutline: UIColor? - + let peer: GiftItemComponent.Peer? let subject: GiftItemComponent.Subject var resellAmount: CurrencyAmount? - + switch product.gift { case let .generic(gift): subject = .starGift(gift: gift, price: "# \(gift.price)") peer = product.fromPeer.flatMap { .peer($0) } ?? .anonymous - + if let availability = gift.availability { ribbonText = params.presentationData.strings.PeerInfo_Gifts_OneOf(compactNumericCountString(Int(availability.total), decimalSeparator: params.presentationData.dateTimeFormat.decimalSeparator)).string } else { @@ -500,7 +535,7 @@ final class GiftsListView: UIView { subject = .uniqueGift(gift: gift, price: nil) peer = nil resellAmount = gift.resellAmounts?.first(where: { $0.currency == .stars }) - + if !(gift.resellAmounts ?? []).isEmpty { ribbonText = params.presentationData.strings.PeerInfo_Gifts_Sale ribbonFont = .larger @@ -517,19 +552,19 @@ final class GiftsListView: UIView { } } } - + let itemReferenceId = product.reference?.stringValue ?? "" - + var isAdded = false if let ignoreCollection = self.ignoreCollection, let collectionIds = product.collectionIds, collectionIds.contains(ignoreCollection) { isAdded = true } - + var itemAlpha: CGFloat = 1.0 if isAdded { itemAlpha = 0.3 } - + let _ = visibleItem.update( transition: itemTransition, component: AnyComponent( @@ -568,14 +603,14 @@ final class GiftsListView: UIView { self.parentController?.present(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: presentationData.strings.PeerInfo_Gifts_ToastPinLimit_Text(Int32(self.maxPinnedCount)), timeout: nil, customUndoText: nil), elevatedLayout: true, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) return } - + var reorderedPinnedReferences = Set() if let current = self.reorderedPinnedReferences { reorderedPinnedReferences = current } reorderedPinnedReferences.insert(reference) self.reorderedPinnedReferences = reorderedPinnedReferences - + if let maxPinnedIndex = items.lastIndex(where: { $0.pinnedToTop }) { var reorderedReferences: [StarGiftReference] if let current = self.reorderedReferences { @@ -594,7 +629,7 @@ final class GiftsListView: UIView { } else { let allSubjects: [GiftViewScreen.Subject] = (self.starsProducts ?? []).map { .profileGift(self.peerId, $0) } let index = self.starsProducts?.firstIndex(where: { $0 == product }) ?? 0 - + var dismissImpl: (() -> Void)? let controller = GiftViewScreen( context: self.context, @@ -655,12 +690,12 @@ final class GiftsListView: UIView { return false } self.profileGifts.updateStarGiftPinnedToTop(reference: reference, pinnedToTop: pinnedToTop) - + var title = "" if case let .unique(uniqueGift) = product.gift { title = "\(uniqueGift.title) #\(formatCollectibleNumber(uniqueGift.number, dateTimeFormat: params.presentationData.dateTimeFormat))" } - + if pinnedToTop { Queue.mainQueue().after(0.35) { let toastTitle = params.presentationData.strings.PeerInfo_Gifts_ToastPinned_TitleNew(title).string @@ -700,7 +735,7 @@ final class GiftsListView: UIView { if let itemView = visibleItem.view { if itemView.superview == nil { self.addSubview(itemView) - + if !transition.animation.isImmediate { itemView.layer.animateScale(from: 0.01, to: 1.0, duration: 0.25) itemView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) @@ -716,12 +751,12 @@ final class GiftsListView: UIView { } else { itemTransition.setFrame(view: itemView, frame: itemFrame) } - + itemTransition.setAlpha(view: itemView, alpha: itemAlpha) if itemAlpha < 1.0 { itemView.layer.allowsGroupOpacity = true } - + if self.isReordering && (product.pinnedToTop || self.isCollection) { if itemView.layer.animation(forKey: "shaking_position") == nil { itemView.layer.addReorderingShaking() @@ -741,7 +776,7 @@ final class GiftsListView: UIView { } index += 1 } - + var removeIds: [AnyHashable] = [] for (id, item) in self.starsItems { if !validIds.contains(id) { @@ -761,16 +796,16 @@ final class GiftsListView: UIView { for id in removeIds { self.starsItems.removeValue(forKey: id) } - + var contentHeight = ceil(CGFloat(starsProducts.count) / CGFloat(defaultItemsInRow)) * (starsOptionSize.height + optionSpacing) - optionSpacing + topInset + 16.0 - + let size = params.size let sideInset = params.sideInset let bottomInset = params.bottomInset let presentationData = params.presentationData - + self.theme = presentationData.theme - + let textFont = Font.regular(13.0) let boldTextFont = Font.semibold(13.0) let textColor = presentationData.theme.list.itemSecondaryTextColor @@ -778,25 +813,25 @@ final class GiftsListView: UIView { let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: boldTextFont, textColor: linkColor), linkAttribute: { _ in return nil }) - + let buttonSideInset = sideInset + 16.0 let buttonSize = CGSize(width: size.width - buttonSideInset * 2.0, height: 50.0) let effectiveBottomInset = max(8.0, bottomInset) let bottomPanelHeight = effectiveBottomInset + buttonSize.height + 8.0 let visibleHeight = params.visibleHeight - + let panelTransition = ComponentTransition.immediate let fadeTransition = ComponentTransition.easeInOut(duration: 0.25) if self.resultsAreEmpty && self.isCollection { let sideInset: CGFloat = 44.0 let topInset: CGFloat = 52.0 let emptyTextSpacing: CGFloat = 18.0 - + self.emptyResultsClippingView.isHidden = false - + panelTransition.setFrame(view: self.emptyResultsClippingView, frame: CGRect(origin: CGPoint(x: 0.0, y: 48.0), size: params.size)) panelTransition.setBounds(view: self.emptyResultsClippingView, bounds: CGRect(origin: CGPoint(x: 0.0, y: 48.0), size: params.size)) - + let emptyResultsTitleSize = self.emptyResultsTitle.update( transition: .immediate, component: AnyComponent( @@ -843,14 +878,14 @@ final class GiftsListView: UIView { environment: {}, containerSize: CGSize(width: 240.0, height: 52.0) ) - + let emptyTotalHeight = emptyResultsTitleSize.height + emptyTextSpacing + emptyResultsTextSize.height + emptyTextSpacing + emptyResultsActionSize.height let emptyTitleY = topInset + floorToScreenPixels((visibleHeight - topInset - bottomInset - emptyTotalHeight) / 2.0) - + let emptyResultsTitleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsTitleSize.width) / 2.0), y: emptyTitleY), size: emptyResultsTitleSize) let emptyResultsTextFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsTextSize.width) / 2.0), y: emptyResultsTitleFrame.maxY + emptyTextSpacing), size: emptyResultsTextSize) let emptyResultsActionFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsActionSize.width) / 2.0), y: emptyResultsTextFrame.maxY + emptyTextSpacing), size: emptyResultsActionSize) - + if let view = self.emptyResultsTitle.view { if view.superview == nil { view.alpha = 0.0 @@ -885,12 +920,12 @@ final class GiftsListView: UIView { let bottomInset: CGFloat = bottomPanelHeight let emptyAnimationSpacing: CGFloat = 20.0 let emptyTextSpacing: CGFloat = 18.0 - + self.emptyResultsClippingView.isHidden = false - + panelTransition.setFrame(view: self.emptyResultsClippingView, frame: CGRect(origin: CGPoint(x: 0.0, y: 48.0), size: params.size)) panelTransition.setBounds(view: self.emptyResultsClippingView, bounds: CGRect(origin: CGPoint(x: 0.0, y: 48.0), size: params.size)) - + let emptyResultsTitleSize = self.emptyResultsTitle.update( transition: .immediate, component: AnyComponent( @@ -934,16 +969,16 @@ final class GiftsListView: UIView { environment: {}, containerSize: CGSize(width: emptyAnimationHeight, height: emptyAnimationHeight) ) - + let emptyTotalHeight = emptyAnimationHeight + emptyAnimationSpacing + emptyResultsTitleSize.height + emptyResultsActionSize.height + emptyTextSpacing let emptyAnimationY = topInset + floorToScreenPixels((visibleHeight - topInset - bottomInset - emptyTotalHeight) / 2.0) - + let emptyResultsAnimationFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsAnimationSize.width) / 2.0), y: emptyAnimationY), size: emptyResultsAnimationSize) - + let emptyResultsTitleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsTitleSize.width) / 2.0), y: emptyResultsAnimationFrame.maxY + emptyAnimationSpacing), size: emptyResultsTitleSize) - + let emptyResultsActionFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsActionSize.width) / 2.0), y: emptyResultsTitleFrame.maxY + emptyTextSpacing), size: emptyResultsActionSize) - + if let view = self.emptyResultsAnimation.view as? LottieComponent.View { if view.superview == nil { view.alpha = 0.0 @@ -995,9 +1030,9 @@ final class GiftsListView: UIView { }) } } - + fadeTransition.setAlpha(view: self.emptyResultsClippingView, alpha: visibleHeight < 300.0 ? 0.0 : 1.0) - + if self.peerId == self.context.account.peerId, !self.canSelect && !self.filteredResultsAreEmpty && self.profileGifts.collectionId == nil && self.emptyResultsClippingView.isHidden { let footerText: ComponentView if let current = self.footerText { @@ -1034,14 +1069,14 @@ final class GiftsListView: UIView { }) } } - + return contentHeight } - + func update(size: CGSize, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, presentationData: PresentationData, synchronous: Bool, visibleBounds: CGRect, transition: ContainedViewLayoutTransition) -> CGFloat { self.currentParams = (size, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, presentationData) self.presentationDataPromise.set(.single(presentationData)) - + return self.updateScrolling(topInset: self.topInset ?? 0.0, visibleBounds: visibleBounds, transition: ComponentTransition(transition)) } } @@ -1067,14 +1102,14 @@ private final class ReorderGestureRecognizer: UIGestureRecognizer { private let ended: () -> Void private let moved: (CGPoint) -> Void private let isActiveUpdated: (Bool) -> Void - + private var initialLocation: CGPoint? private var longTapTimer: SwiftSignalKit.Timer? private var longPressTimer: SwiftSignalKit.Timer? - + private var id: AnyHashable? private var itemView: ComponentView? - + init(shouldBegin: @escaping (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, id: AnyHashable?, item: ComponentView?), willBegin: @escaping (CGPoint) -> Void, began: @escaping (AnyHashable) -> Void, ended: @escaping () -> Void, moved: @escaping (CGPoint) -> Void, isActiveUpdated: @escaping (Bool) -> Void) { self.shouldBegin = shouldBegin self.willBegin = willBegin @@ -1082,15 +1117,15 @@ private final class ReorderGestureRecognizer: UIGestureRecognizer { self.ended = ended self.moved = moved self.isActiveUpdated = isActiveUpdated - + super.init(target: nil, action: nil) } - + deinit { self.longTapTimer?.invalidate() self.longPressTimer?.invalidate() } - + private func startLongTapTimer() { self.longTapTimer?.invalidate() let longTapTimer = SwiftSignalKit.Timer(timeout: 0.25, repeat: false, completion: { [weak self] in @@ -1099,13 +1134,13 @@ private final class ReorderGestureRecognizer: UIGestureRecognizer { self.longTapTimer = longTapTimer longTapTimer.start() } - + private func stopLongTapTimer() { self.itemView = nil self.longTapTimer?.invalidate() self.longTapTimer = nil } - + private func startLongPressTimer() { self.longPressTimer?.invalidate() let longPressTimer = SwiftSignalKit.Timer(timeout: 0.6, repeat: false, completion: { [weak self] in @@ -1114,40 +1149,40 @@ private final class ReorderGestureRecognizer: UIGestureRecognizer { self.longPressTimer = longPressTimer longPressTimer.start() } - + private func stopLongPressTimer() { self.itemView = nil self.longPressTimer?.invalidate() self.longPressTimer = nil } - + override func reset() { super.reset() - + self.itemView = nil self.stopLongTapTimer() self.stopLongPressTimer() self.initialLocation = nil - + self.isActiveUpdated(false) } - + private func longTapTimerFired() { guard let location = self.initialLocation else { return } - + self.longTapTimer?.invalidate() self.longTapTimer = nil - + self.willBegin(location) } - + private func longPressTimerFired() { guard let _ = self.initialLocation else { return } - + self.isActiveUpdated(true) self.state = .began self.longPressTimer?.invalidate() @@ -1159,23 +1194,23 @@ private final class ReorderGestureRecognizer: UIGestureRecognizer { } self.isActiveUpdated(true) } - + override func touchesBegan(_ touches: Set, with event: UIEvent) { super.touchesBegan(touches, with: event) - + if self.numberOfTouches > 1 { self.isActiveUpdated(false) self.state = .failed self.ended() return } - + if self.state == .possible { if let location = touches.first?.location(in: self.view) { let (allowed, requiresLongPress, id, itemView) = self.shouldBegin(location) if allowed { self.isActiveUpdated(true) - + self.id = id self.itemView = itemView self.initialLocation = location @@ -1198,12 +1233,12 @@ private final class ReorderGestureRecognizer: UIGestureRecognizer { } } } - + override func touchesEnded(_ touches: Set, with event: UIEvent) { super.touchesEnded(touches, with: event) - + self.initialLocation = nil - + self.stopLongTapTimer() if self.longPressTimer != nil { self.stopLongPressTimer() @@ -1216,12 +1251,12 @@ private final class ReorderGestureRecognizer: UIGestureRecognizer { self.state = .failed } } - + override func touchesCancelled(_ touches: Set, with event: UIEvent) { super.touchesCancelled(touches, with: event) - + self.initialLocation = nil - + self.stopLongTapTimer() if self.longPressTimer != nil { self.isActiveUpdated(false) @@ -1234,10 +1269,10 @@ private final class ReorderGestureRecognizer: UIGestureRecognizer { self.state = .failed } } - + override func touchesMoved(_ touches: Set, with event: UIEvent) { super.touchesMoved(touches, with: event) - + if (self.state == .began || self.state == .changed), let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) { self.state = .changed let offset = CGPoint(x: location.x - initialLocation.x, y: location.y - initialLocation.y) @@ -1246,7 +1281,7 @@ private final class ReorderGestureRecognizer: UIGestureRecognizer { let touchLocation = touch.location(in: self.view) let dX = touchLocation.x - initialTapLocation.x let dY = touchLocation.y - initialTapLocation.y - + if dX * dX + dY * dY > 3.0 * 3.0 { self.stopLongTapTimer() self.stopLongPressTimer() diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift index ee9ea2d9fc..597c325374 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift @@ -42,7 +42,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr case all case collection(Int32) case create - + init(rawValue: Int32) { switch rawValue { case 0: @@ -53,7 +53,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr self = .collection(rawValue) } } - + public var rawValue: Int32 { switch self { case .all: @@ -65,7 +65,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } } } - + private let context: AccountContext private let peerId: EnginePeer.Id private let profileGiftsCollections: ProfileGiftsCollectionsContext @@ -74,39 +74,39 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr private let canGift: Bool private var peer: EnginePeer? private let initialGiftCollectionId: Int64? - + private var resultsAreEmpty = false - + private let chatControllerInteraction: ChatControllerInteraction - + public weak var parentController: ViewController? { didSet { self.giftsListView.parentController = self.parentController } } - + private let backgroundNode: ASDisplayNode private let scrollNode: ASScrollNode private var giftsListView: GiftsListView - + private let tabSelector = ComponentView() public private(set) var currentCollection: GiftCollection = .all - + private var panelEdgeEffectView: EdgeEffectView? private var panelContentContainer: UIView? private var panelButton: ComponentView? private var panelCheck: ComponentView? - + private var currentParams: (size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData)? - + private var theme: PresentationTheme? private let presentationDataPromise = Promise() - + private var collectionsDisposable: Disposable? private var collections: [StarGiftCollection]? private var reorderedCollectionIds: [Int32]? private var isReordering = false - + private let ready = Promise() private var didSetReady: Bool = false public var isReady: Signal { @@ -117,20 +117,20 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr public var status: Signal { self.statusPromise.get() } - + public var tabBarOffsetUpdated: ((ContainedViewLayoutTransition) -> Void)? public var tabBarOffset: CGFloat { return 0.0 } - + public var giftsContext: ProfileGiftsContext { return self.giftsListView.profileGifts } - + public var openShareLink: ((String) -> Void)? - + private let collectionsMaxCount: Int - + public init(context: AccountContext, peerId: EnginePeer.Id, chatControllerInteraction: ChatControllerInteraction, profileGiftsCollections: ProfileGiftsCollectionsContext, profileGifts: ProfileGiftsContext, canManage: Bool, canGift: Bool, initialGiftCollectionId: Int64?) { self.context = context self.peerId = peerId @@ -140,25 +140,25 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr self.canManage = canManage self.canGift = canGift self.initialGiftCollectionId = initialGiftCollectionId - + if let value = context.currentAppConfiguration.with({ $0 }).data?["stargifts_collections_limit"] as? Double { self.collectionsMaxCount = Int(value) } else { self.collectionsMaxCount = 6 } - + self.backgroundNode = ASDisplayNode() self.scrollNode = ASScrollNode() self.giftsListView = GiftsListView(context: context, peerId: peerId, profileGifts: profileGifts, giftsCollections: profileGiftsCollections, canSelect: false) - + super.init() - + self.addSubnode(self.backgroundNode) self.addSubnode(self.scrollNode) - + self.statusPromise.set(self.giftsListView.status) self.ready.set(self.giftsListView.isReady) - + self.giftsListView.onContentUpdated = { [weak self] in guard let self else { return @@ -179,7 +179,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } self.displayUnpinScreen(gift: gift, completion: completion) } - + self.collectionsDisposable = (profileGiftsCollections.state |> deliverOnMainQueue).start(next: { [weak self] state in guard let self else { @@ -188,11 +188,11 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr self.collections = state.collections self.updateScrolling(transition: .easeInOut(duration: 0.2)) }) - + if let initialGiftCollectionId { self.setCurrentCollection(collection: .collection(Int32(initialGiftCollectionId))) } - + let _ = (context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) |> deliverOnMainQueue).start(next: { [weak self] peer in guard let self else { @@ -202,29 +202,29 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr self.updateScrolling(transition: .immediate) }) } - + deinit { self.collectionsDisposable?.dispose() } - + public override func didLoad() { super.didLoad() - + self.scrollNode.view.contentInsetAdjustmentBehavior = .never self.scrollNode.view.delegate = self self.scrollNode.view.scrollsToTop = false - + if let tabSelectorView = self.tabSelector.view { self.scrollNode.view.insertSubview(self.giftsListView, aboveSubview: tabSelectorView) } else { self.scrollNode.view.insertSubview(self.giftsListView, at: 0) } } - + private func item(at point: CGPoint) -> (AnyHashable, ComponentView)? { return self.giftsListView.item(at: self.giftsListView.convert(point, from: self.view)) } - + public func createCollection(gifts: [ProfileGiftsContext.State.StarGift] = []) { guard let params = self.currentParams else { return @@ -234,7 +234,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr self.parentController?.present(alertController, in: .window(.root)) return } - + let promptController = promptController(context: self.context, updatedPresentationData: nil, text: params.presentationData.strings.PeerInfo_Gifts_CreateCollection_Title, titleFont: .bold, subtitle: params.presentationData.strings.PeerInfo_Gifts_CreateCollection_Text, value: "", placeholder: params.presentationData.strings.PeerInfo_Gifts_CreateCollection_Placeholder, characterLimit: 12, displayCharacterLimit: true, apply: { [weak self] value in guard let self, let value else { return @@ -245,7 +245,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } if let collection { self.setCurrentCollection(collection: .collection(collection.id)) - + if let tabSelectorView = self.tabSelector.view as? TabSelectorComponent.View { tabSelectorView.scrollToEnd() } @@ -254,7 +254,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr }) self.parentController?.present(promptController, in: .window(.root)) } - + public func deleteCollection(id: Int32) { guard let params = self.currentParams else { return @@ -265,10 +265,10 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr ActionSheetTextItem(title: params.presentationData.strings.PeerInfo_Gifts_RemoveCollectionConfirmation), ActionSheetButtonItem(title: params.presentationData.strings.PeerInfo_Gifts_RemoveCollectionAction, color: .destructive, action: { [weak self, weak actionSheet] in actionSheet?.dismissAnimated() - + self?.setCurrentCollection(collection: .all) let _ = self?.profileGiftsCollections.deleteCollection(id: id).start() - + if let tabSelectorView = self?.tabSelector.view as? TabSelectorComponent.View { tabSelectorView.scrollToStart() } @@ -282,7 +282,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr ]) self.parentController?.present(actionSheet, in: .window(.root)) } - + public func addGiftsToCollection(id: Int32) { var collectionGiftsMaxCount: Int32 = 1000 if let value = self.context.currentAppConfiguration.with({ $0 }).data?["stargifts_collection_gifts_limit"] as? Double { @@ -300,12 +300,12 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr }) self.parentController?.push(screen) } - + public func renameCollection(id: Int32) { guard let params = self.currentParams, let collection = self.collections?.first(where: { $0.id == id }) else { return } - + let promptController = promptController(context: self.context, updatedPresentationData: nil, text: params.presentationData.strings.PeerInfo_Gifts_RenameCollection_Title, titleFont: .bold, value: collection.title, placeholder: params.presentationData.strings.PeerInfo_Gifts_CreateCollection_Placeholder, characterLimit: 12, displayCharacterLimit: true, apply: { [weak self] value in guard let self, let value else { return @@ -314,19 +314,19 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr }) self.parentController?.present(promptController, in: .window(.root)) } - + public func beginReordering() { self.giftsListView.beginReordering() } - + public func endReordering() { self.giftsListView.endReordering() } - + public func updateIsReordering(isReordering: Bool, animated: Bool) { if self.isReordering != isReordering { self.isReordering = isReordering - + if let collections = self.collections { if isReordering { var collectionIds: [Int32] = [] @@ -341,28 +341,88 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr }) } } - + self.giftsListView.updateIsReordering(isReordering: isReordering, animated: animated) self.updateScrolling(transition: .easeInOut(duration: 0.2)) } } - + public func ensureMessageIsVisible(id: EngineMessage.Id) { } - + public func scrollToTop() -> Bool { self.scrollNode.view.setContentOffset(.zero, animated: true) return true } - + public func scrollViewDidScroll(_ scrollView: UIScrollView) { self.updateScrolling(interactive: true, transition: .immediate) } - + public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { cancelContextGestures(view: scrollView) } - + + // WinterGram: adds a gift (`.generic` or `.unique`) to the local visualGifts list shown on the profile. + private func winterGramAddVisualGift(_ gift: StarGift) { + let accountManager = self.context.sharedContext.accountManager + // The settings transaction completes on a background queue; the UndoOverlay (a UIView/ASDisplayNode) + // MUST be created + presented on the main thread, so deliver the completion on the main queue. + let _ = (updateWinterGramSettingsInteractively(accountManager: accountManager, { settings in + var settings = settings + let visualId = "\(Int64.random(in: 0.. deliverOnMainQueue).start(completed: { [weak self] in + guard let self else { + return + } + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + self.parentController?.present(UndoOverlayController(presentationData: presentationData, content: .info(title: presentationData.strings.WinterGram_GiftAddedVisuallyToProfile, text: "", timeout: 1.5, customUndoText: nil), elevatedLayout: false, action: { _ in return false }), in: .window(.root)) + }) + } + + // WinterGram: prompt for an NFT link, resolve it, and add the unique gift visually. + private func winterGramPromptAddNFT() { + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + let prompt = promptController(context: self.context, text: presentationData.strings.WinterGram_AddVisualGift, subtitle: presentationData.strings.WinterGram_EnterNFTLinkEGHttpsTMeNftSlug, value: "", placeholder: "https://t.me/nft/...", apply: { [weak self] link in + guard let self, let link = link, !link.isEmpty else { + return + } + let slug: String + if link.hasPrefix("https://t.me/nft/") { + slug = String(link.dropFirst("https://t.me/nft/".count)) + } else if link.hasPrefix("t.me/nft/") { + slug = String(link.dropFirst("t.me/nft/".count)) + } else { + slug = link + } + let _ = (self.context.engine.payments.getUniqueStarGift(slug: slug) + |> deliverOnMainQueue).start(next: { [weak self] gift in + guard let self else { + return + } + self.winterGramAddVisualGift(.unique(gift)) + }, error: { [weak self] _ in + guard let self else { + return + } + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + self.parentController?.present(UndoOverlayController(presentationData: presentationData, content: .info(title: presentationData.strings.WinterGram_Error, text: presentationData.strings.WinterGram_InvalidNFTLink, timeout: 2.0, customUndoText: nil), elevatedLayout: false, action: { _ in return false }), in: .window(.root)) + }) + }) + self.parentController?.present(prompt, in: .window(.root)) + } + + // WinterGram: push the regular-gift catalog picker; the chosen gift is added visually. + private func winterGramPresentRegularGiftPicker() { + let pickerScreen = WinterGramGiftPickerScreen(context: self.context, completion: { [weak self] gift in + self?.winterGramAddVisualGift(gift) + }) + self.parentController?.push(pickerScreen) + } + private func displayUnpinScreen(gift: ProfileGiftsContext.State.StarGift, completion: (() -> Void)? = nil) { guard let pinnedGifts = self.profileGifts.currentState?.gifts.filter({ $0.pinnedToTop }), let presentationData = self.currentParams?.presentationData else { return @@ -376,25 +436,25 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr return } completion?() - + var replacingTitle = "" for gift in pinnedGifts { if gift.reference == unpinnedReference, case let .unique(uniqueGift) = gift.gift { replacingTitle = "\(uniqueGift.title) #\(formatCollectibleNumber(uniqueGift.number, dateTimeFormat: presentationData.dateTimeFormat))" } } - + var updatedPinnedGifts = self.giftsListView.pinnedReferences if let index = updatedPinnedGifts.firstIndex(of: unpinnedReference), let reference = gift.reference { updatedPinnedGifts[index] = reference } self.profileGifts.updatePinnedToTopStarGifts(references: updatedPinnedGifts) - + var title = "" if case let .unique(uniqueGift) = gift.gift { title = "\(uniqueGift.title) #\(formatCollectibleNumber(uniqueGift.number, dateTimeFormat: presentationData.dateTimeFormat))" } - + let _ = self.scrollToTop() Queue.mainQueue().after(0.35) { let toastTitle = presentationData.strings.PeerInfo_Gifts_ToastPinned_TitleNew(title).string @@ -405,7 +465,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr ) self.parentController?.push(controller) } - + func setCurrentCollection(collection: GiftCollection) { guard self.currentCollection != collection else { return @@ -422,7 +482,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } let previousGiftsListView = self.giftsListView - + let profileGifts: ProfileGiftsContext switch collection { case let .collection(id): @@ -470,37 +530,37 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } self.giftsListView.parentController = self.parentController self.giftsListView.frame = previousGiftsListView.frame - + self.scrollNode.view.insertSubview(self.giftsListView, aboveSubview: previousGiftsListView) - + let multiplier = animateRight ? 1.0 : -1.0 - + previousGiftsListView.layer.animatePosition(from: .zero, to: CGPoint(x: previousGiftsListView.frame.width * multiplier * -1.0, y: 0.0), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true, completion: { _ in previousGiftsListView.removeFromSuperview() }) self.giftsListView.layer.animatePosition(from: CGPoint(x: previousGiftsListView.frame.width * multiplier, y: 0.0), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) - + self.currentCollection = collection self.updateScrolling(transition: .spring(duration: 0.25)) - + if let params = self.currentParams { let visibleBounds = self.scrollNode.bounds.insetBy(dx: 0.0, dy: -10.0) let _ = self.giftsListView.update(size: params.size, sideInset: params.sideInset, bottomInset: params.bottomInset, deviceMetrics: params.deviceMetrics, visibleHeight: params.visibleHeight, isScrollingLockedAtTop: params.isScrollingLockedAtTop, expandProgress: params.expandProgress, presentationData: params.presentationData, synchronous: true, visibleBounds: visibleBounds, transition: .immediate) } } - + func openCollectionContextMenu(id: Int32, sourceNode: ASDisplayNode, gesture: ContextGesture?) { guard let params = self.currentParams, let sourceNode = sourceNode as? ContextExtractedContentContainingNode else { return } - + var canEditCollections = false if self.peerId == self.context.account.peerId || self.canManage { canEditCollections = true } - + var items: [ContextMenuItem] = [] - + if canEditCollections { items.append(.action(ContextMenuActionItem(text: params.presentationData.strings.PeerInfo_Gifts_AddGifts, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/Gifts/AddGift"), color: theme.actionSheet.primaryTextColor) @@ -509,11 +569,11 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr return } f(.default) - + self.setCurrentCollection(collection: .collection(id)) self.addGiftsToCollection(id: id) }))) - + items.append(.action(ContextMenuActionItem(text: params.presentationData.strings.PeerInfo_Gifts_RenameCollection, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.actionSheet.primaryTextColor) }, action: { [weak self] _, f in @@ -521,7 +581,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr return } f(.default) - + Queue.mainQueue().after(0.15) { self.renameCollection(id: id) } @@ -536,11 +596,11 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr return } f(.default) - + self.openShareLink?("https://t.me/\(addressName)/c/\(id)") }))) } - + if canEditCollections { items.append(.action(ContextMenuActionItem(text: params.presentationData.strings.PeerInfo_Gifts_Reorder, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReorderItems"), color: theme.actionSheet.primaryTextColor) @@ -552,7 +612,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr self.beginReordering() }) }))) - + items.append(.action(ContextMenuActionItem(text: params.presentationData.strings.PeerInfo_Gifts_DeleteCollection, textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { [weak self] _, f in @@ -560,13 +620,13 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr return } f(.default) - + Queue.mainQueue().after(0.15) { self.deleteCollection(id: id) } }))) } - + let contextController = makeContextController( presentationData: params.presentationData, source: .extracted(GiftsExtractedContentSource(sourceNode: sourceNode)), @@ -576,13 +636,13 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr ) self.parentController?.presentInGlobalOverlay(contextController) } - + func updateScrolling(interactive: Bool = false, transition: ComponentTransition) { if let params = self.currentParams { let visibleBounds = self.scrollNode.bounds.insetBy(dx: 0.0, dy: -10.0) - + var topInset: CGFloat = params.topInset - + var canEditCollections = false if self.peerId == self.context.account.peerId || self.canManage { canEditCollections = true @@ -591,7 +651,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr if let peer = self.peer, let addressName = peer.addressName, !addressName.isEmpty { canShare = true } - + let hasNonEmptyCollections = self.collections?.contains(where: { $0.count > 0 }) ?? false if let collections = self.collections, !collections.isEmpty && (hasNonEmptyCollections || canEditCollections) { var tabSelectorItems: [TabSelectorComponent.Item] = [] @@ -599,7 +659,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr id: AnyHashable(GiftCollection.all.rawValue), title: params.presentationData.strings.PeerInfo_Gifts_Collections_All )) - + var effectiveCollections: [StarGiftCollection] = collections if let reorderedCollectionIds = self.reorderedCollectionIds { var collectionMap: [Int32: StarGiftCollection] = [:] @@ -614,7 +674,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } effectiveCollections = reorderedCollections } - + for collection in effectiveCollections { if !canEditCollections && collection.count == 0 { continue @@ -638,7 +698,22 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } : nil )) } - + + if self.peerId == self.context.account.peerId { + tabSelectorItems.append(TabSelectorComponent.Item( + id: AnyHashable("add_visual_gift"), + content: .component(AnyComponent( + CollectionTabItemComponent( + context: self.context, + icon: .add, + title: params.presentationData.strings.WinterGram_VisualGift, + theme: params.presentationData.theme + ) + )), + isReorderable: false + )) + } + if canEditCollections { tabSelectorItems.append(TabSelectorComponent.Item( id: AnyHashable(GiftCollection.create.rawValue), @@ -653,7 +728,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr isReorderable: false )) } - + let tabSelectorSize = self.tabSelector.update( transition: transition, component: AnyComponent(TabSelectorComponent( @@ -687,19 +762,43 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr reorderedCollectionIds[sourceIndex] = targetId reorderedCollectionIds[targetIndex] = sourceId self.reorderedCollectionIds = reorderedCollectionIds - + self.updateScrolling(transition: .easeInOut(duration: 0.2)) } : nil, setSelectedId: { [weak self] id in - guard let self, let idValue = id.base as? Int32 else { + guard let self else { return } - - let giftCollection = GiftCollection(rawValue: idValue) - if case .create = giftCollection { - self.createCollection() - } else { - self.setCurrentCollection(collection: giftCollection) + if let idValue = id.base as? Int32 { + let giftCollection = GiftCollection(rawValue: idValue) + if case .create = giftCollection { + self.createCollection() + } else { + self.setCurrentCollection(collection: giftCollection) + } + } else if let idString = id.base as? String, idString == "add_visual_gift" { + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + // Choose what to add: a unique NFT (by link) or a regular gift (from the catalog). + let actionSheet = ActionSheetController(presentationData: presentationData) + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: [ + ActionSheetTextItem(title: presentationData.strings.WinterGram_AddVisualGift), + ActionSheetButtonItem(title: presentationData.strings.WinterGram_NFTGift, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + self?.winterGramPromptAddNFT() + }), + ActionSheetButtonItem(title: presentationData.strings.WinterGram_RegularGift, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + self?.winterGramPresentRegularGiftPicker() + }) + ]), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + self.parentController?.present(actionSheet, in: .window(.root)) } } )), @@ -710,13 +809,13 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr if tabSelectorView.superview == nil { tabSelectorView.alpha = 1.0 self.scrollNode.view.insertSubview(tabSelectorView, at: 0) - + if !transition.animation.isImmediate { tabSelectorView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } } transition.setFrame(view: tabSelectorView, frame: CGRect(origin: CGPoint(x: floor((params.size.width - tabSelectorSize.width) / 2.0), y: topInset), size: tabSelectorSize)) - + topInset += tabSelectorSize.height + 15.0 } } else if let tabSelectorView = self.tabSelector.view { @@ -725,25 +824,25 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr tabSelectorView.removeFromSuperview() }) } - + var contentHeight = self.giftsListView.updateScrolling(topInset: topInset, visibleBounds: visibleBounds, transition: transition) - + var bottomScrollInset: CGFloat = 0.0 let size = params.size let sideInset = params.sideInset let bottomInset = params.bottomInset let presentationData = params.presentationData - + self.theme = presentationData.theme - + let panelEdgeEffectView: EdgeEffectView let panelContentContainer: UIView - + var panelVisibility = params.expandProgress < 1.0 ? 0.0 : 1.0 if !self.canGift || self.resultsAreEmpty { panelVisibility = 0.0 } - + if let current = self.panelContentContainer { panelContentContainer = current } else { @@ -751,7 +850,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr self.view.addSubview(panelContentContainer) self.panelContentContainer = panelContentContainer } - + let panelTransition: ComponentTransition = .immediate if let current = self.panelEdgeEffectView { panelEdgeEffectView = current @@ -760,7 +859,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr panelContentContainer.addSubview(panelEdgeEffectView) self.panelEdgeEffectView = panelEdgeEffectView } - + let panelButton: ComponentView if let current = self.panelButton { panelButton = current @@ -768,9 +867,9 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr panelButton = ComponentView() self.panelButton = panelButton } - + let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: params.bottomInset, innerDiameter: 52.0 * 0.5, sideInset: sideInset + 30.0) - + let buttonTitle: String var buttonIconName: String? if self.peerId == self.context.account.peerId { @@ -783,7 +882,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } else { buttonTitle = params.presentationData.strings.PeerInfo_Gifts_SendGift } - + let buttonAttributedString = NSAttributedString(string: buttonTitle, font: Font.semibold(17.0), textColor: presentationData.theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center) var buttonTitleContent: AnyComponent = AnyComponent(MultilineTextComponent(text: .plain(buttonAttributedString))) if let buttonIconName { @@ -796,7 +895,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr AnyComponentWithIdentity(id: "_title", component: buttonTitleContent) ], spacing: 7.0)) } - + let panelButtonSize = panelButton.update( transition: transition, component: AnyComponent( @@ -820,24 +919,24 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr environment: {}, containerSize: CGSize(width: size.width - buttonInsets.left * 2.0, height: 52.0) ) - + var scrollOffset: CGFloat = max(0.0, size.height - params.visibleHeight) - + let effectiveBottomInset = max(buttonInsets.bottom, bottomInset) let bottomPanelHeight = effectiveBottomInset + panelButtonSize.height + 8.0 if params.visibleHeight < 110.0 { scrollOffset -= bottomPanelHeight } - + if let panelButtonView = panelButton.view { if panelButtonView.superview == nil { panelContentContainer.addSubview(panelButtonView) } panelButtonView.frame = CGRect(origin: CGPoint(x: buttonInsets.left, y: 8.0), size: panelButtonSize) } - + panelTransition.setFrame(view: panelContentContainer, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height - bottomPanelHeight), size: CGSize(width: size.width, height: bottomPanelHeight))) - + if self.canManage { let panelCheck: ComponentView if let current = self.panelCheck { @@ -854,7 +953,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr hasInset: false, hasShadow: false ) - + let panelCheckSize = panelCheck.update( transition: .immediate, component: AnyComponent( @@ -878,10 +977,10 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } let enabled = !(currentState.notificationsEnabled ?? false) self.profileGifts.toggleStarGiftsNotifications(enabled: enabled) - + let animation = enabled ? "anim_profileunmute" : "anim_profilemute" let text = enabled ? presentationData.strings.PeerInfo_Gifts_ChannelNotifyTooltip : presentationData.strings.PeerInfo_Gifts_ChannelNotifyDisabledTooltip - + let controller = UndoOverlayController( presentationData: presentationData, content: .universal(animation: animation, scale: 0.075, colors: ["__allcolors__": UIColor.white], title: nil, text: text, customUndoText: nil, timeout: nil), @@ -889,7 +988,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr action: { _ in return true } ) self.chatControllerInteraction.presentController(controller, nil) - + self.updateScrolling(transition: .immediate) }, animateAlpha: false, @@ -909,25 +1008,25 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr panelButtonView.isHidden = true } } - + let edgeEffectFrame = CGRect(x: 0.0, y: 0.0, width: size.width, height: bottomPanelHeight) panelTransition.setFrame(view: panelEdgeEffectView, frame: edgeEffectFrame) panelEdgeEffectView.update(content: presentationData.theme.list.blocksBackgroundColor, blur: false, rect: edgeEffectFrame, edge: .bottom, edgeSize: 40.0, transition: panelTransition) - + ComponentTransition.spring(duration: 0.4).setSublayerTransform(view: panelContentContainer, transform: CATransform3DMakeTranslation(0.0, bottomPanelHeight * (1.0 - panelVisibility), 0.0)) - + contentHeight += bottomPanelHeight bottomScrollInset = bottomPanelHeight - 40.0 contentHeight += params.bottomInset - + self.scrollNode.view.scrollIndicatorInsets = UIEdgeInsets(top: 50.0, left: 0.0, bottom: bottomScrollInset, right: 0.0) - + let contentSize = CGSize(width: params.size.width, height: contentHeight) if self.scrollNode.view.contentSize != contentSize { self.scrollNode.view.contentSize = contentSize } } - + let bottomContentOffset = max(0.0, self.scrollNode.view.contentSize.height - self.scrollNode.view.contentOffset.y - self.scrollNode.view.frame.height) if bottomContentOffset < 200.0 { Queue.mainQueue().justDispatch { @@ -935,7 +1034,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } } } - + @objc private func buttonPressed() { if self.peerId == self.context.account.peerId || self.canManage { if case let .collection(id) = self.currentCollection { @@ -956,14 +1055,14 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr self.chatControllerInteraction.sendGift(self.peerId) } } - + private func contextAction(gift: ProfileGiftsContext.State.StarGift, view: UIView, gesture: ContextGesture) { guard let currentParams = self.currentParams else { return } let presentationData = currentParams.presentationData let strings = presentationData.strings - + let canManage = self.peerId == self.context.account.peerId || self.canManage var canReorder = false if case .all = self.currentCollection, let currentState = self.profileGifts.currentState { @@ -978,7 +1077,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } else { canReorder = true } - + let profileGifts: ProfileGiftsContext switch self.currentCollection { case let .collection(id): @@ -986,35 +1085,75 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr default: profileGifts = self.profileGifts } - + + var isVisualGift = false + if let reference = gift.reference, case let .slug(slug) = reference, slug.hasPrefix("visual_") { + isVisualGift = true + } + var items: [ContextMenuItem] = [] + + if self.peerId == self.context.account.peerId { + if isVisualGift { + items.append(.action(ContextMenuActionItem(text: strings.Common_Delete, textColor: .destructive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { [weak self] c, f in + f(.default) + guard let self, let reference = gift.reference, case let .slug(slug) = reference else { + return + } + let visualId = String(slug.dropFirst("visual_".count)) + let accountManager = self.context.sharedContext.accountManager + let _ = updateWinterGramSettingsInteractively(accountManager: accountManager, { settings in + var settings = settings + settings.visualGifts.removeAll(where: { $0.id == visualId }) + return settings + }).start() + }))) + } else { + items.append(.action(ContextMenuActionItem(text: presentationData.strings.WinterGram_AddVisuallyToProfile, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Add"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in + f(.default) + guard let self else { + return + } + let accountManager = self.context.sharedContext.accountManager + let _ = updateWinterGramSettingsInteractively(accountManager: accountManager, { settings in + var settings = settings + let visualId = "\(Int64.random(in: 0..= self.giftsListView.maxPinnedCount { self.displayUnpinScreen(gift: gift) return } - + profileGifts.updateStarGiftPinnedToTop(reference: reference, pinnedToTop: pinnedToTop) - + let toastTitle: String? let toastText: String if !pinnedToTop { @@ -1130,14 +1269,14 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr }) }))) } - + var isReorderableGift = false if case .unique = gift.gift { isReorderableGift = true } else if case .collection = self.currentCollection { isReorderableGift = true } - + if isReorderableGift && canManage && canReorder { items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Context_Reorder, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReorderItems"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in c?.dismiss(completion: { [weak self] in @@ -1148,7 +1287,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr }) }))) } - + if case let .unique(uniqueGift) = gift.gift, self.peerId == self.context.account.peerId { items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Context_Wear, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Peer Info/WearIcon"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in c?.dismiss(completion: { [weak self] in @@ -1179,21 +1318,21 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr }))) } } - + if case let .unique(gift) = gift.gift { let link = "https://t.me/nft/\(gift.slug)" - + items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Context_CopyLink, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in c?.dismiss(completion: { [weak self] in guard let self else { return } UIPasteboard.general.string = link - + self.parentController?.present(UndoOverlayController(presentationData: currentParams.presentationData, content: .linkCopied(title: nil, text: currentParams.presentationData.strings.Conversation_LinkCopied), elevatedLayout: true, animateInAsReplacement: false, action: { _ in return false }), in: .current) }) }))) - + items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Context_Share, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in c?.dismiss(completion: { [weak self] in guard let self else { @@ -1275,7 +1414,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr }) }))) } - + if canManage { items.append(.action(ContextMenuActionItem(text: gift.savedToProfile ? strings.PeerInfo_Gifts_Context_Hide : strings.PeerInfo_Gifts_Context_Show, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: gift.savedToProfile ? "Peer Info/HideIcon" : "Peer Info/ShowIcon"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in c?.dismiss(completion: { [weak self] in @@ -1285,7 +1424,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr if let reference = gift.reference { let added = !gift.savedToProfile profileGifts.updateStarGiftAddedToProfile(reference: reference, added: added) - + var animationFile: TelegramMediaFile? switch gift.gift { case let .generic(gift): @@ -1298,14 +1437,14 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } } } - + let text: String if self.peerId.namespace == Namespaces.Peer.CloudChannel { text = added ? presentationData.strings.Gift_Displayed_ChannelText : presentationData.strings.Gift_Hidden_ChannelText } else { text = added ? presentationData.strings.Gift_Displayed_NewText : presentationData.strings.Gift_Hidden_NewText } - + if let animationFile { let resultController = UndoOverlayController( presentationData: presentationData, @@ -1320,7 +1459,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } }) }))) - + if case let .unique(uniqueGift) = gift.gift { items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Context_Transfer, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Peer Info/TransferIcon"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in c?.dismiss(completion: { [weak self] in @@ -1328,7 +1467,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr return } let context = self.context - + guard uniqueGift.hostPeerId == nil else { let presentationData = context.sharedContext.currentPresentationData.with { $0 } let alertController = textAlertController( @@ -1346,7 +1485,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr self.parentController?.present(alertController, in: .window(.root)) return } - + let _ = (context.account.stateManager.contactBirthdays |> take(1) |> deliverOnMainQueue).start(next: { [weak self] birthdays in @@ -1375,19 +1514,19 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr }))) } } - + if canManage, case let .collection(id) = self.currentCollection { items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Context_RemoveFromCollection, textColor: .destructive, textLayout: .twoLinesMax, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Peer Info/Gifts/RemoveFromCollection"), color: theme.contextMenu.destructiveColor) }, action: { [weak self] c, f in f(.default) - + guard let self else { return } - + if let reference = gift.reference { let _ = self.profileGiftsCollections.removeGifts(id: id, gifts: [reference]).start() } - + var giftFile: TelegramMediaFile? var giftTitle: String? switch gift.gift { @@ -1401,7 +1540,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } } } - + if let giftFile, let collection = self.collections?.first(where: { $0.id == id }) { let text: String if let giftTitle { @@ -1409,7 +1548,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } else { text = currentParams.presentationData.strings.PeerInfo_Gifts_RemovedFromCollection(collection.title).string } - + let undoController = UndoOverlayController( presentationData: currentParams.presentationData, content: .sticker(context: self.context, file: giftFile, loop: false, title: nil, text: text, undoText: nil, customAction: nil), @@ -1420,11 +1559,11 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } }))) } - + guard !items.isEmpty else { return } - + let previewController = GiftContextPreviewController(context: self.context, gift: gift) let contextController = makeContextController( context: self.context, @@ -1434,51 +1573,51 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr ) self.parentController?.presentInGlobalOverlay(contextController) } - + public func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) { self.currentParams = (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) self.presentationDataPromise.set(.single(presentationData)) - + self.backgroundNode.backgroundColor = presentationData.theme.list.blocksBackgroundColor transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset), size: CGSize(width: size.width, height: size.height - topInset))) transition.updateFrame(node: self.scrollNode, frame: CGRect(origin: CGPoint(), size: size)) - + let visibleBounds = self.scrollNode.bounds.insetBy(dx: 0.0, dy: -10.0) - + let contentHeight = self.giftsListView.update(size: size, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, presentationData: presentationData, synchronous: synchronous, visibleBounds: visibleBounds, transition: transition) transition.updateFrame(view: self.giftsListView, frame: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: max(size.height, contentHeight)))) - + if isScrollingLockedAtTop { self.scrollNode.view.contentOffset = .zero } self.scrollNode.view.isScrollEnabled = !isScrollingLockedAtTop - + self.updateScrolling(transition: ComponentTransition(transition)) } - + public func findLoadedMessage(id: EngineMessage.Id) -> EngineMessage? { return nil } - + public func updateHiddenMedia() { } - + public func transferVelocity(_ velocity: CGFloat) { if velocity > 0.0 { // self.scrollNode.transferVelocity(velocity) } } - + public func cancelPreviewGestures() { } - + public func transitionNodeForGallery(messageId: EngineMessage.Id, media: EngineMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { return nil } - + public func addToTransitionSurface(view: UIView) { } - + public func updateSelectedMessages(animated: Bool) { } } @@ -1499,19 +1638,19 @@ private func cancelContextGestures(view: UIView) { private final class ContextControllerContentSourceImpl: ContextControllerContentSource { let controller: ViewController weak var sourceView: UIView? - + let navigationController: NavigationController? = nil - + let passthroughTouches: Bool = false - + init(controller: ViewController, sourceView: UIView?) { self.controller = controller self.sourceView = sourceView } - + func transitionInfo() -> ContextControllerTakeControllerInfo? { let sourceView = self.sourceView - + return ContextControllerTakeControllerInfo(contentAreaInScreenSpace: CGRect(origin: CGPoint(), size: CGSize(width: 10.0, height: 10.0)), sourceNode: { [weak sourceView] in if let sourceView { return (sourceView, sourceView.bounds) @@ -1520,7 +1659,7 @@ private final class ContextControllerContentSourceImpl: ContextControllerContent } }) } - + func animatedIn() { self.controller.didAppearInContextPreview() } @@ -1530,17 +1669,17 @@ private final class GiftsExtractedContentSource: ContextExtractedContentSource { let keepInPlace: Bool = false let ignoreContentTouches: Bool = false let blurBackground: Bool = true - + private let sourceNode: ContextExtractedContentContainingNode - + init(sourceNode: ContextExtractedContentContainingNode) { self.sourceNode = sourceNode } - + func takeView() -> ContextControllerTakeViewInfo? { return ContextControllerTakeViewInfo(containingItem: .node(self.sourceNode), contentAreaInScreenSpace: UIScreen.main.bounds) } - + func putBack() -> ContextControllerPutBackViewInfo? { return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds) } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/WinterGramGiftPickerScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/WinterGramGiftPickerScreen.swift new file mode 100644 index 0000000000..f6b2c4f5f9 --- /dev/null +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/WinterGramGiftPickerScreen.swift @@ -0,0 +1,157 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import TelegramCore +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. +public final class WinterGramGiftPickerScreen: ViewController { + private final class Node: ViewControllerTracingNode { + private weak var controller: WinterGramGiftPickerScreen? + private let context: AccountContext + private var presentationData: PresentationData + + private let scrollNode: ASScrollNode + private var itemViews: [ComponentHostView] = [] + private var gifts: [StarGift] = [] + private var disposable: Disposable? + private var keepUpdatedDisposable: Disposable? + private var validLayout: ContainerViewLayout? + + init(controller: WinterGramGiftPickerScreen, context: AccountContext) { + self.controller = controller + self.context = context + self.presentationData = context.sharedContext.currentPresentationData.with { $0 } + self.scrollNode = ASScrollNode() + + super.init() + + self.backgroundColor = self.presentationData.theme.list.blocksBackgroundColor + 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. + self.keepUpdatedDisposable = context.engine.payments.keepStarGiftsUpdated().startStrict() + + self.disposable = (context.engine.payments.cachedStarGifts() + |> deliverOnMainQueue).start(next: { [weak self] gifts in + guard let self else { + return + } + self.gifts = (gifts ?? []).filter { gift in + if case .generic = gift { + return true + } + return false + } + if let layout = self.validLayout { + self.containerLayoutUpdated(layout: layout, transition: .immediate) + } + }) + } + + deinit { + self.disposable?.dispose() + self.keepUpdatedDisposable?.dispose() + } + + func containerLayoutUpdated(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + self.validLayout = layout + + let topInset = self.controller?.navigationLayout(layout: layout).navigationFrame.maxY ?? ((layout.statusBarHeight ?? 20.0) + 44.0) + transition.updateFrame(node: self.scrollNode, frame: CGRect(origin: .zero, size: layout.size)) + + let columns = 3 + let sideInset = layout.safeInsets.left + 12.0 + let spacing: CGFloat = 10.0 + let availableWidth = layout.size.width - sideInset * 2.0 + let itemWidth = floor((availableWidth - spacing * CGFloat(columns - 1)) / CGFloat(columns)) + let itemHeight = itemWidth + 34.0 + + while self.itemViews.count < self.gifts.count { + let view = ComponentHostView() + self.scrollNode.view.addSubview(view) + self.itemViews.append(view) + } + while self.itemViews.count > self.gifts.count { + self.itemViews.removeLast().removeFromSuperview() + } + + for (index, gift) in self.gifts.enumerated() { + guard case let .generic(genericGift) = gift else { + continue + } + let column = index % columns + let row = index / columns + let x = sideInset + CGFloat(column) * (itemWidth + spacing) + let y = topInset + 8.0 + CGFloat(row) * (itemHeight + spacing) + + let view = self.itemViews[index] + let _ = view.update( + transition: .immediate, + component: AnyComponent(GiftItemComponent( + context: self.context, + theme: self.presentationData.theme, + strings: self.presentationData.strings, + subject: .starGift(gift: genericGift, price: "\(genericGift.price)"), + isSoldOut: genericGift.soldOut != nil, + action: { [weak self] in + self?.controller?.selectGift(gift) + } + )), + environment: {}, + containerSize: CGSize(width: itemWidth, height: itemHeight) + ) + view.frame = CGRect(origin: CGPoint(x: x, y: y), size: CGSize(width: itemWidth, height: itemHeight)) + } + + let rows = self.gifts.isEmpty ? 0 : (self.gifts.count + columns - 1) / columns + let contentHeight = topInset + 8.0 + CGFloat(rows) * (itemHeight + spacing) + layout.intrinsicInsets.bottom + 16.0 + self.scrollNode.view.contentSize = CGSize(width: layout.size.width, height: max(layout.size.height, contentHeight)) + } + } + + private let context: AccountContext + private let completion: (StarGift) -> Void + + public init(context: AccountContext, completion: @escaping (StarGift) -> Void) { + self.context = context + self.completion = completion + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: presentationData)) + + self.title = presentationData.strings.WinterGram_AddVisualGift + self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed)) + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func cancelPressed() { + self.dismiss() + } + + fileprivate func selectGift(_ gift: StarGift) { + self.completion(gift) + self.dismiss() + } + + override public func loadDisplayNode() { + self.displayNode = Node(controller: self, context: self.context) + self.displayNodeDidLoad() + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + (self.displayNode as! Node).containerLayoutUpdated(layout: layout, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Settings/ThemeCarouselItem/Sources/ThemeCarouselItem.swift b/submodules/TelegramUI/Components/Settings/ThemeCarouselItem/Sources/ThemeCarouselItem.swift index ed537cfedb..ab75f108ed 100644 --- a/submodules/TelegramUI/Components/Settings/ThemeCarouselItem/Sources/ThemeCarouselItem.swift +++ b/submodules/TelegramUI/Components/Settings/ThemeCarouselItem/Sources/ThemeCarouselItem.swift @@ -32,11 +32,11 @@ private struct ThemeCarouselThemeEntry: Comparable, Identifiable { let theme: PresentationTheme let strings: PresentationStrings let wallpaper: TelegramWallpaper? - + var stableId: Int { return index } - + static func ==(lhs: ThemeCarouselThemeEntry, rhs: ThemeCarouselThemeEntry) -> Bool { if lhs.index != rhs.index { return false @@ -73,11 +73,11 @@ private struct ThemeCarouselThemeEntry: Comparable, Identifiable { } return true } - + static func <(lhs: ThemeCarouselThemeEntry, rhs: ThemeCarouselThemeEntry) -> Bool { return lhs.index < rhs.index } - + func item(context: AccountContext, action: @escaping (PresentationThemeReference?) -> Void, contextAction: ((PresentationThemeReference, ASDisplayNode, ContextGesture?) -> Void)?) -> ListViewItem { return ThemeCarouselThemeIconItem(context: context, emojiFile: self.emojiFile, themeReference: self.themeReference, nightMode: self.nightMode, channelMode: self.channelMode, themeSpecificAccentColors: self.themeSpecificAccentColors, themeSpecificChatWallpapers: self.themeSpecificChatWallpapers, selected: self.selected, theme: self.theme, strings: self.strings, wallpaper: self.wallpaper, action: action, contextAction: contextAction) } @@ -98,7 +98,7 @@ public class ThemeCarouselThemeIconItem: ListViewItem { public let wallpaper: TelegramWallpaper? public let action: (PresentationThemeReference?) -> Void public let contextAction: ((PresentationThemeReference, ASDisplayNode, ContextGesture?) -> Void)? - + public init(context: AccountContext, emojiFile: TelegramMediaFile?, themeReference: PresentationThemeReference?, nightMode: Bool, channelMode: Bool, themeSpecificAccentColors: [Int64: PresentationThemeAccentColor], themeSpecificChatWallpapers: [Int64: TelegramWallpaper], selected: Bool, theme: PresentationTheme, strings: PresentationStrings, wallpaper: TelegramWallpaper?, action: @escaping (PresentationThemeReference?) -> Void, contextAction: ((PresentationThemeReference, ASDisplayNode, ContextGesture?) -> Void)?) { self.context = context self.emojiFile = emojiFile @@ -114,15 +114,14 @@ public class ThemeCarouselThemeIconItem: ListViewItem { self.action = action self.contextAction = contextAction } - + public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { async { - let node = ThemeCarouselThemeItemIconNode() - let (nodeLayout, apply) = node.asyncLayout()(self, params) - node.insets = nodeLayout.insets - node.contentSize = nodeLayout.contentSize - Queue.mainQueue().async { + let node = ThemeCarouselThemeItemIconNode() + let (nodeLayout, apply) = node.asyncLayout()(self, params) + node.insets = nodeLayout.insets + node.contentSize = nodeLayout.contentSize completion(node, { return (nil, { _ in apply(false) @@ -131,7 +130,7 @@ public class ThemeCarouselThemeIconItem: ListViewItem { } } } - + public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { Queue.mainQueue().async { assert(node() is ThemeCarouselThemeItemIconNode) @@ -148,7 +147,7 @@ public class ThemeCarouselThemeIconItem: ListViewItem { } } } - + public var selectable = true public func selected(listView: ListView) { self.action(self.themeReference) @@ -173,9 +172,9 @@ private func generateBorderImage(theme: PresentationTheme, bordered: Bool, selec lineWidth = 2.0 context.setLineWidth(lineWidth) context.setStrokeColor(theme.list.itemBlocksBackgroundColor.cgColor) - + context.strokeEllipse(in: bounds.insetBy(dx: 3.0 + lineWidth / 2.0, dy: 3.0 + lineWidth / 2.0)) - + var accentColor = theme.list.itemAccentColor if accentColor.rgb == 0xffffff { accentColor = UIColor(rgb: 0x999999) @@ -208,17 +207,17 @@ private final class ThemeCarouselThemeItemIconNode: ListViewItemNode { private var animatedStickerNode: AnimatedStickerNode? private var placeholderNode: StickerShimmerEffectNode var snapshotView: UIView? - + private let activateAreaNode: AccessibilityAreaNode - + var item: ThemeCarouselThemeIconItem? - + override var visibility: ListViewItemNodeVisibility { didSet { self.visibilityStatus = self.visibility != .none } } - + private var visibilityStatus: Bool = false { didSet { if self.visibilityStatus != oldValue { @@ -226,7 +225,7 @@ private final class ThemeCarouselThemeItemIconNode: ListViewItemNode { } } } - + private let stickerFetchedDisposable = MetaDisposable() init() { @@ -239,7 +238,7 @@ private final class ThemeCarouselThemeItemIconNode: ListViewItemNode { self.imageNode.cornerRadius = 14.0 self.imageNode.clipsToBounds = true self.imageNode.contentAnimations = [.subsequentUpdates] - + self.overlayNode = ASImageNode() self.overlayNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 84.0, height: 110.0)) self.overlayNode.isLayerBacked = true @@ -247,29 +246,29 @@ private final class ThemeCarouselThemeItemIconNode: ListViewItemNode { self.textNode = TextNode() self.textNode.isUserInteractionEnabled = false self.textNode.displaysAsynchronously = false - + self.emojiNode = TextNode() self.emojiNode.isUserInteractionEnabled = false self.emojiNode.displaysAsynchronously = false - + self.emojiImageNode = TransformImageNode() - + self.placeholderNode = StickerShimmerEffectNode() - + self.activateAreaNode = AccessibilityAreaNode() super.init(layerBacked: false, rotated: false, seeThrough: false) - + self.addSubnode(self.containerNode) self.containerNode.addSubnode(self.imageNode) self.containerNode.addSubnode(self.overlayNode) self.containerNode.addSubnode(self.textNode) - + self.addSubnode(self.emojiContainerNode) self.emojiContainerNode.addSubnode(self.emojiNode) self.emojiContainerNode.addSubnode(self.emojiImageNode) self.emojiContainerNode.addSubnode(self.placeholderNode) - + var firstTime = true self.emojiImageNode.imageUpdated = { [weak self] image in guard let strongSelf = self else { @@ -283,22 +282,22 @@ private final class ThemeCarouselThemeItemIconNode: ListViewItemNode { } firstTime = false } - + self.addSubnode(self.activateAreaNode) } deinit { self.stickerFetchedDisposable.dispose() } - + override func didLoad() { super.didLoad() - + if #available(iOS 13.0, *) { self.imageNode.layer.cornerCurve = .continuous } } - + private func removePlaceholder(animated: Bool) { if !animated { self.placeholderNode.removeFromSupernode() @@ -309,26 +308,26 @@ private final class ThemeCarouselThemeItemIconNode: ListViewItemNode { }) } } - + override func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { let emojiFrame = CGRect(origin: CGPoint(x: 33.0, y: 79.0), size: CGSize(width: 24.0, height: 24.0)) self.placeholderNode.updateAbsoluteRect(CGRect(origin: CGPoint(x: rect.minX + emojiFrame.minX, y: rect.minY + emojiFrame.minY), size: emojiFrame.size), within: containerSize) } - + override func selected() { let wasSelected = self.item?.selected ?? false super.selected() - + if let animatedStickerNode = self.animatedStickerNode { Queue.mainQueue().after(0.1) { if !wasSelected { animatedStickerNode.seekTo(.frameIndex(0)) animatedStickerNode.play(firstFrame: false, fromIndex: nil) - + let scale: CGFloat = 2.6 animatedStickerNode.transform = CATransform3DMakeScale(scale, scale, 1.0) animatedStickerNode.layer.animateSpring(from: 1.0 as NSNumber, to: scale as NSNumber, keyPath: "transform.scale", duration: 0.45) - + animatedStickerNode.completed = { [weak animatedStickerNode, weak self] _ in guard let item = self?.item, item.selected else { return @@ -339,14 +338,14 @@ private final class ThemeCarouselThemeItemIconNode: ListViewItemNode { } } } - + } - + func asyncLayout() -> (ThemeCarouselThemeIconItem, ListViewItemLayoutParams) -> (ListViewItemNodeLayout, (Bool) -> Void) { let makeTextLayout = TextNode.asyncLayout(self.textNode) let makeEmojiLayout = TextNode.asyncLayout(self.emojiNode) let makeImageLayout = self.imageNode.asyncLayout() - + let currentItem = self.item return { [weak self] item, params in @@ -356,7 +355,7 @@ private final class ThemeCarouselThemeItemIconNode: ListViewItemNode { var updatedChannelMode = false var updatedWallpaper = false var updatedSelected = false - + if currentItem?.themeReference != item.themeReference { updatedThemeReference = true } @@ -375,7 +374,7 @@ private final class ThemeCarouselThemeItemIconNode: ListViewItemNode { if currentItem?.selected != item.selected { updatedSelected = true } - + let text = NSAttributedString(string: item.strings.Wallpaper_NoWallpaper, font: Font.regular(13.0), textColor: item.theme.list.itemSecondaryTextColor) let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: text, backgroundColor: nil, maximumNumberOfLines: 2, truncationType: .end, constrainedSize: CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) @@ -385,72 +384,72 @@ private final class ThemeCarouselThemeItemIconNode: ListViewItemNode { self?.imageNode.backgroundColor = item.theme.list.mediaPlaceholderColor } else if let _ = item.themeReference?.emoticon { } else { - string = item.channelMode ? "" : "🎨" + string = item.channelMode ? "" : "🎨" } - + let emojiTitle = NSAttributedString(string: string ?? "", font: Font.regular(20.0), textColor: .black) let (_, emojiApply) = makeEmojiLayout(TextNodeLayoutArguments(attributedString: emojiTitle, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) - + let itemLayout = ListViewItemNodeLayout(contentSize: CGSize(width: 120.0, height: 90.0), insets: UIEdgeInsets()) return (itemLayout, { animated in if let strongSelf = self { strongSelf.item = item - + if updatedThemeReference || updatedWallpaper || updatedNightMode || updatedChannelMode { if var themeReference = item.themeReference { if case .builtin = themeReference, item.nightMode { themeReference = .builtin(.night) } - + let color = item.themeSpecificAccentColors[themeReference.index] let wallpaper = item.themeSpecificChatWallpapers[themeReference.index] - + strongSelf.imageNode.setSignal(themeIconImage(account: item.context.account, accountManager: item.context.sharedContext.accountManager, theme: themeReference, color: color, wallpaper: wallpaper ?? item.wallpaper, nightMode: item.nightMode, channelMode: item.channelMode, emoticon: true)) strongSelf.imageNode.backgroundColor = nil } else { - + } } - + if updatedTheme || updatedSelected { strongSelf.overlayNode.image = generateBorderImage(theme: item.theme, bordered: false, selected: item.selected) } - + if !item.selected && currentItem?.selected == true, let animatedStickerNode = strongSelf.animatedStickerNode { animatedStickerNode.transform = CATransform3DIdentity - + let initialScale: CGFloat = CGFloat((animatedStickerNode.value(forKeyPath: "layer.presentationLayer.transform.scale.x") as? NSNumber)?.floatValue ?? 1.0) animatedStickerNode.layer.animateSpring(from: initialScale as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.45) } - + strongSelf.containerNode.transform = CATransform3DMakeRotation(CGFloat.pi / 2.0, 0.0, 0.0, 1.0) strongSelf.containerNode.frame = CGRect(origin: CGPoint(x: 15.0, y: -15.0), size: CGSize(width: 90.0, height: 120.0)) - + strongSelf.emojiContainerNode.transform = CATransform3DMakeRotation(CGFloat.pi / 2.0, 0.0, 0.0, 1.0) strongSelf.emojiContainerNode.frame = CGRect(origin: CGPoint(x: 15.0, y: -15.0), size: CGSize(width: 90.0, height: 120.0)) - + let _ = emojiApply() let imageSize = CGSize(width: 82.0, height: 108.0) strongSelf.imageNode.frame = CGRect(origin: CGPoint(x: 4.0, y: 6.0), size: imageSize) let applyLayout = makeImageLayout(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets(), emptyColor: .clear)) applyLayout() - + strongSelf.overlayNode.frame = strongSelf.imageNode.frame.insetBy(dx: -1.0, dy: -1.0) strongSelf.emojiNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 78.0), size: CGSize(width: 90.0, height: 30.0)) strongSelf.emojiNode.isHidden = string == nil - + let _ = textApply() strongSelf.textNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((90.0 - textLayout.size.width) / 2.0), y: 24.0), size: textLayout.size) strongSelf.textNode.isHidden = item.themeReference != nil - + let emojiFrame = CGRect(origin: CGPoint(x: 33.0, y: 79.0), size: CGSize(width: 24.0, height: 24.0)) if let file = item.emojiFile, currentItem?.emojiFile == nil { let imageApply = strongSelf.emojiImageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: emojiFrame.size, boundingSize: emojiFrame.size, intrinsicInsets: UIEdgeInsets())) imageApply() strongSelf.emojiImageNode.setSignal(chatMessageStickerPackThumbnail(postbox: item.context.account.postbox, resource: file.resource, animated: true, nilIfEmpty: true)) strongSelf.emojiImageNode.frame = emojiFrame - + let animatedStickerNode: AnimatedStickerNode if let current = strongSelf.animatedStickerNode { animatedStickerNode = current @@ -463,24 +462,24 @@ private final class ThemeCarouselThemeItemIconNode: ListViewItemNode { strongSelf.emojiContainerNode.insertSubnode(animatedStickerNode, belowSubnode: strongSelf.placeholderNode) let pathPrefix = item.context.engine.resources.shortLivedResourceCachePathPrefix(id: EngineMediaResource.Id(file.resource.id)) animatedStickerNode.setup(source: AnimatedStickerResourceSource(account: item.context.account, resource: file.resource), width: 128, height: 128, playbackMode: .still(.start), mode: .direct(cachePathPrefix: pathPrefix)) - + animatedStickerNode.anchorPoint = CGPoint(x: 0.5, y: 1.0) } animatedStickerNode.autoplay = true animatedStickerNode.visibility = strongSelf.visibilityStatus - + strongSelf.stickerFetchedDisposable.set(item.context.engine.resources.fetch(reference: MediaResourceReference.media(media: .standalone(media: file), resource: file.resource), userLocation: .other, userContentType: .sticker).start()) - + let thumbnailDimensions = PixelDimensions(width: 512, height: 512) strongSelf.placeholderNode.update(backgroundColor: nil, foregroundColor: UIColor(rgb: 0xffffff, alpha: 0.2), shimmeringColor: UIColor(rgb: 0xffffff, alpha: 0.3), data: file.immediateThumbnailData, size: emojiFrame.size, enableEffect: item.context.sharedContext.energyUsageSettings.fullTranslucency, imageSize: thumbnailDimensions.cgSize) strongSelf.placeholderNode.frame = emojiFrame } - + if let animatedStickerNode = strongSelf.animatedStickerNode { animatedStickerNode.frame = emojiFrame animatedStickerNode.updateLayout(size: emojiFrame.size) } - + let presentationData = item.context.sharedContext.currentPresentationData.with { $0 } strongSelf.activateAreaNode.accessibilityLabel = item.themeReference?.emoticon.flatMap { presentationData.strings.Appearance_VoiceOver_Theme($0).string } if item.selected { @@ -488,18 +487,18 @@ private final class ThemeCarouselThemeItemIconNode: ListViewItemNode { } else { strongSelf.activateAreaNode.accessibilityTraits = [.button] } - + strongSelf.activateAreaNode.frame = CGRect(origin: .zero, size: itemLayout.size) } }) } } - + func prepareCrossfadeTransition() { guard self.snapshotView == nil else { return } - + if let snapshotView = self.containerNode.view.snapshotView(afterScreenUpdates: false) { snapshotView.transform = self.containerNode.view.transform snapshotView.frame = self.containerNode.view.frame @@ -507,33 +506,33 @@ private final class ThemeCarouselThemeItemIconNode: ListViewItemNode { self.snapshotView = snapshotView } } - + func animateCrossfadeTransition() { guard self.snapshotView?.layer.animationKeys()?.isEmpty ?? true else { return } - + self.snapshotView?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak self] _ in self?.snapshotView?.removeFromSuperview() self?.snapshotView = nil }) } - + override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { super.animateInsertion(currentTimestamp, duration: duration, options: options) - + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } - + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { super.animateRemoved(currentTimestamp, duration: duration) - + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) } - + override func animateAdded(_ currentTimestamp: Double, duration: Double) { super.animateAdded(currentTimestamp, duration: duration) - + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } @@ -578,13 +577,13 @@ public class ThemeCarouselThemeItem: ListViewItem, ItemListItem, ListItemCompone public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { async { - let node = ThemeCarouselThemeItemNode() - let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) - - node.contentSize = layout.contentSize - node.insets = layout.insets - Queue.mainQueue().async { + let node = ThemeCarouselThemeItemNode() + let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + + node.contentSize = layout.contentSize + node.insets = layout.insets + completion(node, { return (nil, { _ in apply() }) }) @@ -608,11 +607,11 @@ public class ThemeCarouselThemeItem: ListViewItem, ItemListItem, ListItemCompone } } } - + public func item() -> ListViewItem { return self } - + public static func ==(lhs: ThemeCarouselThemeItem, rhs: ThemeCarouselThemeItem) -> Bool { if lhs.context !== rhs.context { return false @@ -644,7 +643,7 @@ public class ThemeCarouselThemeItem: ListViewItem, ItemListItem, ListItemCompone if lhs.currentTheme != rhs.currentTheme { return false } - + return true } } @@ -660,11 +659,11 @@ private struct ThemeCarouselThemeItemNodeTransition { private func preparedTransition(context: AccountContext, action: @escaping (PresentationThemeReference?) -> Void, contextAction: ((PresentationThemeReference, ASDisplayNode, ContextGesture?) -> Void)?, from fromEntries: [ThemeCarouselThemeEntry], to toEntries: [ThemeCarouselThemeEntry], crossfade: Bool, updatePosition: Bool) -> ThemeCarouselThemeItemNodeTransition { let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) - + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, action: action, contextAction: contextAction), directionHint: .Down) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, action: action, contextAction: contextAction), directionHint: nil) } - + return ThemeCarouselThemeItemNodeTransition(deletions: deletions, insertions: insertions, updates: updates, crossfade: crossfade, entries: toEntries, updatePosition: false) } @@ -692,7 +691,7 @@ public class ThemeCarouselThemeItemNode: ListViewItemNode, ItemListItemNode { private let bottomStripeNode: ASDisplayNode private let maskNode: ASImageNode private var snapshotView: UIView? - + private let listNode: ListView private var entries: [ThemeCarouselThemeEntry]? private var enqueuedTransitions: [ThemeCarouselThemeItemNodeTransition] = [] @@ -702,14 +701,14 @@ public class ThemeCarouselThemeItemNode: ListViewItemNode, ItemListItemNode { private var layoutParams: ListViewItemLayoutParams? private var tapping = false - + public var tag: ItemListItemTag? { return self.item?.tag } public init() { self.containerNode = ASDisplayNode() - + self.backgroundNode = ASDisplayNode() self.backgroundNode.isLayerBacked = true @@ -734,29 +733,29 @@ public class ThemeCarouselThemeItemNode: ListViewItemNode, ItemListItemNode { super.didLoad() self.listNode.view.disablesInteractiveTransitionGestureRecognizer = true } - + private func enqueueTransition(_ transition: ThemeCarouselThemeItemNodeTransition) { self.enqueuedTransitions.append(transition) - + if let _ = self.item { while !self.enqueuedTransitions.isEmpty { self.dequeueTransition() } } } - + private func dequeueTransition() { guard let item = self.item, let transition = self.enqueuedTransitions.first else { return } self.enqueuedTransitions.remove(at: 0) - + var options = ListViewDeleteAndInsertOptions() if transition.crossfade { options.insert(.AnimateCrossfade) } options.insert(.Synchronous) - + var scrollToItem: ListViewScrollToItem? if !self.initialized || !self.tapping { if let index = transition.entries.firstIndex(where: { entry in @@ -766,7 +765,7 @@ public class ThemeCarouselThemeItemNode: ListViewItemNode, ItemListItemNode { self.initialized = true } } - + self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, scrollToItem: scrollToItem, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { _ in }) } @@ -791,7 +790,7 @@ public class ThemeCarouselThemeItemNode: ListViewItemNode, ItemListItemNode { strongSelf.backgroundNode.backgroundColor = item.theme.list.itemBlocksBackgroundColor strongSelf.topStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor strongSelf.bottomStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor - + if strongSelf.backgroundNode.supernode == nil { strongSelf.containerNode.insertSubnode(strongSelf.backgroundNode, at: 0) } @@ -804,7 +803,7 @@ public class ThemeCarouselThemeItemNode: ListViewItemNode, ItemListItemNode { if strongSelf.maskNode.supernode == nil { strongSelf.containerNode.insertSubnode(strongSelf.maskNode, at: 3) } - + if params.isStandalone { strongSelf.topStripeNode.isHidden = true strongSelf.bottomStripeNode.isHidden = true @@ -834,11 +833,11 @@ public class ThemeCarouselThemeItemNode: ListViewItemNode, ItemListItemNode { hasBottomCorners = true strongSelf.bottomStripeNode.isHidden = hasCorners } - + strongSelf.bottomStripeNode.isHidden = true - + strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.theme, top: hasTopCorners, bottom: hasBottomCorners) : nil - + strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)) strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) } @@ -851,14 +850,14 @@ public class ThemeCarouselThemeItemNode: ListViewItemNode, ItemListItemNode { var listInsets = UIEdgeInsets() listInsets.top += params.leftInset + 12.0 listInsets.bottom += params.rightInset + 12.0 - + strongSelf.listNode.bounds = CGRect(x: 0.0, y: 0.0, width: contentSize.height, height: contentSize.width) strongSelf.listNode.position = CGPoint(x: contentSize.width / 2.0, y: contentSize.height / 2.0 - 2.0) strongSelf.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: CGSize(width: contentSize.height, height: contentSize.width), insets: listInsets, duration: 0.0, curve: .Default(duration: nil)), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) - + var entries: [ThemeCarouselThemeEntry] = [] var index: Int = 0 - + var hasCurrentTheme = false if item.hasNoTheme { let selected = item.currentTheme == nil @@ -877,11 +876,11 @@ public class ThemeCarouselThemeItemNode: ListViewItemNode, ItemListItemNode { entries.append(ThemeCarouselThemeEntry(index: index, emojiFile: emojiFile?._parse(), themeReference: theme, nightMode: item.nightMode, channelMode: item.channelMode, themeSpecificAccentColors: item.themeSpecificAccentColors, themeSpecificChatWallpapers: item.themeSpecificChatWallpapers, selected: selected, theme: item.theme, strings: item.strings, wallpaper: nil)) index += 1 } - + if !hasCurrentTheme { entries.insert(ThemeCarouselThemeEntry(index: index, emojiFile: nil, themeReference: item.currentTheme, nightMode: false, channelMode: item.channelMode, themeSpecificAccentColors: item.themeSpecificAccentColors, themeSpecificChatWallpapers: item.themeSpecificChatWallpapers, selected: true, theme: item.theme, strings: item.strings, wallpaper: item.hasNoTheme ? item.selectedWallpaper : nil), at: item.hasNoTheme ? 1 : entries.count) } - + let action: (PresentationThemeReference?) -> Void = { [weak self] themeReference in if let strongSelf = self { strongSelf.tapping = true @@ -898,13 +897,13 @@ public class ThemeCarouselThemeItemNode: ListViewItemNode, ItemListItemNode { let crossfade = (previousEntries.count > 0 && previousEntries.count != entries.count) || (previousEntries.count > 0 && previousEntries.count < 3 && entries.count > 3) let transition = preparedTransition(context: item.context, action: action, contextAction: item.contextAction, from: previousEntries, to: entries, crossfade: crossfade, updatePosition: false) strongSelf.enqueueTransition(transition) - + strongSelf.entries = entries } }) } } - + override public func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) } @@ -912,17 +911,17 @@ public class ThemeCarouselThemeItemNode: ListViewItemNode, ItemListItemNode { override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) } - + public func prepareCrossfadeTransition() { guard self.snapshotView == nil else { return } - + if let snapshotView = self.containerNode.view.snapshotView(afterScreenUpdates: false) { self.view.insertSubview(snapshotView, aboveSubview: self.containerNode.view) self.snapshotView = snapshotView } - + self.listNode.enumerateItemNodes { node in if let node = node as? ThemeCarouselThemeItemIconNode { node.prepareCrossfadeTransition() @@ -930,7 +929,7 @@ public class ThemeCarouselThemeItemNode: ListViewItemNode, ItemListItemNode { return true } } - + public func animateCrossfadeTransition() { guard self.snapshotView?.layer.animationKeys()?.isEmpty ?? true else { return diff --git a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/PieChartComponent.swift b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/PieChartComponent.swift index 8392185a9a..20630e9d93 100644 --- a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/PieChartComponent.swift +++ b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/PieChartComponent.swift @@ -14,9 +14,9 @@ import EmojiStatusComponent private func processChartData(data: PieChartComponent.ChartData) -> PieChartComponent.ChartData { var data = data - + let minValue: Double = 0.01 - + var totalSum: CGFloat = 0.0 for i in 0 ..< data.items.count { if data.items[i].value > 0.00001 { @@ -24,7 +24,7 @@ private func processChartData(data: PieChartComponent.ChartData) -> PieChartComp } totalSum += data.items[i].value } - + var hasOneItem = false for i in 0 ..< data.items.count { if data.items[i].value != 0 && totalSum == data.items[i].value { @@ -33,11 +33,11 @@ private func processChartData(data: PieChartComponent.ChartData) -> PieChartComp break } } - + if !hasOneItem { if abs(totalSum - 1.0) > 0.0001 { let deltaValue = totalSum - 1.0 - + var availableSum: Double = 0.0 for i in 0 ..< data.items.count { let itemValue = data.items[i].value @@ -58,14 +58,14 @@ private func processChartData(data: PieChartComponent.ChartData) -> PieChartComp totalSum += data.items[i].value } } - + if totalSum > 0.0 && totalSum < 1.0 - 0.0001 { for i in 0 ..< data.items.count { data.items[i].value /= totalSum } } } - + return data } @@ -76,7 +76,7 @@ private final class ChartSelectionTooltip: Component { let fractionText: String let title: String let sizeText: String - + init( theme: PresentationTheme, fractionText: String, @@ -88,41 +88,41 @@ private final class ChartSelectionTooltip: Component { self.title = title self.sizeText = sizeText } - + static func ==(lhs: ChartSelectionTooltip, rhs: ChartSelectionTooltip) -> Bool { return true } - + final class View: UIView { private let backgroundView: BlurredBackgroundView private let title = ComponentView() - + override init(frame: CGRect) { self.backgroundView = BlurredBackgroundView(color: .clear, enableBlur: true) - + self.backgroundView.layer.shadowOpacity = 0.12 self.backgroundView.layer.shadowColor = UIColor(white: 0.0, alpha: 1.0).cgColor self.backgroundView.layer.shadowOffset = CGSize(width: 0.0, height: 2.0) self.backgroundView.layer.shadowRadius = 8.0 - + super.init(frame: frame) - + self.addSubview(self.backgroundView) } - + required init(coder: NSCoder) { preconditionFailure() } - + func update(component: ChartSelectionTooltip, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let sideInset: CGFloat = 10.0 let height: CGFloat = 24.0 - + let text = NSMutableAttributedString() text.append(NSAttributedString(string: component.fractionText + " ", font: Font.semibold(12.0), textColor: component.theme.list.itemPrimaryTextColor)) text.append(NSAttributedString(string: component.title + " ", font: Font.regular(12.0), textColor: component.theme.list.itemPrimaryTextColor)) text.append(NSAttributedString(string: component.sizeText, font: Font.semibold(12.0), textColor: component.theme.list.itemAccentColor)) - + let titleSize = self.title.update( transition: transition, component: AnyComponent(MultilineTextComponent( @@ -138,23 +138,23 @@ private final class ChartSelectionTooltip: Component { } transition.setFrame(view: titleView, frame: titleFrame) } - + let size = CGSize(width: sideInset * 2.0 + titleSize.width, height: height) - + transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: size)) self.backgroundView.updateColor(color: component.theme.list.plainBackgroundColor.withMultipliedAlpha(0.88), transition: .immediate) self.backgroundView.update(size: size, cornerRadius: 10.0, transition: transition.containedViewLayoutTransition) - + self.backgroundView.layer.shadowPath = UIBezierPath(roundedRect: self.backgroundView.bounds, cornerRadius: 10.0).cgPath - + return size } } - + func makeView() -> View { return View(frame: CGRect()) } - + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } @@ -163,35 +163,35 @@ private final class ChartSelectionTooltip: Component { private final class ChartLabel: UIView { private let label: ImmediateTextView private var currentText: String? - + override init(frame: CGRect) { self.label = ImmediateTextView() - + super.init(frame: frame) - + self.addSubview(self.label) } - + required init(coder: NSCoder) { preconditionFailure() } - + func update(text: String) -> CGSize { if self.currentText == text { return self.label.bounds.size } - + var snapshotView: UIView? if self.currentText != nil { snapshotView = self.label.snapshotView(afterScreenUpdates: false) snapshotView?.frame = self.label.frame } - + self.currentText = text self.label.attributedText = NSAttributedString(string: text, font: chartLabelFont, textColor: .white) let size = self.label.updateLayout(CGSize(width: 100.0, height: 100.0)) self.label.frame = CGRect(origin: CGPoint(x: floor(-size.width * 0.5), y: floor(-size.height * 0.5)), size: size) - + if let snapshotView { self.addSubview(snapshotView) snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak snapshotView] _ in @@ -201,14 +201,14 @@ private final class ChartLabel: UIView { self.label.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.label.layer.animateScale(from: 0.001, to: 1.0, duration: 0.2) } - + return size } } -final class PieChartComponent: Component { - struct ChartData: Equatable { - struct Item: Equatable { +public final class PieChartComponent: Component { + public struct ChartData: Equatable { + public struct Item: Equatable { var id: AnyHashable var displayValue: Double var displaySize: Int64 @@ -218,8 +218,8 @@ final class PieChartComponent: Component { var title: String var mergeable: Bool var mergeFactor: CGFloat - - init(id: AnyHashable, displayValue: Double, displaySize: Int64, value: Double, color: UIColor, particle: String?, title: String, mergeable: Bool, mergeFactor: CGFloat) { + + public init(id: AnyHashable, displayValue: Double, displaySize: Int64, value: Double, color: UIColor, particle: String?, title: String, mergeable: Bool, mergeFactor: CGFloat) { self.id = id self.displayValue = displayValue self.displaySize = displaySize @@ -231,20 +231,20 @@ final class PieChartComponent: Component { self.mergeFactor = mergeFactor } } - + var items: [Item] - - init(items: [Item]) { + + public init(items: [Item]) { self.items = items } } - + let theme: PresentationTheme let strings: PresentationStrings let emptyColor: UIColor let chartData: ChartData - - init( + + public init( theme: PresentationTheme, strings: PresentationStrings, emptyColor: UIColor, @@ -255,8 +255,8 @@ final class PieChartComponent: Component { self.emptyColor = emptyColor self.chartData = chartData } - - static func ==(lhs: PieChartComponent, rhs: PieChartComponent) -> Bool { + + public static func ==(lhs: PieChartComponent, rhs: PieChartComponent) -> Bool { if lhs.theme !== rhs.theme { return false } @@ -271,14 +271,14 @@ final class PieChartComponent: Component { } return true } - + private struct CalculatedLabel { var image: UIImage var alpha: CGFloat var angle: CGFloat var radius: CGFloat var scale: CGFloat - + init( image: UIImage, alpha: CGFloat, @@ -292,7 +292,7 @@ final class PieChartComponent: Component { self.radius = radius self.scale = scale } - + func interpolateTo(_ other: CalculatedLabel, amount: CGFloat) -> CalculatedLabel { return CalculatedLabel( image: other.image, @@ -303,7 +303,7 @@ final class PieChartComponent: Component { ) } } - + private struct CalculatedSection { var id: AnyHashable var color: UIColor @@ -314,7 +314,7 @@ final class PieChartComponent: Component { var innerRadius: CGFloat var outerRadius: CGFloat var label: CalculatedLabel? - + init( id: AnyHashable, color: UIColor, @@ -337,44 +337,44 @@ final class PieChartComponent: Component { self.label = label } } - + private struct ItemAngleData { var angleValue: CGFloat var startAngle: CGFloat var endAngle: CGFloat } - + private struct CalculatedLayout { var size: CGSize var sections: [CalculatedSection] var isEmpty: Bool - + init(size: CGSize, sections: [CalculatedSection]) { self.size = size self.sections = sections self.isEmpty = sections.isEmpty } - + init(interpolating start: CalculatedLayout, to end: CalculatedLayout, progress: CGFloat, size: CGSize) { self.size = size self.sections = [] self.isEmpty = end.isEmpty - + for i in 0 ..< end.sections.count { let right = end.sections[i] - + if i < start.sections.count { let left = start.sections[i] let innerAngle: Range = left.innerAngle.lowerBound.interpolate(to: right.innerAngle.lowerBound, amount: progress) ..< left.innerAngle.upperBound.interpolate(to: right.innerAngle.upperBound, amount: progress) let outerAngle: Range = left.outerAngle.lowerBound.interpolate(to: right.outerAngle.lowerBound, amount: progress) ..< left.outerAngle.upperBound.interpolate(to: right.outerAngle.upperBound, amount: progress) - + var label: CalculatedLabel? if let leftLabel = left.label, let rightLabel = right.label { label = leftLabel.interpolateTo(rightLabel, amount: progress) } else { label = right.label } - + self.sections.append(CalculatedSection( id: right.id, color: left.color.interpolateTo(right.color, fraction: progress) ?? right.color, @@ -391,36 +391,36 @@ final class PieChartComponent: Component { } } } - + init(size: CGSize, items: [ChartData.Item], selectedKey: AnyHashable?, isEmpty: Bool, emptyColor: UIColor) { self.size = size self.sections = [] self.isEmpty = isEmpty - + if items.isEmpty { return } - + let innerDiameter: CGFloat = isEmpty ? 90.0 : 100.0 let spacing: CGFloat = isEmpty ? -0.5 : 2.0 let innerAngleSpacing: CGFloat = spacing / (innerDiameter * 0.5) - + var angles: [Double] = [] for i in 0 ..< items.count { let item = items[i] let angle = item.value * CGFloat.pi * 2.0 angles.append(angle) } - + let diameter: CGFloat = isEmpty ? (innerDiameter + 6.0 * 2.0) : 200.0 let reducedDiameter: CGFloat = floor(0.85 * diameter) - + var anglesData: [ItemAngleData] = [] - + var startAngle: CGFloat = 0.0 for i in 0 ..< items.count { let item = items[i] - + let itemOuterDiameter: CGFloat if let selectedKey { if selectedKey == AnyHashable(item.id) { @@ -431,14 +431,14 @@ final class PieChartComponent: Component { } else { itemOuterDiameter = diameter } - + let angleSpacing: CGFloat = spacing / (itemOuterDiameter * 0.5) - + let angleValue: CGFloat = angles[i] - + let beforeSpacingFraction: CGFloat = 1.0 let afterSpacingFraction: CGFloat = 1.0 - + let itemInnerAngleSpacing: CGFloat let itemAngleSpacing: CGFloat if abs(angleValue - CGFloat.pi * 2.0) <= 0.0001 { @@ -448,24 +448,24 @@ final class PieChartComponent: Component { itemInnerAngleSpacing = innerAngleSpacing itemAngleSpacing = angleSpacing } - + let innerStartAngle = startAngle + itemInnerAngleSpacing * 0.5 let arcInnerStartAngle = startAngle + itemInnerAngleSpacing * 0.5 * beforeSpacingFraction - + var innerEndAngle = startAngle + angleValue - itemInnerAngleSpacing * 0.5 innerEndAngle = max(innerEndAngle, innerStartAngle) var arcInnerEndAngle = startAngle + angleValue - itemInnerAngleSpacing * 0.5 * afterSpacingFraction arcInnerEndAngle = max(arcInnerEndAngle, arcInnerStartAngle) - + let outerStartAngle = startAngle + itemAngleSpacing * 0.5 let arcOuterStartAngle = startAngle + itemAngleSpacing * 0.5 * beforeSpacingFraction var outerEndAngle = startAngle + angleValue - itemAngleSpacing * 0.5 outerEndAngle = max(outerEndAngle, outerStartAngle) var arcOuterEndAngle = startAngle + angleValue - itemAngleSpacing * 0.5 * afterSpacingFraction arcOuterEndAngle = max(arcOuterEndAngle, arcOuterStartAngle) - + let itemColor: UIColor = isEmpty ? emptyColor : item.color - + self.sections.append(CalculatedSection( id: item.id, color: itemColor, @@ -477,15 +477,15 @@ final class PieChartComponent: Component { outerRadius: itemOuterDiameter * 0.5, label: nil )) - + startAngle += angleValue - + anglesData.append(ItemAngleData(angleValue: angleValue, startAngle: innerStartAngle, endAngle: innerEndAngle)) } - + for i in 0 ..< items.count { let item = items[i] - + var isDimmedBySelection = false if let selectedKey { if selectedKey == AnyHashable(item.id) { @@ -493,7 +493,7 @@ final class PieChartComponent: Component { isDimmedBySelection = true } } - + self.updateLabel( index: i, displayValue: item.displayValue, @@ -506,7 +506,7 @@ final class PieChartComponent: Component { ) } } - + private mutating func updateLabel( index: Int, displayValue: Double, @@ -518,7 +518,7 @@ final class PieChartComponent: Component { isDimmedBySelection: Bool ) { let normalAlpha: CGFloat = isDimmedBySelection ? 0.0 : 1.0 - + let fractionValue: Double = floor(displayValue * 100.0 * 10.0) / 10.0 let fractionString: String if displayValue == 0.0 { @@ -530,7 +530,7 @@ final class PieChartComponent: Component { } else { fractionString = "\(fractionValue)%" } - + let labelString = NSAttributedString(string: fractionString, font: chartLabelFont, textColor: .white) let labelBounds = labelString.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: [.usesLineFragmentOrigin], context: nil) let labelSize = CGSize(width: ceil(labelBounds.width), height: ceil(labelBounds.height)) @@ -542,30 +542,30 @@ final class PieChartComponent: Component { }) else { return } - + var resultLabel: CalculatedLabel? - + if innerAngle.upperBound - innerAngle.lowerBound >= 0.001 { for step in 0 ... 10 { let stepFraction: CGFloat = CGFloat(step) / 10.0 let centerOffset: CGFloat = 0.5 * (1.0 - stepFraction) + 0.65 * stepFraction - + let midAngle: CGFloat = (innerAngle.lowerBound + innerAngle.upperBound) * 0.5 let centerDistance: CGFloat = (innerRadius + (outerRadius - innerRadius) * centerOffset) - + let relLabelCenter = CGPoint( x: cos(midAngle) * centerDistance, y: sin(midAngle) * centerDistance ) - + func lineCircleIntersection(_ center: CGPoint, _ p1: CGPoint, _ p2: CGPoint, _ r: CGFloat) -> CGFloat { let dx: CGFloat = p2.x - p1.x let dy: CGFloat = p2.y - p1.y let dr: CGFloat = sqrt(dx * dx + dy * dy) let D: CGFloat = p1.x * p2.y - p2.x * p1.y - + var minDistance: CGFloat = 10000.0 - + for i in 0 ..< 2 { let signFactor: CGFloat = i == 0 ? 1.0 : (-1.0) let dysign: CGFloat = dy < 0.0 ? -1.0 : 1.0 @@ -574,10 +574,10 @@ final class PieChartComponent: Component { let distance: CGFloat = sqrt(pow(ix - center.x, 2.0) + pow(iy - center.y, 2.0)) minDistance = min(minDistance, distance) } - + return minDistance } - + func lineLineIntersection(_ p1: CGPoint, _ p2: CGPoint, _ p3: CGPoint, _ p4: CGPoint) -> CGFloat { let x1 = p1.x let y1 = p1.y @@ -587,33 +587,33 @@ final class PieChartComponent: Component { let y3 = p3.y let x4 = p4.x let y4 = p4.y - + let d: CGFloat = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4) if abs(d) <= 0.00001 { return 10000.0 } - + let px: CGFloat = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / d let py: CGFloat = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / d - + let distance: CGFloat = sqrt(pow(px - p1.x, 2.0) + pow(py - p1.y, 2.0)) return distance } - + let intersectionOuterTopRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), outerRadius) let intersectionInnerTopRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), innerRadius) let intersectionOuterBottomRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), outerRadius) let intersectionInnerBottomRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), innerRadius) - + let horizontalInset: CGFloat = 2.0 let intersectionOuterLeft = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y), outerRadius) - horizontalInset let intersectionInnerLeft = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y), innerRadius) - horizontalInset - + let intersectionLine1TopRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerAngle.lowerBound), y: sin(innerAngle.lowerBound))) let intersectionLine1BottomRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerAngle.lowerBound), y: sin(innerAngle.lowerBound))) let intersectionLine2TopRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerAngle.upperBound), y: sin(innerAngle.upperBound))) let intersectionLine2BottomRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerAngle.upperBound), y: sin(innerAngle.upperBound))) - + var distances: [CGFloat] = [ intersectionOuterTopRight, intersectionInnerTopRight, @@ -622,7 +622,7 @@ final class PieChartComponent: Component { intersectionOuterLeft, intersectionInnerLeft ] - + if innerAngle.upperBound - innerAngle.lowerBound < CGFloat.pi / 2.0 { distances.append(contentsOf: [ intersectionLine1TopRight, @@ -631,22 +631,22 @@ final class PieChartComponent: Component { intersectionLine2BottomRight ] as [CGFloat]) } - + var minDistance: CGFloat = 1000.0 for distance in distances { minDistance = min(minDistance, max(distance, 1.0)) } - + let diagonalAngle = atan2(labelSize.height, labelSize.width) - + let maxHalfWidth = cos(diagonalAngle) * minDistance let maxHalfHeight = sin(diagonalAngle) * minDistance - + let maxSize = CGSize(width: maxHalfWidth * 2.0, height: maxHalfHeight * 2.0) let finalSize = CGSize(width: min(labelSize.width, maxSize.width), height: min(labelSize.height, maxSize.height)) - + let currentScale = finalSize.width / labelSize.width - + if currentScale >= 1.0 - 0.001 { resultLabel = CalculatedLabel( image: labelImage, @@ -673,7 +673,7 @@ final class PieChartComponent: Component { } else { let midAngle: CGFloat = (innerAngle.lowerBound + innerAngle.upperBound) * 0.5 let centerDistance: CGFloat = (innerRadius + (outerRadius - innerRadius) * 0.5) - + resultLabel = CalculatedLabel( image: labelImage, alpha: 0.0, @@ -682,13 +682,13 @@ final class PieChartComponent: Component { scale: 0.001 ) } - + if let resultLabel { self.sections[index].label = resultLabel } } } - + private struct Particle { var trackIndex: Int var position: CGPoint @@ -696,7 +696,7 @@ final class PieChartComponent: Component { var alpha: CGFloat var direction: CGPoint var velocity: CGFloat - + init( trackIndex: Int, position: CGPoint, @@ -712,7 +712,7 @@ final class PieChartComponent: Component { self.direction = direction self.velocity = velocity } - + mutating func update(deltaTime: CGFloat) { var position = self.position position.x += self.direction.x * self.velocity * deltaTime @@ -720,22 +720,22 @@ final class PieChartComponent: Component { self.position = position } } - + private final class ParticleSet { private let innerRadius: CGFloat private let maxRadius: CGFloat private(set) var particles: [Particle] = [] - + init(innerRadius: CGFloat, maxRadius: CGFloat, preAdvance: Bool) { self.innerRadius = innerRadius self.maxRadius = maxRadius - + self.generateParticles(preAdvance: preAdvance) } - + private func generateParticles(preAdvance: Bool) { let maxDirections = 24 - + if self.particles.count < maxDirections { var allTrackIndices: [Int] = Array(repeating: 0, count: maxDirections) for i in 0 ..< maxDirections { @@ -753,19 +753,19 @@ final class PieChartComponent: Component { availableTrackIndices.append(index) } } - + if !availableTrackIndices.isEmpty { availableTrackIndices.shuffle() - + for takeIndex in availableTrackIndices { let directionIndex = takeIndex let angle = (CGFloat(directionIndex % maxDirections) / CGFloat(maxDirections)) * CGFloat.pi * 2.0 - + let direction = CGPoint(x: cos(angle), y: sin(angle)) let velocity = CGFloat.random(in: 20.0 ..< 40.0) let alpha = CGFloat.random(in: 0.1 ..< 0.4) let scale = CGFloat.random(in: 0.5 ... 1.0) * 0.22 - + var position = CGPoint(x: 100.0, y: 100.0) var initialOffset: CGFloat = 0.4 if preAdvance { @@ -773,7 +773,7 @@ final class PieChartComponent: Component { } position.x += direction.x * initialOffset * 105.0 position.y += direction.y * initialOffset * 105.0 - + let particle = Particle( trackIndex: directionIndex, position: position, @@ -787,83 +787,83 @@ final class PieChartComponent: Component { } } } - + func update(deltaTime: CGFloat) { let size = CGSize(width: 200.0, height: 200.0) let radius = size.width * 0.5 + 10.0 for i in (0 ..< self.particles.count).reversed() { self.particles[i].update(deltaTime: deltaTime) let position = self.particles[i].position - + let distance = sqrt(pow(position.x - size.width * 0.5, 2.0) + pow(position.y - size.height * 0.5, 2.0)) if distance > radius { self.particles.remove(at: i) } } - + self.generateParticles(preAdvance: false) } } - + private final class SectionLayer: SimpleLayer { private let maskLayer: SimpleShapeLayer private let gradientLayer: SimpleGradientLayer private let labelLayer: SimpleLayer - + private var currentLabelImage: UIImage? - + private var particleImage: UIImage? private var particleLayers: [SimpleLayer] = [] - + init(particle: String?) { self.maskLayer = SimpleShapeLayer() self.maskLayer.fillColor = UIColor.white.cgColor - + self.gradientLayer = SimpleGradientLayer() self.gradientLayer.type = .radial self.gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5) self.gradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0) - + self.labelLayer = SimpleLayer() - + super.init() - + self.mask = self.maskLayer self.addSublayer(self.gradientLayer) self.addSublayer(self.labelLayer) - + if let particle { self.particleImage = UIImage(bundleImageName: particle)?.precomposed() } } - + override init(layer: Any) { self.maskLayer = SimpleShapeLayer() self.gradientLayer = SimpleGradientLayer() self.labelLayer = SimpleLayer() - + super.init(layer: layer) } - + required init(coder: NSCoder) { preconditionFailure() } - + func isPointOnGraph(point: CGPoint) -> Bool { if let path = self.maskLayer.path { return path.contains(point) } return false } - + func tooltipLocation() -> CGPoint { return self.labelLayer.position } - + func update(size: CGSize, section: CalculatedSection) { self.maskLayer.frame = CGRect(origin: CGPoint(), size: size) self.gradientLayer.frame = CGRect(origin: CGPoint(), size: size) - + let normalColor = section.color.cgColor let darkerColor = section.color.withMultipliedBrightnessBy(0.96).cgColor let colors: [CGColor] = [ @@ -874,7 +874,7 @@ final class PieChartComponent: Component { darkerColor ] self.gradientLayer.colors = colors - + let locations: [CGFloat] = [ 0.0, 0.3, @@ -886,18 +886,18 @@ final class PieChartComponent: Component { let location = location * 0.5 + 0.5 return location as NSNumber } - + let path = CGMutablePath() path.addArc(center: CGPoint(x: size.width * 0.5, y: size.height * 0.5), radius: section.innerRadius, startAngle: section.innerAngle.upperBound, endAngle: section.innerAngle.lowerBound, clockwise: true) path.addArc(center: CGPoint(x: size.width * 0.5, y: size.height * 0.5), radius: section.outerRadius, startAngle: section.outerAngle.lowerBound, endAngle: section.outerAngle.upperBound, clockwise: false) self.maskLayer.path = path - + if let label = section.label { if self.currentLabelImage !== label.image { self.currentLabelImage = label.image self.labelLayer.contents = label.image.cgImage } - + let position = CGPoint(x: size.width * 0.5 + cos(label.angle) * label.radius, y: size.height * 0.5 + sin(label.angle) * label.radius) let labelSize = CGSize(width: label.image.size.width * label.scale, height: label.image.size.height * label.scale) let labelFrame = CGRect(origin: CGPoint(x: position.x - labelSize.width * 0.5, y: position.y - labelSize.height * 0.5), size: labelSize) @@ -908,14 +908,14 @@ final class PieChartComponent: Component { self.labelLayer.contents = nil } } - + func updateParticles(particleSet: ParticleSet, alpha: CGFloat) { guard let particleImage = self.particleImage else { return } for i in 0 ..< particleSet.particles.count { let particle = particleSet.particles[i] - + let particleLayer: SimpleLayer if i < self.particleLayers.count { particleLayer = self.particleLayers[i] @@ -927,7 +927,7 @@ final class PieChartComponent: Component { self.particleLayers.append(particleLayer) self.insertSublayer(particleLayer, above: self.gradientLayer) } - + particleLayer.position = particle.position particleLayer.transform = CATransform3DMakeScale(particle.scale, particle.scale, 1.0) particleLayer.opacity = Float(particle.alpha * alpha) @@ -939,61 +939,61 @@ final class PieChartComponent: Component { } } } - + private final class DoneLayer: SimpleLayer { private let particleColor: UIColor private let maskShapeLayer: CAShapeLayer private var particleImage: UIImage? private var particleSet: ParticleSet? private var particleLayers: [SimpleLayer] = [] - + init(particleColor: UIColor) { self.particleColor = particleColor - + self.maskShapeLayer = CAShapeLayer() self.maskShapeLayer.fillColor = UIColor.black.cgColor self.maskShapeLayer.fillRule = .evenOdd - + super.init() - + self.particleImage = UIImage(bundleImageName: "Settings/Storage/ParticleStar")?.precomposed() - + let path = CGMutablePath() - + path.addRect(CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 200.0, height: 200.0))) path.addEllipse(in: CGRect(origin: CGPoint(x: floor((200.0 - 102.0) * 0.5), y: floor((200.0 - 102.0) * 0.5)), size: CGSize(width: 102.0, height: 102.0))) - + self.maskShapeLayer.path = path self.mask = self.maskShapeLayer - + self.particleSet = ParticleSet(innerRadius: 45.0, maxRadius: 100.0, preAdvance: true) } - + override init(layer: Any) { self.particleColor = .white self.maskShapeLayer = CAShapeLayer() - + super.init(layer: layer) } - + required init(coder: NSCoder) { preconditionFailure() } - + func updateParticles(deltaTime: CGFloat) { guard let particleSet = self.particleSet else { return } particleSet.update(deltaTime: deltaTime) - + let size = CGSize(width: 200.0, height: 200.0) - + guard let particleImage = self.particleImage else { return } for i in 0 ..< particleSet.particles.count { let particle = particleSet.particles[i] - + let particleLayer: SimpleLayer if i < self.particleLayers.count { particleLayer = self.particleLayers[i] @@ -1004,13 +1004,13 @@ final class PieChartComponent: Component { particleLayer.bounds = CGRect(origin: CGPoint(), size: particleImage.size) self.particleLayers.append(particleLayer) self.addSublayer(particleLayer) - + particleLayer.layerTintColor = self.particleColor.cgColor } - + particleLayer.position = particle.position particleLayer.transform = CATransform3DMakeScale(particle.scale * 1.2, particle.scale * 1.2, 1.0) - + let distance = sqrt(pow(particle.position.x - size.width * 0.5, 2.0) + pow(particle.position.y - size.height * 0.5, 2.0)) var mulAlpha: CGFloat = 1.0 let outerDistanceNorm: CGFloat = 20.0 @@ -1019,7 +1019,7 @@ final class PieChartComponent: Component { let alphaFactor: CGFloat = max(0.0, min(1.0, outerDistanceFactor)) mulAlpha = alphaFactor } - + particleLayer.opacity = Float(particle.alpha * mulAlpha) } if particleSet.particles.count < self.particleLayers.count { @@ -1029,44 +1029,44 @@ final class PieChartComponent: Component { } } } - + private final class ChartDataView: UIView { private(set) var theme: PresentationTheme? private(set) var data: ChartData? private var emptyColor: UIColor? private(set) var selectedKey: AnyHashable? - + private var currentAnimation: (start: CalculatedLayout, startTime: Double, duration: Double)? private var currentLayout: CalculatedLayout? private var animator: DisplayLinkAnimator? - + private var displayLink: SharedDisplayLinkDriver.Link? - + private var sectionLayers: [AnyHashable: SectionLayer] = [:] private let particleSet: ParticleSet private var doneLayer: DoneLayer? - + override init(frame: CGRect) { self.particleSet = ParticleSet(innerRadius: 50.0, maxRadius: 100.0, preAdvance: true) - + super.init(frame: frame) - + self.backgroundColor = nil self.isOpaque = false - + self.displayLink = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max, { [weak self] delta in self?.update(deltaTime: CGFloat(delta)) }) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + deinit { self.animator?.invalidate() } - + func sectionKey(at point: CGPoint) -> AnyHashable? { for (id, itemLayer) in self.sectionLayers { if itemLayer.isPointOnGraph(point: point) { @@ -1075,7 +1075,7 @@ final class PieChartComponent: Component { } return nil } - + func tooltipLocation(forKey key: AnyHashable) -> CGPoint? { for (id, itemLayer) in self.sectionLayers { if id == key { @@ -1084,17 +1084,17 @@ final class PieChartComponent: Component { } return nil } - + func setItems(theme: PresentationTheme, emptyColor: UIColor, data: ChartData, selectedKey: AnyHashable?, animated: Bool) { self.emptyColor = emptyColor - + let data = processChartData(data: data) - + if self.theme !== theme || self.data != data || self.selectedKey != selectedKey { self.theme = theme self.selectedKey = selectedKey let previousData = self.data - + if animated, let previous = self.currentLayout { var initialState = previous if let currentAnimation = self.currentAnimation { @@ -1102,7 +1102,7 @@ final class PieChartComponent: Component { let mappedProgress = listViewAnimationCurveSystem(CGFloat(currentProgress)) initialState = CalculatedLayout(interpolating: currentAnimation.start, to: previous, progress: mappedProgress, size: previous.size) } - + let targetLayout: CalculatedLayout if let previousData = previousData, data.items.isEmpty { targetLayout = CalculatedLayout( @@ -1121,7 +1121,7 @@ final class PieChartComponent: Component { emptyColor: emptyColor ) } - + self.currentLayout = targetLayout self.currentAnimation = (initialState, CACurrentMediaTime(), 0.4) } else { @@ -1143,16 +1143,16 @@ final class PieChartComponent: Component { ) } } - + self.data = data - + self.update(deltaTime: 0.0) } } - + private func update(deltaTime: CGFloat) { self.particleSet.update(deltaTime: deltaTime) - + var validIds: [AnyHashable] = [] if let currentLayout = self.currentLayout, let emptyColor = self.emptyColor { var effectiveLayout = currentLayout @@ -1164,9 +1164,9 @@ final class PieChartComponent: Component { if let currentAnimation = self.currentAnimation { let currentProgress: Double = max(0.0, min(1.0, (CACurrentMediaTime() - currentAnimation.startTime) / currentAnimation.duration)) let mappedProgress = listViewAnimationCurveSystem(CGFloat(currentProgress)) - + effectiveLayout = CalculatedLayout(interpolating: currentAnimation.start, to: currentLayout, progress: mappedProgress, size: currentLayout.size) - + let fromVerticalOffset: CGFloat let fromRotationAngle: CGFloat if currentAnimation.start.isEmpty { @@ -1185,14 +1185,14 @@ final class PieChartComponent: Component { toVerticalOffset = 0.0 toRotationAngle = 0.0 } - + verticalOffset = (1.0 - mappedProgress) * fromVerticalOffset + mappedProgress * toVerticalOffset rotationAngle = (1.0 - mappedProgress) * fromRotationAngle + mappedProgress * toRotationAngle - + if currentLayout.isEmpty { particleAlpha = 1.0 - mappedProgress } - + if currentProgress >= 1.0 - CGFloat.ulpOfOne { self.currentAnimation = nil } @@ -1203,7 +1203,7 @@ final class PieChartComponent: Component { rotationAngle = emptyRotationAngle } } - + if currentLayout.isEmpty { let doneLayer: DoneLayer if let current = self.doneLayer { @@ -1222,10 +1222,10 @@ final class PieChartComponent: Component { doneLayer.removeFromSuperlayer() } } - + for section in effectiveLayout.sections { validIds.append(section.id) - + let sectionLayer: SectionLayer if let current = self.sectionLayers[section.id] { sectionLayer = current @@ -1234,7 +1234,7 @@ final class PieChartComponent: Component { self.sectionLayers[section.id] = sectionLayer self.layer.addSublayer(sectionLayer) } - + let sectionLayerFrame = CGRect(origin: CGPoint(x: 0.0, y: verticalOffset), size: CGSize(width: 200.0, height: 200.0)) sectionLayer.position = sectionLayerFrame.center sectionLayer.bounds = CGRect(origin: CGPoint(), size: sectionLayerFrame.size) @@ -1243,7 +1243,7 @@ final class PieChartComponent: Component { sectionLayer.updateParticles(particleSet: self.particleSet, alpha: particleAlpha) } } - + var removeIds: [AnyHashable] = [] for (id, sectionLayer) in self.sectionLayers { if !validIds.contains(id) { @@ -1256,30 +1256,30 @@ final class PieChartComponent: Component { } } } - - class View: UIView { + + public final class View: UIView { private let dataView: ChartDataView private var tooltip: (key: AnyHashable, value: ComponentView)? - + var selectedKey: AnyHashable? - + private var component: PieChartComponent? private weak var state: EmptyComponentState? - + override init(frame: CGRect) { self.dataView = ChartDataView() - + super.init(frame: frame) - + self.addSubview(self.dataView) - + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { let point = recognizer.location(in: self.dataView) @@ -1295,20 +1295,20 @@ final class PieChartComponent: Component { self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.3, curve: .spring))) } } - + func update(component: PieChartComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let dataUpdated = self.component?.chartData != component.chartData - + self.state = state self.component = component - + if dataUpdated { self.selectedKey = nil } - + transition.setFrame(view: self.dataView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - 200.0) / 2.0), y: 0.0), size: CGSize(width: 200.0, height: 200.0))) self.dataView.setItems(theme: component.theme, emptyColor: component.emptyColor, data: component.chartData, selectedKey: self.selectedKey, animated: !transition.animation.isImmediate) - + if let selectedKey = self.selectedKey, let item = component.chartData.items.first(where: { $0.id == selectedKey }) { let tooltip: ComponentView var tooltipTransition = transition @@ -1331,7 +1331,7 @@ final class PieChartComponent: Component { tooltip = ComponentView() self.tooltip = (selectedKey, tooltip) } - + let fractionValue: Double = floor(item.displayValue * 100.0 * 10.0) / 10.0 let fractionString: String if fractionValue < 0.1 { @@ -1341,7 +1341,7 @@ final class PieChartComponent: Component { } else { fractionString = "\(fractionValue)%" } - + let tooltipSize = tooltip.update( transition: tooltipTransition, component: AnyComponent(ChartSelectionTooltip( @@ -1353,11 +1353,11 @@ final class PieChartComponent: Component { environment: {}, containerSize: availableSize ) - + if let relativeTooltipLocation = self.dataView.tooltipLocation(forKey: selectedKey) { let tooltipLocation = relativeTooltipLocation.offsetBy(dx: self.dataView.frame.minX, dy: self.dataView.frame.minY) let tooltipFrame = CGRect(origin: CGPoint(x: floor(tooltipLocation.x - tooltipSize.width / 2.0), y: tooltipLocation.y - 16.0 - tooltipSize.height), size: tooltipSize) - + if let tooltipView = tooltip.view { if tooltipView.superview == nil { self.addSubview(tooltipView) @@ -1380,16 +1380,16 @@ final class PieChartComponent: Component { } } } - + return CGSize(width: availableSize.width, height: 200.0) } } - - func makeView() -> View { + + public func makeView() -> View { return View(frame: CGRect()) } - - func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } diff --git a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift index 82bb8586e2..5beea754ea 100644 --- a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift +++ b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift @@ -19,6 +19,7 @@ import ContextUI import EmojiTextAttachmentView import TextFormat import PhotoResources +import TelegramUIPreferences import ListSectionComponent import ListItemSwipeOptionContainer @@ -32,12 +33,12 @@ private func generateDisclosureImage() -> UIImage? { return generateImage(CGSize(width: 7.0, height: 12.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setStrokeColor(UIColor.white.cgColor) - + let lineWidth: CGFloat = 2.0 context.setLineWidth(lineWidth) context.setLineJoin(.round) context.setLineCap(.round) - + context.move(to: CGPoint(x: lineWidth * 0.5, y: lineWidth * 0.5)) context.addLine(to: CGPoint(x: size.width - lineWidth * 0.5, y: size.height * 0.5)) context.addLine(to: CGPoint(x: lineWidth * 0.5, y: size.height - lineWidth * 0.5)) @@ -49,69 +50,69 @@ private let disclosureImage: UIImage? = generateDisclosureImage() public final class PeerListItemComponent: Component { public final class TransitionHint { public let synchronousLoad: Bool - + public init(synchronousLoad: Bool) { self.synchronousLoad = synchronousLoad } } - + public enum Style { case generic case compact } - + public enum SelectionState: Equatable { case none case editing(isSelected: Bool, isTinted: Bool) } - + public enum SelectionPosition: Equatable { case left case right } - + public enum SubtitleAccessory: Equatable { case none case checks case repost case forward } - + public enum RightAccessory: Equatable { case none case disclosure case check } - + public struct Avatar: Equatable { public var icon: String public var color: AvatarBackgroundColor public var clipStyle: AvatarNodeClipStyle - + public init(icon: String, color: AvatarBackgroundColor, clipStyle: AvatarNodeClipStyle) { self.icon = icon self.color = color self.clipStyle = clipStyle } } - + public final class InlineAction: Equatable { public enum Color: Equatable { case destructive } - + public let id: AnyHashable public let title: String public let color: Color public let action: () -> Void - + public init(id: AnyHashable, title: String, color: Color, action: @escaping () -> Void) { self.id = id self.title = title self.color = color self.action = action } - + public static func ==(lhs: InlineAction, rhs: InlineAction) -> Bool { if lhs === rhs { return true @@ -128,14 +129,14 @@ public final class PeerListItemComponent: Component { return true } } - + public final class InlineActionsState: Equatable { public let actions: [InlineAction] - + public init(actions: [InlineAction]) { self.actions = actions } - + public static func ==(lhs: InlineActionsState, rhs: InlineActionsState) -> Bool { if lhs === rhs { return true @@ -146,12 +147,12 @@ public final class PeerListItemComponent: Component { return true } } - + public final class Reaction: Equatable { public let reaction: MessageReaction.Reaction public let file: TelegramMediaFile? public let animationFileId: Int64? - + public init( reaction: MessageReaction.Reaction, file: TelegramMediaFile?, @@ -161,7 +162,7 @@ public final class PeerListItemComponent: Component { self.file = file self.animationFileId = animationFileId } - + public static func ==(lhs: Reaction, rhs: Reaction) -> Bool { if lhs === rhs { return true @@ -175,36 +176,36 @@ public final class PeerListItemComponent: Component { if lhs.animationFileId != rhs.animationFileId { return false } - + return true } } - + public struct Subtitle: Equatable { public enum Color: Equatable { case neutral case accent case constructive } - + public var text: String public var color: Color - + public init(text: String, color: Color) { self.text = text self.color = color } } - + public final class ExtractedTheme: Equatable { public let inset: CGFloat public let background: UIColor - + public init(inset: CGFloat, background: UIColor) { self.inset = inset self.background = background } - + public static func ==(lhs: ExtractedTheme, rhs: ExtractedTheme) -> Bool { if lhs === rhs { return true @@ -218,7 +219,7 @@ public final class PeerListItemComponent: Component { return true } } - + let context: AccountContext let theme: PresentationTheme let strings: PresentationStrings @@ -248,7 +249,7 @@ public final class PeerListItemComponent: Component { let inlineActions: InlineActionsState? let contextAction: ((EnginePeer, ContextExtractedContentContainingView, ContextGesture) -> Void)? let openStories: ((EnginePeer, AvatarNode) -> Void)? - + public init( context: AccountContext, theme: PresentationTheme, @@ -310,7 +311,7 @@ public final class PeerListItemComponent: Component { self.contextAction = contextAction self.openStories = openStories } - + public static func ==(lhs: PeerListItemComponent, rhs: PeerListItemComponent) -> Bool { if lhs.context !== rhs.context { return false @@ -398,13 +399,13 @@ public final class PeerListItemComponent: Component { } return true } - + public final class View: ContextControllerSourceView, ListSectionComponent.ChildView { public let extractedContainerView: ContextExtractedContentContainingView private let containerButton: HighlightTrackingButton - + private let swipeOptionContainer: ListItemSwipeOptionContainer - + private let title = ComponentView() private var label = ComponentView() private var subtitleView: ComponentView? @@ -413,28 +414,29 @@ public final class PeerListItemComponent: Component { private var avatarImageView: UIImageView? private let avatarButtonView: HighlightTrackingButton private var avatarIcon: ComponentView? - + private var winterGramIcon: ComponentView? + private var avatarComponentView: ComponentView? - + private var rightIconView: UIImageView? private var iconView: UIImageView? private var checkLayer: CheckLayer? private var rightAccessoryComponentView: ComponentView? - + private var reactionLayer: InlineStickerItemLayer? private var heartReactionIcon: UIImageView? private var iconFrame: CGRect? private var file: TelegramMediaFile? private var fileDisposable: Disposable? - + private var imageButtonView: HighlightTrackingButton? public private(set) var imageNode: TransformImageNode? - + private var component: PeerListItemComponent? private weak var state: EmptyComponentState? - + private var presenceManager: PeerPresenceStatusManager? - + public var avatarFrame: CGRect { if let avatarComponentView = self.avatarComponentView, let avatarComponentViewImpl = avatarComponentView.view { return avatarComponentViewImpl.frame @@ -444,11 +446,11 @@ public final class PeerListItemComponent: Component { return CGRect(origin: CGPoint(), size: CGSize()) } } - + public var titleFrame: CGRect? { return self.title.view?.frame } - + public var labelFrame: CGRect? { guard var value = self.label.view?.frame else { return nil @@ -459,53 +461,53 @@ public final class PeerListItemComponent: Component { } return value } - + private var isExtractedToContextMenu: Bool = false - + public var customUpdateIsHighlighted: ((Bool) -> Void)? public var enumerateSiblings: (((UIView) -> Void) -> Void)? public private(set) var separatorInset: CGFloat = 0.0 - + override init(frame: CGRect) { self.separatorLayer = SimpleLayer() - + self.extractedContainerView = ContextExtractedContentContainingView() self.containerButton = HighlightTrackingButton() self.containerButton.layer.anchorPoint = CGPoint() self.containerButton.isExclusiveTouch = true - + self.swipeOptionContainer = ListItemSwipeOptionContainer(frame: CGRect()) - + self.avatarButtonView = HighlightTrackingButton() - + super.init(frame: frame) - + self.addSubview(self.extractedContainerView) self.targetViewForActivationProgress = self.extractedContainerView.contentView - + self.extractedContainerView.contentView.addSubview(self.swipeOptionContainer) - + self.swipeOptionContainer.addSubview(self.containerButton) - + self.layer.addSublayer(self.separatorLayer) - + self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) - + self.addSubview(self.avatarButtonView) self.avatarButtonView.addTarget(self, action: #selector(self.avatarButtonPressed), for: .touchUpInside) - + self.extractedContainerView.isExtractedToContextPreviewUpdated = { [weak self] value in guard let self, let component = self.component else { return } - + let extractedBackgroundColor: UIColor if let extractedTheme = component.extractedTheme { extractedBackgroundColor = extractedTheme.background } else { extractedBackgroundColor = component.theme.rootController.navigationBar.blurredBackgroundColor } - + self.containerButton.clipsToBounds = value self.containerButton.backgroundColor = value ? extractedBackgroundColor : nil self.containerButton.layer.cornerRadius = value ? 10.0 : 0.0 @@ -515,7 +517,7 @@ public final class PeerListItemComponent: Component { return } self.isExtractedToContextMenu = value - + let mappedTransition: ComponentTransition if value { mappedTransition = ComponentTransition(transition) @@ -524,7 +526,7 @@ public final class PeerListItemComponent: Component { } self.state?.updated(transition: mappedTransition) } - + self.activated = { [weak self] gesture, _ in guard let self, let component = self.component, let peer = component.peer else { gesture.cancel() @@ -532,7 +534,7 @@ public final class PeerListItemComponent: Component { } component.contextAction?(peer, self.extractedContainerView, gesture) } - + self.containerButton.highligthedChanged = { [weak self] highlighted in guard let self else { return @@ -541,7 +543,7 @@ public final class PeerListItemComponent: Component { customUpdateIsHighlighted(highlighted) } } - + self.swipeOptionContainer.updateRevealOffset = { [weak self] offset, transition in guard let self else { return @@ -561,22 +563,22 @@ public final class PeerListItemComponent: Component { } } } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + deinit { self.fileDisposable?.dispose() } - + @objc private func pressed() { guard let component = self.component, let peer = component.peer else { return } component.action?(peer, component.message?.id, self) } - + @objc private func avatarButtonPressed() { guard let component = self.component, let peer = component.peer else { return @@ -585,21 +587,21 @@ public final class PeerListItemComponent: Component { component.openStories?(peer, avatarNode) } } - + private func updateReactionLayer() { guard let component = self.component else { return } - + if let reactionLayer = self.reactionLayer { self.reactionLayer = nil reactionLayer.removeFromSuperlayer() } - + guard let file = self.file else { return } - + let reactionLayer = InlineStickerItemLayer( context: component.context, userLocation: .other, @@ -612,12 +614,12 @@ public final class PeerListItemComponent: Component { pointSize: CGSize(width: 64.0, height: 64.0) ) self.reactionLayer = reactionLayer - + if let reaction = component.reaction, case .custom = reaction.reaction { reactionLayer.isVisibleForAnimations = true } self.containerButton.layer.addSublayer(reactionLayer) - + if var iconFrame = self.iconFrame { if let reaction = component.reaction, case .builtin = reaction.reaction { iconFrame = iconFrame.insetBy(dx: -iconFrame.width * 0.5, dy: -iconFrame.height * 0.5) @@ -625,23 +627,23 @@ public final class PeerListItemComponent: Component { reactionLayer.frame = iconFrame } } - + public func updateIsPreviewing(isPreviewing: Bool) { self.imageNode?.isHidden = isPreviewing } - + func update(component: PeerListItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let previousComponent = self.component - + var synchronousLoad = false if let hint = transition.userData(TransitionHint.self) { synchronousLoad = hint.synchronousLoad } - + self.isGestureEnabled = component.contextAction != nil - + let themeUpdated = self.component?.theme !== component.theme - + var hasSelectionUpdated = false if let previousComponent = self.component { switch previousComponent.selectionState { @@ -657,7 +659,7 @@ public final class PeerListItemComponent: Component { } } } - + if let presence = component.presence { let presenceManager: PeerPresenceStatusManager if let current = self.presenceManager { @@ -674,15 +676,15 @@ public final class PeerListItemComponent: Component { self.presenceManager = nil } } - + self.component = component self.state = state - + self.containerButton.alpha = component.isEnabled ? 1.0 : 0.3 self.containerButton.isEnabled = component.action != nil - + self.avatarButtonView.isUserInteractionEnabled = component.storyStats != nil && component.openStories != nil - + let labelData: (String, Subtitle.Color) if let presence = component.presence { let timestamp = CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970 @@ -694,7 +696,7 @@ public final class PeerListItemComponent: Component { } else { labelData = ("", .neutral) } - + let contextInset: CGFloat if self.isExtractedToContextMenu { if let extractedTheme = component.extractedTheme { @@ -705,9 +707,9 @@ public final class PeerListItemComponent: Component { } else { contextInset = 0.0 } - + let verticalInset: CGFloat = component.insets?.top ?? 1.0 - + var leftInset: CGFloat = 53.0 + component.sideInset if case .generic = component.style { leftInset += 9.0 @@ -719,7 +721,7 @@ public final class PeerListItemComponent: Component { if component.story != nil { rightInset += 40.0 } - + var subtitleComponentSize: CGSize? if let subtitleComponent = component.subtitleComponent { let subtitleView: ComponentView @@ -739,7 +741,7 @@ public final class PeerListItemComponent: Component { self.subtitleView = nil subtitleView.view?.removeFromSuperview() } - + var height: CGFloat let titleFont: UIFont let subtitleFont: UIFont @@ -768,7 +770,7 @@ public final class PeerListItemComponent: Component { self.rightAccessoryComponentView = nil rightAccessoryComponentView.view?.removeFromSuperview() } - + var rightAccessoryComponentSize: CGSize? if let rightAccessoryComponent = component.rightAccessoryComponent?.component { var rightAccessoryComponentTransition = transition @@ -793,9 +795,9 @@ public final class PeerListItemComponent: Component { if let rightAccessoryComponentSize { rightInset += 8.0 + rightAccessoryComponentSize.width } - + var avatarLeftInset: CGFloat = component.sideInset + 10.0 - + if case let .editing(isSelected, isTinted) = component.selectionState { let checkSize: CGFloat = 22.0 let checkOriginX: CGFloat @@ -808,7 +810,7 @@ public final class PeerListItemComponent: Component { rightInset += 44.0 checkOriginX = availableSize.width - 11.0 - checkSize } - + let checkLayer: CheckLayer if let current = self.checkLayer { checkLayer = current @@ -841,11 +843,11 @@ public final class PeerListItemComponent: Component { }) } } - + let avatarSize: CGFloat = component.style == .compact ? 30.0 : 40.0 - + let avatarFrame = CGRect(origin: CGPoint(x: avatarLeftInset, y: floorToScreenPixels((height - verticalInset * 2.0 - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize)) - + var statusIcon: EmojiStatusComponent.Content? var particleColor: UIColor? if let peer = component.peer { @@ -864,7 +866,8 @@ public final class PeerListItemComponent: Component { statusIcon = .premium(color: component.theme.list.itemAccentColor) } } - + let isWinterGramOfficial = component.peer.map { isWinterGramOfficialPeer($0) } ?? false + if let avatarComponent = component.avatarComponent { let avatarComponentView: ComponentView var avatarComponentTransition = transition @@ -875,7 +878,7 @@ public final class PeerListItemComponent: Component { avatarComponentView = ComponentView() self.avatarComponentView = avatarComponentView } - + let _ = avatarComponentView.update( transition: avatarComponentTransition, component: avatarComponent, @@ -888,7 +891,7 @@ public final class PeerListItemComponent: Component { } avatarComponentTransition.setFrame(view: avatarComponentViewImpl, frame: avatarFrame) } - + if let avatarNode = self.avatarNode { self.avatarNode = nil avatarNode.layer.removeFromSuperlayer() @@ -904,13 +907,13 @@ public final class PeerListItemComponent: Component { self.avatarNode = avatarNode self.containerButton.layer.insertSublayer(avatarNode.layer, at: 0) } - + if avatarNode.bounds.isEmpty { avatarNode.frame = avatarFrame } else { transition.setFrame(layer: avatarNode.layer, frame: avatarFrame) } - + if let peer = component.peer { let clipStyle: AvatarNodeClipStyle if case let .channel(channel) = peer, channel.isForumOrMonoForum { @@ -920,7 +923,7 @@ public final class PeerListItemComponent: Component { } let _ = clipStyle let _ = synchronousLoad - + if peer.smallProfileImage != nil { avatarNode.setPeerV2( context: component.context, @@ -952,15 +955,15 @@ public final class PeerListItemComponent: Component { } else { avatarNode.isHidden = true } - + if let avatarComponentView = self.avatarComponentView { self.avatarComponentView = nil avatarComponentView.view?.removeFromSuperview() } } - + transition.setFrame(view: self.avatarButtonView, frame: avatarFrame) - + if let avatar = component.avatar { let avatarImageView: UIImageView if let current = self.avatarImageView { @@ -980,13 +983,13 @@ public final class PeerListItemComponent: Component { avatarImageView.removeFromSuperview() } } - + let previousTitleFrame = self.title.view?.frame var previousTitleContents: UIView? if hasSelectionUpdated && !"".isEmpty { previousTitleContents = self.title.view?.snapshotView(afterScreenUpdates: false) } - + let availableTextWidth = availableSize.width - leftInset - rightInset var titleAvailableWidth = component.style == .compact ? availableTextWidth * 0.7 : availableSize.width - leftInset - rightInset switch component.rightAccessory { @@ -997,11 +1000,11 @@ public final class PeerListItemComponent: Component { case .none: break } - + if statusIcon != nil { titleAvailableWidth -= 14.0 } - + let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( @@ -1010,7 +1013,7 @@ public final class PeerListItemComponent: Component { environment: {}, containerSize: CGSize(width: titleAvailableWidth, height: 100.0) ) - + let labelAvailableWidth = component.style == .compact ? availableTextWidth - titleSize.width : availableSize.width - leftInset - rightInset let labelColor: UIColor switch labelData.1 { @@ -1022,7 +1025,7 @@ public final class PeerListItemComponent: Component { //TODO:release labelColor = UIColor(rgb: 0x33C758) } - + var animateLabelDirection: Bool? if !transition.animation.isImmediate, let previousComponent, let previousSubtitle = previousComponent.subtitle, let subtitle = component.subtitle, subtitle.color != previousSubtitle.color { let animateLabelDirectionValue: Bool @@ -1040,7 +1043,7 @@ public final class PeerListItemComponent: Component { } self.label = ComponentView() } - + let labelSize = self.label.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( @@ -1049,7 +1052,7 @@ public final class PeerListItemComponent: Component { environment: {}, containerSize: CGSize(width: labelAvailableWidth, height: 100.0) ) - + let titleSpacing: CGFloat = 2.0 let titleVerticalOffset: CGFloat = 0.0 let centralContentHeight: CGFloat @@ -1060,7 +1063,7 @@ public final class PeerListItemComponent: Component { } else { centralContentHeight = titleSize.height } - + let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: titleVerticalOffset + floor((height - verticalInset * 2.0 - centralContentHeight) / 2.0)), size: titleSize) if let titleView = self.title.view { if titleView.superview == nil { @@ -1071,11 +1074,11 @@ public final class PeerListItemComponent: Component { if let previousTitleFrame, previousTitleFrame.origin.x != titleFrame.origin.x { transition.animatePosition(view: titleView, from: CGPoint(x: previousTitleFrame.origin.x - titleFrame.origin.x, y: 0.0), to: CGPoint(), additive: true) } - + if let previousTitleFrame, let previousTitleContents, previousTitleFrame.size != titleSize { previousTitleContents.frame = CGRect(origin: previousTitleFrame.origin, size: previousTitleFrame.size) self.addSubview(previousTitleContents) - + transition.setFrame(view: previousTitleContents, frame: CGRect(origin: titleFrame.origin, size: previousTitleFrame.size)) transition.setAlpha(view: previousTitleContents, alpha: 0.0, completion: { [weak previousTitleContents] _ in previousTitleContents?.removeFromSuperview() @@ -1083,11 +1086,13 @@ public final class PeerListItemComponent: Component { transition.animateAlpha(view: titleView, from: 0.0, to: 1.0) } } - + + var titleIconX = titleFrame.maxX + 4.0 + if let statusIcon, case .generic = component.style { let animationCache = component.context.animationCache let animationRenderer = component.context.animationRenderer - + let avatarIcon: ComponentView var avatarIconTransition = transition if let current = self.avatarIcon { @@ -1097,7 +1102,7 @@ public final class PeerListItemComponent: Component { avatarIcon = ComponentView() self.avatarIcon = avatarIcon } - + let avatarIconComponent = EmojiStatusComponent( context: component.context, animationCache: animationCache, @@ -1114,22 +1119,58 @@ public final class PeerListItemComponent: Component { environment: {}, containerSize: CGSize(width: 20.0, height: 20.0) ) - + if let avatarIconView = avatarIcon.view { if avatarIconView.superview == nil { avatarIconView.isUserInteractionEnabled = false self.containerButton.addSubview(avatarIconView) } - avatarIconTransition.setFrame(view: avatarIconView, frame: CGRect(origin: CGPoint(x: titleFrame.maxX + 4.0, y: floorToScreenPixels(titleFrame.midY - iconSize.height / 2.0)), size: iconSize)) + avatarIconTransition.setFrame(view: avatarIconView, frame: CGRect(origin: CGPoint(x: titleIconX, y: floorToScreenPixels(titleFrame.midY - iconSize.height / 2.0)), size: iconSize)) + titleIconX += iconSize.width + 4.0 } } else if let avatarIcon = self.avatarIcon { self.avatarIcon = nil avatarIcon.view?.removeFromSuperview() } - + + if isWinterGramOfficial, case .generic = component.style { + let snowflakeIcon: ComponentView + var snowflakeTransition = transition + if let current = self.winterGramIcon { + snowflakeIcon = current + } else { + snowflakeTransition = transition.withAnimation(.none) + snowflakeIcon = ComponentView() + self.winterGramIcon = snowflakeIcon + } + let iconSize = snowflakeIcon.update( + transition: snowflakeTransition, + component: AnyComponent(EmojiStatusComponent( + context: component.context, + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + content: component.peer.flatMap { isWinterGramOfficialPeer($0) ? .winterGramBadge(backplateColor: winterGramBadgeBackplateColor(theme: component.theme)) : EmojiStatusComponent.Content.none } ?? EmojiStatusComponent.Content.none, + isVisibleForAnimations: true, + action: nil + )), + environment: {}, + containerSize: CGSize(width: 20.0, height: 20.0) + ) + if let snowflakeView = snowflakeIcon.view { + if snowflakeView.superview == nil { + snowflakeView.isUserInteractionEnabled = false + self.containerButton.addSubview(snowflakeView) + } + snowflakeTransition.setFrame(view: snowflakeView, frame: CGRect(origin: CGPoint(x: titleIconX, y: floorToScreenPixels(titleFrame.midY - iconSize.height / 2.0)), size: iconSize)) + } + } else if let winterGramIcon = self.winterGramIcon { + self.winterGramIcon = nil + winterGramIcon.view?.removeFromSuperview() + } + if let labelView = self.label.view { var iconLabelOffset: CGFloat = 0.0 - + if case .none = component.subtitleAccessory { if let iconView = self.iconView { self.iconView = nil @@ -1156,13 +1197,13 @@ public final class PeerListItemComponent: Component { self.iconView = iconView self.containerButton.addSubview(iconView) } - + if let image = iconView.image { iconLabelOffset = image.size.width + 4.0 transition.setFrame(view: iconView, frame: CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY + titleSpacing + 2.0 + floor((labelSize.height - image.size.height) * 0.5)), size: image.size)) } } - + let labelFrame: CGRect switch component.style { case .generic: @@ -1170,19 +1211,19 @@ public final class PeerListItemComponent: Component { case .compact: labelFrame = CGRect(origin: CGPoint(x: titleFrame.maxX + 4.0, y: floor((height - verticalInset * 2.0 - centralContentHeight) / 2.0)), size: labelSize) } - + if labelView.superview == nil { labelView.isUserInteractionEnabled = false labelView.layer.anchorPoint = CGPoint() self.containerButton.addSubview(labelView) - + labelView.center = labelFrame.origin } else { transition.setPosition(view: labelView, position: labelFrame.origin) } - + labelView.bounds = CGRect(origin: CGPoint(), size: labelFrame.size) - + if let animateLabelDirection { transition.animatePosition(view: labelView, from: CGPoint(x: 0.0, y: animateLabelDirection ? 6.0 : -6.0), to: CGPoint(), additive: true) if !transition.animation.isImmediate { @@ -1190,7 +1231,7 @@ public final class PeerListItemComponent: Component { } } } - + if let subtitleComponentView = self.subtitleView?.view, let subtitleComponentSize { let subtitleFrame = CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY + titleSpacing), size: subtitleComponentSize) @@ -1201,10 +1242,10 @@ public final class PeerListItemComponent: Component { } transition.setFrame(view: subtitleComponentView, frame: subtitleFrame) } - + let imageSize = CGSize(width: 22.0, height: 22.0) self.iconFrame = CGRect(origin: CGPoint(x: availableSize.width - (contextInset * 2.0 + 14.0 + component.sideInset) - imageSize.width, y: floor((height - verticalInset * 2.0 - imageSize.height) * 0.5)), size: imageSize) - + if case .none = component.rightAccessory { if case .none = component.subtitleAccessory { if let rightIconView = self.rightIconView { @@ -1234,7 +1275,7 @@ public final class PeerListItemComponent: Component { self.rightIconView = rightIconView self.containerButton.addSubview(rightIconView) } - + if let image = rightIconView.image { let iconFrame: CGRect switch component.rightAccessory { @@ -1243,11 +1284,11 @@ public final class PeerListItemComponent: Component { default: iconFrame = CGRect(origin: CGPoint(x: availableSize.width - image.size.width, y: floor((height - verticalInset * 2.0 - image.size.height) / 2.0)), size: image.size) } - + transition.setFrame(view: rightIconView, frame: iconFrame) } } - + if let rightAccessoryComponentViewImpl = self.rightAccessoryComponentView?.view, let rightAccessoryComponentSize { var rightAccessoryComponentTransition = transition if rightAccessoryComponentViewImpl.superview == nil { @@ -1257,13 +1298,13 @@ public final class PeerListItemComponent: Component { } rightAccessoryComponentTransition.setFrame(view: rightAccessoryComponentViewImpl, frame: CGRect(origin: CGPoint(x: availableSize.width - (contextInset * 2.0 + component.sideInset) - rightAccessoryComponentSize.width, y: floor((height - verticalInset * 2.0 - rightAccessoryComponentSize.width) / 2.0)), size: rightAccessoryComponentSize)) } - + var reactionIconTransition = transition if previousComponent?.reaction != component.reaction { if let reaction = component.reaction, case .builtin("❤") = reaction.reaction { self.file = nil self.updateReactionLayer() - + let heartReactionIcon: UIImageView if let current = self.heartReactionIcon { heartReactionIcon = current @@ -1279,7 +1320,7 @@ public final class PeerListItemComponent: Component { self.heartReactionIcon = nil heartReactionIcon.removeFromSuperview() } - + if let reaction = component.reaction { switch reaction.reaction { case .builtin: @@ -1304,11 +1345,11 @@ public final class PeerListItemComponent: Component { } } } - + if let heartReactionIcon = self.heartReactionIcon, let image = heartReactionIcon.image, let iconFrame = self.iconFrame { reactionIconTransition.setFrame(view: heartReactionIcon, frame: image.size.centered(around: iconFrame.center)) } - + if let reactionLayer = self.reactionLayer, let iconFrame = self.iconFrame { var adjustedIconFrame = iconFrame if let reaction = component.reaction, case .builtin = reaction.reaction { @@ -1316,8 +1357,8 @@ public final class PeerListItemComponent: Component { } transition.setFrame(layer: reactionLayer, frame: adjustedIconFrame) } - - + + var mediaReference: AnyMediaReference? if let peer = component.peer, let peerReference = PeerReference(peer) { if let story = component.story { @@ -1336,7 +1377,7 @@ public final class PeerListItemComponent: Component { } } } - + if let peer = component.peer, let mediaReference { let contentImageSize = CGSize(width: 30.0, height: 42.0) var dimensions: CGSize? @@ -1345,7 +1386,7 @@ public final class PeerListItemComponent: Component { } else if let imageMedia = mediaReference.media as? TelegramMediaFile { dimensions = imageMedia.dimensions?.cgSize } - + let imageButtonView: HighlightTrackingButton let imageNode: TransformImageNode if let current = self.imageNode, let currentButton = self.imageButtonView { @@ -1356,14 +1397,14 @@ public final class PeerListItemComponent: Component { imageNode.displaysAsynchronously = false imageNode.isUserInteractionEnabled = false self.imageNode = imageNode - + imageButtonView = HighlightTrackingButton() imageButtonView.isEnabled = false self.imageButtonView = imageButtonView - + self.containerButton.addSubview(imageNode.view) self.addSubview(imageButtonView) - + var imageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? if let imageReference = mediaReference.concrete(TelegramMediaImage.self) { imageSignal = mediaGridMessagePhoto(account: component.context.account, userLocation: .peer(peer.id), photoReference: imageReference) @@ -1374,12 +1415,12 @@ public final class PeerListItemComponent: Component { imageNode.setSignal(imageSignal) } } - + if let dimensions { let makeImageLayout = imageNode.asyncLayout() let applyImageLayout = makeImageLayout(TransformImageArguments(corners: ImageCorners(radius: 5.0), imageSize: dimensions.aspectFilled(contentImageSize), boundingSize: contentImageSize, intrinsicInsets: UIEdgeInsets())) applyImageLayout() - + let imageFrame = CGRect(origin: CGPoint(x: availableSize.width - contentImageSize.width - 10.0 - contextInset, y: floorToScreenPixels((height - contentImageSize.height) / 2.0)), size: contentImageSize) imageNode.frame = imageFrame transition.setFrame(view: imageButtonView, frame: imageFrame) @@ -1390,30 +1431,30 @@ public final class PeerListItemComponent: Component { self.imageButtonView?.removeFromSuperview() self.imageButtonView = nil } - + if themeUpdated { self.separatorLayer.backgroundColor = component.theme.list.itemPlainSeparatorColor.cgColor } transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: leftInset, y: height), size: CGSize(width: availableSize.width - leftInset, height: UIScreenPixel))) self.separatorLayer.isHidden = !component.hasNext - + let resultBounds = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: height)) transition.setFrame(view: self.extractedContainerView, frame: resultBounds) transition.setFrame(view: self.extractedContainerView.contentView, frame: resultBounds) self.extractedContainerView.contentRect = resultBounds - + let containerFrame = CGRect(origin: CGPoint(x: contextInset, y: verticalInset), size: CGSize(width: availableSize.width - contextInset * 2.0, height: height - verticalInset * 2.0)) - + let swipeOptionContainerFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: height)) transition.setFrame(view: self.swipeOptionContainer, frame: swipeOptionContainerFrame) - + transition.setPosition(view: self.containerButton, position: containerFrame.origin) transition.setBounds(view: self.containerButton, bounds: CGRect(origin: self.containerButton.bounds.origin, size: containerFrame.size)) - + self.separatorInset = leftInset - + self.swipeOptionContainer.updateLayout(size: swipeOptionContainerFrame.size, leftInset: 0.0, rightInset: 0.0) - + var rightOptions: [ListItemSwipeOptionContainer.Option] = [] if let inlineActions = component.inlineActions { rightOptions = inlineActions.actions.map { action in @@ -1424,7 +1465,7 @@ public final class PeerListItemComponent: Component { color = component.theme.list.itemDisclosureActions.destructive.fillColor textColor = component.theme.list.itemDisclosureActions.destructive.foregroundColor } - + return ListItemSwipeOptionContainer.Option( key: action.id, title: action.title, @@ -1435,15 +1476,15 @@ public final class PeerListItemComponent: Component { } } self.swipeOptionContainer.setRevealOptions(([], rightOptions)) - + return CGSize(width: availableSize.width, height: height) } } - + public func makeView() -> View { return View(frame: CGRect()) } - + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD index f9476bb580..0bed019b71 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD @@ -58,6 +58,7 @@ swift_library( "//submodules/TelegramUI/Components/TextNodeWithEntities", "//submodules/InvisibleInkDustNode", "//submodules/PresentationDataUtils", + "//submodules/AlertUI", "//submodules/UrlEscaping", "//submodules/OverlayStatusController", "//submodules/Utils/VolumeButtons", diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/OpenStories.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/OpenStories.swift index 71192f8763..1d4e8b5bfc 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/OpenStories.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/OpenStories.swift @@ -5,6 +5,9 @@ import AccountContext import SwiftSignalKit import TelegramCore import AvatarNode +import TelegramUIPreferences +import PresentationDataUtils +import AlertUI public extension StoryContainerScreen { static func openArchivedStories(context: AccountContext, parentController: ViewController, avatarNode: AvatarNode, sharedProgressDisposable: MetaDisposable?) { @@ -34,7 +37,7 @@ public extension StoryContainerScreen { ) avatarNode.isHidden = true } - + let storyContainerScreen = StoryContainerScreen( context: context, content: storyContent, @@ -80,13 +83,13 @@ public extension StoryContainerScreen { parentController?.push(storyContainerScreen) } |> ignoreValues - + let disposable = avatarNode.pushLoadingStatus(signal: signal) if let sharedProgressDisposable { sharedProgressDisposable.set(disposable) } } - + static func openPeerStories(context: AccountContext, peerId: EnginePeer.Id, parentController: ViewController, avatarNode: AvatarNode?, sharedProgressDisposable: MetaDisposable? = nil) { return openPeerStoriesCustom( context: context, @@ -158,7 +161,7 @@ public extension StoryContainerScreen { } ) } - + static func openPeerStoriesCustom( context: AccountContext, peerId: EnginePeer.Id, @@ -172,6 +175,46 @@ public extension StoryContainerScreen { setFocusedItem: @escaping (Signal) -> Void, setProgress: @escaping (Signal) -> Void, completion: @escaping (StoryContainerScreen) -> Void = { _ in } + ) { + // WinterGram: optionally offer to enable ghost story-viewing before opening, so the + // author isn't marked. Only when the toggle is on and stories aren't already hidden. + if currentWinterGramSettings.suggestGhostBeforeStory && !currentWinterGramSettings.suppressesStoryViews { + let proceed: () -> Void = { + StoryContainerScreen.openPeerStoriesContinue(context: context, peerId: peerId, focusOnId: focusOnId, isHidden: isHidden, initialOrder: initialOrder, singlePeer: singlePeer, parentController: parentController, transitionIn: transitionIn, transitionOut: transitionOut, setFocusedItem: setFocusedItem, setProgress: setProgress, completion: completion) + } + let alert = textAlertController(context: context, title: "WinterGram", text: "View stories without marking them as seen?", actions: [ + TextAlertAction(type: .genericAction, title: "View Normally", action: { + proceed() + }), + TextAlertAction(type: .defaultAction, title: "Ghost View", action: { + let _ = updateWinterGramSettingsInteractively(accountManager: context.sharedContext.accountManager, { current in + var current = current + current.ghostModeEnabled = true + current.sendReadStories = false + return current + }).start() + proceed() + }) + ]) + parentController.present(alert, in: .window(.root)) + return + } + StoryContainerScreen.openPeerStoriesContinue(context: context, peerId: peerId, focusOnId: focusOnId, isHidden: isHidden, initialOrder: initialOrder, singlePeer: singlePeer, parentController: parentController, transitionIn: transitionIn, transitionOut: transitionOut, setFocusedItem: setFocusedItem, setProgress: setProgress, completion: completion) + } + + private static func openPeerStoriesContinue( + context: AccountContext, + peerId: EnginePeer.Id, + focusOnId: Int32?, + isHidden: Bool, + initialOrder: [EnginePeer.Id], + singlePeer: Bool, + parentController: ViewController, + transitionIn: @escaping () -> StoryContainerScreen.TransitionIn?, + transitionOut: @escaping (EnginePeer.Id) -> StoryContainerScreen.TransitionOut?, + setFocusedItem: @escaping (Signal) -> Void, + setProgress: @escaping (Signal) -> Void, + completion: @escaping (StoryContainerScreen) -> Void ) { let storyContent = StoryContentContextImpl(context: context, isHidden: isHidden, focusedPeerId: peerId, focusedStoryId: focusOnId, singlePeer: singlePeer, fixedOrder: initialOrder) let signal = storyContent.state @@ -184,7 +227,7 @@ public extension StoryContainerScreen { |> delay(4.0, queue: .mainQueue()) } #endif - + return waitUntilStoryMediaPreloaded(context: context, peerId: slice.effectivePeer.id, storyItem: slice.item.storyItem) |> timeout(4.0, queue: .mainQueue(), alternate: .complete()) |> map { _ -> StoryContentContextState in @@ -199,9 +242,9 @@ public extension StoryContainerScreen { if state.slice == nil { return } - + let transitionIn: StoryContainerScreen.TransitionIn? = transitionIn() - + let storyContainerScreen = StoryContainerScreen( context: context, content: storyContent, @@ -215,7 +258,7 @@ public extension StoryContainerScreen { completion(storyContainerScreen) } |> ignoreValues - + setProgress(signal) } } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift index fbda84417d..6298da0ec5 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift @@ -5,6 +5,7 @@ import ComponentFlow import SwiftSignalKit import AccountContext import TelegramCore +import TelegramUIPreferences import Postbox import MediaResources import RangeSet @@ -18,17 +19,17 @@ public final class StoryContentContextImpl: StoryContentContext { private final class PeerContext { private let context: AccountContext let peerId: EnginePeer.Id - + private(set) var sliceValue: StoryContentContextState.FocusedSlice? fileprivate var nextItems: [EngineStoryItem] = [] - + let updated = Promise() - + private(set) var isReady: Bool = false - + private var disposable: Disposable? private var loadDisposable: Disposable? - + private let currentFocusedIdUpdatedPromise = Promise() private var storedFocusedId: Int32? private var currentMappedItems: [EngineStoryItem]? @@ -40,19 +41,19 @@ public final class StoryContentContextImpl: StoryContentContext { } } } - + private var currentForwardInfoStories: [StoryId: Promise] = [:] - + init(context: AccountContext, peerId: EnginePeer.Id, focusedId initialFocusedId: Int32?, loadIds: @escaping ([StoryKey]) -> Void) { self.context = context self.peerId = peerId - + self.currentFocusedId = initialFocusedId self.storedFocusedId = self.currentFocusedId self.currentFocusedIdUpdatedPromise.set(.single(Void())) - + context.engine.account.viewTracker.refreshCanSendMessagesForPeerIds(peerIds: [peerId]) - + let preferHighQualityStories: Signal = combineLatest( context.sharedContext.automaticMediaDownloadSettings |> map { settings in @@ -68,7 +69,7 @@ public final class StoryContentContextImpl: StoryContentContext { return setting && isPremium } |> distinctUntilChanged - + var inputKeys: [PostboxViewKey] = [ PostboxViewKey.basicPeer(peerId), PostboxViewKey.cachedPeerData(peerId: peerId), @@ -93,7 +94,7 @@ public final class StoryContentContextImpl: StoryContentContext { var peers: [PeerId: Peer] = [:] var forwardInfoStories: [StoryId: EngineStoryItem?] = [:] var allEntityFiles: [MediaId: TelegramMediaFile] = [:] - + if let itemsView = views.views[PostboxViewKey.storyItems(peerId: peerId)] as? StoryItemsView { for item in itemsView.items { if let item = item.value.get(Stories.StoredItem.self), case let .item(itemValue) = item { @@ -149,7 +150,7 @@ public final class StoryContentContextImpl: StoryContentContext { } } } - + var pendingForwardsInfo: [Int64: EngineStoryItem.ForwardInfo] = [:] if let stateView = views.views[PostboxViewKey.storiesState(key: .local)] as? StoryStatesView, let localState = stateView.value?.get(Stories.LocalState.self) { for item in localState.items { @@ -158,7 +159,7 @@ public final class StoryContentContextImpl: StoryContentContext { } } } - + return (views, peers, data, allEntityFiles, pendingForwardsInfo, forwardInfoStories, preferHighQualityStories) } } @@ -183,9 +184,9 @@ public final class StoryContentContextImpl: StoryContentContext { if let presencesView = views.views[PostboxViewKey.peerPresences(peerIds: Set([peerId]))] as? PeerPresencesView { peerPresence = presencesView.presences[peerId] } - + let (globalNotificationSettings, isPremiumRequiredForMessaging) = data - + for (storyId, story) in forwardInfoStories { let promise: Promise var added = false @@ -202,7 +203,7 @@ public final class StoryContentContextImpl: StoryContentContext { promise.set(self.context.engine.messages.getStory(peerId: storyId.peerId, id: storyId.id)) } } - + if let cachedPeerDataView = views.views[PostboxViewKey.cachedPeerData(peerId: peerId)] as? CachedPeerDataView { if let cachedUserData = cachedPeerDataView.cachedPeerData as? CachedUserData { var isMuted = false @@ -265,7 +266,7 @@ public final class StoryContentContextImpl: StoryContentContext { ) } let state = stateView.value?.get(Stories.PeerState.self) - + var mappedItems: [EngineStoryItem] = peerStoryItemsView.items.compactMap { item -> EngineStoryItem? in guard case let .item(item) = item.value.get(Stories.StoredItem.self) else { return nil @@ -273,7 +274,7 @@ public final class StoryContentContextImpl: StoryContentContext { guard let media = item.media else { return nil } - + var forwardInfo = item.forwardInfo.flatMap { EngineStoryItem.ForwardInfo($0, peers: peers) } if forwardInfo == nil { for mediaArea in item.mediaAreas { @@ -283,7 +284,7 @@ public final class StoryContentContextImpl: StoryContentContext { } } } - + return EngineStoryItem( id: item.id, timestamp: item.timestamp, @@ -332,7 +333,7 @@ public final class StoryContentContextImpl: StoryContentContext { } else if case .peer(peerId) = item.target { matches = true } - + if matches { mappedItems.append(EngineStoryItem( id: item.stableId, @@ -365,9 +366,9 @@ public final class StoryContentContextImpl: StoryContentContext { } } } - + let currentFocusedId = self.storedFocusedId - + var focusedIndex: Int? if let currentFocusedId { focusedIndex = mappedItems.firstIndex(where: { $0.id == currentFocusedId }) @@ -381,7 +382,7 @@ public final class StoryContentContextImpl: StoryContentContext { } } } - + if focusedIndex == nil && previousIndex != 0 { for index in (0 ..< previousIndex).reversed() { if let value = mappedItems.firstIndex(where: { $0.id == currentMappedItems[index].id }) { @@ -408,24 +409,24 @@ public final class StoryContentContextImpl: StoryContentContext { focusedIndex = 0 } } - + self.currentMappedItems = mappedItems - + if let focusedIndex { self.storedFocusedId = mappedItems[focusedIndex].id - + var previousItemId: StoryId? var nextItemId: StoryId? - + if focusedIndex != 0 { previousItemId = StoryId(peerId: peerId, id: mappedItems[focusedIndex - 1].id) } if focusedIndex != mappedItems.count - 1 { nextItemId = StoryId(peerId: peerId, id: mappedItems[focusedIndex + 1].id) } - + let mappedFocusedIndex = peerStoryItemsView.items.firstIndex(where: { $0.id == mappedItems[focusedIndex].id }) - + var loadKeys: [StoryKey] = [] if let mappedFocusedIndex { for index in (mappedFocusedIndex - 2) ... (mappedFocusedIndex + 2) { @@ -439,10 +440,10 @@ public final class StoryContentContextImpl: StoryContentContext { loadIds(loadKeys) } } - + do { let mappedItem = mappedItems[focusedIndex] - + var nextItems: [EngineStoryItem] = [] for i in (focusedIndex + 1) ..< min(focusedIndex + 4, mappedItems.count) { do { @@ -450,7 +451,7 @@ public final class StoryContentContextImpl: StoryContentContext { nextItems.append(item) } } - + let allItems = mappedItems.map { item in return StoryContentItem( id: StoryId(peerId: peer.id, id: item.id), @@ -462,7 +463,7 @@ public final class StoryContentContextImpl: StoryContentContext { itemPeer: nil ) } - + self.nextItems = nextItems self.sliceValue = StoryContentContextState.FocusedSlice( peer: peer, @@ -491,31 +492,31 @@ public final class StoryContentContextImpl: StoryContentContext { } }) } - + deinit { self.disposable?.dispose() self.loadDisposable?.dispose() } } - + private final class StateContext { let centralPeerContext: PeerContext let previousPeerContext: PeerContext? let nextPeerContext: PeerContext? - + let updated = Promise() - + var isReady: Bool { if !self.centralPeerContext.isReady { return false } return true } - + private var centralDisposable: Disposable? private var previousDisposable: Disposable? private var nextDisposable: Disposable? - + init( centralPeerContext: PeerContext, previousPeerContext: PeerContext?, @@ -524,7 +525,7 @@ public final class StoryContentContextImpl: StoryContentContext { self.centralPeerContext = centralPeerContext self.previousPeerContext = previousPeerContext self.nextPeerContext = nextPeerContext - + self.centralDisposable = (centralPeerContext.updated.get() |> deliverOnMainQueue).startStrict(next: { [weak self] _ in guard let self else { @@ -532,7 +533,7 @@ public final class StoryContentContextImpl: StoryContentContext { } self.updated.set(.single(Void())) }) - + if let previousPeerContext { self.previousDisposable = (previousPeerContext.updated.get() |> deliverOnMainQueue).startStrict(next: { [weak self] _ in @@ -542,7 +543,7 @@ public final class StoryContentContextImpl: StoryContentContext { self.updated.set(.single(Void())) }) } - + if let nextPeerContext { self.nextDisposable = (nextPeerContext.updated.get() |> deliverOnMainQueue).startStrict(next: { [weak self] _ in @@ -553,13 +554,13 @@ public final class StoryContentContextImpl: StoryContentContext { }) } } - + deinit { self.centralDisposable?.dispose() self.previousDisposable?.dispose() self.nextDisposable?.dispose() } - + func findPeerContext(id: EnginePeer.Id) -> PeerContext? { if self.centralPeerContext.sliceValue?.peer.id == id { return self.centralPeerContext @@ -573,43 +574,43 @@ public final class StoryContentContextImpl: StoryContentContext { return nil } } - + private let context: AccountContext private let isHidden: Bool - + public private(set) var stateValue: StoryContentContextState? public var state: Signal { return self.statePromise.get() } private let statePromise = Promise() - + private let updatedPromise = Promise() public var updated: Signal { return self.updatedPromise.get() } - + private var focusedItem: (peerId: EnginePeer.Id, storyId: Int32?)? - + private var currentState: StateContext? private var stateIsEmpty: Bool = false private var currentStateUpdatedDisposable: Disposable? - + private var pendingState: StateContext? private var pendingStateReadyDisposable: Disposable? - + private var storySubscriptions: EngineStorySubscriptions? private var fixedSubscriptionOrder: [EnginePeer.Id] = [] private var startedWithUnseen: Bool? private var storySubscriptionsDisposable: Disposable? - + private var requestedStoryKeys = Set() private var requestStoryDisposables = DisposableSet() - + private var preloadStoryResourceDisposables: [MediaId: Disposable] = [:] private var pollStoryMetadataDisposables: [StoryId: Disposable] = [:] - + private var singlePeerListContext: PeerExpiringStoryListContext? - + public init( context: AccountContext, isHidden: Bool, @@ -624,7 +625,7 @@ public final class StoryContentContextImpl: StoryContentContext { self.focusedItem = (focusedPeerId, focusedStoryId) } self.fixedSubscriptionOrder = fixedOrder - + if singlePeer { guard let focusedPeerId else { assertionFailure() @@ -632,7 +633,7 @@ public final class StoryContentContextImpl: StoryContentContext { } let singlePeerListContext = PeerExpiringStoryListContext(account: context.account, peerId: focusedPeerId) self.singlePeerListContext = singlePeerListContext - + self.storySubscriptionsDisposable = (combineLatest( context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: focusedPeerId)), singlePeerListContext.state @@ -641,11 +642,11 @@ public final class StoryContentContextImpl: StoryContentContext { guard let self, let peer else { return } - + if state.isLoading { return } - + let storySubscriptions = EngineStorySubscriptions( accountItem: nil, items: state.items.isEmpty ? [] : [EngineStorySubscriptions.Item( @@ -660,15 +661,15 @@ public final class StoryContentContextImpl: StoryContentContext { )], hasMoreToken: nil ) - + var preFilterOrder = false - + let startedWithUnseen: Bool if let current = self.startedWithUnseen { startedWithUnseen = current } else { var startedWithUnseenValue = false - + if let (focusedPeerId, _) = self.focusedItem, focusedPeerId == self.context.account.peerId { } else { var centralIndex: Int? @@ -687,19 +688,19 @@ public final class StoryContentContextImpl: StoryContentContext { centralIndex = 0 } } - + if let centralIndex { if storySubscriptions.items[centralIndex].hasUnseen { startedWithUnseenValue = true } } } - + self.startedWithUnseen = startedWithUnseenValue startedWithUnseen = startedWithUnseenValue preFilterOrder = true } - + var sortedItems: [EngineStorySubscriptions.Item] = [] for peerId in self.fixedSubscriptionOrder { if let index = storySubscriptions.items.firstIndex(where: { $0.peer.id == peerId }) { @@ -722,7 +723,7 @@ public final class StoryContentContextImpl: StoryContentContext { } } self.fixedSubscriptionOrder = sortedItems.map(\.peer.id) - + self.storySubscriptions = EngineStorySubscriptions( accountItem: storySubscriptions.accountItem, items: sortedItems, @@ -736,20 +737,20 @@ public final class StoryContentContextImpl: StoryContentContext { guard let self else { return } - + var preFilterOrder = false - + let startedWithUnseen: Bool if let current = self.startedWithUnseen { startedWithUnseen = current } else { var startedWithUnseenValue = false - + if let (focusedPeerId, _) = self.focusedItem, focusedPeerId == self.context.account.peerId, let accountItem = storySubscriptions.accountItem { startedWithUnseenValue = accountItem.hasUnseen || accountItem.hasPending } else { var centralIndex: Int? - + if let (focusedPeerId, _) = self.focusedItem { if let index = storySubscriptions.items.firstIndex(where: { $0.peer.id == focusedPeerId }) { centralIndex = index @@ -765,19 +766,19 @@ public final class StoryContentContextImpl: StoryContentContext { centralIndex = 0 } } - + if let centralIndex { if storySubscriptions.items[centralIndex].hasUnseen { startedWithUnseenValue = true } } } - + self.startedWithUnseen = startedWithUnseenValue startedWithUnseen = startedWithUnseenValue preFilterOrder = true } - + var sortedItems: [EngineStorySubscriptions.Item] = [] if !isHidden, let accountItem = storySubscriptions.accountItem { if self.fixedSubscriptionOrder.contains(context.account.peerId) { @@ -813,7 +814,7 @@ public final class StoryContentContextImpl: StoryContentContext { } } self.fixedSubscriptionOrder = sortedItems.map(\.peer.id) - + self.storySubscriptions = EngineStorySubscriptions( accountItem: storySubscriptions.accountItem, items: sortedItems, @@ -823,7 +824,7 @@ public final class StoryContentContextImpl: StoryContentContext { }) } } - + deinit { self.storySubscriptionsDisposable?.dispose() self.requestStoryDisposables.dispose() @@ -837,21 +838,21 @@ public final class StoryContentContextImpl: StoryContentContext { self.currentStateUpdatedDisposable?.dispose() self.pendingStateReadyDisposable?.dispose() } - + private func updatePeerContexts() { if let currentState = self.currentState, let storySubscriptions = self.storySubscriptions, !storySubscriptions.items.contains(where: { $0.peer.id == currentState.centralPeerContext.peerId }) { self.currentState = nil } - + if self.currentState == nil { self.switchToFocusedPeerId() } } - + private func switchToFocusedPeerId() { if let currentStorySubscriptions = self.storySubscriptions { let subscriptionItems = currentStorySubscriptions.items - + if self.pendingState == nil { let loadIds: ([StoryKey]) -> Void = { [weak self] keys in guard let self else { @@ -872,7 +873,7 @@ public final class StoryContentContextImpl: StoryContentContext { } } } - + var centralIndex: Int? var centralStoryId: Int32? if let (focusedPeerId, focusedStoryId) = self.focusedItem { @@ -886,7 +887,7 @@ public final class StoryContentContextImpl: StoryContentContext { centralIndex = 0 } } - + if let centralIndex { let centralPeerContext: PeerContext if let currentState = self.currentState, let existingContext = currentState.findPeerContext(id: subscriptionItems[centralIndex].peer.id) { @@ -894,7 +895,7 @@ public final class StoryContentContextImpl: StoryContentContext { } else { centralPeerContext = PeerContext(context: self.context, peerId: subscriptionItems[centralIndex].peer.id, focusedId: centralStoryId, loadIds: loadIds) } - + var previousPeerContext: PeerContext? if centralIndex != 0 { if let currentState = self.currentState, let existingContext = currentState.findPeerContext(id: subscriptionItems[centralIndex - 1].peer.id) { @@ -903,7 +904,7 @@ public final class StoryContentContextImpl: StoryContentContext { previousPeerContext = PeerContext(context: self.context, peerId: subscriptionItems[centralIndex - 1].peer.id, focusedId: nil, loadIds: loadIds) } } - + var nextPeerContext: PeerContext? if centralIndex != subscriptionItems.count - 1 { if let currentState = self.currentState, let existingContext = currentState.findPeerContext(id: subscriptionItems[centralIndex + 1].peer.id) { @@ -912,7 +913,7 @@ public final class StoryContentContextImpl: StoryContentContext { nextPeerContext = PeerContext(context: self.context, peerId: subscriptionItems[centralIndex + 1].peer.id, focusedId: nil, loadIds: loadIds) } } - + let pendingState = StateContext( centralPeerContext: centralPeerContext, previousPeerContext: previousPeerContext, @@ -927,11 +928,11 @@ public final class StoryContentContextImpl: StoryContentContext { self.pendingState = nil self.pendingStateReadyDisposable?.dispose() self.pendingStateReadyDisposable = nil - + self.currentState = pendingState - + self.updateState() - + self.currentStateUpdatedDisposable?.dispose() self.currentStateUpdatedDisposable = (pendingState.updated.get() |> deliverOnMainQueue).startStrict(next: { [weak self, weak pendingState] _ in @@ -950,13 +951,13 @@ public final class StoryContentContextImpl: StoryContentContext { self.updateState() } } - + private func updateState() { guard let currentState = self.currentState else { if self.stateIsEmpty { self.stateValue = nil self.statePromise.set(.single(StoryContentContextState(slice: nil, previousSlice: nil, nextSlice: nil))) - + self.updatedPromise.set(.single(Void())) } return @@ -968,9 +969,9 @@ public final class StoryContentContextImpl: StoryContentContext { ) self.stateValue = stateValue self.statePromise.set(.single(stateValue)) - + self.updatedPromise.set(.single(Void())) - + var possibleItems: [(EnginePeer, EngineStoryItem)] = [] var pollItems: [StoryKey] = [] if let slice = currentState.centralPeerContext.sliceValue { @@ -983,10 +984,10 @@ public final class StoryContentContextImpl: StoryContentContext { if shouldPollItem { pollItems.append(StoryKey(peerId: slice.peer.id, id: slice.item.storyItem.id)) } - + for item in currentState.centralPeerContext.nextItems { possibleItems.append((slice.peer, item)) - + var shouldPollNextItem = false if slice.peer.id == self.context.account.peerId { shouldPollNextItem = true @@ -1004,7 +1005,7 @@ public final class StoryContentContextImpl: StoryContentContext { possibleItems.append((slice.peer, item)) } } - + var nextPriority = 0 var resultResources: [EngineMedia.Id: StoryPreloadInfo] = [:] for i in 0 ..< min(possibleItems.count, 3) { @@ -1019,14 +1020,14 @@ public final class StoryContentContextImpl: StoryContentContext { } } } - + var selectedMedia: EngineMedia if let slice = stateValue.slice, let alternativeMediaValue = item.alternativeMediaList.first, (!slice.additionalPeerData.preferHighQualityStories && !item.isMy) { selectedMedia = alternativeMediaValue } else { selectedMedia = item.media } - + resultResources[mediaId] = StoryPreloadInfo( peer: peerReference, storyId: item.id, @@ -1037,7 +1038,7 @@ public final class StoryContentContextImpl: StoryContentContext { nextPriority += 1 } } - + var validIds: [EngineMedia.Id] = [] for (id, info) in resultResources.sorted(by: { $0.value.priority < $1.value.priority }) { validIds.append(id) @@ -1045,7 +1046,7 @@ public final class StoryContentContextImpl: StoryContentContext { self.preloadStoryResourceDisposables[id] = preloadStoryMedia(context: context, info: info).startStrict() } } - + var removeIds: [EngineMedia.Id] = [] for (id, disposable) in self.preloadStoryResourceDisposables { if !validIds.contains(id) { @@ -1056,7 +1057,7 @@ public final class StoryContentContextImpl: StoryContentContext { for id in removeIds { self.preloadStoryResourceDisposables.removeValue(forKey: id) } - + var pollIdByPeerId: [EnginePeer.Id: [Int32]] = [:] for storyKey in pollItems.prefix(3) { if pollIdByPeerId[storyKey.peerId] == nil { @@ -1073,7 +1074,7 @@ public final class StoryContentContextImpl: StoryContentContext { } } } - + public func resetSideStates() { guard let currentState = self.currentState else { return @@ -1085,12 +1086,12 @@ public final class StoryContentContextImpl: StoryContentContext { nextPeerContext.currentFocusedId = nil } } - + public func navigate(navigation: StoryContentContextNavigation) { guard let currentState = self.currentState else { return } - + switch navigation { case let .peer(direction): switch direction { @@ -1128,8 +1129,12 @@ public final class StoryContentContextImpl: StoryContentContext { } } } - + public func markAsSeen(id: StoryId) { + let wnt = currentWinterGramSettings + if wnt.ghostModeEnabled && !wnt.sendReadStories { + return + } if !self.context.sharedContext.immediateExperimentalUISettings.skipReadHistory { let _ = self.context.engine.messages.markStoryAsSeen(peerId: id.peerId, id: id.id, asPinned: false).startStandalone() } @@ -1139,25 +1144,25 @@ public final class StoryContentContextImpl: StoryContentContext { public final class SingleStoryContentContextImpl: StoryContentContext { private let context: AccountContext private let readGlobally: Bool - + public private(set) var stateValue: StoryContentContextState? public var state: Signal { return self.statePromise.get() } private let statePromise = Promise() - + private let updatedPromise = Promise() public var updated: Signal { return self.updatedPromise.get() } - + private var storyDisposable: Disposable? - + private var requestedStoryKeys = Set() private var requestStoryDisposables = DisposableSet() - + private var currentForwardInfoStories: [StoryId: Promise] = [:] - + public init( context: AccountContext, storyId: StoryId, @@ -1166,7 +1171,7 @@ public final class SingleStoryContentContextImpl: StoryContentContext { ) { self.context = context self.readGlobally = readGlobally - + let item: Signal if let storyItem { item = .single(.item(storyItem.asStoryItem())) @@ -1176,9 +1181,9 @@ public final class SingleStoryContentContextImpl: StoryContentContext { return (views.views[PostboxViewKey.story(id: storyId)] as? StoryView)?.item?.get(Stories.StoredItem.self) } } - + context.engine.account.viewTracker.refreshCanSendMessagesForPeerIds(peerIds: [storyId.peerId]) - + let preferHighQualityStories: Signal = combineLatest( context.sharedContext.automaticMediaDownloadSettings |> map { settings in @@ -1194,7 +1199,7 @@ public final class SingleStoryContentContextImpl: StoryContentContext { return setting && isPremium } |> distinctUntilChanged - + self.storyDisposable = (combineLatest(queue: .mainQueue(), context.engine.data.subscribe( TelegramEngine.EngineData.Item.Peer.Peer(id: storyId.peerId), @@ -1276,16 +1281,16 @@ public final class SingleStoryContentContextImpl: StoryContentContext { guard let self else { return } - + let (peer, presence, areVoiceMessagesAvailable, canViewStats, notificationSettings, globalNotificationSettings, isPremiumRequiredForMessaging, boostsToUnrestrict, appliedBoosts, sendPaidMessageStars) = data let (item, peers, allEntityFiles, forwardInfoStories) = itemAndPeers - + guard let peer else { return } let isMuted = resolvedAreStoriesMuted(globalSettings: globalNotificationSettings._asGlobalNotificationSettings(), peer: peer, peerSettings: notificationSettings._asNotificationSettings(), topSearchPeers: []) - + let additionalPeerData = StoryContentContextState.AdditionalPeerData( isMuted: isMuted, areVoiceMessagesAvailable: areVoiceMessagesAvailable, @@ -1297,7 +1302,7 @@ public final class SingleStoryContentContextImpl: StoryContentContext { appliedBoosts: appliedBoosts, sendPaidMessageStars: sendPaidMessageStars ) - + for (storyId, story) in forwardInfoStories { let promise: Promise var added = false @@ -1314,16 +1319,16 @@ public final class SingleStoryContentContextImpl: StoryContentContext { promise.set(self.context.engine.messages.getStory(peerId: storyId.peerId, id: storyId.id)) } } - + if item == nil { let storyKey = StoryKey(peerId: storyId.peerId, id: storyId.id) if !self.requestedStoryKeys.contains(storyKey) { self.requestedStoryKeys.insert(storyKey) - + self.requestStoryDisposables.add(self.context.engine.messages.refreshStories(peerId: storyId.peerId, ids: [storyId.id]).startStrict()) } } - + if let item, case let .item(itemValue) = item, let media = itemValue.media { var forwardInfo = itemValue.forwardInfo.flatMap { EngineStoryItem.ForwardInfo($0, peers: peers) } if forwardInfo == nil { @@ -1334,7 +1339,7 @@ public final class SingleStoryContentContextImpl: StoryContentContext { } } } - + let mappedItem = EngineStoryItem( id: itemValue.id, timestamp: itemValue.timestamp, @@ -1373,7 +1378,7 @@ public final class SingleStoryContentContextImpl: StoryContentContext { author: itemValue.authorId.flatMap { peers[$0].flatMap(EnginePeer.init) }, folderIds: itemValue.folderIds ) - + let mainItem = StoryContentItem( id: StoryId(peerId: peer.id, id: mappedItem.id), position: 0, @@ -1397,7 +1402,7 @@ public final class SingleStoryContentContextImpl: StoryContentContext { previousSlice: nil, nextSlice: nil ) - + if self.stateValue == nil || self.stateValue?.slice != stateValue.slice { self.stateValue = stateValue self.statePromise.set(.single(stateValue)) @@ -1409,7 +1414,7 @@ public final class SingleStoryContentContextImpl: StoryContentContext { previousSlice: nil, nextSlice: nil ) - + if self.stateValue == nil || self.stateValue?.slice != stateValue.slice { self.stateValue = stateValue self.statePromise.set(.single(stateValue)) @@ -1418,19 +1423,21 @@ public final class SingleStoryContentContextImpl: StoryContentContext { } }) } - + deinit { self.storyDisposable?.dispose() self.requestStoryDisposables.dispose() } - + public func resetSideStates() { } - + public func navigate(navigation: StoryContentContextNavigation) { } - + public func markAsSeen(id: StoryId) { + let wnt = currentWinterGramSettings + if wnt.ghostModeEnabled && !wnt.sendReadStories { return } if self.readGlobally { if !self.context.sharedContext.immediateExperimentalUISettings.skipReadHistory { let _ = self.context.engine.messages.markStoryAsSeen(peerId: id.peerId, id: id.id, asPinned: false).startStandalone() @@ -1443,7 +1450,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { private struct DayIndex: Hashable { var year: Int32 var day: Int32 - + init(timestamp: Int32) { var time: time_t = time_t(timestamp) var timeinfo: tm = tm() @@ -1453,7 +1460,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { self.day = timeinfo.tm_yday } } - + private struct PeerData { let data: (TelegramEngine.EngineData.Item.Peer.Peer.Result, TelegramEngine.EngineData.Item.Peer.Presence.Result, @@ -1466,46 +1473,46 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { TelegramEngine.EngineData.Item.Peer.AppliedBoosts.Result, TelegramEngine.EngineData.Item.Peer.SendPaidMessageStars.Result ) - + init(data: (TelegramEngine.EngineData.Item.Peer.Peer.Result, TelegramEngine.EngineData.Item.Peer.Presence.Result, TelegramEngine.EngineData.Item.Peer.AreVoiceMessagesAvailable.Result, TelegramEngine.EngineData.Item.Peer.CanViewStats.Result, TelegramEngine.EngineData.Item.Peer.NotificationSettings.Result, TelegramEngine.EngineData.Item.NotificationSettings.Global.Result, TelegramEngine.EngineData.Item.Peer.IsPremiumRequiredForMessaging.Result, TelegramEngine.EngineData.Item.Peer.BoostsToUnrestrict.Result, TelegramEngine.EngineData.Item.Peer.AppliedBoosts.Result, TelegramEngine.EngineData.Item.Peer.SendPaidMessageStars.Result)) { self.data = data } } - + private let context: AccountContext let listContext: StoryListContext - + public private(set) var stateValue: StoryContentContextState? public var state: Signal { return self.statePromise.get() } private let statePromise = Promise() - + private let updatedPromise = Promise() public var updated: Signal { return self.updatedPromise.get() } - + private var storyDisposable: Disposable? private var storyDataDisposable = MetaDisposable() - + private var requestedStoryKeys = Set() private var requestStoryDisposables = DisposableSet() - + private var listState: StoryListContext.State? - + private var focusedId: StoryId? private var focusedIdUpdated = Promise(Void()) - + private var preloadStoryResourceDisposables: [EngineMedia.Id: Disposable] = [:] private var pollStoryMetadataDisposables = DisposableSet() - + private var currentPeerData: (EnginePeer.Id, Promise)? - + public init(context: AccountContext, listContext: StoryListContext, initialId: StoryId?, splitIndexIntoDays: Bool) { self.context = context self.listContext = listContext - + let preferHighQualityStories: Signal = combineLatest( context.sharedContext.automaticMediaDownloadSettings |> map { settings in @@ -1521,7 +1528,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { return setting && isPremium } |> distinctUntilChanged - + self.storyDisposable = (combineLatest(queue: .mainQueue(), listContext.state, self.focusedIdUpdated.get(), @@ -1531,7 +1538,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { guard let self else { return } - + let focusedIndex: Int? if let current = self.focusedId { if let index = state.items.firstIndex(where: { $0.id == current }) { @@ -1556,7 +1563,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { focusedIndex = nil } } - + let peerData: Signal if let focusedIndex { let peerId = state.items[focusedIndex].id.peerId @@ -1564,7 +1571,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { peerData = currentPeerData.1.get() |> map(Optional.init) } else { context.engine.account.viewTracker.refreshCanSendMessagesForPeerIds(peerIds: [peerId]) - + let currentPeerData: (EnginePeer.Id, Promise) = (peerId, Promise()) currentPeerData.1.set(context.engine.data.subscribe( TelegramEngine.EngineData.Item.Peer.Peer(id: peerId), @@ -1579,21 +1586,21 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { TelegramEngine.EngineData.Item.Peer.SendPaidMessageStars(id: peerId) ) |> map { PeerData(data: $0) }) self.currentPeerData = currentPeerData - + peerData = currentPeerData.1.get() |> map(Optional.init) } } else { peerData = .single(nil) } - + self.storyDataDisposable.set((peerData |> deliverOnMainQueue).start(next: { [weak self] data in guard let self else { return } - + self.listState = state - + let stateValue: StoryContentContextState if let focusedIndex, let (peer, presence, areVoiceMessagesAvailable, canViewStats, notificationSettings, globalNotificationSettings, isPremiumRequiredForMessaging, boostsToUnrestrict, appliedBoosts, sendPaidMessageStars) = data?.data, let peer { let isMuted = resolvedAreStoriesMuted(globalSettings: globalNotificationSettings._asGlobalNotificationSettings(), peer: peer, peerSettings: notificationSettings._asNotificationSettings(), topSearchPeers: []) @@ -1608,15 +1615,15 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { appliedBoosts: appliedBoosts, sendPaidMessageStars: sendPaidMessageStars ) - + let item = state.items[focusedIndex] self.focusedId = item.id - + var allItems: [StoryContentItem] = [] - + var dayCounts: [DayIndex: Int] = [:] var itemDayIndices: [StoryId: (Int, DayIndex)] = [:] - + for i in 0 ..< state.items.count { let stateItem = state.items[i] allItems.append(StoryContentItem( @@ -1628,7 +1635,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { entityFiles: extractItemEntityFiles(item: stateItem.storyItem, allEntityFiles: state.allEntityFiles), itemPeer: stateItem.peer )) - + let day: DayIndex if splitIndexIntoDays { day = DayIndex(timestamp: stateItem.storyItem.timestamp) @@ -1645,7 +1652,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { } itemDayIndices[stateItem.id] = (dayCount - 1, day) } - + var dayCounters: StoryContentItem.DayCounters? if let (offset, day) = itemDayIndices[item.id], let dayCount = dayCounts[day] { dayCounters = StoryContentItem.DayCounters( @@ -1653,7 +1660,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { totalCount: dayCount ) } - + stateValue = StoryContentContextState( slice: StoryContentContextState.FocusedSlice( peer: peer, @@ -1678,38 +1685,38 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { ) } else { self.focusedId = nil - + stateValue = StoryContentContextState( slice: nil, previousSlice: nil, nextSlice: nil ) } - + if self.stateValue == nil || self.stateValue?.slice != stateValue.slice { self.stateValue = stateValue self.statePromise.set(.single(stateValue)) self.updatedPromise.set(.single(Void())) - + var resultResources: [EngineMedia.Id: StoryPreloadInfo] = [:] var pollItems: [StoryKey] = [] - + if let focusedIndex, let slice = stateValue.slice { var possibleItems: [(EnginePeer, StoryListContext.State.Item)] = [] if slice.item.id.peerId == self.context.account.peerId { pollItems.append(StoryKey(peerId: slice.item.id.peerId, id: slice.item.id.id)) } - + for i in focusedIndex ..< min(focusedIndex + 4, state.items.count) { if i != focusedIndex { possibleItems.append((slice.peer, state.items[i])) } - + if slice.peer.id == self.context.account.peerId { pollItems.append(StoryKey(peerId: slice.peer.id, id: state.items[i].storyItem.id)) } } - + var nextPriority = 0 for i in 0 ..< min(possibleItems.count, 3) { let peer = possibleItems[i].0 @@ -1723,14 +1730,14 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { } } } - + var selectedMedia: EngineMedia if let alternativeMediaValue = item.storyItem.alternativeMediaList.first, (!preferHighQualityStories && !item.storyItem.isMy) { selectedMedia = alternativeMediaValue } else { selectedMedia = item.storyItem.media } - + resultResources[mediaId] = StoryPreloadInfo( peer: peerReference, storyId: item.storyItem.id, @@ -1742,7 +1749,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { } } } - + var validIds: [EngineMedia.Id] = [] for (_, info) in resultResources.sorted(by: { $0.value.priority < $1.value.priority }) { if let mediaId = info.media.id { @@ -1752,7 +1759,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { } } } - + var removeIds: [EngineMedia.Id] = [] for (id, disposable) in self.preloadStoryResourceDisposables { if !validIds.contains(id) { @@ -1763,7 +1770,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { for id in removeIds { self.preloadStoryResourceDisposables.removeValue(forKey: id) } - + var pollIdByPeerId: [EnginePeer.Id: [Int32]] = [:] for storyKey in pollItems.prefix(3) { if pollIdByPeerId[storyKey.peerId] == nil { @@ -1779,21 +1786,21 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { })) }) } - + deinit { self.storyDisposable?.dispose() self.storyDataDisposable.dispose() self.requestStoryDisposables.dispose() - + for (_, disposable) in self.preloadStoryResourceDisposables { disposable.dispose() } self.pollStoryMetadataDisposables.dispose() } - + public func resetSideStates() { } - + public func navigate(navigation: StoryContentContextNavigation) { switch navigation { case .peer: @@ -1810,7 +1817,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { indexDifference = nextIndex - index } } - + if let indexDifference, let listState = self.listState, let focusedId = self.focusedId { if let index = listState.items.firstIndex(where: { $0.id == focusedId }) { var nextIndex = index + indexDifference @@ -1828,8 +1835,10 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { } } } - + public func markAsSeen(id: StoryId) { + let wnt = currentWinterGramSettings + if wnt.ghostModeEnabled && !wnt.sendReadStories { return } if !self.context.sharedContext.immediateExperimentalUISettings.skipReadHistory { let _ = self.context.engine.messages.markStoryAsSeen(peerId: id.peerId, id: id.id, asPinned: true).startStandalone() } @@ -1838,9 +1847,9 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { public func preloadStoryMedia(context: AccountContext, info: StoryPreloadInfo) -> Signal { var signals: [Signal] = [] - + let selectedMedia: EngineMedia = info.media - + switch selectedMedia { case let .image(image): if let representation = largestImageRepresentation(image.representations) { @@ -1860,7 +1869,7 @@ public func preloadStoryMedia(context: AccountContext, info: StoryPreloadInfo) - break } } - + if let representation = file.previewRepresentations.first { signals.append(fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: .peer(info.peer.id), userContentType: .story, reference: .media(media: .story(peer: info.peer, id: info.storyId, media: selectedMedia._asMedia()), resource: representation.resource), range: nil) |> ignoreValues @@ -1868,7 +1877,7 @@ public func preloadStoryMedia(context: AccountContext, info: StoryPreloadInfo) - return .complete() }) } - + signals.append(fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: .peer(info.peer.id), userContentType: .story, reference: .media(media: .story(peer: info.peer, id: info.storyId, media: selectedMedia._asMedia()), resource: file.resource), range: fetchRange) |> ignoreValues |> `catch` { _ -> Signal in @@ -1879,7 +1888,7 @@ public func preloadStoryMedia(context: AccountContext, info: StoryPreloadInfo) - default: break } - + var builtinReactions: [String] = [] var customReactions: [Int64] = [] for reaction in info.reactions { @@ -1903,9 +1912,9 @@ public func preloadStoryMedia(context: AccountContext, info: StoryPreloadInfo) - guard let availableReactions = availableReactions else { return .complete() } - + var files: [TelegramMediaFile] = [] - + for reaction in availableReactions.reactions { for value in builtinReactions { if case .builtin(value) = reaction.value { @@ -1913,7 +1922,7 @@ public func preloadStoryMedia(context: AccountContext, info: StoryPreloadInfo) - } } } - + return combineLatest(files.map { file -> Signal in return Signal { subscriber in let loadSignal = context.engine.resources.fetch(reference: .standalone(resource: file.resource), userLocation: .other, userContentType: .sticker) @@ -1921,7 +1930,7 @@ public func preloadStoryMedia(context: AccountContext, info: StoryPreloadInfo) - |> `catch` { _ -> Signal in return .complete() } - + let statusSignal = context.engine.resources.status(resource: EngineMediaResource(file.resource)) |> filter { status in if case .Local = status { @@ -1934,12 +1943,12 @@ public func preloadStoryMedia(context: AccountContext, info: StoryPreloadInfo) - |> map { _ -> Void in return Void() } - + let statusDisposable = statusSignal.start(completed: { subscriber.putCompletion() }) let loadDisposable = loadSignal.start() - + return ActionDisposable { statusDisposable.dispose() loadDisposable.dispose() @@ -1954,13 +1963,13 @@ public func preloadStoryMedia(context: AccountContext, info: StoryPreloadInfo) - |> take(1) |> mapToSignal { resolvedFiles -> Signal in var files: [TelegramMediaFile] = [] - + for (_, file) in resolvedFiles { if customReactions.contains(file.fileId.id) { files.append(file) } } - + return combineLatest(files.map { file -> Signal in return Signal { subscriber in let loadSignal = context.engine.resources.fetch(reference: .standalone(resource: file.resource), userLocation: .other, userContentType: .sticker) @@ -1968,7 +1977,7 @@ public func preloadStoryMedia(context: AccountContext, info: StoryPreloadInfo) - |> `catch` { _ -> Signal in return .complete() } - + let statusSignal = context.engine.resources.status(resource: EngineMediaResource(file.resource)) |> filter { status in if case .Local = status { @@ -1981,12 +1990,12 @@ public func preloadStoryMedia(context: AccountContext, info: StoryPreloadInfo) - |> map { _ -> Void in return Void() } - + let statusDisposable = statusSignal.start(completed: { subscriber.putCompletion() }) let loadDisposable = loadSignal.start() - + return ActionDisposable { statusDisposable.dispose() loadDisposable.dispose() @@ -1996,7 +2005,7 @@ public func preloadStoryMedia(context: AccountContext, info: StoryPreloadInfo) - |> ignoreValues }) } - + return combineLatest(signals) |> ignoreValues } @@ -2016,7 +2025,7 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine return setting && isPremium } |> distinctUntilChanged - + return combineLatest( context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: peerId) @@ -2031,18 +2040,18 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine guard let peer = PeerReference(peerValue) else { return .complete() } - + var statusSignals: [Signal] = [] var loadSignals: [Signal] = [] var fetchPriorityDisposable: Disposable? - + let selectedMedia: EngineMedia if !preferHighQualityStories, let alternativeMediaValue = storyItem.alternativeMediaList.first { selectedMedia = alternativeMediaValue } else { selectedMedia = storyItem.media } - + var fetchPriorityResourceId: String? switch selectedMedia { case let .image(image): @@ -2056,11 +2065,11 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine default: break } - + if let fetchPriorityResourceId { fetchPriorityDisposable = context.engine.resources.pushPriorityDownload(resourceId: fetchPriorityResourceId, priority: 2) } - + switch selectedMedia { case let .image(image): if let representation = largestImageRepresentation(image.representations) { @@ -2072,7 +2081,7 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine |> take(1) |> ignoreValues ) - + loadSignals.append(fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: .peer(peer.id), userContentType: .story, reference: .media(media: .story(peer: peer, id: storyItem.id, media: selectedMedia._asMedia()), resource: representation.resource), range: nil) |> ignoreValues |> `catch` { _ -> Signal in @@ -2089,7 +2098,7 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine break } } - + statusSignals.append( context.engine.resources.resourceRangesStatus(resource: EngineMediaResource(file.resource)) |> filter { ranges in @@ -2102,7 +2111,7 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine |> take(1) |> ignoreValues ) - + loadSignals.append(fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: .peer(peer.id), userContentType: .story, reference: .media(media: .story(peer: peer, id: storyItem.id, media: selectedMedia._asMedia()), resource: file.resource), range: fetchRange) |> ignoreValues |> `catch` { _ -> Signal in @@ -2113,7 +2122,7 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine default: break } - + var builtinReactions: [String] = [] var customReactions: [Int64] = [] for mediaArea in storyItem.mediaAreas { @@ -2139,9 +2148,9 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine guard let availableReactions = availableReactions else { return .complete() } - + var files: [TelegramMediaFile] = [] - + for reaction in availableReactions.reactions { for value in builtinReactions { if case .builtin(value) = reaction.value { @@ -2149,7 +2158,7 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine } } } - + return combineLatest(files.map { file -> Signal in return Signal { subscriber in let loadSignal = context.engine.resources.fetch(reference: .standalone(resource: file.resource), userLocation: .other, userContentType: .sticker) @@ -2157,7 +2166,7 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine |> `catch` { _ -> Signal in return .complete() } - + let statusSignal = context.engine.resources.status(resource: EngineMediaResource(file.resource)) |> filter { status in if case .Local = status { @@ -2170,13 +2179,13 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine |> map { _ -> Void in return Void() } - + let statusDisposable = statusSignal.start(completed: { subscriber.putCompletion() }) let loadDisposable = loadSignal.start() let fileFetchPriorityDisposable = context.engine.resources.pushPriorityDownload(resourceId: file.resource.id.stringRepresentation, priority: 1) - + return ActionDisposable { statusDisposable.dispose() loadDisposable.dispose() @@ -2192,13 +2201,13 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine |> take(1) |> mapToSignal { resolvedFiles -> Signal in var files: [TelegramMediaFile] = [] - + for (_, file) in resolvedFiles { if customReactions.contains(file.fileId.id) { files.append(file) } } - + return combineLatest(files.map { file -> Signal in return Signal { subscriber in let loadSignal = context.engine.resources.fetch(reference: .standalone(resource: file.resource), userLocation: .other, userContentType: .sticker) @@ -2206,7 +2215,7 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine |> `catch` { _ -> Signal in return .complete() } - + let statusSignal = context.engine.resources.status(resource: EngineMediaResource(file.resource)) |> filter { status in if case .Local = status { @@ -2219,13 +2228,13 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine |> map { _ -> Void in return Void() } - + let statusDisposable = statusSignal.start(completed: { subscriber.putCompletion() }) let loadDisposable = loadSignal.start() let fileFetchPriorityDisposable = context.engine.resources.pushPriorityDownload(resourceId: file.resource.id.stringRepresentation, priority: 1) - + return ActionDisposable { statusDisposable.dispose() loadDisposable.dispose() @@ -2236,13 +2245,13 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine |> ignoreValues }) } - + return Signal { subscriber in let statusDisposable = combineLatest(statusSignals).start(completed: { subscriber.putCompletion() }) let loadDisposable = combineLatest(loadSignals).start() - + return ActionDisposable { statusDisposable.dispose() loadDisposable.dispose() @@ -2325,17 +2334,17 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { private final class PeerContext { private let context: AccountContext let peerId: EnginePeer.Id - + private(set) var sliceValue: StoryContentContextState.FocusedSlice? fileprivate var nextItems: [EngineStoryItem] = [] - + let updated = Promise() - + private(set) var isReady: Bool = false - + private var disposable: Disposable? private var loadDisposable: Disposable? - + private let currentFocusedIdUpdatedPromise = Promise() private var storedFocusedId: Int32? private var currentMappedItems: [EngineStoryItem]? @@ -2347,9 +2356,9 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { } } } - + private var currentForwardInfoStories: [StoryId: Promise] = [:] - + init( context: AccountContext, originalPeerId: EnginePeer.Id, @@ -2360,12 +2369,12 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { ) { self.context = context self.peerId = peerId - + self.currentFocusedId = initialFocusedId self.currentFocusedIdUpdatedPromise.set(.single(Void())) - + context.engine.account.viewTracker.refreshCanSendMessagesForPeerIds(peerIds: [peerId]) - + let preferHighQualityStories: Signal = combineLatest( context.sharedContext.automaticMediaDownloadSettings |> map { settings in @@ -2381,9 +2390,9 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { return setting && isPremium } |> distinctUntilChanged - + let originalStoryId = StoryId(peerId: originalPeerId, id: originalStory.id) - + let inputKeys: [PostboxViewKey] = [ PostboxViewKey.basicPeer(peerId), PostboxViewKey.cachedPeerData(peerId: peerId), @@ -2405,7 +2414,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { var peers: [PeerId: Peer] = [:] var forwardInfoStories: [StoryId: EngineStoryItem?] = [:] var allEntityFiles: [MediaId: TelegramMediaFile] = [:] - + for item in items { if let forwardInfo = item.forwardInfo, case let .known(peer, id, _) = forwardInfo { let storyId = StoryId(peerId: peer.id, id: id) @@ -2461,9 +2470,9 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { if let presencesView = views.views[PostboxViewKey.peerPresences(peerIds: Set([peerId]))] as? PeerPresencesView { peerPresence = presencesView.presences[peerId] } - + let (globalNotificationSettings, isPremiumRequiredForMessaging) = data - + for (storyId, story) in forwardInfoStories { let promise: Promise var added = false @@ -2482,7 +2491,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { promise.set(self.context.engine.messages.getStory(peerId: storyId.peerId, id: storyId.id)) } } - + if let cachedPeerDataView = views.views[PostboxViewKey.cachedPeerData(peerId: peerId)] as? CachedPeerDataView { if let cachedUserData = cachedPeerDataView.cachedPeerData as? CachedUserData { var isMuted = false @@ -2545,12 +2554,12 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { sendPaidMessageStars: nil ) } - + let mappedItems = items let totalCount = mappedItems.count - + let currentFocusedId = self.storedFocusedId - + var focusedIndex: Int? if let currentFocusedId { focusedIndex = mappedItems.firstIndex(where: { $0.id == currentFocusedId }) @@ -2564,7 +2573,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { } } } - + if focusedIndex == nil && previousIndex != 0 { for index in (0 ..< previousIndex).reversed() { if let value = mappedItems.firstIndex(where: { $0.id == currentMappedItems[index].id }) { @@ -2582,27 +2591,27 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { focusedIndex = 0 } } - + self.currentMappedItems = mappedItems - + if let focusedIndex { self.storedFocusedId = mappedItems[focusedIndex].id - + var previousItemId: StoryId? var nextItemId: StoryId? - + if focusedIndex != 0 { previousItemId = StoryId(peerId: peerId, id: mappedItems[focusedIndex - 1].id) } if focusedIndex != mappedItems.count - 1 { nextItemId = StoryId(peerId: peerId, id: mappedItems[focusedIndex + 1].id) } - + let mappedFocusedIndex = mappedItems.firstIndex(where: { $0.id == mappedItems[focusedIndex].id }) - + do { let mappedItem = mappedItems[focusedIndex] - + var nextItems: [EngineStoryItem] = [] for i in (focusedIndex + 1) ..< min(focusedIndex + 4, mappedItems.count) { do { @@ -2610,7 +2619,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { nextItems.append(item) } } - + let allItems = mappedItems.map { item in return StoryContentItem( id: StoryId(peerId: peer.id, id: item.id), @@ -2622,7 +2631,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { itemPeer: nil ) } - + self.nextItems = nextItems self.sliceValue = StoryContentContextState.FocusedSlice( peer: peer, @@ -2651,31 +2660,31 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { } }) } - + deinit { self.disposable?.dispose() self.loadDisposable?.dispose() } } - + private final class StateContext { let centralPeerContext: PeerContext let previousPeerContext: PeerContext? let nextPeerContext: PeerContext? - + let updated = Promise() - + var isReady: Bool { if !self.centralPeerContext.isReady { return false } return true } - + private var centralDisposable: Disposable? private var previousDisposable: Disposable? private var nextDisposable: Disposable? - + init( centralPeerContext: PeerContext, previousPeerContext: PeerContext?, @@ -2684,7 +2693,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { self.centralPeerContext = centralPeerContext self.previousPeerContext = previousPeerContext self.nextPeerContext = nextPeerContext - + self.centralDisposable = (centralPeerContext.updated.get() |> deliverOnMainQueue).startStrict(next: { [weak self] _ in guard let self else { @@ -2692,7 +2701,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { } self.updated.set(.single(Void())) }) - + if let previousPeerContext { self.previousDisposable = (previousPeerContext.updated.get() |> deliverOnMainQueue).startStrict(next: { [weak self] _ in @@ -2702,7 +2711,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { self.updated.set(.single(Void())) }) } - + if let nextPeerContext { self.nextDisposable = (nextPeerContext.updated.get() |> deliverOnMainQueue).startStrict(next: { [weak self] _ in @@ -2713,13 +2722,13 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { }) } } - + deinit { self.centralDisposable?.dispose() self.previousDisposable?.dispose() self.nextDisposable?.dispose() } - + func findPeerContext(id: EnginePeer.Id) -> PeerContext? { if self.centralPeerContext.sliceValue?.peer.id == id { return self.centralPeerContext @@ -2733,51 +2742,51 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { return nil } } - + private final class PeerStoryItem { let peer: EnginePeer let story: EngineStoryItem - + init(peer: EnginePeer, story: EngineStoryItem) { self.peer = peer self.story = story } } - + private let context: AccountContext private let originalPeerId: EnginePeer.Id private let originalStory: EngineStoryItem private let viewListContext: EngineStoryViewListContext private let readGlobally: Bool - + public private(set) var stateValue: StoryContentContextState? public var state: Signal { return self.statePromise.get() } private let statePromise = Promise() - + private let updatedPromise = Promise() public var updated: Signal { return self.updatedPromise.get() } - + private var focusedItem: (peerId: EnginePeer.Id, storyId: Int32?)? - + private var currentState: StateContext? private var currentStateUpdatedDisposable: Disposable? - + private var pendingState: StateContext? private var pendingStateReadyDisposable: Disposable? - + private var storyItems: [PeerStoryItem]? private var storySubscriptionsDisposable: Disposable? - + private var requestedStoryKeys = Set() private var requestStoryDisposables = DisposableSet() - + private var preloadStoryResourceDisposables: [MediaId: Disposable] = [:] private var pollStoryMetadataDisposables: [StoryId: Disposable] = [:] - + public init( context: AccountContext, originalPeerId: EnginePeer.Id, @@ -2792,20 +2801,20 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { self.focusedItem = (focusedStoryId.peerId, focusedStoryId.id) self.viewListContext = viewListContext self.readGlobally = readGlobally - + self.storySubscriptionsDisposable = (viewListContext.state |> deliverOnMainQueue).startStrict(next: { [weak self] viewListState in guard let self else { return } - + let storyItems = viewListState.items.compactMap { item in if let story = item.story { return PeerStoryItem(peer: item.peer, story: story) } return nil } - + var centralIndex: Int? if let (focusedPeerId, _) = self.focusedItem { if let index = storyItems.firstIndex(where: { $0.peer.id == focusedPeerId }) { @@ -2815,12 +2824,12 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { if centralIndex == nil && !storyItems.isEmpty { centralIndex = 0 } - + self.storyItems = storyItems self.updatePeerContexts() }) } - + deinit { self.storySubscriptionsDisposable?.dispose() self.requestStoryDisposables.dispose() @@ -2834,17 +2843,17 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { self.currentStateUpdatedDisposable?.dispose() self.pendingStateReadyDisposable?.dispose() } - + private func updatePeerContexts() { if let currentState = self.currentState, let storyItems = self.storyItems, !storyItems.contains(where: { $0.peer.id == currentState.centralPeerContext.peerId }) { self.currentState = nil } - + if self.currentState == nil { self.switchToFocusedPeerId() } } - + private func switchToFocusedPeerId() { if let currentStoryItems = self.storyItems { if self.pendingState == nil { @@ -2859,7 +2868,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { centralIndex = 0 } } - + if let centralIndex { let centralPeerContext: PeerContext if let currentState = self.currentState, let existingContext = currentState.findPeerContext(id: currentStoryItems[centralIndex].peer.id) { @@ -2867,7 +2876,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { } else { centralPeerContext = PeerContext(context: self.context, originalPeerId: self.originalPeerId, originalStory: self.originalStory, peerId: currentStoryItems[centralIndex].peer.id, focusedId: nil, items: [currentStoryItems[centralIndex].story]) } - + var previousPeerContext: PeerContext? if centralIndex != 0 { if let currentState = self.currentState, let existingContext = currentState.findPeerContext(id: currentStoryItems[centralIndex - 1].peer.id) { @@ -2876,7 +2885,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { previousPeerContext = PeerContext(context: self.context, originalPeerId: self.originalPeerId, originalStory: self.originalStory, peerId: currentStoryItems[centralIndex - 1].peer.id, focusedId: nil, items: [currentStoryItems[centralIndex - 1].story]) } } - + var nextPeerContext: PeerContext? if centralIndex != currentStoryItems.count - 1 { if let currentState = self.currentState, let existingContext = currentState.findPeerContext(id: currentStoryItems[centralIndex + 1].peer.id) { @@ -2885,7 +2894,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { nextPeerContext = PeerContext(context: self.context, originalPeerId: self.originalPeerId, originalStory: self.originalStory, peerId: currentStoryItems[centralIndex + 1].peer.id, focusedId: nil, items: [currentStoryItems[centralIndex + 1].story]) } } - + let pendingState = StateContext( centralPeerContext: centralPeerContext, previousPeerContext: previousPeerContext, @@ -2900,11 +2909,11 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { self.pendingState = nil self.pendingStateReadyDisposable?.dispose() self.pendingStateReadyDisposable = nil - + self.currentState = pendingState - + self.updateState() - + self.currentStateUpdatedDisposable?.dispose() self.currentStateUpdatedDisposable = (pendingState.updated.get() |> deliverOnMainQueue).startStrict(next: { [weak self, weak pendingState] _ in @@ -2920,7 +2929,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { self.updateState() } } - + private func updateState() { guard let currentState = self.currentState else { return @@ -2932,9 +2941,9 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { ) self.stateValue = stateValue self.statePromise.set(.single(stateValue)) - + self.updatedPromise.set(.single(Void())) - + var possibleItems: [(EnginePeer, EngineStoryItem)] = [] var pollItems: [StoryKey] = [] if let slice = currentState.centralPeerContext.sliceValue { @@ -2947,10 +2956,10 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { if shouldPollItem { pollItems.append(StoryKey(peerId: slice.peer.id, id: slice.item.storyItem.id)) } - + for item in currentState.centralPeerContext.nextItems { possibleItems.append((slice.peer, item)) - + var shouldPollNextItem = false if slice.peer.id == self.context.account.peerId { shouldPollNextItem = true @@ -2968,7 +2977,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { possibleItems.append((slice.peer, item)) } } - + var nextPriority = 0 var resultResources: [EngineMedia.Id: StoryPreloadInfo] = [:] for i in 0 ..< min(possibleItems.count, 3) { @@ -2983,14 +2992,14 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { } } } - + var selectedMedia: EngineMedia if let slice = stateValue.slice, let alternativeMediaValue = item.alternativeMediaList.first, (!slice.additionalPeerData.preferHighQualityStories && !item.isMy) { selectedMedia = alternativeMediaValue } else { selectedMedia = item.media } - + resultResources[mediaId] = StoryPreloadInfo( peer: peerReference, storyId: item.id, @@ -3001,7 +3010,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { nextPriority += 1 } } - + var validIds: [EngineMedia.Id] = [] for (id, info) in resultResources.sorted(by: { $0.value.priority < $1.value.priority }) { validIds.append(id) @@ -3009,7 +3018,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { self.preloadStoryResourceDisposables[id] = preloadStoryMedia(context: context, info: info).startStrict() } } - + var removeIds: [EngineMedia.Id] = [] for (id, disposable) in self.preloadStoryResourceDisposables { if !validIds.contains(id) { @@ -3020,7 +3029,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { for id in removeIds { self.preloadStoryResourceDisposables.removeValue(forKey: id) } - + var pollIdByPeerId: [EnginePeer.Id: [Int32]] = [:] for storyKey in pollItems.prefix(3) { if pollIdByPeerId[storyKey.peerId] == nil { @@ -3037,7 +3046,7 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { } } } - + public func resetSideStates() { guard let currentState = self.currentState else { return @@ -3049,12 +3058,12 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { nextPeerContext.currentFocusedId = nil } } - + public func navigate(navigation: StoryContentContextNavigation) { guard let currentState = self.currentState else { return } - + switch navigation { case let .peer(direction): switch direction { @@ -3092,8 +3101,10 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { } } } - + public func markAsSeen(id: StoryId) { + let wnt = currentWinterGramSettings + if wnt.ghostModeEnabled && !wnt.sendReadStories { return } if !self.context.sharedContext.immediateExperimentalUISettings.skipReadHistory { let _ = self.context.engine.messages.markStoryAsSeen(peerId: id.peerId, id: id.id, asPinned: false).startStandalone() } diff --git a/submodules/TelegramUI/Images.xcassets/Item List/Icons/GitHub.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Item List/Icons/GitHub.imageset/Contents.json new file mode 100644 index 0000000000..a9abf44265 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Item List/Icons/GitHub.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "github.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Item List/Icons/GitHub.imageset/github.png b/submodules/TelegramUI/Images.xcassets/Item List/Icons/GitHub.imageset/github.png new file mode 100644 index 0000000000..c52e86579f Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Item List/Icons/GitHub.imageset/github.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Item List/Icons/WinterGram.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Item List/Icons/WinterGram.imageset/Contents.json index d3e59ac636..24c25ff585 100644 --- a/submodules/TelegramUI/Images.xcassets/Item List/Icons/WinterGram.imageset/Contents.json +++ b/submodules/TelegramUI/Images.xcassets/Item List/Icons/WinterGram.imageset/Contents.json @@ -1,8 +1,18 @@ { "images" : [ { - "filename" : "wintergram_snowball_30.pdf", - "idiom" : "universal" + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "wintergram_snowflake_30@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "wintergram_snowflake_30@3x.png", + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/submodules/TelegramUI/Images.xcassets/Item List/Icons/WinterGram.imageset/wintergram_snowball_30.pdf b/submodules/TelegramUI/Images.xcassets/Item List/Icons/WinterGram.imageset/wintergram_snowball_30.pdf deleted file mode 100644 index fead9c9762..0000000000 Binary files a/submodules/TelegramUI/Images.xcassets/Item List/Icons/WinterGram.imageset/wintergram_snowball_30.pdf and /dev/null differ diff --git a/submodules/TelegramUI/Images.xcassets/Item List/Icons/WinterGram.imageset/wintergram_snowflake_30@2x.png b/submodules/TelegramUI/Images.xcassets/Item List/Icons/WinterGram.imageset/wintergram_snowflake_30@2x.png new file mode 100644 index 0000000000..611bf37589 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Item List/Icons/WinterGram.imageset/wintergram_snowflake_30@2x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Item List/Icons/WinterGram.imageset/wintergram_snowflake_30@3x.png b/submodules/TelegramUI/Images.xcassets/Item List/Icons/WinterGram.imageset/wintergram_snowflake_30@3x.png new file mode 100644 index 0000000000..3451fbcc3d Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Item List/Icons/WinterGram.imageset/wintergram_snowflake_30@3x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/WntGramDeveloperBadge.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Peer Info/WntGramDeveloperBadge.imageset/Contents.json new file mode 100644 index 0000000000..b20614002d --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/WntGramDeveloperBadge.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "badge.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "badge@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "badge@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/WntGramDeveloperBadge.imageset/badge.png b/submodules/TelegramUI/Images.xcassets/Peer Info/WntGramDeveloperBadge.imageset/badge.png new file mode 100644 index 0000000000..3b12aef209 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Peer Info/WntGramDeveloperBadge.imageset/badge.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/WntGramDeveloperBadge.imageset/badge@2x.png b/submodules/TelegramUI/Images.xcassets/Peer Info/WntGramDeveloperBadge.imageset/badge@2x.png new file mode 100644 index 0000000000..e62ae8e755 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Peer Info/WntGramDeveloperBadge.imageset/badge@2x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/WntGramDeveloperBadge.imageset/badge@3x.png b/submodules/TelegramUI/Images.xcassets/Peer Info/WntGramDeveloperBadge.imageset/badge@3x.png new file mode 100644 index 0000000000..971cce5e4f Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Peer Info/WntGramDeveloperBadge.imageset/badge@3x.png differ diff --git a/submodules/TelegramUI/Sources/AccountContext.swift b/submodules/TelegramUI/Sources/AccountContext.swift index dcbf7c913f..7dbbb52307 100644 --- a/submodules/TelegramUI/Sources/AccountContext.swift +++ b/submodules/TelegramUI/Sources/AccountContext.swift @@ -28,10 +28,10 @@ import DirectMediaImageCache private final class DeviceSpecificContactImportContext { let disposable = MetaDisposable() var reference: DeviceContactBasicDataWithReference? - + init() { } - + deinit { self.disposable.dispose() } @@ -39,22 +39,22 @@ private final class DeviceSpecificContactImportContext { private final class DeviceSpecificContactImportContexts { private let queue: Queue - + private var contexts: [PeerId: DeviceSpecificContactImportContext] = [:] - + init(queue: Queue) { self.queue = queue } - + deinit { assert(self.queue.isCurrent()) } - + func update(account: Account, deviceContactDataManager: DeviceContactDataManager, references: [PeerId: DeviceContactBasicDataWithReference]) { var validIds = Set() for (peerId, reference) in references { validIds.insert(peerId) - + let context: DeviceSpecificContactImportContext if let current = self.contexts[peerId] { context = current @@ -64,7 +64,7 @@ private final class DeviceSpecificContactImportContexts { } if context.reference != reference { context.reference = reference - + let signal = TelegramEngine(account: account).data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) |> map { peer -> String? in if case let .user(user) = peer { @@ -96,7 +96,7 @@ private final class DeviceSpecificContactImportContexts { context.disposable.set(signal.start()) } } - + var removeIds: [PeerId] = [] for peerId in self.contexts.keys { if !validIds.contains(peerId) { @@ -116,14 +116,14 @@ public final class AccountContextImpl: AccountContext { } public let account: Account public let engine: TelegramEngine - + public let fetchManager: FetchManager public let prefetchManager: PrefetchManager? - + public var keyShortcutsController: KeyShortcutsController? - + public let downloadedMediaStoreManager: DownloadedMediaStoreManager - + public let liveLocationManager: LiveLocationManager? public let wallpaperUploadManager: WallpaperUploadManager? private let themeUpdateManager: ThemeUpdateManager? @@ -131,56 +131,56 @@ public final class AccountContextImpl: AccountContext { public let starsContext: StarsContext? public let tonContext: StarsContext? public let giftAuctionsManager: GiftAuctionsManager? - + public let peerChannelMemberCategoriesContextsManager = PeerChannelMemberCategoriesContextsManager() - + public let currentLimitsConfiguration: Atomic private let _limitsConfiguration = Promise() public var limitsConfiguration: Signal { return self._limitsConfiguration.get() } - + public var currentContentSettings: Atomic private let _contentSettings = Promise() public var contentSettings: Signal { return self._contentSettings.get() } - + public var currentAppConfiguration: Atomic private let _appConfiguration = Promise() public var appConfiguration: Signal { return self._appConfiguration.get() } - + public var currentCountriesConfiguration: Atomic private let _countriesConfiguration = Promise() public var countriesConfiguration: Signal { return self._countriesConfiguration.get() } - + private var storedPassword: (String, CFAbsoluteTime, SwiftSignalKit.Timer)? private var limitsConfigurationDisposable: Disposable? private var contentSettingsDisposable: Disposable? private var appConfigurationDisposable: Disposable? private var countriesConfigurationDisposable: Disposable? - + private let deviceSpecificContactImportContexts: QueueLocalObject private var managedAppSpecificContactsDisposable: Disposable? - + private var experimentalUISettingsDisposable: Disposable? - + public let cachedGroupCallContexts: AccountGroupCallContextCache - + public let animationCache: AnimationCache public let animationRenderer: MultiAnimationRenderer - + private var animatedEmojiStickersDisposable: Disposable? public private(set) var animatedEmojiStickersValue: [String: [StickerPackItem]] = [:] private let animatedEmojiStickersPromise = Promise<[String: [StickerPackItem]]>() public var animatedEmojiStickers: Signal<[String: [StickerPackItem]], NoError> { return self.animatedEmojiStickersPromise.get() } - + private var additionalAnimatedEmojiStickersPromise: Promise<[String: [Int: StickerPackItem]]>? public var additionalAnimatedEmojiStickers: Signal<[String: [Int: StickerPackItem]], NoError> { let additionalAnimatedEmojiStickersPromise: Promise<[String: [Int: StickerPackItem]]> @@ -210,7 +210,7 @@ public final class AccountContextImpl: AccountContext { emoji = nil indexEmoji = nil } - + if let emoji = emoji?.strippedEmoji, let indexEmoji = indexEmoji?.strippedEmoji.first, let strIndex = sequence.firstIndex(of: indexEmoji) { let index = sequence.distance(from: sequence.startIndex, to: strIndex) if animatedEmojiStickers[emoji] != nil { @@ -229,7 +229,7 @@ public final class AccountContextImpl: AccountContext { } return additionalAnimatedEmojiStickersPromise.get() } - + private var availableReactionsValue: Promise? public var availableReactions: Signal { let availableReactionsValue: Promise @@ -242,7 +242,7 @@ public final class AccountContextImpl: AccountContext { } return availableReactionsValue.get() } - + private var availableMessageEffectsValue: Promise? public var availableMessageEffects: Signal { let availableMessageEffectsValue: Promise @@ -255,39 +255,42 @@ public final class AccountContextImpl: AccountContext { } return availableMessageEffectsValue.get() } - + private var userLimitsConfigurationDisposable: Disposable? public private(set) var userLimits: EngineConfiguration.UserLimits - + private var peerNameColorsConfigurationDisposable: Disposable? public private(set) var peerNameColors: PeerNameColors - + private var audioTranscriptionTrialDisposable: Disposable? public private(set) var audioTranscriptionTrial: AudioTranscription.TrialState - - public private(set) var isPremium: Bool - + + private var actualIsPremium: Bool + public var isPremium: Bool { + return self.actualIsPremium || currentWinterGramSettings.localPremium + } + private var isFrozenDisposable: Disposable? public private(set) var isFrozen: Bool - + public let imageCache: AnyObject? - + public init(sharedContext: SharedAccountContextImpl, account: Account, limitsConfiguration: LimitsConfiguration, contentSettings: ContentSettings, appConfiguration: AppConfiguration, availableReplyColors: EngineAvailableColorOptions, availableProfileColors: EngineAvailableColorOptions, temp: Bool = false) { self.sharedContextImpl = sharedContext self.account = account self.engine = TelegramEngine(account: account) - + self.imageCache = DirectMediaImageCache(account: account) - + self.userLimits = EngineConfiguration.UserLimits(UserLimitsConfiguration.defaultValue) self.peerNameColors = PeerNameColors.with(availableReplyColors: availableReplyColors, availableProfileColors: availableProfileColors) self.audioTranscriptionTrial = AudioTranscription.TrialState.defaultValue - self.isPremium = false + self.actualIsPremium = false self.isFrozen = false - + self.downloadedMediaStoreManager = DownloadedMediaStoreManagerImpl(postbox: account.postbox, accountManager: sharedContext.accountManager) - + if let locationManager = self.sharedContextImpl.locationManager { self.liveLocationManager = LiveLocationManagerImpl(engine: self.engine, locationManager: locationManager, inForeground: sharedContext.applicationBindings.applicationInForeground) } else { @@ -298,7 +301,7 @@ public final class AccountContextImpl: AccountContext { self.prefetchManager = PrefetchManagerImpl(sharedContext: sharedContext, account: account, engine: self.engine, fetchManager: self.fetchManager) self.wallpaperUploadManager = WallpaperUploadManagerImpl(sharedContext: sharedContext, account: account, presentationData: sharedContext.presentationData) self.themeUpdateManager = ThemeUpdateManagerImpl(sharedContext: sharedContext, account: account) - + self.inAppPurchaseManager = InAppPurchaseManager(engine: .authorized(self.engine)) self.starsContext = self.engine.payments.peerStarsContext() self.tonContext = self.engine.payments.peerTonContext() @@ -312,12 +315,12 @@ public final class AccountContextImpl: AccountContext { self.tonContext = nil self.giftAuctionsManager = nil } - + self.account.stateManager.starsContext = self.starsContext self.account.stateManager.tonContext = self.starsContext - + self.cachedGroupCallContexts = AccountGroupCallContextCacheImpl() - + let cacheStorageBox = self.account.postbox.mediaBox.cacheStorageBox self.animationCache = DCTAnimationCacheImpl(basePath: self.account.postbox.mediaBox.basePath + "/animation-cache", allocateTempFile: { return TempBox.shared.tempFile(fileName: "file").path @@ -328,48 +331,48 @@ public final class AccountContextImpl: AccountContext { }) self.animationRenderer = DCTMultiAnimationRendererImpl() (self.animationRenderer as? DCTMultiAnimationRendererImpl)?.useYuvA = sharedContext.immediateExperimentalUISettings.compressedEmojiCache - + let updatedLimitsConfiguration = self.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key: PreferencesKeys.limitsConfiguration)) |> map { preferences -> LimitsConfiguration in return preferences?.get(LimitsConfiguration.self) ?? LimitsConfiguration.defaultValue } - + self.currentLimitsConfiguration = Atomic(value: limitsConfiguration) self._limitsConfiguration.set(.single(limitsConfiguration) |> then(updatedLimitsConfiguration)) - + let currentLimitsConfiguration = self.currentLimitsConfiguration self.limitsConfigurationDisposable = (self._limitsConfiguration.get() |> deliverOnMainQueue).start(next: { value in let _ = currentLimitsConfiguration.swap(value) }) - + let updatedContentSettings = getContentSettings(postbox: account.postbox) self.currentContentSettings = Atomic(value: contentSettings) self._contentSettings.set(.single(contentSettings) |> then(updatedContentSettings)) - + let currentContentSettings = self.currentContentSettings self.contentSettingsDisposable = (self._contentSettings.get() |> deliverOnMainQueue).start(next: { value in let _ = currentContentSettings.swap(value) }) - + let updatedAppConfiguration = getAppConfiguration(engine: self.engine) self.currentAppConfiguration = Atomic(value: appConfiguration) self._appConfiguration.set(.single(appConfiguration) |> then(updatedAppConfiguration)) - + let currentAppConfiguration = self.currentAppConfiguration self.appConfigurationDisposable = (self._appConfiguration.get() |> deliverOnMainQueue).start(next: { value in let _ = currentAppConfiguration.swap(value) - + guard let data = appConfiguration.data else { return } - + if data["ios_killswitch_contact_diffing"] != nil { sharedDisableDeviceContactDataDiffing = true } - + if let url = data["ios_update_url"] as? String, !url.isEmpty { let _ = (sharedContext.accountManager.transaction { transaction -> Void in transaction.updateSharedData(ApplicationSpecificSharedDataKeys.updateSettings, { _ in @@ -378,12 +381,12 @@ public final class AccountContextImpl: AccountContext { }).start() } }) - + let queue = Queue() self.deviceSpecificContactImportContexts = QueueLocalObject(queue: queue, generate: { return DeviceSpecificContactImportContexts(queue: queue) }) - + let langCode = sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode self.currentCountriesConfiguration = Atomic(value: CountriesConfiguration(countries: loadCountryCodes())) if !temp { @@ -395,7 +398,7 @@ public final class AccountContextImpl: AccountContext { self?._countriesConfiguration.set(.single(configuration)) }) } - + if let contactDataManager = sharedContext.contactDataManager { let deviceSpecificContactImportContexts = self.deviceSpecificContactImportContexts self.managedAppSpecificContactsDisposable = (contactDataManager.appSpecificReferences() @@ -405,11 +408,11 @@ public final class AccountContextImpl: AccountContext { } }) } - + account.callSessionManager.updateVersions(versions: PresentationCallManagerImpl.voipVersions(includeExperimental: true, includeReference: true).map { version, supportsVideo -> CallSessionManagerImplementationVersion in CallSessionManagerImplementationVersion(version: version, supportsVideo: supportsVideo) }) - + self.animatedEmojiStickersDisposable = (self.engine.stickers.loadedStickerPack(reference: .animatedEmoji, forceActualized: false) |> map { animatedEmoji -> [String: [StickerPackItem]] in var animatedEmojiStickers: [String: [StickerPackItem]] = [:] @@ -436,7 +439,7 @@ public final class AccountContextImpl: AccountContext { strongSelf.animatedEmojiStickersValue = stickers strongSelf.animatedEmojiStickersPromise.set(.single(stickers)) }) - + self.userLimitsConfigurationDisposable = (self.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: account.peerId)) |> mapToSignal { peer -> Signal<(Bool, EngineConfiguration.UserLimits), NoError> in let isPremium = peer?.isPremium ?? false @@ -449,10 +452,10 @@ public final class AccountContextImpl: AccountContext { guard let self = self else { return } - self.isPremium = isPremium + self.actualIsPremium = isPremium self.userLimits = userLimits }) - + self.peerNameColorsConfigurationDisposable = (combineLatest( self.engine.accountData.observeAvailableColorOptions(scope: .replies), self.engine.accountData.observeAvailableColorOptions(scope: .profile) @@ -463,10 +466,10 @@ public final class AccountContextImpl: AccountContext { } self.peerNameColors = PeerNameColors.with(availableReplyColors: availableReplyColors, availableProfileColors: availableProfileColors) }) - + self.audioTranscriptionTrialDisposable = (self.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: account.peerId)) |> mapToSignal { peer -> Signal in - let isPremium = peer?.isPremium ?? false + let isPremium = (peer?.isPremium ?? false) || currentWinterGramSettings.localPremium if isPremium { return .single(AudioTranscription.TrialState(cooldownUntilTime: nil, remainingCount: 1)) } else { @@ -479,7 +482,7 @@ public final class AccountContextImpl: AccountContext { } self.audioTranscriptionTrial = audioTranscriptionTrial }) - + self.isFrozenDisposable = (self.appConfiguration |> map { appConfiguration in return AccountFreezeConfiguration.with(appConfiguration: appConfiguration).freezeUntilDate != nil @@ -491,7 +494,7 @@ public final class AccountContextImpl: AccountContext { } self.isFrozen = isFrozen }) - + self.experimentalUISettingsDisposable = (sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.experimentalUISettings]) |> deliverOnMainQueue).start(next: { [weak self] sharedData in guard let self else { @@ -503,7 +506,7 @@ public final class AccountContextImpl: AccountContext { (self.animationRenderer as? DCTMultiAnimationRendererImpl)?.useYuvA = settings.compressedEmojiCache }) } - + deinit { self.limitsConfigurationDisposable?.dispose() self.managedAppSpecificContactsDisposable?.dispose() @@ -516,7 +519,7 @@ public final class AccountContextImpl: AccountContext { self.peerNameColorsConfigurationDisposable?.dispose() self.isFrozenDisposable?.dispose() } - + public func storeSecureIdPassword(password: String) { self.storedPassword?.2.invalidate() let timer = SwiftSignalKit.Timer(timeout: 1.0 * 60.0 * 60.0, repeat: false, completion: { [weak self] in @@ -525,7 +528,7 @@ public final class AccountContextImpl: AccountContext { self.storedPassword = (password, CFAbsoluteTimeGetCurrent(), timer) timer.start() } - + public func getStoredSecureIdPassword() -> String? { if let (password, timestamp, timer) = self.storedPassword { if CFAbsoluteTimeGetCurrent() > timestamp + 1.0 * 60.0 * 60.0 { @@ -537,7 +540,7 @@ public final class AccountContextImpl: AccountContext { return nil } } - + public func chatLocationInput(for location: ChatLocation, contextHolder: Atomic) -> ChatLocationInput { switch location { case let .peer(peerId): @@ -553,7 +556,7 @@ public final class AccountContextImpl: AccountContext { preconditionFailure() } } - + public func chatLocationOutgoingReadState(for location: ChatLocation, contextHolder: Atomic) -> Signal { switch location { case .peer: @@ -603,8 +606,13 @@ public final class AccountContextImpl: AccountContext { return .single(0) } } - + public func applyMaxReadIndex(for location: ChatLocation, contextHolder: Atomic, messageIndex: MessageIndex) { + let settings = currentWinterGramSettings + if settings.suppressesReadReceipts { + return + } + switch location { case .peer: let _ = self.engine.messages.applyMaxReadIndexInteractively(index: messageIndex).start() @@ -615,11 +623,11 @@ public final class AccountContextImpl: AccountContext { break } } - + public func scheduleGroupCall(peerId: PeerId, parentController: ViewController) { let _ = self.sharedContext.callManager?.scheduleGroupCall(context: self, peerId: peerId, endCurrentIfAny: true, parentController: parentController) } - + public func joinGroupCall(peerId: PeerId, invite: String?, requestJoinAsPeerId: ((@escaping (PeerId?) -> Void) -> Void)?, activeCall: EngineGroupCallDescription) { let callResult = self.sharedContext.callManager?.joinGroupCall(context: self, peerId: peerId, invite: invite, requestJoinAsPeerId: requestJoinAsPeerId, initialCall: activeCall, endCurrentIfAny: false) if let callResult = callResult, case let .alreadyInProgress(currentCallType) = callResult { @@ -640,7 +648,7 @@ public final class AccountContextImpl: AccountContext { return (peer, nil) } } - + let _ = (dataInput |> deliverOnMainQueue).start(next: { [weak self] peer, current in guard let strongSelf = self else { @@ -691,7 +699,7 @@ public final class AccountContextImpl: AccountContext { } } } - + public func joinConferenceCall(call: JoinCallLinkInformation, isVideo: Bool, unmuteByDefault: Bool) { guard let callManager = self.sharedContext.callManager else { return @@ -721,7 +729,7 @@ public final class AccountContextImpl: AccountContext { } else { dataInput = .single(nil) } - + let _ = (dataInput |> deliverOnMainQueue).start(next: { [weak self] current in guard let strongSelf = self else { @@ -818,12 +826,12 @@ public final class AccountContextImpl: AccountContext { }) } } - + public func requestCall(peerId: PeerId, isVideo: Bool, completion: @escaping () -> Void) { guard let callResult = self.sharedContext.callManager?.requestCall(context: self, peerId: peerId, isVideo: isVideo, endCurrentIfAny: false) else { return } - + if case let .alreadyInProgress(currentCallType) = callResult { if case let .peer(currentPeerId) = currentCallType, currentPeerId == peerId { completion() @@ -843,7 +851,7 @@ public final class AccountContextImpl: AccountContext { return (peer, nil) } } - + let _ = (dataInput |> deliverOnMainQueue).start(next: { [weak self] peer, current in guard let strongSelf = self else { @@ -903,7 +911,7 @@ private func chatLocationContext(holder: Atomic, acc private final class ChatLocationReplyContextHolderImpl: ChatLocationContextHolder { let context: ReplyThreadHistoryContext - + init(account: Account, data: ChatReplyThreadMessage) { self.context = ReplyThreadHistoryContext(account: account, peerId: data.peerId, data: data) } @@ -928,38 +936,38 @@ private func loadCountryCodes() -> [Country] { guard let data = String(data: stringData, encoding: .utf8) else { return [] } - + let delimiter = ";" let endOfLine = "\n" - + var result: [Country] = [] // var countriesByPrefix: [String: (Country, Country.CountryCode)] = [:] - + var currentLocation = data.startIndex - + let locale = Locale(identifier: "en-US") - + while true { guard let codeRange = data.range(of: delimiter, options: [], range: currentLocation ..< data.endIndex) else { break } - + let countryCode = String(data[currentLocation ..< codeRange.lowerBound]) - + guard let idRange = data.range(of: delimiter, options: [], range: codeRange.upperBound ..< data.endIndex) else { break } - + let countryId = String(data[codeRange.upperBound ..< idRange.lowerBound]) - + guard let patternRange = data.range(of: delimiter, options: [], range: idRange.upperBound ..< data.endIndex) else { break } - + let pattern = String(data[idRange.upperBound ..< patternRange.lowerBound]) - + let maybeNameRange = data.range(of: endOfLine, options: [], range: patternRange.upperBound ..< data.endIndex) - + let countryName = locale.localizedString(forIdentifier: countryId) ?? "" if let _ = Int(countryCode) { let code = Country.CountryCode(code: countryCode, prefixes: [], patterns: !pattern.isEmpty ? [pattern] : []) @@ -967,13 +975,13 @@ private func loadCountryCodes() -> [Country] { result.append(country) // countriesByPrefix["\(code.code)"] = (country, code) } - + if let maybeNameRange = maybeNameRange { currentLocation = maybeNameRange.upperBound } else { break } } - + return result } diff --git a/submodules/TelegramUI/Sources/AppDelegate.swift b/submodules/TelegramUI/Sources/AppDelegate.swift index 2375483275..e5aa783716 100644 --- a/submodules/TelegramUI/Sources/AppDelegate.swift +++ b/submodules/TelegramUI/Sources/AppDelegate.swift @@ -99,11 +99,11 @@ private func isKeyboardViewContainer(view: NSObject) -> Bool { private class ApplicationStatusBarHost: StatusBarHost { private weak var scene: UIWindowScene? - + init(scene: UIWindowScene?) { self.scene = scene } - + var isApplicationInForeground: Bool { guard let scene = self.scene else { return false @@ -121,19 +121,19 @@ private class ApplicationStatusBarHost: StatusBarHost { return false } } - + var statusBarFrame: CGRect { guard let scene = self.scene else { return CGRect() } return scene.statusBarManager?.statusBarFrame ?? CGRect() } - + var keyboardWindow: UIWindow? { if #available(iOS 16.0, *) { return UIApplication.shared.internalGetKeyboard() } - + for window in UIApplication.shared.windows { if isKeyboardWindow(window: window) { return window @@ -141,12 +141,12 @@ private class ApplicationStatusBarHost: StatusBarHost { } return nil } - + var keyboardView: UIView? { guard let keyboardWindow = self.keyboardWindow else { return nil } - + for view in keyboardWindow.subviews { if isKeyboardViewContainer(view: view) { for subview in view.subviews { @@ -187,7 +187,7 @@ final class SharedApplicationContext { let wakeupManager: SharedWakeupManager let overlayMediaController: ViewController & OverlayMediaController var minimizedContainer: [AccountRecordId: MinimizedContainer] = [:] - + init(sharedContext: SharedAccountContextImpl, notificationManager: SharedNotificationManager, wakeupManager: SharedWakeupManager) { self.sharedContext = sharedContext self.notificationManager = notificationManager @@ -231,49 +231,51 @@ private func extractAccountManagerState(records: AccountRecordsView(false, ignoreRepeated: true) private var isInForegroundValue = false private let isActivePromise = ValuePromise(false, ignoreRepeated: true) private var isActiveValue = false let hasActiveAudioSession = Promise(false) - + private let sharedContextPromise = Promise() private var accountManager: AccountManager? private var accountManagerState: AccountManagerState? - + private var contextValue: AuthorizedApplicationContext? private let context = Promise() private let contextDisposable = MetaDisposable() - + private var authContextValue: UnauthorizedApplicationContext? private let authContext = Promise() private let authContextDisposable = MetaDisposable() - + private let logoutDisposable = MetaDisposable() - + private let openNotificationSettingsWhenReadyDisposable = MetaDisposable() private let openChatWhenReadyDisposable = MetaDisposable() private let openUrlWhenReadyDisposable = MetaDisposable() private let winterGramSettingsDisposable = MetaDisposable() + private let winterGramGlassDisposable = MetaDisposable() + private var winterGramTopBannerView: WinterGramTopBannerView? private let badgeDisposable = MetaDisposable() private let quickActionsDisposable = MetaDisposable() - + private var pushRegistry: PKPushRegistry? - + private let notificationAuthorizationDisposable = MetaDisposable() - + private var replyFromNotificationsDisposables = DisposableSet() private var watchedCallsDisposables = DisposableSet() - + private var _notificationTokenPromise: Promise? private let voipTokenPromise = Promise() - + private var firebaseSecrets: [String: String] = [:] { didSet { if self.firebaseSecrets != oldValue { @@ -282,7 +284,7 @@ private func extractAccountManagerState(records: AccountRecordsView([:]) - + private var firebaseRequestVerificationSecrets: [String: String] = [:] { didSet { if self.firebaseRequestVerificationSecrets != oldValue { @@ -291,13 +293,13 @@ private func extractAccountManagerState(records: AccountRecordsView([:]) - + private var urlSessions: [URLSession] = [] private func urlSession(identifier: String) -> URLSession { if let existingSession = self.urlSessions.first(where: { $0.configuration.identifier == identifier }) { return existingSession } - + let baseAppBundleId = Bundle.main.bundleIdentifier! let appGroupName = "group.\(baseAppBundleId)" @@ -308,44 +310,44 @@ private func extractAccountManagerState(records: AccountRecordsView Void)? - + private var notificationTokenPromise: Promise { if let current = self._notificationTokenPromise { return current } else { let promise = Promise() self._notificationTokenPromise = promise - + return promise } } - + private var clearNotificationsManager: ClearNotificationsManager? - + private let idleTimerExtensionSubscribers = Bag() - + private var alertActions: (primary: (() -> Void)?, other: (() -> Void)?)? - + private let voipDeviceToken = Promise(nil) private let regularDeviceToken = Promise(nil) - + private var recaptchaClientsBySiteKey: [String: Promise] = [:] - + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { precondition(!testIsLaunched) testIsLaunched = true - + let _ = voipTokenPromise.get().start(next: { token in self.voipDeviceToken.set(.single(token)) }) let _ = notificationTokenPromise.get().start(next: { token in self.regularDeviceToken.set(.single(token)) }) - + let launchStartTime = CFAbsoluteTimeGetCurrent() - + defaultNavigationBarImpl = { presentationData in return NavigationBarImpl(presentationData: presentationData) } @@ -405,7 +407,7 @@ private func extractAccountManagerState(records: AccountRecordsView if #available(iOS 10.0, *) { autolockDeadine = .single(nil) @@ -563,7 +578,7 @@ private func extractAccountManagerState(records: AccountRecordsView() self.recaptchaClientsBySiteKey[siteKey] = recaptchaClient - + Recaptcha.fetchClient(withSiteKey: siteKey) { client, error in Queue.mainQueue().async { guard let client else { @@ -608,7 +623,7 @@ private func extractAccountManagerState(records: AccountRecordsView take(1) |> mapToSignal { recaptchaClient -> Signal in @@ -620,11 +635,11 @@ private func extractAccountManagerState(records: AccountRecordsView = .single(false) if CallKitIntegration.isAvailable, let callKitIntegration = CallKitIntegration.shared { hasActiveCalls = callKitIntegration.hasActiveCalls @@ -805,7 +820,7 @@ private func extractAccountManagerState(records: AccountRecordsView distinctUntilChanged ) - + let applicationBindings = TelegramApplicationBindings(isMainApp: true, appBundleId: baseAppBundleId, appBuildType: buildConfig.isAppStoreBuild ? .public : .internal, containerPath: appGroupUrl.path, appSpecificScheme: buildConfig.appSpecificUrlScheme, openUrl: { url in var parsedUrl = URL(string: url) if let parsed = parsedUrl { @@ -816,7 +831,7 @@ private func extractAccountManagerState(records: AccountRecordsView(basePath: rootPath + "/accounts-metadata", isTemporary: false, isReadOnly: false, useCaches: true, removeDatabaseOnError: true) self.accountManager = accountManager @@ -1082,7 +1102,7 @@ private func extractAccountManagerState(records: AccountRecordsView map { initialPresentationDataAndSettings -> (AccountManager, InitialPresentationDataAndSettings) in return (accountManager, initialPresentationDataAndSettings) @@ -1090,14 +1110,14 @@ private func extractAccountManagerState(records: AccountRecordsView deliverOnMainQueue |> mapToSignal { accountManager, initialPresentationDataAndSettings -> Signal<(SharedApplicationContext, LoggingSettings), NoError> in self.mainWindow?.hostView.containerView.backgroundColor = initialPresentationDataAndSettings.presentationData.theme.chatList.backgroundColor - + let legacyBasePath = appGroupUrl.path - + let presentationDataPromise = Promise() let appLockContext = AppLockContextImpl(rootPath: rootPath, window: self.mainWindow!, rootController: self.window?.rootViewController, applicationBindings: applicationBindings, accountManager: accountManager, presentationDataSignal: presentationDataPromise.get(), lockIconInitialFrame: { return (self.mainWindow?.viewController as? TelegramRootController)?.chatListController?.lockViewFrame }) - + var setPresentationCall: ((PresentationCall?) -> Void)? let sharedContext = SharedAccountContextImpl(mainWindow: self.mainWindow, sharedContainerPath: legacyBasePath, basePath: rootPath, encryptionParameters: encryptionParameters, accountManager: accountManager, appLockContext: appLockContext, notificationController: nil, applicationBindings: applicationBindings, initialPresentationDataAndSettings: initialPresentationDataAndSettings, networkArguments: networkArguments, hasInAppPurchases: buildConfig.isAppStoreBuild && buildConfig.apiId == 1, rootPath: rootPath, legacyBasePath: legacyBasePath, apsNotificationToken: self.notificationTokenPromise.get() |> map(Optional.init), voipNotificationToken: self.voipTokenPromise.get() |> map(Optional.init), firebaseSecretStream: self.firebaseSecretStream.get(), setNotificationCall: { call in setPresentationCall?(call) @@ -1120,9 +1140,9 @@ private func extractAccountManagerState(records: AccountRecordsView map { primary, accounts, _ in accounts.map({ ($0.1.account, $0.1.account.id == primary?.account.id) }) }, pollLiveLocationOnce: { accountId in let _ = (self.context.get() |> filter { @@ -1184,7 +1204,7 @@ private func extractAccountManagerState(records: AccountRecordsView deliverOnMainQueue).start(next: { settings in + let glass = settings.liquidGlass + let newEnabled = glass.enabled + let newAlpha = CGFloat(1.0 - glass.transparency) + let newBlurRadius = glass.blurRadius > 0.0 ? CGFloat(glass.blurRadius) : nil + let newVibrancy = glass.vibrancy + + let changed = DisplayLiquidGlass.enabled != newEnabled || + DisplayLiquidGlass.alpha != newAlpha || + DisplayLiquidGlass.blurRadius != newBlurRadius || + DisplayLiquidGlass.vibrancy != newVibrancy + + DisplayLiquidGlass.enabled = newEnabled + DisplayLiquidGlass.alpha = newAlpha + DisplayLiquidGlass.blurRadius = newBlurRadius + DisplayLiquidGlass.vibrancy = newVibrancy + + if changed { + NotificationCenter.default.post(name: NSNotification.Name("winterGramLiquidGlassChanged"), object: nil) + } + + // WinterGram: push custom font family names into the Display font factory. + winterGramCustomFontName = (settings.customFont?.isEmpty ?? true) ? nil : settings.customFont + winterGramMonoFontName = (settings.monoFont?.isEmpty ?? true) ? nil : settings.monoFont + + // WinterGram: persistent top-center branding pill near the Dynamic Island / notch. + if let window = self.window { + let bannerView: WinterGramTopBannerView + if let existing = self.winterGramTopBannerView { + bannerView = existing + } else { + bannerView = WinterGramTopBannerView() + bannerView.frame = window.bounds + self.winterGramTopBannerView = bannerView + } + if bannerView.superview !== window { + window.addSubview(bannerView) + } + window.bringSubviewToFront(bannerView) + let bannerStyle: WinterGramTopBannerStyle = settings.topBannerStyle == .off && !settings.topBannerName.isEmpty ? .solid : settings.topBannerStyle + bannerView.update(style: bannerStyle, imageName: settings.topBannerName, text: settings.useDefaultBranding ? "Telegram" : "WinterGram") + } + })) + return accountManager.transaction { transaction -> (SharedApplicationContext, LoggingSettings) in return (sharedApplicationContext, transaction.getSharedData(SharedDataKeys.loggingSettings)?.get(LoggingSettings.self) ?? LoggingSettings.defaultSettings) } @@ -1214,10 +1278,10 @@ private func extractAccountManagerState(records: AccountRecordsView deliverOnMainQueue |> mapToSignal { sharedApplicationContext -> Signal in @@ -1267,7 +1331,7 @@ private func extractAccountManagerState(records: AccountRecordsView deliverOnMainQueue |> mapToSignal { sharedApplicationContext -> Signal in @@ -1321,21 +1385,21 @@ private func extractAccountManagerState(records: AccountRecordsView deliverOnMainQueue).start(next: { context in print("Application: context took \(CFAbsoluteTimeGetCurrent() - startTime) to become available") - + var network: Network? if let context = context { network = context.context.account.network } - + Logger.shared.log("App \(self.episodeId)", "received context \(String(describing: context)) account \(String(describing: context?.context.account.id)) network \(String(describing: network))") - + let firstTime = self.contextValue == nil if let contextValue = self.contextValue { contextValue.passcodeController?.dismiss() @@ -1358,7 +1422,7 @@ private func extractAccountManagerState(records: AccountRecordsView deliverOnMainQueue).start(next: { context in var network: Network? if let context = context { network = context.account.network } - + Logger.shared.log("App \(self.episodeId)", "received auth context \(String(describing: context)) account \(String(describing: context?.account.id)) network \(String(describing: network))") - + if let authContextValue = self.authContextValue { authContextValue.account.shouldBeServiceTaskMaster.set(.single(.never)) if authContextValue.authorizationCompleted { @@ -1420,7 +1484,7 @@ private func extractAccountManagerState(records: AccountRecordsView { [weak self] subscriber in let statusController = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil)) self?.mainWindow.present(statusController, on: .root) @@ -1433,7 +1497,7 @@ private func extractAccountManagerState(records: AccountRecordsView runOn(Queue.mainQueue()) |> delay(0.5, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() - + let isReady: Signal = context.isReady.get() authContextReadyDisposable.set((isReady |> filter { $0 } @@ -1480,15 +1544,15 @@ private func extractAccountManagerState(records: AccountRecordsView mapToSignal { context -> Signal<[ApplicationShortcutItem], NoError> in if let context = context { let presentationData = context.context.sharedContext.currentPresentationData.with { $0 } - + return activeAccountsAndPeers(context: context.context) |> take(1) |> map { primaryAndAccounts -> (AccountContext, EnginePeer, Int32)? in @@ -1517,14 +1581,14 @@ private func extractAccountManagerState(records: AccountRecordsView= minReindexTimestamp { } else { UserDefaults.standard.set(timestamp as NSNumber, forKey: "TelegramCacheIndexTimestamp_v2") - + Logger.shared.log("App \(self.episodeId)", "Executing low-impact cache reindex in foreground") let _ = self.runCacheReindexTasks(lowImpact: true, completion: { Logger.shared.log("App \(self.episodeId)", "Executing low-impact cache reindex in foreground — done") }) } - + if #available(iOS 12.0, *) { UIApplication.shared.registerForRemoteNotifications() } - + let _ = self.urlSession(identifier: "\(baseAppBundleId).backroundSession") - + var previousReportedMemoryConsumption = 0 let _ = Foundation.Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: { _ in let value = getMemoryConsumption() if abs(value - previousReportedMemoryConsumption) > 1 * 1024 * 1024 { previousReportedMemoryConsumption = value Logger.shared.log("App \(self.episodeId)", "Memory consumption: \(value / (1024 * 1024)) MB") - + if self.contextValue?.context.sharedContext.immediateExperimentalUISettings.crashOnMemoryPressure == true { let memoryUsageOverlayView: UILabel if let current = self.memoryUsageOverlayView { @@ -1636,10 +1700,10 @@ private func extractAccountManagerState(records: AccountRecordsView= 2000 * 1024 * 1024 { if self.contextValue?.context.sharedContext.immediateExperimentalUISettings.crashOnMemoryPressure == true { @@ -1657,16 +1721,16 @@ private func extractAccountManagerState(records: AccountRecordsView deliverOnMainQueue).startStandalone(next: { context in reflectorBenchmarkDisposable.set(nil) runReflectorBenchmarkDisposable.set(nil) - + guard let context = context?.context else { return } @@ -1685,7 +1749,7 @@ private func extractAccountManagerState(records: AccountRecordsView Void>] = [:] - + func uploadInBackround(postbox: Postbox, resource: MediaResource) -> Signal { let baseAppBundleId = Bundle.main.bundleIdentifier! let session = self.urlSession(identifier: "\(baseAppBundleId).backroundSession") - + let signal = Signal { subscriber in let disposable = MetaDisposable() - + let _ = session.getAllTasks(completionHandler: { tasks in var alreadyExists = false for task in tasks { @@ -1727,7 +1791,7 @@ private func extractAccountManagerState(records: AccountRecordsView { subscriber in let dataDisposable = (postbox.mediaBox.resourceData(resource) @@ -1737,7 +1801,7 @@ private func extractAccountManagerState(records: AccountRecordsView runOn(.mainQueue()) - + return Signal { subscriber in let bag: Bag<(String?) -> Void> if let current = self.backgroundUploadResultSubscribers[resource.id.stringRepresentation] { @@ -1762,12 +1826,12 @@ private func extractAccountManagerState(records: AccountRecordsView runOn(.mainQueue()) } - + private func addBackgroundUploadTask(id: String, path: String) { let baseAppBundleId = Bundle.main.bundleIdentifier! let session = self.urlSession(identifier: "\(baseAppBundleId).backroundSession") - + let fileName = "upload-\(UInt32.random(in: 0 ... UInt32.max))" let uploadFilePath = NSTemporaryDirectory() + "/" + fileName guard let sourceFile = ManagedFile(queue: nil, path: uploadFilePath, mode: .readwrite) else { @@ -1793,25 +1857,25 @@ private func extractAccountManagerState(records: AccountRecordsView Void) -> Disposable { let disposable = MetaDisposable() - + let _ = (self.sharedContextPromise.get() |> take(1) |> deliverOnMainQueue).start(next: { sharedApplicationContext in @@ -1874,17 +1938,17 @@ private func extractAccountManagerState(records: AccountRecordsView take(1) |> deliverOnMainQueue).start(next: { activeAccounts in var signals: Signal = .complete() - + for (_, context, _) in activeAccounts.accounts { signals = signals |> then(context.account.cleanupTasks(lowImpact: lowImpact)) } - + disposable.set(signals.start(completed: { completion() })) }) }) - + return disposable } @@ -1913,7 +1977,7 @@ private func extractAccountManagerState(records: AccountRecordsView take(1) |> deliverOnMainQueue).start(next: { activeAccounts in @@ -1959,18 +2023,18 @@ private func extractAccountManagerState(records: AccountRecordsView take(1) @@ -2022,11 +2086,11 @@ private func extractAccountManagerState(records: AccountRecordsView Void) { let _ = (self.sharedContextPromise.get() |> take(1) |> deliverOnMainQueue).start(next: { sharedApplicationContext in sharedApplicationContext.wakeupManager.allowBackgroundTimeExtension(timeout: 2.0) }) - + var redactedPayload = userInfo if var aps = redactedPayload["aps"] as? [AnyHashable: Any] { if Logger.shared.redactSensitiveData { @@ -2076,9 +2140,9 @@ private func extractAccountManagerState(records: AccountRecordsView take(1) |> deliverOnMainQueue).start(next: { sharedApplicationContext in @@ -2127,29 +2191,29 @@ private func extractAccountManagerState(records: AccountRecordsView Void) { Logger.shared.log("App \(self.episodeId) PushRegistry", "pushRegistry didReceiveIncomingPushWith \(payload.dictionaryPayload)") - + self.pushRegistryImpl(registry, didReceiveIncomingPushWith: payload, for: type, completion: completion) } - + public func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType) { Logger.shared.log("App \(self.episodeId) PushRegistry", "pushRegistry didReceiveIncomingPushWith \(payload.dictionaryPayload)") - + self.pushRegistryImpl(registry, didReceiveIncomingPushWith: payload, for: type, completion: {}) } - + private func pushRegistryImpl(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { Logger.shared.log("App \(self.episodeId) PushRegistry", "pushRegistry processing push notification") - + let decryptedPayloadAndAccountId: ([AnyHashable: Any], AccountRecordId)? - + if let accountIdString = payload.dictionaryPayload["accountId"] as? String, let accountId = Int64(accountIdString) { decryptedPayloadAndAccountId = (payload.dictionaryPayload, AccountRecordId(rawValue: accountId)) } else { @@ -2205,25 +2269,25 @@ private func extractAccountManagerState(records: AccountRecordsView take(1) @@ -2249,7 +2313,7 @@ private func extractAccountManagerState(records: AccountRecordsView take(1) |> deliverOnMainQueue).start(next: { sharedApplicationContext in @@ -2283,10 +2347,10 @@ private func extractAccountManagerState(records: AccountRecordsView map { ringingStates -> Bool in @@ -2314,27 +2378,27 @@ private func extractAccountManagerState(records: AccountRecordsView take(1) |> deliverOnMainQueue).startStrict(next: { _ in callKitIntegration.dropCall(uuid: internalId) })) } - + processed = true - + break } } - + if !processed { callKitIntegration.dropCall(uuid: internalId) } }) - + sharedApplicationContext.wakeupManager.allowBackgroundTimeExtension(timeout: 2.0) - + if case PKPushType.voIP = type { Logger.shared.log("App \(self.episodeId) PushRegistry", "pushRegistry payload: \(payload.dictionaryPayload)") sharedApplicationContext.notificationManager.addNotification(payload.dictionaryPayload) @@ -2347,7 +2411,7 @@ private func extractAccountManagerState(records: AccountRecordsView take(1) |> deliverOnMainQueue).start(next: { sharedApplicationContext in @@ -2405,10 +2469,10 @@ private func extractAccountManagerState(records: AccountRecordsView deliverOnMainQueue).start(next: { state in switch state.state { @@ -2418,36 +2482,36 @@ private func extractAccountManagerState(records: AccountRecordsView Signal { return self.context.get() |> mapToSignal { context -> Signal in @@ -2487,31 +2551,31 @@ private func extractAccountManagerState(records: AccountRecordsView Bool { self.openUrl(url: url) return true } - + func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool { self.openUrl(url: url) return true } - + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { guard self.openUrlInProgress != url else { return true } - + self.openUrl(url: url) return true } - + func application(_ application: UIApplication, handleOpen url: URL) -> Bool { self.openUrl(url: url) return true } - + private func openUrl(url: URL) { let url = normalizeWinterGramUrlScheme(url) let _ = (self.sharedContextPromise.get() @@ -2537,7 +2601,7 @@ private func extractAccountManagerState(records: AccountRecordsView Void) -> Bool { if #available(iOS 10.0, *) { var startCallContacts: [INPerson]? @@ -2568,12 +2632,12 @@ private func extractAccountManagerState(records: AccountRecordsView Void = { peerId in self.startCallWhenReady(accountId: nil, peerId: peerId, isVideo: isVideo) } - + func cleanPhoneNumber(_ text: String) -> String { var result = "" for c in text { @@ -2587,7 +2651,7 @@ private func extractAccountManagerState(records: AccountRecordsView Bool { if lhs.count < 10 && lhs.count == rhs.count { return lhs == rhs @@ -2597,7 +2661,7 @@ private func extractAccountManagerState(records: AccountRecordsView if let context = self.contextValue?.context, let contactIdentifier = contact.contactIdentifier { @@ -2605,7 +2669,7 @@ private func extractAccountManagerState(records: AccountRecordsView deliverOnMainQueue).start(next: { peerByContact in var processed = false if let peerByContact = peerByContact { @@ -2656,7 +2720,7 @@ private func extractAccountManagerState(records: AccountRecordsView take(1) |> mapToSignal { sharedApplicationContext -> Signal<(AccountRecordId?, [AccountContext?]), NoError> in @@ -2709,7 +2773,7 @@ private func extractAccountManagerState(records: AccountRecordsView Void) { let _ = (self.sharedContextPromise.get() @@ -2767,7 +2831,7 @@ private func extractAccountManagerState(records: AccountRecordsView take(1) @@ -2775,7 +2839,7 @@ private func extractAccountManagerState(records: AccountRecordsView take(1) @@ -2797,7 +2861,7 @@ private func extractAccountManagerState(records: AccountRecordsView take(1) @@ -2820,11 +2884,11 @@ private func extractAccountManagerState(records: AccountRecordsView take(1) |> deliverOnMainQueue @@ -2844,13 +2908,13 @@ private func extractAccountManagerState(records: AccountRecordsView deliverOnMainQueue).start(next: { [weak self] context in context.openUrl(url, external: external) - + Queue.mainQueue().after(1.0, { self?.openUrlInProgress = nil }) })) } - + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let _ = (accountIdFromNotification(response.notification, sharedContext: self.sharedContextPromise.get()) |> deliverOnMainQueue).start(next: { accountId in @@ -2925,7 +2989,7 @@ private func extractAccountManagerState(records: AccountRecordsView deliverOnMainQueue - + let disposable = MetaDisposable() disposable.set((signal |> afterDisposed { [weak disposable] in @@ -2942,14 +3006,14 @@ private func extractAccountManagerState(records: AccountRecordsView Void = { _ in }) { let presentationData = context.sharedContext.currentPresentationData.with { $0 } let _ = (context.sharedContext.accountManager.transaction { transaction -> Bool in @@ -2966,7 +3030,7 @@ private func extractAccountManagerState(records: AccountRecordsView Void) { let _ = (accountIdFromNotification(notification, sharedContext: self.sharedContextPromise.get()) @@ -3049,20 +3113,20 @@ private func extractAccountManagerState(records: AccountRecordsView Void) { Logger.shared.log("App \(self.episodeId)", "handleEventsForBackgroundURLSession \(identifier)") completionHandler() } - + private var lastCheckForUpdatesTimestamp: Double? private let currentCheckForUpdatesDisposable = MetaDisposable() - + private func maybeCheckForUpdates() { #if targetEnvironment(simulator) #else @@ -3072,7 +3136,7 @@ private func extractAccountManagerState(records: AccountRecordsView take(1) |> mapToSignal { sharedContext in @@ -3125,14 +3189,14 @@ private func extractAccountManagerState(records: AccountRecordsView deliverOnMainQueue).start(next: { logs in @@ -3140,13 +3204,13 @@ private func extractAccountManagerState(records: AccountRecordsView (peerId: PeerId, threadId: Int64?)? { let threadId = notification.request.content.userInfo["threadId"] as? Int64 - + if let peerId = notification.request.content.userInfo["peerId"] as? Int64 { return (PeerId(peerId), threadId) } else if let peerIdString = notification.request.content.userInfo["peerId"] as? String, let peerId = Int64(peerIdString) { @@ -3256,7 +3320,7 @@ private func peerIdFromNotification(_ notification: UNNotification) -> (peerId: let fromIdValue = fromId as! NSString peerId = PeerId(namespace: Namespaces.Peer.SecretChat, id: PeerId.Id._internalFromInt64Value(Int64(fromIdValue as String) ?? 0)) } - + if let peerId = peerId { return (peerId, threadId) } else { @@ -3270,7 +3334,7 @@ private func messageIdFromNotification(peerId: PeerId, notification: UNNotificat if let messageIdNamespace = payload["messageId.namespace"] as? Int32, let messageIdId = payload["messageId.id"] as? Int32 { return MessageId(peerId: peerId, namespace: messageIdNamespace, id: messageIdId) } - + if let msgId = payload["msg_id"] { let msgIdValue = msgId as! NSString return MessageId(peerId: peerId, namespace: Namespaces.Message.Cloud, id: Int32(msgIdValue.intValue)) @@ -3304,7 +3368,7 @@ private func downloadHTTPData(url: URL) -> Signal { } }) downloadTask.resume() - + return ActionDisposable { if !completed.with({ $0 }) { downloadTask.cancel() @@ -3334,11 +3398,11 @@ private func getMemoryConsumption() -> Int { final class UpdateSettings: Codable, Equatable { let url: String? - + init(url: String?) { self.url = url } - + static func ==(lhs: UpdateSettings, rhs: UpdateSettings) -> Bool { return lhs.url == rhs.url } diff --git a/submodules/TelegramUI/Sources/ApplicationContext.swift b/submodules/TelegramUI/Sources/ApplicationContext.swift index db742a0b15..618aba9b14 100644 --- a/submodules/TelegramUI/Sources/ApplicationContext.swift +++ b/submodules/TelegramUI/Sources/ApplicationContext.swift @@ -37,33 +37,33 @@ import BrowserUI final class UnauthorizedApplicationContext { let sharedContext: SharedAccountContextImpl let account: UnauthorizedAccount - + let rootController: AuthorizationSequenceController - + let isReady = Promise() - + var authorizationCompleted: Bool = false private var serviceNotificationEventsDisposable: Disposable? - + init(apiId: Int32, apiHash: String, sharedContext: SharedAccountContextImpl, account: UnauthorizedAccount, otherAccountPhoneNumbers: ((String, AccountRecordId, Bool)?, [(String, AccountRecordId, Bool)])) { self.sharedContext = sharedContext self.account = account let presentationData = sharedContext.currentPresentationData.with { $0 } - + var authorizationCompleted: (() -> Void)? - + self.rootController = AuthorizationSequenceController(sharedContext: sharedContext, account: account, otherAccountPhoneNumbers: otherAccountPhoneNumbers, presentationData: presentationData, openUrl: sharedContext.applicationBindings.openUrl, apiId: apiId, apiHash: apiHash, authorizationCompleted: { authorizationCompleted?() }) (self.rootController as NavigationController).statusBarHost = sharedContext.mainWindow?.statusBarHost - + authorizationCompleted = { [weak self] in self?.authorizationCompleted = true } - + self.isReady.set(self.rootController.ready.get()) - + account.shouldBeServiceTaskMaster.set(sharedContext.applicationBindings.applicationInForeground |> map { value -> AccountServiceTaskMasterMode in if value { return .always @@ -71,7 +71,7 @@ final class UnauthorizedApplicationContext { return .never } }) - + DeviceAccess.authorizeAccess(to: .cellularData, presentationData: sharedContext.currentPresentationData.with { $0 }, present: { [weak self] c, a in if let strongSelf = self { (strongSelf.rootController.viewControllers.last as? ViewController)?.present(c, in: .window(.root)) @@ -102,15 +102,15 @@ final class AuthorizedApplicationContext { let sharedApplicationContext: SharedApplicationContext let mainWindow: Window1 let lockedCoveringView: LockedWindowCoveringView - + let context: AccountContextImpl - + let rootController: TelegramRootController let notificationController: NotificationContainerController - + private let scheduledCallPeerDisposable = MetaDisposable() private var scheduledOpenExternalUrl: URL? - + private let passcodeStatusDisposable = MetaDisposable() private let passcodeLockDisposable = MetaDisposable() private let loggedOutDisposable = MetaDisposable() @@ -121,55 +121,55 @@ final class AuthorizedApplicationContext { private let watchNavigateToMessageDisposable = MetaDisposable() private let permissionsDisposable = MetaDisposable() private let appUpdateInfoDisposable = MetaDisposable() - + private var inAppNotificationSettings: InAppNotificationSettings? - + var passcodeController: PasscodeEntryController? - + private var currentAppUpdateInfo: AppUpdateInfo? private var currentTermsOfServiceUpdate: TermsOfServiceUpdate? private var currentPermissionsController: PermissionController? private var currentPermissionsState: PermissionState? - + private let unlockedStatePromise = Promise() var unlockedState: Signal { return self.unlockedStatePromise.get() } - + var applicationBadge: Signal { return renderedTotalUnreadCount(accountManager: self.context.sharedContext.accountManager, engine: self.context.engine) |> map { $0.0 } } - + let isReady = Promise() - + private var presentationDataDisposable: Disposable? private var displayAlertsDisposable: Disposable? private var removeNotificationsDisposable: Disposable? - + private var applicationInForegroundDisposable: Disposable? - + private var showCallsTab: Bool private var showCallsTabDisposable: Disposable? private var enablePostboxTransactionsDiposable: Disposable? - + init(sharedApplicationContext: SharedApplicationContext, mainWindow: Window1, context: AccountContextImpl, accountManager: AccountManager, showCallsTab: Bool, reinitializedNotificationSettings: @escaping () -> Void) { self.sharedApplicationContext = sharedApplicationContext - + setupLegacyComponents(context: context) let presentationData = context.sharedContext.currentPresentationData.with { $0 } - + self.mainWindow = mainWindow self.lockedCoveringView = LockedWindowCoveringView(theme: presentationData.theme) - + self.context = context - + self.showCallsTab = showCallsTab - + self.notificationController = NotificationContainerController(context: context) - + self.rootController = TelegramRootController(context: context) self.rootController.minimizedContainer = self.sharedApplicationContext.minimizedContainer[context.account.id] self.rootController.minimizedContainerUpdated = { [weak self] minimizedContainer in @@ -178,7 +178,7 @@ final class AuthorizedApplicationContext { } self.sharedApplicationContext.minimizedContainer[self.context.account.id] = minimizedContainer } - + self.rootController.globalOverlayControllersUpdated = { [weak self] in guard let strongSelf = self else { return @@ -190,10 +190,10 @@ final class AuthorizedApplicationContext { break } } - + strongSelf.notificationController.updateIsTemporaryHidden(hasContext) } - + if KeyShortcutsController.isAvailable { let keyShortcutsController = KeyShortcutsController { [weak self] f in if let strongSelf = self, let appLockContext = strongSelf.context.sharedContext.appLockContext as? AppLockContextImpl { @@ -205,7 +205,7 @@ final class AuthorizedApplicationContext { } if let tabController = strongSelf.rootController.rootTabController { let selectedController = tabController.controllers[tabController.selectedIndex] - + if let index = strongSelf.rootController.viewControllers.lastIndex(where: { controller in guard let controller = controller as? ViewController else { return false @@ -229,7 +229,7 @@ final class AuthorizedApplicationContext { return } } - + if let controller = strongSelf.rootController.topViewController as? ViewController, controller !== selectedController { if !f(controller) { return @@ -247,7 +247,7 @@ final class AuthorizedApplicationContext { } context.keyShortcutsController = keyShortcutsController } - + if self.rootController.rootTabController == nil { self.rootController.addRootControllers(showCallsTab: self.showCallsTab) } @@ -261,7 +261,7 @@ final class AuthorizedApplicationContext { } else { self.isReady.set(.single(true)) } - + let accountId = context.account.id self.loggedOutDisposable.set((context.account.loggedOut |> deliverOnMainQueue).start(next: { [weak self] value in @@ -277,7 +277,7 @@ final class AuthorizedApplicationContext { } } })) - + self.inAppNotificationSettingsDisposable.set(((context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.inAppNotificationSettings])) |> deliverOnMainQueue).start(next: { [weak self] sharedData in if let strongSelf = self { if let settings = sharedData.entries[ApplicationSpecificSharedDataKeys.inAppNotificationSettings]?.get(InAppNotificationSettings.self) { @@ -307,6 +307,15 @@ final class AuthorizedApplicationContext { guard let message = item.0.first else { return false } + let wnt = currentWinterGramSettings + if wnt.stashedPeerIds.contains(message.id.peerId.toInt64()) { + if wnt.stashAutoMarkRead { + let _ = engine.messages.applyMaxReadIndexInteractively(index: message.index).start() + } + if wnt.stashMuteNotifications { + return false + } + } if let maybeChatListIndex = chatListIndexMap[message.id.peerId], maybeChatListIndex != nil { return true } else { @@ -333,18 +342,18 @@ final class AuthorizedApplicationContext { } chatLocation = .peer(EnginePeer(peer)) } - + var chatIsVisible = false if let topController = strongSelf.rootController.topViewController as? ChatControllerImpl, topController.traceVisibility() { if topController.chatLocation.peerId == firstMessage.id.peerId, (topController.chatLocation.threadId == nil || topController.chatLocation.threadId == firstMessage.threadId) { chatIsVisible = true } } - + if !notify { chatIsVisible = true } - + if !chatIsVisible { strongSelf.mainWindow.forEachViewController({ controller in if let controller = controller as? ChatControllerImpl, controller.chatLocation.peerId == chatLocation.peerId, (chatLocation.threadId == nil || chatLocation.threadId == controller.chatLocation.threadId) { @@ -354,14 +363,14 @@ final class AuthorizedApplicationContext { return true }) } - + let inAppNotificationSettings: InAppNotificationSettings if let current = strongSelf.inAppNotificationSettings { inAppNotificationSettings = current } else { inAppNotificationSettings = InAppNotificationSettings.defaultSettings } - + if let appLockContext = strongSelf.context.sharedContext.appLockContext as? AppLockContextImpl { let _ = (appLockContext.isCurrentlyLocked |> take(1) @@ -369,7 +378,7 @@ final class AuthorizedApplicationContext { guard let strongSelf = self else { return } - + guard !locked else { return } @@ -403,11 +412,11 @@ final class AuthorizedApplicationContext { } } } - + if chatIsVisible { return } - + if firstMessage.restrictionReason(platform: "ios", contentSettings: strongSelf.context.currentContentSettings.with { $0 }) != nil { return } @@ -416,7 +425,7 @@ final class AuthorizedApplicationContext { return } } - + if inAppNotificationSettings.displayPreviews { let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } strongSelf.notificationController.enqueue(ChatMessageNotificationItem(context: strongSelf.context, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, messages: messages, threadData: threadData, tapAction: { @@ -429,18 +438,18 @@ final class AuthorizedApplicationContext { } return true }, excludeNavigationSubControllers: true) - + if foundOverlay { return true } - + if let topController = strongSelf.rootController.topViewController as? ViewController, isInlineControllerForChatNotificationOverlayPresentation(topController) { return true } - + if let topController = strongSelf.rootController.topViewController as? ChatControllerImpl, topController.chatLocation.peerId == chatLocation.peerId, (topController.chatLocation.threadId == nil || topController.chatLocation.threadId == chatLocation.threadId) { strongSelf.notificationController.removeItemsWithGroupingKey(firstMessage.id.peerId) - + return false } @@ -495,7 +504,7 @@ final class AuthorizedApplicationContext { return false } } - + return proceedAction(true) } return false @@ -512,13 +521,13 @@ final class AuthorizedApplicationContext { } } })) - + self.termsOfServiceUpdatesDisposable.set((context.account.stateManager.termsOfServiceUpdate |> deliverOnMainQueue).start(next: { [weak self] termsOfServiceUpdate in guard let strongSelf = self, strongSelf.currentTermsOfServiceUpdate != termsOfServiceUpdate else { return } - + strongSelf.currentTermsOfServiceUpdate = termsOfServiceUpdate if let termsOfServiceUpdate = termsOfServiceUpdate { let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } @@ -533,7 +542,7 @@ final class AuthorizedApplicationContext { UIApplication.shared.open(parsedUrl, options: [:], completionHandler: nil) } }) - + acceptImpl = { [weak controller] botName in controller?.inProgress = true guard let strongSelf = self else { @@ -558,7 +567,7 @@ final class AuthorizedApplicationContext { } }) } - + declineImpl = { [weak controller] in guard let strongSelf = self else { return @@ -578,24 +587,24 @@ final class AuthorizedApplicationContext { let _ = logoutFromAccount(id: accountId, accountManager: accountManager, alreadyLoggedOutRemotely: true).start() }) } - + (strongSelf.rootController.viewControllers.last as? ViewController)?.present(controller, in: .window(.root)) } })) - + self.appUpdateInfoDisposable.set((context.account.stateManager.appUpdateInfo |> deliverOnMainQueue).start(next: { [weak self] appUpdateInfo in guard let strongSelf = self, strongSelf.currentAppUpdateInfo != appUpdateInfo else { return } - + strongSelf.currentAppUpdateInfo = appUpdateInfo if let appUpdateInfo = appUpdateInfo { let controller = updateInfoController(context: strongSelf.context, appUpdateInfo: appUpdateInfo) strongSelf.mainWindow.present(controller, on: .update) } })) - + if #available(iOS 10.0, *) { let permissionsPosition = ValuePromise(0, ignoreRepeated: true) self.permissionsDisposable.set((combineLatest(queue: .mainQueue(), requiredPermissions(context: context), permissionUISplitTest(postbox: context.account.postbox), permissionsPosition.get(), context.sharedContext.accountManager.noticeEntry(key: ApplicationSpecificNotice.permissionWarningKey(permission: .contacts)!), context.sharedContext.accountManager.noticeEntry(key: ApplicationSpecificNotice.permissionWarningKey(permission: .notifications)!), context.sharedContext.accountManager.noticeEntry(key: ApplicationSpecificNotice.permissionWarningKey(permission: .cellularData)!)) @@ -603,7 +612,7 @@ final class AuthorizedApplicationContext { guard let strongSelf = self else { return } - + let contactsTimestamp = contactsPermissionWarningNotice.value.flatMap({ ApplicationSpecificNotice.getTimestampValue($0) }) let notificationsTimestamp = notificationsPermissionWarningNotice.value.flatMap({ ApplicationSpecificNotice.getTimestampValue($0) }) let cellularDataTimestamp = cellularDataPermissionWarningNotice.value.flatMap({ ApplicationSpecificNotice.getTimestampValue($0) }) @@ -613,7 +622,7 @@ final class AuthorizedApplicationContext { if notificationsTimestamp == nil, case .requestable = required.1.status { ApplicationSpecificNotice.setPermissionWarning(accountManager: context.sharedContext.accountManager, permission: .notifications, value: 1) } - + let config = splitTest.configuration var order = config.order if !order.contains(.cellularData) { @@ -656,7 +665,7 @@ final class AuthorizedApplicationContext { } i += 1 } - + if let (state, modal) = requestedPermissions.first { if modal { var didAppear = false @@ -668,7 +677,7 @@ final class AuthorizedApplicationContext { controller = PermissionController(context: context, splitTest: splitTest) strongSelf.currentPermissionsController = controller } - + controller.setState(.permission(state), animated: didAppear) controller.proceed = { resolved in permissionsPosition.set(position + 1) @@ -683,7 +692,7 @@ final class AuthorizedApplicationContext { break } } - + if !didAppear { Queue.mainQueue().after(0.15, { (strongSelf.rootController.viewControllers.last as? ViewController)?.present(controller, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) @@ -746,7 +755,7 @@ final class AuthorizedApplicationContext { } })) } - + self.displayAlertsDisposable = (context.account.stateManager.displayAlerts |> deliverOnMainQueue).start(next: { [weak self] alerts in if let strongSelf = self { @@ -767,14 +776,14 @@ final class AuthorizedApplicationContext { } } }) - + self.removeNotificationsDisposable = (context.account.stateManager.appliedIncomingReadMessages |> deliverOnMainQueue).start(next: { [weak self] ids in if let strongSelf = self { strongSelf.context.sharedContext.applicationBindings.clearMessageNotifications(ids) } }) - + let importableContacts = self.context.sharedContext.contactDataManager?.importable() ?? .single([:]) let optionalImportableContacts = self.context.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key: PreferencesKeys.contactsSettings)) |> mapToSignal { preferences -> Signal<[DeviceContactNormalizedPhoneNumber: ImportableDeviceContactData], NoError> in @@ -790,7 +799,7 @@ final class AuthorizedApplicationContext { |> map { contacts in return Set(contacts.keys.map { cleanPhoneNumber($0.rawValue) }) }) - + let previousTheme = Atomic(value: nil) self.presentationDataDisposable = (context.sharedContext.presentationData |> deliverOnMainQueue).start(next: { [weak self] presentationData in @@ -801,7 +810,7 @@ final class AuthorizedApplicationContext { } } }) - + let showCallsTabSignal = context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.callListSettings]) |> map { sharedData -> Bool in var value = CallListSettings.defaultSettings.showTab @@ -818,7 +827,7 @@ final class AuthorizedApplicationContext { } } }) - + self.rootController.setForceInCallStatusBar((self.context.sharedContext as! SharedAccountContextImpl).currentCallStatusBarNode) if let groupCallController = self.context.sharedContext.currentGroupCallController as? VoiceChatController { if let overlayController = groupCallController.currentOverlayController { @@ -827,7 +836,7 @@ final class AuthorizedApplicationContext { } } } - + deinit { self.context.account.postbox.clearCaches() self.context.account.shouldKeepOnlinePresence.set(.single(false)) @@ -847,11 +856,11 @@ final class AuthorizedApplicationContext { self.permissionsDisposable.dispose() self.scheduledCallPeerDisposable.dispose() } - + func openNotificationSettings() { self.rootController.pushViewController(notificationsAndSoundsController(context: self.context, exceptionsList: nil)) } - + func startCall(peerId: PeerId, isVideo: Bool) { guard let appLockContext = self.context.sharedContext.appLockContext as? AppLockContextImpl else { return @@ -868,7 +877,7 @@ final class AuthorizedApplicationContext { let _ = strongSelf.context.sharedContext.callManager?.requestCall(context: strongSelf.context, peerId: peerId, isVideo: isVideo, endCurrentIfAny: false) })) } - + func openChatWithPeerId(peerId: PeerId, threadId: Int64?, messageId: MessageId? = nil, activateInput: Bool = false, storyId: StoryId?, openAppIfAny: Bool = false, alwaysKeepMessageId: Bool = false) { if let storyId { var controllers = self.rootController.viewControllers @@ -879,14 +888,14 @@ final class AuthorizedApplicationContext { return true } self.rootController.setViewControllers(controllers, animated: false) - + self.rootController.chatListController?.openStoriesFromNotification(peerId: storyId.peerId, storyId: storyId.id) } else { var visiblePeerId: PeerId? if let controller = self.rootController.topViewController as? ChatControllerImpl, controller.chatLocation.peerId == peerId, controller.chatLocation.threadId == threadId { visiblePeerId = peerId } - + if visiblePeerId != peerId || messageId != nil { let isOutgoingMessage: Signal if let messageId { @@ -910,7 +919,7 @@ final class AuthorizedApplicationContext { guard let peer = peer else { return } - + let chatLocation: NavigateToChatControllerParams.Location if let threadId = threadId { chatLocation = .replyThread(ChatReplyThreadMessage( @@ -919,7 +928,7 @@ final class AuthorizedApplicationContext { } else { chatLocation = .peer(peer) } - + if openAppIfAny, case let .user(user) = peer, let botInfo = user.botInfo, botInfo.flags.contains(.hasWebApp), let parentController = self.rootController.viewControllers.last as? ViewController { self.context.sharedContext.openWebApp( context: self.context, @@ -943,7 +952,7 @@ final class AuthorizedApplicationContext { } } } - + func openUrl(_ url: URL, external: Bool = false) { if self.rootController.rootTabController != nil { let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } @@ -954,23 +963,23 @@ final class AuthorizedApplicationContext { self.scheduledOpenExternalUrl = url } } - + func openRootSearch() { self.rootController.openChatsController(activateSearch: true) } - + func openRootCompose() { self.rootController.openRootCompose() } - + func openRootCamera() { self.rootController.openRootCamera() } - + func openAppIcon() { self.rootController.openAppIcon() } - + func switchAccount() { let _ = (activeAccountsAndPeers(context: self.context) |> take(1) @@ -991,7 +1000,7 @@ final class AuthorizedApplicationContext { strongSelf.context.sharedContext.switchToAccount(id: context.account.id, fromSettingsController: nil, withChatListController: nil) }) } - + private func updateCoveringViewSnaphot(_ visible: Bool) { if visible { let scale: CGFloat = 0.5 diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift index 9934503998..91bd4e042d 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift @@ -131,12 +131,12 @@ import ComponentDisplayAdapters extension ChatControllerImpl { func reloadChatLocation(chatLocation: ChatLocation, chatLocationContextHolder: Atomic, historyNode: ChatHistoryListNodeImpl, apply: @escaping ((ContainedViewLayoutTransition?) -> Void) -> Void) { self.contentDataReady.set(false) - + self.contentDataDisposable?.dispose() - + self.newTopicEventsDisposable?.dispose() self.newTopicEventsDisposable = nil - + let configuration: Signal = self.presentationInterfaceStatePromise.get() |> map { presentationInterfaceState -> ChatControllerImpl.ContentData.Configuration in return ChatControllerImpl.ContentData.Configuration( @@ -146,7 +146,7 @@ extension ChatControllerImpl { ) } |> distinctUntilChanged - + let contentData = ChatControllerImpl.ContentData( context: self.context, chatLocation: chatLocation, @@ -169,37 +169,37 @@ extension ChatControllerImpl { guard let self, let contentData, self.pendingContentData?.contentData === contentData else { return } - + apply({ [weak self, weak contentData] forceAnimationTransition in guard let self, let contentData, self.pendingContentData?.contentData === contentData else { return } - + self.contentData = contentData self.pendingContentData = nil - + self.contentDataUpdated(synchronous: true, forceAnimationTransition: forceAnimationTransition, previousState: contentData.state) - + self.chatThemePromise.set(contentData.chatThemePromise.get()) self.chatWallpaperPromise.set(contentData.chatWallpaperPromise.get()) - + if let historyNode { self.setupChatHistoryNode(historyNode: historyNode) - + historyNode.contentPositionChanged(historyNode.visibleContentOffset()) } - + self.contentDataReady.set(true) - + contentData.onUpdated = { [weak self, weak contentData] previousState in guard let self, let contentData, self.contentData === contentData else { return } - + self.contentDataUpdated(synchronous: false, forceAnimationTransition: nil, previousState: previousState) } }) - + if self.newTopicEventsDisposable == nil, let peerId = chatLocation.peerId, chatLocation.threadId == EngineMessage.newTopicThreadId { self.newTopicEventsDisposable = (self.context.account.pendingMessageManager.newTopicEvents(peerId: peerId) |> mapToSignal { event -> Signal in @@ -224,13 +224,13 @@ extension ChatControllerImpl { } }) } - + func contentDataUpdated(synchronous: Bool, forceAnimationTransition: ContainedViewLayoutTransition?, previousState: ContentData.State) { guard let contentData = self.contentData else { return } self.navigationBar?.userInfo = contentData.state.navigationUserInfo - + if let infoAvatar = contentData.state.infoAvatar { switch infoAvatar { case let .peer(peer, imageOverride, contextActionIsEnabled, accessibilityLabel): @@ -245,7 +245,7 @@ extension ChatControllerImpl { } else { (self.chatInfoNavigationButton?.buttonItem.customDisplayNode as? ChatAvatarNavigationNode)?.contextActionIsEnabled = false } - + if let avatarNode = self.avatarNode { avatarNode.avatarNode.setStoryStats(storyStats: contentData.state.storyStats.flatMap { storyStats -> AvatarNode.StoryStats? in if storyStats.totalCount == 0 { @@ -266,9 +266,9 @@ extension ChatControllerImpl { inactiveLineWidth: 1.5 ), transition: .immediate) } - + self.chatDisplayNode.overlayTitle = contentData.overlayTitle - + self.chatDisplayNode.historyNode.nextChannelToRead = contentData.state.nextChannelToRead.flatMap { nextChannelToRead -> (peer: EnginePeer, threadData: (id: Int64, data: MessageHistoryThreadData)?, unreadCount: Int, location: TelegramEngine.NextUnreadChannelLocation)? in return ( nextChannelToRead.peer, @@ -281,7 +281,7 @@ extension ChatControllerImpl { } self.chatDisplayNode.historyNode.nextChannelToReadDisplayName = contentData.state.nextChannelToReadDisplayName self.updateNextChannelToReadVisibility() - + var animated = false if self.presentationInterfaceState.adMessage?.id != contentData.state.adMessage?.id { animated = true @@ -294,7 +294,7 @@ extension ChatControllerImpl { animated = true } } - + var didDisplayActionsPanel = false if let contactStatus = previousState.contactStatus, !contactStatus.isEmpty, let peerStatusSettings = contactStatus.peerStatusSettings { if !peerStatusSettings.flags.isEmpty { @@ -315,7 +315,7 @@ extension ChatControllerImpl { if self.presentationInterfaceState.search != nil && self.presentationInterfaceState.hasSearchTags { didDisplayActionsPanel = true } - + var previousInvitationPeers: [EnginePeer] = [] if let requestsState = previousState.requestsState { previousInvitationPeers = Array(requestsState.importers.compactMap({ $0.peer.peer.flatMap({ EnginePeer($0) }) }).prefix(3)) @@ -330,7 +330,7 @@ extension ChatControllerImpl { if previousState.removePaidMessageFeeData != nil { didDisplayActionsPanel = true } - + var displayActionsPanel = false if let contactStatus = contentData.state.contactStatus, !contactStatus.isEmpty, let peerStatusSettings = contactStatus.peerStatusSettings { if !peerStatusSettings.flags.isEmpty { @@ -351,7 +351,7 @@ extension ChatControllerImpl { if self.presentationInterfaceState.search != nil && contentData.state.hasSearchTags { displayActionsPanel = true } - + var invitationPeers: [EnginePeer] = [] if let requestsState = contentData.state.requestsState { invitationPeers = Array(requestsState.importers.compactMap({ $0.peer.peer.flatMap({ EnginePeer($0) }) }).prefix(3)) @@ -366,7 +366,7 @@ extension ChatControllerImpl { if contentData.state.removePaidMessageFeeData != nil { displayActionsPanel = true } - + if displayActionsPanel != didDisplayActionsPanel { animated = true } @@ -385,7 +385,7 @@ extension ChatControllerImpl { if previousState.canStopIncomingStreamingMessage != contentData.state.canStopIncomingStreamingMessage { animated = true } - + var transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.4, curve: .spring) : .immediate if let forceAnimationTransition { transition = forceAnimationTransition @@ -393,7 +393,7 @@ extension ChatControllerImpl { if !self.willAppear { transition = .immediate } - + self.updateChatPresentationInterfaceState(transition: transition, interactive: false, force: true, { presentationInterfaceState in var presentationInterfaceState = presentationInterfaceState presentationInterfaceState = presentationInterfaceState.updatedPeer({ _ in @@ -450,7 +450,7 @@ extension ChatControllerImpl { presentationInterfaceState = presentationInterfaceState.updatedViewForumAsMessages(contentData.state.viewForumAsMessages) presentationInterfaceState = presentationInterfaceState.updatedHasTopics(contentData.state.hasTopics) presentationInterfaceState = presentationInterfaceState.updatedCanStopIncomingStreamingMessage(contentData.state.canStopIncomingStreamingMessage) - + presentationInterfaceState = presentationInterfaceState.updatedTitlePanelContext({ context in if contentData.state.pinnedMessageId != nil { if !context.contains(where: { item in @@ -489,7 +489,7 @@ extension ChatControllerImpl { if let requestsState = contentData.state.requestsState { peers = Array(requestsState.importers.compactMap({ $0.peer.peer.flatMap({ EnginePeer($0) }) }).prefix(3)) } - + var peersDismissed = false if let dismissedInvitationRequests = contentData.state.dismissedInvitationRequests, Set(peers.map({ $0.id.toInt64() })) == Set(dismissedInvitationRequests) { peersDismissed = true @@ -532,17 +532,17 @@ extension ChatControllerImpl { } } } - + let initialInterfaceState = contentData.initialInterfaceState contentData.initialInterfaceState = nil let initialTextInputState = self.initialTextInputState self.initialTextInputState = nil - + if !self.didInitializePersistentPeerInterfaceData, let initialPersistentPeerData = contentData.initialPersistentPeerData { self.didInitializePersistentPeerInterfaceData = true presentationInterfaceState = presentationInterfaceState.updatedPersistentData(initialPersistentPeerData) } - + presentationInterfaceState = presentationInterfaceState.updatedInterfaceState { interfaceState in var interfaceState = interfaceState if let initialInterfaceState { @@ -551,7 +551,7 @@ extension ChatControllerImpl { if let initialTextInputState { interfaceState = interfaceState.withUpdatedComposeInputState(initialTextInputState) } - + if let channel = contentData.state.renderedPeer?.peer as? TelegramChannel { if channel.hasBannedPermission(.banSendVoice) != nil && channel.hasBannedPermission(.banSendInstantVideos) != nil { interfaceState = interfaceState.withUpdatedMediaRecordingMode(.audio) @@ -577,28 +577,28 @@ extension ChatControllerImpl { } } } - + return interfaceState } - + if let editMessage = initialInterfaceState?.editMessage { let (updatedState, updatedPreviewQueryState) = updatedChatEditInterfaceMessageState(context: self.context, state: presentationInterfaceState, message: editMessage) presentationInterfaceState = updatedState self.editingUrlPreviewQueryState?.1.dispose() self.editingUrlPreviewQueryState = updatedPreviewQueryState } - + return presentationInterfaceState }) - + if let initialNavigationBadge = contentData.initialNavigationBadge { contentData.initialNavigationBadge = nil self.navigationItem.badge = initialNavigationBadge } - + if let performDismissAction = contentData.state.performDismissAction, !self.didHandlePerformDismissAction { self.didHandlePerformDismissAction = true - + switch performDismissAction { case let .upgraded(upgradedToPeerId): if let navigationController = self.effectiveNavigationController { @@ -617,15 +617,15 @@ extension ChatControllerImpl { self.dismiss() } } - + self.updatePreloadNextChatPeerId() } - + func updatePreloadNextChatPeerId() { if !self.checkedPeerChatServiceActions { return } - + if self.preloadNextChatPeerId != self.contentData?.state.preloadNextChatPeerId { self.preloadNextChatPeerId = self.contentData?.state.preloadNextChatPeerId if let nextPeerId = self.contentData?.state.preloadNextChatPeerId { @@ -638,13 +638,13 @@ extension ChatControllerImpl { } } } - + func reloadCachedData() { var measure_isFirstTime = true #if DEBUG let initTimestamp = self.initTimestamp #endif - + self.ready.set(combineLatest(queue: .mainQueue(), [ self.contentDataReady.get(), self.wallpaperReady.get(), @@ -666,7 +666,7 @@ extension ChatControllerImpl { } }) } - + func loadDisplayNodeImpl() { self.navigationBar?.backPressed = { [weak self] in guard let self else { @@ -682,20 +682,21 @@ extension ChatControllerImpl { } } } - - if #available(iOS 18.0, *) { - if engineExperimentalInternalTranslationService == nil, let hostView = self.context.sharedContext.mainWindow?.hostView { - let translationService = ExperimentalInternalTranslationServiceImpl(view: hostView.containerView) - engineExperimentalInternalTranslationService = translationService - } + + // WinterGram: install a translation router that honors the chosen provider + // (Google / system / Telegram). The on-device Apple backend it wraps is still + // gated to iOS 18+, but Google works on any version. + if engineExperimentalInternalTranslationService == nil { + let hostView = self.context.sharedContext.mainWindow?.hostView + engineExperimentalInternalTranslationService = WinterGramTranslationService(view: hostView?.containerView) } - + self.displayNode = ChatControllerNode(context: self.context, chatLocation: self.chatLocation, chatLocationContextHolder: self.chatLocationContextHolder, subject: self.subject, controllerInteraction: self.controllerInteraction!, chatPresentationInterfaceState: self.presentationInterfaceState, automaticMediaDownloadSettings: self.automaticMediaDownloadSettings, navigationBar: self.navigationBar, statusBar: self.statusBar, backgroundNode: self.chatBackgroundNode, controller: self) - + if let currentItem = self.globalControlPanelsContext?.tempVoicePlaylistCurrentItem { self.chatDisplayNode.historyNode.voicePlaylistItemChanged(nil, currentItem) } - + if case .peer(self.context.account.peerId) = self.chatLocation { var didDisplayTooltip = false if "".isEmpty { @@ -709,7 +710,7 @@ extension ChatControllerImpl { return } didDisplayTooltip = true - + let _ = (ApplicationSpecificNotice.getSavedMessagesChatsSuggestion(accountManager: self.context.sharedContext.accountManager) |> deliverOnMainQueue).startStandalone(next: { [weak self] counter in guard let self else { @@ -721,12 +722,12 @@ extension ChatControllerImpl { guard let navigationBar = self.navigationBar else { return } - + let tooltipScreen = TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .plain(text: self.presentationData.strings.Chat_SavedMessagesChatsTooltip), location: .point(navigationBar.frame, .top), displayDuration: .manual, shouldDismissOnTouch: { point, _ in return .ignore }) self.present(tooltipScreen, in: .current) - + let _ = ApplicationSpecificNotice.incrementSavedMessagesChatsSuggestion(accountManager: self.context.sharedContext.accountManager).startStandalone() }) } @@ -738,7 +739,7 @@ extension ChatControllerImpl { } strongSelf.chatDisplayNode.messageTransitionNode.addContentOffset(offset: offset, itemNode: itemNode) } - + var closeOnEmpty = false if case .pinnedMessages = self.presentationInterfaceState.subject { closeOnEmpty = true @@ -748,7 +749,7 @@ extension ChatControllerImpl { closeOnEmpty = true } } - + if closeOnEmpty { self.chatDisplayNode.historyNode.addSetLoadStateUpdated({ [weak self] state, _ in guard let self else { @@ -769,7 +770,7 @@ extension ChatControllerImpl { } }) } - + let currentAccountPeer = self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId)) |> mapToSignal { peer -> Signal in if let peer { @@ -781,7 +782,7 @@ extension ChatControllerImpl { |> map { peer in return SendAsPeer(peer: peer, subscribers: nil, isPremiumRequired: false) } - + if let peerId = self.chatLocation.peerId, [Namespaces.Peer.CloudChannel, Namespaces.Peer.CloudGroup].contains(peerId.namespace) { self.sendAsPeersDisposable = (combineLatest( queue: Queue.mainQueue(), @@ -792,18 +793,18 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + if let channel = peerViewMainPeer(peerView) as? TelegramChannel, channel.isMonoForum { return } - + let isPremium = strongSelf.presentationInterfaceState.isPremium - + var allPeers: [SendAsPeer]? if !peers.isEmpty { if let channel = peerViewMainPeer(peerView) as? TelegramChannel, case .group = channel.info, channel.hasPermission(.canBeAnonymous) { allPeers = peers - + var hasAnonymousPeer = false for peer in peers { if peer.peer.id == channel.id { @@ -816,7 +817,7 @@ extension ChatControllerImpl { } } else if let channel = peerViewMainPeer(peerView) as? TelegramChannel, case let .broadcast(info) = channel.info, (info.flags.contains(.messagesShouldHaveSignatures) || info.flags.contains(.messagesShouldHaveProfiles)) { allPeers = peers - + var hasAnonymousPeer = false var hasSelfPeer = false for peer in peers { @@ -840,7 +841,7 @@ extension ChatControllerImpl { if allPeers?.count == 1 { allPeers = nil } - + var currentSendAsPeerId = strongSelf.presentationInterfaceState.currentSendAsPeerId if let peerId = currentSendAsPeerId, let peer = allPeers?.first(where: { $0.peer.id == peerId }) { if !isPremium && peer.isPremiumRequired { @@ -849,13 +850,13 @@ extension ChatControllerImpl { } else { currentSendAsPeerId = nil } - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { return $0.updatedSendAsPeers(allPeers).updatedCurrentSendAsPeerId(currentSendAsPeerId) }) }) } - + if let peerId = self.chatLocation.peerId { self.chatThemePromise.set(self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.ChatTheme(id: peerId))) let chatWallpaper = self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Wallpaper(id: peerId)) @@ -865,9 +866,9 @@ extension ChatControllerImpl { self.chatThemePromise.set(.single(nil)) self.chatWallpaperPromise.set(.single(nil)) } - + self.reloadCachedData() - + if self.context.sharedContext.immediateExperimentalUISettings.crashOnLongQueries { let _ = (self.ready.get() |> filter({ $0 }) @@ -876,19 +877,19 @@ extension ChatControllerImpl { preconditionFailure() })).startStandalone() } - + self.setupChatHistoryNode(historyNode: self.chatDisplayNode.historyNode) - + self.chatDisplayNode.requestLayout = { [weak self] transition in self?.requestLayout(transition: transition) } - + var enableSendAnimationV2 = false if let data = self.context.currentAppConfiguration.with({ $0 }).data, let _ = data["ios_killswitch_disable_send_animation_v2"] { } else { enableSendAnimationV2 = true } - + self.chatDisplayNode.setupSendActionOnViewUpdate = { [weak self] f, messageCorrelationId in //print("setup layoutActionOnViewTransition") @@ -896,7 +897,7 @@ extension ChatControllerImpl { return } self.layoutActionOnViewTransitionAction = f - + if !enableSendAnimationV2 { self.chatDisplayNode.historyNode.pinToTopStableId = nil } else if !self.chatDisplayNode.historyNode.isStrictlyScrolledToPinToEdgeItem() { @@ -906,9 +907,9 @@ extension ChatControllerImpl { f() if let strongSelf = self, let validLayout = strongSelf.validLayout { strongSelf.layoutActionOnViewTransitionAction = nil - + var mappedTransition: (ChatHistoryListViewTransition, ListViewUpdateSizeAndInsets?)? - + let isScheduledMessages: Bool if case .scheduledMessages = strongSelf.presentationInterfaceState.subject { isScheduledMessages = true @@ -920,7 +921,7 @@ extension ChatControllerImpl { let controlPoints: (Float, Float, Float, Float) = strongSelf.chatDisplayNode.messageTransitionNode.hasScheduledTransitions ? ChatMessageTransitionNodeImpl.verticalAnimationControlPoints : (0.5, 0.33, 0.0, 0.0) let shouldUseFastMessageSendAnimation = strongSelf.chatDisplayNode.shouldUseFastMessageSendAnimation - + strongSelf.chatDisplayNode.containerLayoutUpdated(validLayout, navigationBarHeight: strongSelf.navigationLayout(layout: validLayout).navigationFrame.maxY, transition: .animated(duration: duration, curve: curve), listViewTransaction: { [weak strongSelf] updateSizeAndInsets, _, _, _ in var options = transition.options @@ -967,14 +968,14 @@ extension ChatControllerImpl { stationaryItemRange = (maxInsertedItem + 1, Int.max) } } - + mappedTransition = (ChatHistoryListViewTransition(historyView: transition.historyView, deleteItems: deleteItems, insertItems: insertItems, updateItems: transition.updateItems, options: options, scrollToItem: scrollToItem, stationaryItemRange: stationaryItemRange, initialData: transition.initialData, keyboardButtonsMessage: transition.keyboardButtonsMessage, cachedData: transition.cachedData, cachedDataMessages: transition.cachedDataMessages, readStateData: transition.readStateData, scrolledToIndex: transition.scrolledToIndex, scrolledToSomeIndex: transition.scrolledToSomeIndex, peerType: transition.peerType, networkType: transition.networkType, animateIn: false, reason: transition.reason, flashIndicators: transition.flashIndicators, animateFromPreviousFilter: false), updateSizeAndInsets) }, updateExtraNavigationBarBackgroundHeight: { value, hitTestSlop, cutout, _ in strongSelf.additionalNavigationBarBackgroundHeight = value strongSelf.additionalNavigationBarHitTestSlop = hitTestSlop strongSelf.additionalNavigationBarCutout = cutout }) - + if let mappedTransition = mappedTransition { return mappedTransition } @@ -982,12 +983,12 @@ extension ChatControllerImpl { return (transition, nil) }, messageCorrelationId) } - + self.chatDisplayNode.sendMessages = { [weak self] messages, silentPosting, scheduleTime, repeatPeriod, isAnyMessageTextPartitioned, postpone in guard let strongSelf = self else { return } - + var correlationIds: [Int64] = [] for message in messages { switch message { @@ -1000,13 +1001,13 @@ extension ChatControllerImpl { } } strongSelf.commitPurposefulAction() - + if let peerId = strongSelf.chatLocation.peerId { var hasDisabledContent = false if "".isEmpty { hasDisabledContent = false } - + if let channel = strongSelf.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isRestrictedBySlowmode { let forwardCount = messages.reduce(0, { count, message -> Int in if case .forward = message { @@ -1015,7 +1016,7 @@ extension ChatControllerImpl { return count } }) - + var errorText: String? if forwardCount > 1 { errorText = strongSelf.presentationData.strings.Chat_AttachmentMultipleForwardDisabled @@ -1024,7 +1025,7 @@ extension ChatControllerImpl { } else if hasDisabledContent { errorText = strongSelf.restrictedSendingContentsText() } - + if let errorText = errorText { let alertController = textAlertController( context: strongSelf.context, @@ -1038,16 +1039,16 @@ extension ChatControllerImpl { return } } - + let effectiveSilentPosting = silentPosting ?? strongSelf.presentationInterfaceState.interfaceState.silentPosting let transformedMessages = strongSelf.transformEnqueueMessages(messages, silentPosting: effectiveSilentPosting, scheduleTime: scheduleTime, repeatPeriod: repeatPeriod, postpone: postpone) - + var forwardedMessages: [[EnqueueMessage]] = [] var forwardSourcePeerIds = Set() for message in transformedMessages { if case let .forward(source, _, _, _, _) = message { forwardSourcePeerIds.insert(source.peerId) - + var added = false if var last = forwardedMessages.last { if let currentMessage = last.first, case let .forward(currentSource, _, _, _, _) = currentMessage, currentSource.peerId == source.peerId { @@ -1060,7 +1061,7 @@ extension ChatControllerImpl { } } } - + let _ = (strongSelf.shouldDivertMessagesToScheduled(messages: transformedMessages) |> deliverOnMainQueue).start(next: { shouldDivert in let signal: Signal<[MessageId?], NoError> @@ -1080,7 +1081,7 @@ extension ChatControllerImpl { } shouldOpenScheduledMessages = true } - + var signals: [Signal<[MessageId?], NoError>] = [] for messagesGroup in forwardedMessages { signals.append(enqueueMessages(account: strongSelf.context.account, peerId: peerId, messages: messagesGroup)) @@ -1106,10 +1107,10 @@ extension ChatControllerImpl { } shouldOpenScheduledMessages = true } - + signal = enqueueMessages(account: strongSelf.context.account, peerId: peerId, messages: transformedMessages) } - + let _ = (signal |> deliverOnMainQueue).startStandalone(next: { messageIds in guard let strongSelf = self else { @@ -1118,7 +1119,7 @@ extension ChatControllerImpl { if case .scheduledMessages = strongSelf.presentationInterfaceState.subject { } else { strongSelf.chatDisplayNode.historyNode.scrollToEndOfHistory() - + if shouldOpenScheduledMessages { if let layoutActionOnViewTransitionAction = strongSelf.layoutActionOnViewTransitionAction { strongSelf.layoutActionOnViewTransitionAction = nil @@ -1127,7 +1128,7 @@ extension ChatControllerImpl { } } }) - + donateSendMessageIntent(account: strongSelf.context.account, sharedContext: strongSelf.context.sharedContext, intentContext: .chat, peerIds: [peerId]) }) } else if case let .customChatContents(customChatContents) = strongSelf.subject { @@ -1150,7 +1151,7 @@ extension ChatControllerImpl { strongSelf.present(alertController, in: .window(.root)) return } - + var text: String = "" var entities: [MessageTextEntity] = [] if let message = messages.first { @@ -1163,18 +1164,18 @@ extension ChatControllerImpl { } } } - + let _ = strongSelf.context.engine.accountData.editBusinessChatLink(url: link.url, message: text, entities: entities, title: link.title).start() if case let .customChatContents(customChatContents) = strongSelf.subject { customChatContents.businessLinkUpdate(message: text, entities: entities, title: link.title) } - + strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .succeed(text: strongSelf.presentationData.strings.Business_Links_EditLinkToastSaved, timeout: nil, customUndoText: nil), elevatedLayout: false, action: { _ in return false }), in: .current) } } strongSelf.updateChatPresentationInterfaceState(interactive: true, { $0.updatedShowCommands(false) }) } - + if case let .customChatContents(customChatContents) = self.subject { customChatContents.hashtagSearchResultsUpdate = { [weak self] searchResult in guard let self else { @@ -1208,15 +1209,15 @@ extension ChatControllerImpl { self.searchResult.set(.single((results, state, .general(scope: .channels, groupId: nil, tags: nil, minDate: nil, maxDate: nil, folderId: nil)))) } } - + self.chatDisplayNode.requestUpdateChatInterfaceState = { [weak self] transition, saveInterfaceState, f in self?.updateChatPresentationInterfaceState(transition: transition, interactive: true, saveInterfaceState: saveInterfaceState, { $0.updatedInterfaceState(f) }) } - + self.chatDisplayNode.requestUpdateInterfaceState = { [weak self] transition, interactive, f in self?.updateChatPresentationInterfaceState(transition: transition, interactive: interactive, f) } - + self.chatDisplayNode.displayAttachmentMenu = { [weak self] in guard let strongSelf = self else { return @@ -1283,18 +1284,18 @@ extension ChatControllerImpl { Signal.single(false) |> delay(4.0, queue: Queue.mainQueue()) )) - + if !strongSelf.didDisplayGroupEmojiTip, value { strongSelf.didDisplayGroupEmojiTip = true - + Queue.mainQueue().after(2.0) { strongSelf.displayGroupEmojiTooltip() } } - + if !strongSelf.didDisplaySendWhenOnlineTip, value { strongSelf.didDisplaySendWhenOnlineTip = true - + strongSelf.displaySendWhenOnlineTipDisposable.set( (strongSelf.typingActivityPromise.get() |> filter { !$0 } @@ -1313,7 +1314,7 @@ extension ChatControllerImpl { } } } - + self.chatDisplayNode.dismissUrlPreview = { [weak self] in if let strongSelf = self { if let _ = strongSelf.presentationInterfaceState.interfaceState.editMessage { @@ -1345,12 +1346,12 @@ extension ChatControllerImpl { } } } - + self.chatDisplayNode.navigateButtons.downPressed = { [weak self] in guard let self else { return } - + if case let .customChatContents(contents) = self.presentationInterfaceState.subject, case .hashTagSearch = contents.kind { self.chatDisplayNode.historyNode.scrollToEndOfHistory() } else if let resultsState = self.presentationInterfaceState.search?.resultsState, !resultsState.messageIndices.isEmpty { @@ -1383,12 +1384,12 @@ extension ChatControllerImpl { guard let self else { return } - + if self.presentationInterfaceState.search?.resultsState != nil { self.interfaceInteraction?.navigateMessageSearch(.earlier) } } - + self.chatDisplayNode.navigateButtons.mentionsPressed = { [weak self] in if let strongSelf = self, strongSelf.isNodeLoaded, let peerId = strongSelf.chatLocation.peerId { let signal = strongSelf.context.engine.messages.earliestUnseenPersonalMentionMessage(peerId: peerId, threadId: strongSelf.chatLocation.threadId) @@ -1406,15 +1407,15 @@ extension ChatControllerImpl { })) } } - + self.chatDisplayNode.navigateButtons.mentionsButton.activated = { [weak self] gesture, _ in guard let strongSelf = self else { gesture.cancel() return } - + strongSelf.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() - + var menuItems: [ContextMenuItem] = [] menuItems.append(.action(ContextMenuActionItem( id: nil, @@ -1426,7 +1427,7 @@ extension ChatControllerImpl { }, action: { _, f in f(.dismissWithoutContent) - + guard let strongSelf = self, let peerId = strongSelf.chatLocation.peerId else { return } @@ -1434,9 +1435,9 @@ extension ChatControllerImpl { } ))) let items = ContextController.Items(content: .list(menuItems)) - + let controller = makeContextController(presentationData: strongSelf.presentationData, source: .extracted(ChatMessageNavigationButtonContextExtractedContentSource(chatNode: strongSelf.chatDisplayNode, contentNode: strongSelf.chatDisplayNode.navigateButtons.mentionsButton.containerNode)), items: .single(items), recognizer: nil, gesture: gesture) - + strongSelf.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss() @@ -1445,7 +1446,7 @@ extension ChatControllerImpl { }) strongSelf.window?.presentInGlobalOverlay(controller) } - + self.chatDisplayNode.navigateButtons.reactionsPressed = { [weak self] in if let strongSelf = self, strongSelf.isNodeLoaded, let peerId = strongSelf.chatLocation.peerId { let signal = strongSelf.context.engine.messages.earliestUnseenPersonalReactionMessage(peerId: peerId, threadId: strongSelf.chatLocation.threadId) @@ -1475,22 +1476,22 @@ extension ChatControllerImpl { } } } - + guard let (updatedReaction, updatedReactionIsLarge, updatedReactionPeer) = maybeUpdatedReaction else { return } - + guard let availableReactions = item.associatedData.availableReactions else { return } - + var avatarPeers: [EnginePeer] = [] if item.message.id.peerId.namespace != Namespaces.Peer.CloudUser, let updatedReactionPeer = updatedReactionPeer { avatarPeers.append(updatedReactionPeer) } - + var reactionItem: ReactionItem? - + switch updatedReaction { case .builtin, .stars: for reaction in availableReactions.reactions { @@ -1529,15 +1530,15 @@ extension ChatControllerImpl { ) } } - + guard let targetView = itemNode.targetReactionView(value: updatedReaction) else { return } if let reactionItem = reactionItem { let standaloneReactionAnimation = StandaloneReactionAnimation(genericReactionEffect: strongSelf.chatDisplayNode.historyNode.takeGenericReactionEffect()) - + strongSelf.chatDisplayNode.messageTransitionNode.addMessageStandaloneReactionAnimation(messageId: item.message.id, standaloneReactionAnimation: standaloneReactionAnimation) - + strongSelf.chatDisplayNode.addSubnode(standaloneReactionAnimation) standaloneReactionAnimation.frame = strongSelf.chatDisplayNode.bounds standaloneReactionAnimation.animateReactionSelection( @@ -1563,7 +1564,7 @@ extension ChatControllerImpl { ) } } - + strongSelf.chatDisplayNode.historyNode.suspendReadingReactions = false }) } @@ -1574,15 +1575,15 @@ extension ChatControllerImpl { })) } } - + self.chatDisplayNode.navigateButtons.reactionsButton.activated = { [weak self] gesture, _ in guard let strongSelf = self else { gesture.cancel() return } - + strongSelf.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() - + var menuItems: [ContextMenuItem] = [] menuItems.append(.action(ContextMenuActionItem( id: nil, @@ -1594,7 +1595,7 @@ extension ChatControllerImpl { }, action: { _, f in f(.dismissWithoutContent) - + guard let strongSelf = self, let peerId = strongSelf.chatLocation.peerId else { return } @@ -1602,9 +1603,9 @@ extension ChatControllerImpl { } ))) let items = ContextController.Items(content: .list(menuItems)) - + let controller = makeContextController(presentationData: strongSelf.presentationData, source: .extracted(ChatMessageNavigationButtonContextExtractedContentSource(chatNode: strongSelf.chatDisplayNode, contentNode: strongSelf.chatDisplayNode.navigateButtons.reactionsButton.containerNode)), items: .single(items), recognizer: nil, gesture: gesture) - + strongSelf.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss() @@ -1613,7 +1614,7 @@ extension ChatControllerImpl { }) strongSelf.window?.presentInGlobalOverlay(controller) } - + self.chatDisplayNode.navigateButtons.pollVotesPressed = { [weak self] in if let strongSelf = self, strongSelf.isNodeLoaded, let peerId = strongSelf.chatLocation.peerId { let signal = strongSelf.context.engine.messages.earliestUnseenPollVoteMessage(peerId: peerId, threadId: strongSelf.chatLocation.threadId) @@ -1634,15 +1635,15 @@ extension ChatControllerImpl { })) } } - + self.chatDisplayNode.navigateButtons.pollVotesButton.activated = { [weak self] gesture, _ in guard let strongSelf = self else { gesture.cancel() return } - + strongSelf.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() - + var menuItems: [ContextMenuItem] = [] menuItems.append(.action(ContextMenuActionItem( id: nil, @@ -1654,7 +1655,7 @@ extension ChatControllerImpl { }, action: { _, f in f(.dismissWithoutContent) - + guard let strongSelf = self, let peerId = strongSelf.chatLocation.peerId else { return } @@ -1662,9 +1663,9 @@ extension ChatControllerImpl { } ))) let items = ContextController.Items(content: .list(menuItems)) - + let controller = makeContextController(presentationData: strongSelf.presentationData, source: .extracted(ChatMessageNavigationButtonContextExtractedContentSource(chatNode: strongSelf.chatDisplayNode, contentNode: strongSelf.chatDisplayNode.navigateButtons.pollVotesButton.containerNode)), items: .single(items), recognizer: nil, gesture: gesture) - + strongSelf.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss() @@ -1673,17 +1674,17 @@ extension ChatControllerImpl { }) strongSelf.window?.presentInGlobalOverlay(controller) } - + let interfaceInteraction = ChatPanelInterfaceInteraction(setupReplyMessage: { [weak self] messageId, innerSubject, completion in guard let strongSelf = self, strongSelf.isNodeLoaded else { return } - + guard !strongSelf.presentAccountFrozenInfoIfNeeded(delay: true) else { completion(.immediate, {}) return } - + if let messageId = messageId { let intrinsicCanSendMessagesHere = canSendMessagesToChat(strongSelf.presentationInterfaceState) var canSendMessagesHere = intrinsicCanSendMessagesHere @@ -1693,7 +1694,7 @@ extension ChatControllerImpl { if case .inline = strongSelf.presentationInterfaceState.mode { canSendMessagesHere = false } - + if canSendMessagesHere { let _ = strongSelf.presentVoiceMessageDiscardAlert(action: { if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId)?._asMessage() { @@ -1727,7 +1728,7 @@ extension ChatControllerImpl { quote: nil, innerSubject: innerSubject ) - + completion(.immediate, { guard let self else { return @@ -1757,7 +1758,7 @@ extension ChatControllerImpl { state = state.updatedEditMessageState(nil) return state }, completion: completion) - + return } let _ = strongSelf.presentVoiceMessageDiscardAlert(action: { @@ -1779,7 +1780,7 @@ extension ChatControllerImpl { webpageUrl = content.url } } - + let inputText: NSAttributedString if let richTextAttribute = message.attributes.first(where: { $0 is RichTextMessageAttribute }) as? RichTextMessageAttribute { inputText = chatInputTextWithReattachedCustomEmoji(markdownStringFromInstantPage(richTextAttribute.instantPage)) @@ -1790,24 +1791,24 @@ extension ChatControllerImpl { if webpageUrl == nil { disableUrlPreviews = detectUrls(inputText) } - + var updated = state.updatedInterfaceState { interfaceState in return interfaceState.withUpdatedEditMessage(ChatEditMessageState(messageId: messageId, inputState: ChatTextInputState(inputText: inputText), disableUrlPreviews: disableUrlPreviews, inputTextMaxLength: inputTextMaxLength, mediaCaptionIsAbove: nil)) } - + let (updatedState, updatedPreviewQueryState) = updatedChatEditInterfaceMessageState(context: strongSelf.context, state: updated, message: message) updated = updatedState strongSelf.editingUrlPreviewQueryState?.1.dispose() strongSelf.editingUrlPreviewQueryState = updatedPreviewQueryState - + updated = updated.updatedInputMode({ _ in return .text }) updated = updated.updatedShowCommands(false) - + return updated }, completion: completion) - + if !strongSelf.chatDisplayNode.ensureInputViewFocused() { DispatchQueue.main.async { [weak self] in guard let self else { @@ -1825,7 +1826,7 @@ extension ChatControllerImpl { if let strongSelf = self, strongSelf.isNodeLoaded { let _ = strongSelf.presentVoiceMessageDiscardAlert(action: { strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withUpdatedSelectedMessages(messageIds) }.updatedShowCommands(false) }, completion: completion) - + if let selectionState = strongSelf.presentationInterfaceState.interfaceState.selectionState { let count = selectionState.selectedIds.count let text = strongSelf.presentationData.strings.VoiceOver_Chat_MessagesSelected(Int32(count)) @@ -1911,13 +1912,13 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + let author = message.forwardInfo?.author - + guard let peer = author else { return } - + let presentationData = strongSelf.presentationData let controller = ActionSheetController(presentationData: presentationData) let dismissAction: () -> Void = { [weak controller] in @@ -1947,7 +1948,7 @@ extension ChatControllerImpl { let _ = strongSelf.context.engine.peers.reportRepliesMessage(messageId: message.id, deleteMessage: true, deleteHistory: true, reportSpam: reportSpam).startStandalone() }) ] as [ActionSheetItem]) - + controller.setItemGroups([ ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, action: { dismissAction() })]) @@ -1965,14 +1966,14 @@ extension ChatControllerImpl { completion(.default) return } - + let messageIds = Set(messages.map { $0.id }) self.messageContextDisposable.set((self.context.sharedContext.chatAvailableMessageActions(engine: self.context.engine, accountPeerId: self.context.account.peerId, messageIds: messageIds, keepUpdated: false) |> deliverOnMainQueue).startStrict(next: { [weak self] actions in guard let self, !actions.options.isEmpty else { return } - + if actions.options.contains(.deleteGlobally), let message = messages.first(where: { message in message.attributes.contains(where: { $0 is PublishedSuggestedPostMessageAttribute }) }), let attribute = message.attributes.first(where: { $0 is PublishedSuggestedPostMessageAttribute }) as? PublishedSuggestedPostMessageAttribute, message.timestamp > Int32(Date().timeIntervalSince1970) - 60 * 60 * 24 { let commit = { [weak self] in guard let self else { @@ -1988,7 +1989,7 @@ extension ChatControllerImpl { titleString = self.presentationData.strings.Chat_DeletePaidMessageTon_Title textString = self.presentationData.strings.Chat_DeletePaidMessageTon_Text } - + let alertController = textAlertController( context: self.context, title: titleString, @@ -2013,7 +2014,7 @@ extension ChatControllerImpl { } return } - + if let banAuthor = actions.banAuthor { if let contextController = contextController { contextController.dismiss(completion: { [weak self] in @@ -2078,7 +2079,7 @@ extension ChatControllerImpl { guard !strongSelf.presentAccountFrozenInfoIfNeeded(delay: true) else { return } - + strongSelf.commitPurposefulAction() let forwardMessageIds = messages.map { $0.id }.sorted() strongSelf.forwardMessages(messageIds: forwardMessageIds) @@ -2119,7 +2120,7 @@ extension ChatControllerImpl { |> deliverOnMainQueue).startStandalone(next: { messages in if let strongSelf = self, !messages.isEmpty { strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState({ $0.withoutSelectionState() }) }) - + let shareController = strongSelf.context.sharedContext.makeShareController(context: strongSelf.context, params: ShareControllerParams(subject: .messages(messages.sorted(by: { lhs, rhs in return lhs.index < rhs.index }).map { $0._asMessage() }), externalShare: true, immediateExternalShare: true, updatedPresentationData: strongSelf.updatedPresentationData)) @@ -2136,7 +2137,7 @@ extension ChatControllerImpl { return interfaceState.withUpdatedEffectiveInputState(updatedState) }.updatedInputMode({ _ in updatedMode }) }) - + if !strongSelf.presentationInterfaceState.interfaceState.effectiveInputState.inputText.string.isEmpty { strongSelf.silentPostTooltipController?.dismiss() } @@ -2175,18 +2176,18 @@ extension ChatControllerImpl { guard let strongSelf = self, let editMessage = strongSelf.presentationInterfaceState.interfaceState.editMessage else { return } - + let sourceMessage: Signal sourceMessage = strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Messages.Message(id: editMessage.messageId)) - + let _ = (sourceMessage |> deliverOnMainQueue).start(next: { [weak strongSelf] message in guard let strongSelf, let message else { return } - + var disableUrlPreview = false - + var webpage: TelegramMediaWebpage? var webpagePreviewAttribute: WebpagePreviewMessageAttribute? if let urlPreview = strongSelf.presentationInterfaceState.editingUrlPreview { @@ -2197,13 +2198,13 @@ extension ChatControllerImpl { webpagePreviewAttribute = WebpagePreviewMessageAttribute(leadingPreview: !urlPreview.positionBelowText, forceLargeMedia: urlPreview.largeMedia, isManuallyAdded: true, isSafe: false) } } - + var invertedMediaAttribute: InvertMediaMessageAttribute? if webpagePreviewAttribute == nil { if let attribute = message.attributes.first(where: { $0 is InvertMediaMessageAttribute }) { invertedMediaAttribute = attribute as? InvertMediaMessageAttribute } - + if let mediaCaptionIsAbove = editMessage.mediaCaptionIsAbove { if mediaCaptionIsAbove { invertedMediaAttribute = InvertMediaMessageAttribute() @@ -2212,7 +2213,7 @@ extension ChatControllerImpl { } } } - + let text = trimChatInputText(convertMarkdownToAttributes(expandedInputStateAttributedString(editMessage.inputState.inputText))) var isSpecialChatContents = false @@ -2229,7 +2230,7 @@ extension ChatControllerImpl { if !entities.isEmpty { entitiesAttribute = TextEntitiesMessageAttribute(entities: entities) } - + var inlineStickers: [MediaId: TelegramMediaFile] = [:] var firstLockedPremiumEmoji: TelegramMediaFile? text.enumerateAttribute(ChatTextInputAttributes.customEmoji, in: NSRange(location: 0, length: text.length), using: { value, _, _ in @@ -2244,7 +2245,7 @@ extension ChatControllerImpl { } } }) - + if let firstLockedPremiumEmoji = firstLockedPremiumEmoji { let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } strongSelf.controllerInteraction?.displayUndo(.sticker(context: strongSelf.context, file: firstLockedPremiumEmoji, loop: true, title: nil, text: presentationData.strings.EmojiInput_PremiumEmojiToast_Text, undoText: presentationData.strings.EmojiInput_PremiumEmojiToast_Action, customAction: { @@ -2252,7 +2253,7 @@ extension ChatControllerImpl { return } strongSelf.chatDisplayNode.dismissTextInput() - + let context = strongSelf.context var replaceImpl: ((ViewController) -> Void)? let controller = context.sharedContext.makePremiumDemoController(context: context, subject: .animatedEmoji, forceDark: false, action: { @@ -2264,10 +2265,10 @@ extension ChatControllerImpl { } strongSelf.push(controller) })) - + return } - + if text.length == 0 { if strongSelf.presentationInterfaceState.editMessageState?.mediaReference != nil { } else if message.effectiveMedia.contains(where: { media in @@ -2287,7 +2288,7 @@ extension ChatControllerImpl { return } } - + var updatingMedia = false let media: RequestEditMessageMedia if let editMediaReference = strongSelf.presentationInterfaceState.editMessageState?.mediaReference { @@ -2298,7 +2299,7 @@ extension ChatControllerImpl { } else { media = .keep } - + let _ = (strongSelf.context.account.postbox.messageAtId(editMessage.messageId) |> deliverOnMainQueue).startStandalone(next: { [weak self] currentMessage in if let strongSelf = self { @@ -2318,7 +2319,7 @@ extension ChatControllerImpl { } } } - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in var state = state state = state.updatedInterfaceState({ $0.withUpdatedEditMessage(nil) }) @@ -2332,14 +2333,14 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + let _ = strongSelf.presentVoiceMessageDiscardAlert(action: { var interactive = true if strongSelf.chatDisplayNode.isInputViewFocused { interactive = false strongSelf.context.sharedContext.mainWindow?.doNotAnimateLikelyKeyboardAutocorrectionSwitch() } - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: interactive, { current in return current.updatedSearch(current.search == nil ? ChatSearchData(domain: domain).withUpdatedQuery(query) : current.search?.withUpdatedDomain(domain).withUpdatedQuery(query)) }, completion: { [weak strongSelf] _ in @@ -2354,12 +2355,12 @@ extension ChatControllerImpl { guard let self else { return } - + if let customDismissSearch = self.customDismissSearch { customDismissSearch() return } - + self.updateChatPresentationInterfaceState(animated: true, interactive: true, { current in return current.updatedSearch(nil).updatedHistoryFilter(nil) }) @@ -2504,7 +2505,7 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + if let navigationController = strongSelf.effectiveNavigationController { strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), subject: nil, keepStack: .always)) } @@ -2557,7 +2558,7 @@ extension ChatControllerImpl { strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({ if let strongSelf = self { strongSelf.chatDisplayNode.collapseInput() - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { $0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil).withUpdatedPostSuggestionState(nil).withUpdatedComposeInputState(ChatTextInputState(inputText: NSAttributedString(string: ""))).withUpdatedComposeDisableUrlPreviews([]) } }) @@ -2581,19 +2582,19 @@ extension ChatControllerImpl { guard let peerId = self.chatLocation.peerId else { return } - + self.updateChatPresentationInterfaceState(animated: true, interactive: false, { $0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil).withUpdatedPostSuggestionState(nil).withUpdatedComposeInputState(ChatTextInputState(inputText: NSAttributedString(string: ""))).withUpdatedComposeDisableUrlPreviews([]) } }) - + if !self.presentationInterfaceState.isPremium { let controller = PremiumIntroScreen(context: self.context, source: .settings) self.push(controller) return } - + self.context.engine.accountData.sendMessageShortcut(peerId: peerId, id: shortcutId) - + /*self.chatDisplayNode.setupSendActionOnViewUpdate({ [weak self] in guard let self else { return @@ -2602,7 +2603,7 @@ extension ChatControllerImpl { $0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil).withUpdatedPostSuggestionState(nil).withUpdatedComposeInputState(ChatTextInputState(inputText: NSAttributedString(string: ""))).withUpdatedComposeDisableUrlPreviews([]) } }) }, nil) - + var messages: [EnqueueMessage] = [] do { let message = shortcut.topMessage @@ -2611,7 +2612,7 @@ extension ChatControllerImpl { if !entities.isEmpty { attributes.append(TextEntitiesMessageAttribute(entities: entities)) } - + messages.append(.message( text: message.text, attributes: attributes, @@ -2625,7 +2626,7 @@ extension ChatControllerImpl { bubbleUpEmojiOrStickersets: [] )) } - + self.sendMessages(messages)*/ }, openEditShortcuts: { [weak self] in guard let self else { @@ -2637,7 +2638,7 @@ extension ChatControllerImpl { guard let self else { return } - + let controller = self.context.sharedContext.makeQuickReplySetupScreen(context: self.context, initialData: initialData) controller.navigationPresentation = .modal self.push(controller) @@ -2663,14 +2664,14 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + strongSelf.dismissAllTooltips() - + strongSelf.mediaRecordingModeTooltipController?.dismiss() strongSelf.interfaceInteraction?.updateShowWebView { _ in return false } - + var bannedMediaInput = false if let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer { if let channel = peer as? TelegramChannel { @@ -2703,12 +2704,12 @@ extension ChatControllerImpl { } } } - + if bannedMediaInput { strongSelf.controllerInteraction?.displayUndo(.universal(animation: "premium_unlock", scale: 1.0, colors: ["__allcolors__": UIColor(white: 1.0, alpha: 1.0)], title: nil, text: strongSelf.restrictedSendingContentsText(), customUndoText: nil, timeout: nil)) return } - + let requestId = strongSelf.beginMediaRecordingRequestId let begin: () -> Void = { guard let strongSelf = self, strongSelf.beginMediaRecordingRequestId == requestId else { @@ -2738,7 +2739,7 @@ extension ChatControllerImpl { } }) } - + DeviceAccess.authorizeAccess(to: .microphone(isVideo ? .video : .audio), presentationData: strongSelf.presentationData, present: { c, a in self?.present(c, in: .window(.root), with: a) }, openSettings: { @@ -2795,10 +2796,10 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + let canBypassRestrictions = canBypassRestrictions(chatPresentationInterfaceState: strongSelf.presentationInterfaceState) - + let subjectFlags: [TelegramChatBannedRightsFlags] switch subject { case .stickers: @@ -2806,7 +2807,7 @@ extension ChatControllerImpl { case .mediaRecording, .premiumVoiceMessages: subjectFlags = [.banSendVoice, .banSendInstantVideos] } - + var bannedPermission: (Int32, Bool)? = nil if let channel = strongSelf.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel { for subjectFlag in subjectFlags { @@ -2823,14 +2824,14 @@ extension ChatControllerImpl { } } } - + if let boostsToUnrestrict = (strongSelf.contentData?.state.peerView?.cachedData as? CachedChannelData)?.boostsToUnrestrict, boostsToUnrestrict > 0, let bannedPermission, !bannedPermission.1 { strongSelf.interfaceInteraction?.openBoostToUnrestrict() return } - + var displayToast = false - + if let (untilDate, personal) = bannedPermission { let banDescription: String switch subject { @@ -2858,9 +2859,9 @@ extension ChatControllerImpl { strongSelf.recordingModeFeedback = HapticFeedback() strongSelf.recordingModeFeedback?.prepareError() } - + strongSelf.recordingModeFeedback?.error() - + switch displayType { case .tooltip: if displayToast { @@ -2878,7 +2879,7 @@ extension ChatControllerImpl { case .mediaRecording, .premiumVoiceMessages: rect = strongSelf.chatDisplayNode.frameForInputActionButton() } - + if let tooltipController = strongSelf.mediaRestrictedTooltipController, strongSelf.mediaRestrictedTooltipControllerMode == isStickers { tooltipController.updateContent(.text(banDescription), animated: true, extendTimer: true) } else if let rect = rect { @@ -2903,7 +2904,7 @@ extension ChatControllerImpl { strongSelf.present(textAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, title: nil, text: banDescription, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) } } - + if case .premiumVoiceMessages = subject { let text: String if let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer.flatMap({ EnginePeer($0) }) { @@ -2997,7 +2998,7 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + var bannedMediaInput = false if let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer { if let channel = peer as? TelegramChannel { @@ -3030,20 +3031,20 @@ extension ChatControllerImpl { } } } - + if bannedMediaInput { strongSelf.controllerInteraction?.displayUndo(.universal(animation: "premium_unlock", scale: 1.0, colors: ["__allcolors__": UIColor(white: 1.0, alpha: 1.0)], title: nil, text: strongSelf.restrictedSendingContentsText(), customUndoText: nil, timeout: nil)) return } - + if strongSelf.recordingModeFeedback == nil { strongSelf.recordingModeFeedback = HapticFeedback() strongSelf.recordingModeFeedback?.prepareImpact() } - + strongSelf.recordingModeFeedback?.impact() var updatedMode: ChatTextInputMediaRecordingButtonMode? - + strongSelf.updateChatPresentationInterfaceState(interactive: true, { return $0.updatedInterfaceState({ current in let mode: ChatTextInputMediaRecordingButtonMode @@ -3057,11 +3058,11 @@ extension ChatControllerImpl { return current.withUpdatedMediaRecordingMode(mode) }).updatedShowWebView(false) }) - + if let updatedMode = updatedMode, updatedMode == .video { let _ = ApplicationSpecificNotice.incrementChatMediaMediaRecordingTips(accountManager: strongSelf.context.sharedContext.accountManager, count: 3).startStandalone() } - + strongSelf.displayMediaRecordingTooltip() }, setupMessageAutoremoveTimeout: { [weak self] in guard let strongSelf = self, case let .peer(peerId) = strongSelf.chatLocation else { @@ -3072,7 +3073,7 @@ extension ChatControllerImpl { } if peerId.namespace == Namespaces.Peer.SecretChat { strongSelf.chatDisplayNode.dismissInput() - + if let peer = peer as? TelegramSecretChat { let timeoutValues: [Int32] = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 30, @@ -3115,7 +3116,7 @@ extension ChatControllerImpl { } else { var currentAutoremoveTimeout: Int32? = strongSelf.presentationInterfaceState.autoremoveTimeout var canSetupAutoremoveTimeout = false - + if let secretChat = peer as? TelegramSecretChat { currentAutoremoveTimeout = secretChat.messageAutoremoveTimeout canSetupAutoremoveTimeout = true @@ -3132,16 +3133,16 @@ extension ChatControllerImpl { canSetupAutoremoveTimeout = true } } - + if canSetupAutoremoveTimeout { strongSelf.presentAutoremoveSetup() } else if let currentAutoremoveTimeout = currentAutoremoveTimeout, let rect = strongSelf.chatDisplayNode.frameForInputPanelAccessoryButton(.messageAutoremoveTimeout(currentAutoremoveTimeout)) { - + let intervalText = timeIntervalString(strings: strongSelf.presentationData.strings, value: currentAutoremoveTimeout) let text: String = strongSelf.presentationData.strings.Conversation_AutoremoveTimerSetToastText(intervalText).string - + strongSelf.mediaRecordingModeTooltipController?.dismiss() - + if let tooltipController = strongSelf.silentPostTooltipController { tooltipController.updateContent(.text(text), animated: true, extendTimer: true) } else { @@ -3179,7 +3180,7 @@ extension ChatControllerImpl { contextController?.dismiss(completion: nil) return } - + if let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer { if strongSelf.canManagePin() { let pinAction: (Bool, Bool) -> Void = { notify, forThisPeerOnlyIfPossible in @@ -3199,7 +3200,7 @@ extension ChatControllerImpl { })) } } - + if let peer = peer as? TelegramChannel, case .broadcast = peer.info, let contextController = contextController { contextController.dismiss(completion: { pinAction(true, false) @@ -3216,60 +3217,60 @@ extension ChatControllerImpl { pinAction(true, false) }) }))) - + contextItems.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Conversation_PinMessagesForMe, textColor: .primary, icon: { _ in nil }, action: { c, _ in c?.dismiss(completion: { pinAction(true, true) }) }))) - + contextController.setItems(.single(ContextController.Items(content: .list(contextItems))), minHeight: nil, animated: true) } return } else { if let contextController = contextController { var contextItems: [ContextMenuItem] = [] - + contextItems.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Conversation_PinMessageAlert_PinAndNotifyMembers, textColor: .primary, icon: { _ in nil }, action: { c, _ in c?.dismiss(completion: { pinAction(true, false) }) }))) - + contextItems.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Conversation_PinMessageAlert_OnlyPin, textColor: .primary, icon: { _ in nil }, action: { c, _ in c?.dismiss(completion: { pinAction(false, false) }) }))) - + contextController.setItems(.single(ContextController.Items(content: .list(contextItems))), minHeight: nil, animated: true) - + return } else { let continueAction: () -> Void = { guard let strongSelf = self else { return } - + var pinImmediately = false if let channel = peer as? TelegramChannel, case .broadcast = channel.info { pinImmediately = true } else if let _ = peer as? TelegramUser { pinImmediately = true } - + if pinImmediately { pinAction(true, false) } else { let topPinnedMessage: Signal = ChatControllerImpl.topPinnedMessageSignal(context: strongSelf.context, chatLocation: strongSelf.chatLocation, referenceMessage: nil) |> take(1) - + let _ = (topPinnedMessage |> deliverOnMainQueue).startStandalone(next: { value in guard let strongSelf = self else { return } - + let title: String? let text: String let actionLayout: TextAlertContentActionLayout @@ -3298,12 +3299,12 @@ extension ChatControllerImpl { }) ] } - + strongSelf.present(textAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, title: title, text: text, actions: actions, actionLayout: actionLayout), in: .window(.root)) }) } } - + continueAction() } } @@ -3329,7 +3330,7 @@ extension ChatControllerImpl { guard let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer else { return } - + if strongSelf.canManagePin() { let action: () -> Void = { if let strongSelf = self { @@ -3340,13 +3341,13 @@ extension ChatControllerImpl { disposable = MetaDisposable() strongSelf.unpinMessageDisposable = disposable } - + if askForConfirmation { strongSelf.chatDisplayNode.historyNode.pendingUnpinnedAllMessages = true strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { return $0.updatedPendingUnpinnedAllMessages(true) }) - + strongSelf.present( UndoOverlayController( presentationData: strongSelf.presentationData, @@ -3446,7 +3447,7 @@ extension ChatControllerImpl { } else { if let pinnedMessage = strongSelf.presentationInterfaceState.pinnedMessage { let previousClosedPinnedMessageId = strongSelf.presentationInterfaceState.interfaceState.messageActionsState.closedPinnedMessageId - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { return $0.updatedInterfaceState({ $0.withUpdatedMessageActionsState({ value in var value = value @@ -3491,7 +3492,7 @@ extension ChatControllerImpl { } } } - + if let contextController = contextController { contextController.dismiss(completion: { impl() @@ -3503,19 +3504,19 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + let topPinnedMessage: Signal = ChatControllerImpl.topPinnedMessageSignal(context: strongSelf.context, chatLocation: strongSelf.chatLocation, referenceMessage: nil) |> take(1) - + let _ = (topPinnedMessage |> deliverOnMainQueue).startStandalone(next: { topPinnedMessage in guard let strongSelf = self, let topPinnedMessage = topPinnedMessage else { return } - + if strongSelf.canManagePin() { let count = strongSelf.presentationInterfaceState.pinnedMessage?.totalCount ?? 1 - + strongSelf.requestedUnpinAllMessages?(count, topPinnedMessage.topMessageId) strongSelf.dismiss() } else { @@ -3614,7 +3615,7 @@ extension ChatControllerImpl { } }) strongSelf.saveInterfaceState() - + if let navigationController = strongSelf.navigationController as? NavigationController { for controller in navigationController.globalOverlayControllers { if controller is VoiceChatOverlayController { @@ -3622,19 +3623,19 @@ extension ChatControllerImpl { } } } - + var rect: CGRect? = strongSelf.chatDisplayNode.frameForInputPanelAccessoryButton(.silentPost(true)) if rect == nil { rect = strongSelf.chatDisplayNode.frameForInputPanelAccessoryButton(.silentPost(false)) } - + let text: String if !value { text = strongSelf.presentationData.strings.Conversation_SilentBroadcastTooltipOn } else { text = strongSelf.presentationData.strings.Conversation_SilentBroadcastTooltipOff } - + if let tooltipController = strongSelf.silentPostTooltipController { tooltipController.updateContent(.text(text), animated: true, extendTimer: true) } else if let rect = rect { @@ -3657,7 +3658,7 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + var signal = strongSelf.context.engine.messages.requestMessageSelectPollOption(messageId: id, opaqueIdentifiers: []) let disposables: DisposableDict if let current = strongSelf.selectMessagePollOptionDisposables { @@ -3666,7 +3667,7 @@ extension ChatControllerImpl { disposables = DisposableDict() strongSelf.selectMessagePollOptionDisposables = disposables } - + var cancelImpl: (() -> Void)? let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } let progressSignal = Signal { subscriber in @@ -3683,7 +3684,7 @@ extension ChatControllerImpl { |> runOn(Queue.mainQueue()) |> delay(0.3, queue: Queue.mainQueue()) let progressDisposable = progressSignal.startStrict() - + signal = signal |> afterDisposed { Queue.mainQueue().async { @@ -3693,7 +3694,7 @@ extension ChatControllerImpl { cancelImpl = { disposables.set(nil, forKey: id) } - + disposables.set((signal |> deliverOnMainQueue).startStrict(completed: { [weak self] in guard let self else { @@ -3708,7 +3709,7 @@ extension ChatControllerImpl { guard let strongSelf = self, let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(id)?._asMessage() else { return } - + var maybePoll: TelegramMediaPoll? for media in message.media { if let poll = media as? TelegramMediaPoll { @@ -3716,11 +3717,11 @@ extension ChatControllerImpl { break } } - + guard let poll = maybePoll else { return } - + let actionTitle: String let actionButtonText: String switch poll.kind { @@ -3731,7 +3732,7 @@ extension ChatControllerImpl { actionTitle = strongSelf.presentationData.strings.Conversation_StopQuizConfirmationTitle actionButtonText = strongSelf.presentationData.strings.Conversation_StopQuizConfirmation } - + let actionSheet = ActionSheetController(presentationData: strongSelf.presentationData) actionSheet.setItemGroups([ActionSheetItemGroup(items: [ ActionSheetTextItem(title: actionTitle), @@ -3772,7 +3773,7 @@ extension ChatControllerImpl { actionSheet?.dismissAnimated() }) ])]) - + strongSelf.chatDisplayNode.dismissInput() strongSelf.present(actionSheet, in: .window(.root)) }, updateInputLanguage: { [weak self] f in @@ -3800,7 +3801,7 @@ extension ChatControllerImpl { inputMode = state.inputMode return state }) - + var link: String? if let text { text.enumerateAttributes(in: NSMakeRange(0, text.length)) { attributes, _, _ in @@ -3809,7 +3810,7 @@ extension ChatControllerImpl { } } } - + let controller = chatTextLinkEditController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, text: strongSelf.presentationData.strings.TextFormat_AddLinkText(text?.string ?? "").string, link: link, apply: { [weak self] link, _ in if let strongSelf = self, let inputMode = inputMode, let selectionRange = selectionRange { if let link { @@ -3831,7 +3832,7 @@ extension ChatControllerImpl { } }) strongSelf.present(controller, in: .window(.root)) - + strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: false, { $0.updatedInputMode({ _ in return .none }) }) } }, openDateEditing: { [weak self] in @@ -3849,7 +3850,7 @@ extension ChatControllerImpl { inputMode = state.inputMode return state }) - + var date: Int32? var suggestedDate: Int32? if let text { @@ -3867,7 +3868,7 @@ extension ChatControllerImpl { }) } } - + let controller = ChatScheduleTimeScreen( context: self.context, mode: .format, @@ -3887,7 +3888,7 @@ extension ChatControllerImpl { return (chatTextInputRemoveDateAttribute(current, selectionRange: selectionRange), inputMode) } } - + self.updateChatPresentationInterfaceState(animated: false, interactive: true, { return $0.updatedInputMode({ _ in return inputMode }).updatedInterfaceState({ $0.withUpdatedEffectiveInputState(ChatTextInputState(inputText: $0.effectiveInputState.inputText, selectionRange: selectionRange.endIndex ..< selectionRange.endIndex)) @@ -3896,18 +3897,18 @@ extension ChatControllerImpl { } ) self.push(controller) - + self.updateChatPresentationInterfaceState(animated: false, interactive: false, { $0.updatedInputMode({ _ in return .none }) }) }, displaySlowmodeTooltip: { [weak self] sourceView, nodeRect in guard let strongSelf = self, let slowmodeState = strongSelf.presentationInterfaceState.slowmodeState else { return } - + if let boostsToUnrestrict = (strongSelf.contentData?.state.peerView?.cachedData as? CachedChannelData)?.boostsToUnrestrict, boostsToUnrestrict > 0 { strongSelf.interfaceInteraction?.openBoostToUnrestrict() return } - + let rect = sourceView.convert(nodeRect, to: strongSelf.view) if let slowmodeTooltipController = strongSelf.slowmodeTooltipController { if let arguments = slowmodeTooltipController.presentationArguments as? TooltipControllerPresentationArguments, case let .node(f) = arguments.sourceAndRect, let (previousNode, previousRect) = f() { @@ -3915,11 +3916,11 @@ extension ChatControllerImpl { return } } - + strongSelf.slowmodeTooltipController = nil slowmodeTooltipController.dismiss() } - let slowmodeTooltipController = ChatSlowmodeHintController(presentationData: strongSelf.presentationData, slowmodeState: + let slowmodeTooltipController = ChatSlowmodeHintController(presentationData: strongSelf.presentationData, slowmodeState: slowmodeState) slowmodeTooltipController.presentationArguments = TooltipControllerPresentationArguments(sourceNodeAndRect: { if let strongSelf = self { @@ -3928,7 +3929,7 @@ extension ChatControllerImpl { return nil }) strongSelf.slowmodeTooltipController = slowmodeTooltipController - + strongSelf.window?.presentInGlobalOverlay(slowmodeTooltipController) }, displaySendMessageOptions: { [weak self] node, gesture in guard let self else { @@ -3963,19 +3964,19 @@ extension ChatControllerImpl { return } unarchiveAutomaticallyArchivedPeer(account: strongSelf.context.account, peerId: peerId) - + strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .succeed(text: strongSelf.presentationData.strings.Conversation_UnarchiveDone, timeout: nil, customUndoText: nil), elevatedLayout: false, action: { _ in return false }), in: .current) }, scrollToTop: { [weak self] in guard let strongSelf = self else { return } - + strongSelf.chatDisplayNode.historyNode.scrollToStartOfHistory() }, viewReplies: { [weak self] sourceMessageId, replyThreadResult in guard let strongSelf = self else { return } - + if let navigationController = strongSelf.effectiveNavigationController { let subject: ChatControllerSubject? = sourceMessageId.flatMap { ChatControllerSubject.message(id: .id($0), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false) } strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .replyThread(replyThreadResult), subject: subject, keepStack: .always)) @@ -3992,9 +3993,9 @@ extension ChatControllerImpl { } let count = pinnedMessage.totalCount let topMessageId = pinnedMessage.topMessageId - + var items: [ContextMenuItem] = [] - + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Chat_PinnedListPreview_ShowAllMessages, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/PinnedList"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in @@ -4004,7 +4005,7 @@ extension ChatControllerImpl { strongSelf.openPinnedMessages(at: nil) f(.dismissWithoutContent) }))) - + if strongSelf.canManagePin() { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Chat_PinnedListPreview_UnpinAllMessages, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Unpin"), color: theme.contextMenu.primaryColor) @@ -4022,30 +4023,30 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + strongSelf.performUpdatedClosedPinnedMessageId(pinnedMessageId: topMessageId) f(.dismissWithoutContent) }))) } - + let chatLocation: ChatLocation if let _ = strongSelf.chatLocation.threadId { chatLocation = strongSelf.chatLocation } else { chatLocation = .peer(id: peerId) } - + let chatController = strongSelf.context.sharedContext.makeChatController(context: strongSelf.context, chatLocation: chatLocation, subject: .pinnedMessages(id: pinnedMessage.message.id), botStart: nil, mode: .standard(.previewing), params: nil) chatController.customNavigationController = strongSelf.navigationController as? NavigationController var dismissPreviewing: ((Bool) -> (() -> Void))? chatController.dismissPreviewing = { animateIn in return dismissPreviewing?(animateIn) ?? {} } - + chatController.canReadHistory.set(false) - + strongSelf.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() - + let contextController = makeContextController(presentationData: strongSelf.presentationData, source: .controller(ChatContextControllerContentSourceImpl(controller: chatController, sourceNode: node, passthroughTouches: true)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture) dismissPreviewing = { [weak self, weak contextController] animateIn in if let self, let contextController { @@ -4090,7 +4091,7 @@ extension ChatControllerImpl { guard let monoforumPeerId = channel.linkedMonoforumId else { return } - + let _ = (self.context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: monoforumPeerId) ) @@ -4129,13 +4130,13 @@ extension ChatControllerImpl { guard let strongSelf = self, let peerId = strongSelf.chatLocation.peerId, let node = node as? ContextReferenceContentNode, let peers = strongSelf.presentationInterfaceState.sendAsPeers, let layout = strongSelf.validLayout else { return } - + let isPremium = strongSelf.presentationInterfaceState.isPremium - + let cleanInsets = layout.intrinsicInsets let insets = layout.insets(options: .input) let bottomInset = max(insets.bottom, cleanInsets.bottom) + 54.0 - + let defaultMyPeerId: PeerId if let channel = strongSelf.presentationInterfaceState.renderedPeer?.chatMainPeer as? TelegramChannel, case .group = channel.info, channel.hasPermission(.canBeAnonymous) { defaultMyPeerId = channel.id @@ -4145,7 +4146,7 @@ extension ChatControllerImpl { defaultMyPeerId = strongSelf.context.account.peerId } let myPeerId = strongSelf.presentationInterfaceState.currentSendAsPeerId ?? defaultMyPeerId - + var items: [ContextMenuItem] = [] items.append(.custom(ChatSendAsPeerTitleContextItem(text: strongSelf.presentationInterfaceState.strings.Conversation_SendMesageAs.uppercased()), false)) items.append(.custom(ChatSendAsPeerListContextItem(context: strongSelf.context, chatPeerId: peerId, peers: peers, selectedPeerId: myPeerId, isPremium: isPremium, action: { [weak self] peer in @@ -4156,25 +4157,25 @@ extension ChatControllerImpl { }, presentToast: { [weak self] peer in if let strongSelf = self { HapticFeedback().impact() - + strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .invitedToVoiceChat(context: strongSelf.context, peer: peer, title: nil, text: strongSelf.presentationData.strings.Conversation_SendMesageAsPremiumInfo, action: strongSelf.presentationData.strings.EmojiInput_PremiumEmojiToast_Action, duration: 3), elevatedLayout: false, action: { [weak self] action in guard let strongSelf = self else { return true } if case .undo = action { strongSelf.chatDisplayNode.dismissTextInput() - + let controller = PremiumIntroScreen(context: strongSelf.context, source: .settings) strongSelf.push(controller) } return true }), in: .current) } - + }), false)) - + strongSelf.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() - + let contextController = makeContextController(presentationData: strongSelf.presentationData, source: .reference(ChatControllerContextReferenceContentSource(controller: strongSelf, sourceView: node.view, insets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: bottomInset, right: 0.0), contentInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0))), items: .single(ContextController.Items(content: .list(items))), gesture: gesture, workaroundUseLegacyImplementation: true) contextController.dismissed = { [weak self] in if let strongSelf = self { @@ -4184,7 +4185,7 @@ extension ChatControllerImpl { } } strongSelf.presentInGlobalOverlay(contextController) - + strongSelf.updateChatPresentationInterfaceState(interactive: true, { return $0.updatedShowSendAsPeers(true) }) @@ -4229,7 +4230,7 @@ extension ChatControllerImpl { } else { type = .group } - + let text: String switch type { case .group: @@ -4241,7 +4242,7 @@ extension ChatControllerImpl { case .user: text = save ? strongSelf.presentationData.strings.Conversation_CopyProtectionSavingDisabledSecret : strongSelf.presentationData.strings.Conversation_CopyProtectionForwardingDisabledSecret } - + strongSelf.copyProtectionTooltipController?.dismiss() let tooltipController = TooltipController(content: .text(text), baseFontSize: strongSelf.presentationData.listsFontSize.baseDisplaySize, dismissByTapOutside: true, dismissImmediatelyOnLayoutUpdate: true) strongSelf.copyProtectionTooltipController = tooltipController @@ -4282,9 +4283,9 @@ extension ChatControllerImpl { } interfaceInteraction.updateTextInputStateAndMode { textInputState, inputMode in let inputText = NSMutableAttributedString(attributedString: textInputState.inputText) - + let range = textInputState.selectionRange - + let updatedText = NSMutableAttributedString(attributedString: text) if range.lowerBound < inputText.length { if let quote = inputText.attribute(ChatTextInputAttributes.block, at: range.lowerBound, effectiveRange: nil) { @@ -4292,12 +4293,12 @@ extension ChatControllerImpl { } } inputText.replaceCharacters(in: NSMakeRange(range.lowerBound, range.count), with: updatedText) - + let selectionPosition = range.lowerBound + (updatedText.string as NSString).length - + return (ChatTextInputState(inputText: inputText, selectionRange: selectionPosition ..< selectionPosition), inputMode) } - + strongSelf.chatDisplayNode.updateTypingActivity(true) }, backwardsDeleteText: { [weak self] in guard let strongSelf = self else { @@ -4366,7 +4367,7 @@ extension ChatControllerImpl { let _ = updateChatTranslationStateInteractively(engine: self.context.engine, peerId: peerId, threadId: self.chatLocation.threadId, { current in return nil }).startStandalone() - + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } var languageCode = presentationData.strings.baseLanguageCode let rawSuffix = "-raw" @@ -4375,7 +4376,7 @@ extension ChatControllerImpl { } let locale = Locale(identifier: languageCode) let fromLanguage: String = locale.localizedString(forLanguageCode: langCode) ?? "" - + self.present(UndoOverlayController(presentationData: presentationData, content: .image(image: generateTintedImage(image: UIImage(bundleImageName: "Chat/Title Panels/Translate"), color: .white)!, title: nil, text: presentationData.strings.Conversation_Translation_AddedToDoNotTranslateText(fromLanguage).string, round: false, undoText: presentationData.strings.Conversation_Translation_Settings), elevatedLayout: false, animateInAsReplacement: false, action: { [weak self] action in if case .undo = action, let self { let controller = translationSettingsController(context: self.context) @@ -4417,7 +4418,7 @@ extension ChatControllerImpl { guard let self, let peerId = self.chatLocation.peerId else { return } - + if peerId.namespace == Namespaces.Peer.CloudUser { self.presentAttachmentMenu(subject: .gift) Queue.mainQueue().after(0.5) { @@ -4446,10 +4447,10 @@ extension ChatControllerImpl { guard let self else { return } - + if let message { let attribute = message.attributes.first(where: { $0 is SuggestedPostMessageAttribute }) as? SuggestedPostMessageAttribute - + self.updateChatPresentationInterfaceState(interactive: true, { state in var entities: [MessageTextEntity] = [] for attribute in message.attributes { @@ -4467,29 +4468,29 @@ extension ChatControllerImpl { webpageUrl = content.url } } - + let inputText = chatInputStateStringWithAppliedEntities(message.text, entities: entities) var disableUrlPreviews: [String] = [] if webpageUrl == nil { disableUrlPreviews = detectUrls(inputText) } - + var updated = state.updatedInterfaceState { interfaceState in return interfaceState.withUpdatedEditMessage(ChatEditMessageState(messageId: message.id, inputState: ChatTextInputState(inputText: inputText), disableUrlPreviews: disableUrlPreviews, inputTextMaxLength: inputTextMaxLength, mediaCaptionIsAbove: nil)) } - + let (updatedState, updatedPreviewQueryState) = updatedChatEditInterfaceMessageState(context: self.context, state: updated, message: message) updated = updatedState self.editingUrlPreviewQueryState?.1.dispose() self.editingUrlPreviewQueryState = updatedPreviewQueryState - + updated = updated.updatedInputMode({ _ in return .text }) updated = updated.updatedShowCommands(false) updated = updated.updatedInterfaceState { interfaceState in var interfaceState = interfaceState - + interfaceState = interfaceState.withUpdatedPostSuggestionState(ChatInterfaceState.PostSuggestionState( editingOriginalMessageId: message.id, price: attribute?.amount, @@ -4499,7 +4500,7 @@ extension ChatControllerImpl { } return updated }) - + switch mode { case .default, .editMessage: break @@ -4543,22 +4544,22 @@ extension ChatControllerImpl { self.push(controller) }) }, openMessagePayment: { - + }, openBoostToUnrestrict: { [weak self] in guard let self else { return } - + guard !self.presentAccountFrozenInfoIfNeeded() else { return } - + guard let peerId = self.chatLocation.peerId, let cachedData = self.contentData?.state.peerView?.cachedData as? CachedChannelData, let boostToUnrestrict = cachedData.boostsToUnrestrict else { return } - + HapticFeedback().impact() - + let _ = combineLatest(queue: Queue.mainQueue(), context.engine.peers.getChannelBoostStatus(peerId: peerId), context.engine.peers.getMyBoostStatus() @@ -4702,14 +4703,14 @@ extension ChatControllerImpl { guard let self else { return } - + let updatedFilter = update(self.presentationInterfaceState.historyFilter) - + let apply: () -> Void = { [weak self] in guard let self else { return } - + self.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in var state = state.updatedHistoryFilter(updatedFilter) if let updatedFilter, let reaction = ReactionsMessageAttribute.reactionFromMessageTag(tag: updatedFilter.customTag) { @@ -4722,10 +4723,10 @@ extension ChatControllerImpl { return state }) } - + if let updatedFilter, let reaction = ReactionsMessageAttribute.reactionFromMessageTag(tag: updatedFilter.customTag) { let tag = updatedFilter.customTag - + let _ = (self.context.engine.data.get( TelegramEngine.EngineData.Item.Messages.ReactionTagMessageCount(peerId: self.context.account.peerId, threadId: self.chatLocation.threadId, reaction: reaction) ) @@ -4733,18 +4734,18 @@ extension ChatControllerImpl { guard let self else { return } - + var tagSearchInputPanelNode: ChatTagSearchInputPanelNode? if let panelNode = self.chatDisplayNode.inputPanelNode as? ChatTagSearchInputPanelNode { tagSearchInputPanelNode = panelNode } else if let panelNode = self.chatDisplayNode.secondaryInputPanelNode as? ChatTagSearchInputPanelNode { tagSearchInputPanelNode = panelNode } - + if let tagSearchInputPanelNode, let count { tagSearchInputPanelNode.prepareSwitchToFilter(tag: tag, count: count) } - + apply() }) } else { @@ -4778,7 +4779,7 @@ extension ChatControllerImpl { guard let self else { return } - + if !displayAsList { self.alwaysShowSearchResultsAsList = false self.chatDisplayNode.alwaysShowSearchResultsAsList = false @@ -4793,29 +4794,29 @@ extension ChatControllerImpl { }, chatController: { [weak self] in return self }, statuses: ChatPanelInterfaceInteractionStatuses(editingMessage: self.editingMessage.get(), startingBot: self.startingBot.get(), unblockingPeer: self.unblockingPeer.get(), searching: self.searching.get(), loadingMessage: self.loadingMessage.get(), inlineSearch: self.performingInlineSearch.get())) - + self.interfaceInteraction = interfaceInteraction - + if let search = self.focusOnSearchAfterAppearance { self.focusOnSearchAfterAppearance = nil self.interfaceInteraction?.beginMessageSearch(search.0, search.1) } - + self.chatDisplayNode.interfaceInteraction = interfaceInteraction - + self.context.sharedContext.mediaManager.galleryHiddenMediaManager.addTarget(self) self.galleryHiddenMesageAndMediaDisposable.set(self.context.sharedContext.mediaManager.galleryHiddenMediaManager.hiddenIds().startStrict(next: { [weak self] ids in if let strongSelf = self, let controllerInteraction = strongSelf.controllerInteraction { var messageIdAndMedia: [MessageId: [Media]] = [:] - + for id in ids { if case let .chat(accountId, messageId, media) = id, accountId == strongSelf.context.account.id { messageIdAndMedia[messageId] = [media] } } - + controllerInteraction.hiddenMedia = messageIdAndMedia - + strongSelf.chatDisplayNode.historyNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ChatMessageItemView { itemNode.updateHiddenMedia() @@ -4823,7 +4824,7 @@ extension ChatControllerImpl { } } })) - + self.chatDisplayNode.dismissAsOverlay = { [weak self] in if let strongSelf = self { strongSelf.statusBar.statusBarStyle = .Ignore @@ -4832,14 +4833,14 @@ extension ChatControllerImpl { }) } } - + var lastEventTimestamp: Double = 0.0 self.networkSpeedEventsDisposable = (self.context.account.network.networkSpeedLimitedEvents |> deliverOnMainQueue).start(next: { [weak self] event in guard let self else { return } - + switch event { case let .download(subject): if case let .message(messageId) = subject { @@ -4853,7 +4854,7 @@ extension ChatControllerImpl { } } } - + if !isVisible { return } @@ -4861,14 +4862,14 @@ extension ChatControllerImpl { case .upload: break } - + let timestamp = CFAbsoluteTimeGetCurrent() if lastEventTimestamp + 10.0 < timestamp { lastEventTimestamp = timestamp } else { return } - + let title: String let text: String switch event { @@ -4888,9 +4889,9 @@ extension ChatControllerImpl { text = self.presentationData.strings.Chat_SpeedLimitAlert_Upload_Text("\(speedIncreaseFactor)").string } let content: UndoOverlayContent = .universal(animation: "anim_speed_low", scale: 0.066, colors: [:], title: title, text: text, customUndoText: nil, timeout: 5.0) - + self.context.account.network.markNetworkSpeedLimitDisplayed() - + self.present(UndoOverlayController(presentationData: self.presentationData, content: content, elevatedLayout: false, position: .top, action: { [weak self] action in guard let self else { return false @@ -4914,7 +4915,7 @@ extension ChatControllerImpl { return false }), in: .current) }) - + if case .scheduledMessages = self.subject { self.postedScheduledMessagesEventsDisposable = (self.context.account.stateManager.sentScheduledMessageIds |> deliverOnMainQueue).start(next: { [weak self] ids in @@ -4928,7 +4929,7 @@ extension ChatControllerImpl { self.displayPostedScheduledMessagesToast(ids: filteredIds) }) } - + var mediaPlayback = false var liveLocationMode: GlobalControlPanelsContext.LiveLocationMode? if case .standard = self.mode { @@ -4936,7 +4937,7 @@ extension ChatControllerImpl { mediaPlayback = true liveLocationMode = self.chatLocation.peerId.flatMap(GlobalControlPanelsContext.LiveLocationMode.peer) } - + var groupCallPanelSource: EnginePeer.Id? switch self.chatLocation { case let .peer(peerId): @@ -4949,7 +4950,7 @@ extension ChatControllerImpl { case .replyThread, .customChatContents: break } - + let globalControlPanelsContext = GlobalControlPanelsContext( context: self.context, mediaPlayback: mediaPlayback, @@ -4966,10 +4967,10 @@ extension ChatControllerImpl { self.globalControlPanelsContextState = state self.requestLayout(transition: .animated(duration: 0.4, curve: .spring)) }) - + self.displayNodeDidLoad() } - + func setupChatHistoryNode(historyNode: ChatHistoryListNodeImpl) { self.historyStateDisposable?.dispose() self.historyStateDisposable = historyNode.historyState.get().startStrict(next: { [weak self] state in @@ -4977,7 +4978,7 @@ extension ChatControllerImpl { strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: strongSelf.isViewLoaded && strongSelf.view.window != nil, { $0.updatedChatHistoryState(state) }) - + if let botStart = strongSelf.botStart, case let .loaded(isEmpty, _) = state { strongSelf.botStart = nil if !isEmpty { @@ -4986,7 +4987,7 @@ extension ChatControllerImpl { } } }) - + do { let peerId = self.chatLocation.peerId if let subject = self.subject, case .scheduledMessages = subject { @@ -5016,10 +5017,10 @@ extension ChatControllerImpl { return } let unreadCount: Int32 = Int32(peerUnreadCount) - + let inAppSettings = strongSelf.context.sharedContext.currentInAppNotificationSettings.with { $0 } let totalChatCount: Int32 = renderedTotalUnreadCount(inAppSettings: inAppSettings, totalUnreadState: totalReadCounters._asCounters()).0 - + var globalRemainingUnreadChatCount = totalChatCount if !notificationSettings._asNotificationSettings().isRemovedFromTotalUnreadCount(default: false) && unreadCount > 0 { if case .messages = inAppSettings.totalUnreadCountDisplayCategory { @@ -5028,14 +5029,14 @@ extension ChatControllerImpl { globalRemainingUnreadChatCount -= 1 } } - + if globalRemainingUnreadChatCount > 0 { strongSelf.navigationItem.badge = "\(globalRemainingUnreadChatCount)" } else { strongSelf.navigationItem.badge = "" } }) - + self.chatUnreadMentionCountDisposable?.dispose() self.chatUnreadMentionCountDisposable = (self.context.account.viewTracker.unseenPersonalMessagesAndReactionCount(peerId: peerId, threadId: nil) |> deliverOnMainQueue).startStrict(next: { [weak self] mentionCount, reactionCount, pollVoteCount in if let strongSelf = self { @@ -5066,7 +5067,7 @@ extension ChatControllerImpl { } }) } - + let engine = self.context.engine let previousPeerCache = Atomic<[PeerId: EnginePeer]>(value: [:]) @@ -5079,7 +5080,7 @@ extension ChatControllerImpl { case .customChatContents: activitySpace = nil } - + if let activitySpace = activitySpace, let peerId = peerId { self.peerInputActivitiesDisposable?.dispose() self.peerInputActivitiesDisposable = (self.context.account.peerInputActivities(peerId: activitySpace) @@ -5138,9 +5139,9 @@ extension ChatControllerImpl { ), transition: .spring(duration: 0.4) ) - + strongSelf.peerInputActivitiesPromise.set(.single(activities)) - + for activity in activities { if case let .interactingWithEmoji(emoticon, messageId, maybeInteraction) = activity.1, let interaction = maybeInteraction { var found = false @@ -5152,7 +5153,7 @@ extension ChatControllerImpl { } } }) - + if found { let _ = strongSelf.context.account.updateLocalInputActivity(peerId: activitySpace, activity: .seeingEmojiInteraction(emoticon: emoticon), isPresent: true) } @@ -5162,7 +5163,7 @@ extension ChatControllerImpl { }) } } - + if let peerId = peerId { self.sentMessageEventsDisposable.set((self.context.account.pendingMessageManager.deliveredMessageEvents(peerId: peerId) |> deliverOnMainQueue).startStrict(next: { [weak self] eventGroup in @@ -5179,13 +5180,13 @@ extension ChatControllerImpl { guard let self else { return } - + c.dismissAllUndoControllers() - + Queue.mainQueue().after(0.5) { [weak c] in c?.displayProcessingVideoTooltip(messageId: firstEvent.id) } - + c.present( UndoOverlayController( presentationData: self.presentationData, @@ -5208,7 +5209,7 @@ extension ChatControllerImpl { }) } } - + if self.shouldDisplayChecksTooltip { Queue.mainQueue().after(1.0) { [weak self] in self?.displayChecksTooltip() @@ -5216,7 +5217,7 @@ extension ChatControllerImpl { self.shouldDisplayChecksTooltip = false self.checksTooltipDisposable.set(self.context.engine.notices.dismissServerProvidedSuggestion(suggestion: ServerProvidedSuggestion.newcomerTicks.id).startStrict()) } - + if let shouldDisplayProcessingVideoTooltip = self.shouldDisplayProcessingVideoTooltip { self.shouldDisplayProcessingVideoTooltip = nil Queue.mainQueue().after(1.0) { [weak self] in @@ -5224,14 +5225,14 @@ extension ChatControllerImpl { } } })) - + let isScheduledMessages: Bool if case .scheduledMessages = self.presentationInterfaceState.subject { isScheduledMessages = true } else { isScheduledMessages = false } - + self.failedMessageEventsDisposable.set((self.context.account.pendingMessageManager.failedMessageEvents(peerId: peerId, isScheduled: isScheduledMessages) |> deliverOnMainQueue).startStrict(next: { [weak self] reason in guard let strongSelf = self else { @@ -5240,7 +5241,7 @@ extension ChatControllerImpl { guard strongSelf.currentFailedMessagesAlertController == nil else { return } - + let text: String var title: String? let moreInfo: Bool @@ -5287,7 +5288,7 @@ extension ChatControllerImpl { strongSelf.currentFailedMessagesAlertController = controller strongSelf.present(controller, in: .window(.root)) })) - + self.sentPeerMediaMessageEventsDisposable.dispose() self.sentPeerMediaMessageEventsDisposable.set( (self.context.account.pendingPeerMediaUploadManager.sentMessageEvents(peerId: peerId) @@ -5299,7 +5300,7 @@ extension ChatControllerImpl { ) } } - + historyNode.contentPositionChanged = { [weak self, weak historyNode] offset in guard let strongSelf = self, let historyNode, strongSelf.chatDisplayNode.historyNode === historyNode else { return @@ -5323,7 +5324,7 @@ extension ChatControllerImpl { } return false } - + let offsetAlpha: CGFloat let plainInputSeparatorAlpha: CGFloat switch offset { @@ -5345,13 +5346,13 @@ extension ChatControllerImpl { offsetAlpha = 0.0 plainInputSeparatorAlpha = 0.0 } - + strongSelf.shouldDisplayDownButton = !offsetAlpha.isZero strongSelf.controllerInteraction?.recommendedChannelsOpenUp = !strongSelf.shouldDisplayDownButton strongSelf.updateDownButtonVisibility() strongSelf.chatDisplayNode.updatePlainInputSeparatorAlpha(plainInputSeparatorAlpha, transition: .animated(duration: 0.2, curve: .easeInOut)) } - + historyNode.scrolledToIndex = { [weak self] toSubject, initial in if let strongSelf = self, case let .message(index) = toSubject.index { if case let .message(messageSubject, _, _, _) = strongSelf.subject, initial, case let .id(messageId) = messageSubject, messageId != index.id { @@ -5365,19 +5366,19 @@ extension ChatControllerImpl { mappedId = channelMessageId } } - + if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(mappedId)?._asMessage() { if toSubject.setupReply { Queue.mainQueue().after(0.1) { strongSelf.interfaceInteraction?.setupReplyMessage(mappedId, toSubject.subject, { _, f in f() }) } } - + let highlightedState = ChatInterfaceHighlightedState(messageStableId: message.stableId, quote: toSubject.quote.flatMap { quote in ChatInterfaceHighlightedState.Quote(string: quote.string, offset: quote.offset) }, subject: toSubject.subject) controllerInteraction.highlightedState = highlightedState strongSelf.updateItemNodesHighlightedStates(animated: initial) strongSelf.contentData?.scrolledToMessageIdValue = ScrolledToMessageId(id: mappedId, allowedReplacementDirection: []) - + var extendHighlight = false if let quote = toSubject.quote { if message.text.contains(quote.string) { @@ -5388,7 +5389,7 @@ extension ChatControllerImpl { } else if let _ = toSubject.subject { extendHighlight = true } - + strongSelf.messageContextDisposable.set((Signal.complete() |> delay(extendHighlight ? 1.5 : 0.7, queue: Queue.mainQueue())).startStrict(completed: { if let strongSelf = self, let controllerInteraction = strongSelf.controllerInteraction { if controllerInteraction.highlightedState == highlightedState { @@ -5397,7 +5398,7 @@ extension ChatControllerImpl { } } })) - + if let (messageId, params) = strongSelf.scheduledScrollToMessageId { strongSelf.scheduledScrollToMessageId = nil if let timecode = params.timestamp, message.id == messageId { @@ -5414,25 +5415,25 @@ extension ChatControllerImpl { } } } - + historyNode.scrolledToSomeIndex = { [weak self] in guard let strongSelf = self else { return } strongSelf.contentData?.scrolledToMessageIdValue = nil } - + historyNode.maxVisibleMessageIndexUpdated = { [weak self] index in if let strongSelf = self, let contentData = strongSelf.contentData, !contentData.historyNavigationStack.isEmpty { contentData.historyNavigationStack.filterOutIndicesLessThan(index) } } - + self.hasActiveGroupCallDisposable?.dispose() let hasActiveCalls: Signal if let callManager = self.context.sharedContext.callManager as? PresentationCallManagerImpl { hasActiveCalls = callManager.hasActiveCalls - + self.hasActiveGroupCallDisposable = ((callManager.currentGroupCallSignal |> map { call -> Bool in return call != nil @@ -5444,7 +5445,7 @@ extension ChatControllerImpl { } else { hasActiveCalls = .single(false) } - + let shouldBeActive = combineLatest(self.context.sharedContext.mediaManager.audioSession.isPlaybackActive() |> deliverOnMainQueue, historyNode.hasVisiblePlayableItemNodes, hasActiveCalls) |> mapToSignal { [weak self] isPlaybackActive, hasVisiblePlayableItemNodes, hasActiveCalls -> Signal in if hasVisiblePlayableItemNodes && !isPlaybackActive && !hasActiveCalls { @@ -5453,7 +5454,7 @@ extension ChatControllerImpl { subscriber.putCompletion() return EmptyDisposable } - + subscriber.putNext(strongSelf.traceVisibility() && isTopmostChatController(strongSelf) && !strongSelf.context.sharedContext.mediaManager.audioSession.isOtherAudioPlaying()) subscriber.putCompletion() return EmptyDisposable @@ -5462,13 +5463,13 @@ extension ChatControllerImpl { return .single(false) } } - + let buttonAction = { [weak self] in guard let self, self.traceVisibility() && isTopmostChatController(self) else { return } self.videoUnmuteTooltipController?.dismiss() - + var actions: [(Bool, (Double?) -> Void)] = [] var hasUnconsumed = false self.chatDisplayNode.historyNode.forEachVisibleItemNode { itemNode in @@ -5488,7 +5489,7 @@ extension ChatControllerImpl { } } } - + self.volumeButtonsListener = nil self.volumeButtonsListener = VolumeButtonsListener( sharedContext: self.context.sharedContext, @@ -5519,13 +5520,13 @@ extension ChatControllerImpl { default: nextFolderId = nil } - + var updatedChatNavigationStack = strongSelf.chatNavigationStack updatedChatNavigationStack.removeAll(where: { $0 == ChatNavigationStackItem(peerId: peer.id, threadId: threadData?.id) }) if let peerId = strongSelf.chatLocation.peerId { updatedChatNavigationStack.insert(ChatNavigationStackItem(peerId: peerId, threadId: strongSelf.chatLocation.threadId), at: 0) } - + let chatLocation: NavigateToChatControllerParams.Location if let threadData { chatLocation = .replyThread(ChatReplyThreadMessage( @@ -5552,7 +5553,7 @@ extension ChatControllerImpl { }, customChatNavigationStack: strongSelf.customChatNavigationStack)) } } - + historyNode.beganDragging = { [weak self] in guard let self else { return @@ -5562,19 +5563,19 @@ extension ChatControllerImpl { guard let self else { return } - + self.chatDisplayNode.dismissInput() } } } - + historyNode.didScrollWithOffset = { [weak self] offset, transition, itemNode, isTracking in guard let strongSelf = self else { return } //print("didScrollWithOffset offset: \(offset), itemNode: \(String(describing: itemNode))") - + if offset > 0.0 { if var scrolledToMessageIdValue = strongSelf.contentData?.scrolledToMessageIdValue { scrolledToMessageIdValue.allowedReplacementDirection.insert(.up) @@ -5593,25 +5594,25 @@ extension ChatControllerImpl { strongSelf.currentPinchController?.addRelativeContentOffset(CGPoint(x: 0.0, y: -offset), transition: transition) } } - + if isTracking { strongSelf.chatDisplayNode.loadingPlaceholderNode?.addContentOffset(offset: offset, transition: transition) } strongSelf.chatDisplayNode.messageTransitionNode.addExternalOffset(offset: offset, transition: transition, itemNode: itemNode, isRotated: strongSelf.chatDisplayNode.historyNode.rotated) } - + historyNode.hasAtLeast3MessagesUpdated = { [weak self] hasAtLeast3Messages in if let strongSelf = self { strongSelf.updateChatPresentationInterfaceState(interactive: false, { $0.updatedHasAtLeast3Messages(hasAtLeast3Messages) }) } } - + historyNode.hasPlentyOfMessagesUpdated = { [weak self] hasPlentyOfMessages in if let strongSelf = self { strongSelf.updateChatPresentationInterfaceState(interactive: false, { $0.updatedHasPlentyOfMessages(hasPlentyOfMessages) }) } } - + if self.didAppear { historyNode.canReadHistory.set(self.computedCanReadHistoryPromise.get()) } diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift index 2e4d308a10..aa1b1e12ef 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift @@ -127,12 +127,12 @@ extension ChatControllerImpl { self.recorderFeedback = HapticFeedback() self.recorderFeedback?.prepareImpact(.light) } - + var resumeData: AudioRecorderResumeData? if let existingDraft, let path = self.context.engine.resources.completedResourcePath(id: EngineMediaResource.Id(existingDraft.resource.id)), let compressedData = try? Data(contentsOf: URL(fileURLWithPath: path), options: [.mappedIfSafe]), let recorderResumeData = existingDraft.resumeData { resumeData = AudioRecorderResumeData(compressedData: compressedData, resumeData: recorderResumeData) } - + self.audioRecorder.set( self.context.sharedContext.mediaManager.audioRecorder( resumeData: resumeData, @@ -144,7 +144,7 @@ extension ChatControllerImpl { ) } } - + func requestVideoRecorder() { if self.videoRecorderValue == nil { if let currentInputPanelFrame = self.chatDisplayNode.currentInputPanelFrame() { @@ -152,14 +152,14 @@ extension ChatControllerImpl { self.recorderFeedback = HapticFeedback() self.recorderFeedback?.prepareImpact(.light) } - + var isScheduledMessages = false if case .scheduledMessages = self.presentationInterfaceState.subject { isScheduledMessages = true } - + var isBot = false - + var allowLiveUpload = false var viewOnceAvailable = false if let peerId = self.chatLocation.peerId { @@ -168,11 +168,11 @@ extension ChatControllerImpl { } else if case .customChatContents = self.chatLocation { allowLiveUpload = true } - + if let user = self.presentationInterfaceState.renderedPeer?.peer as? TelegramUser, user.botInfo != nil { isBot = true } - + let controller = VideoMessageCameraScreen( context: self.context, updatedPresentationData: self.updatedPresentationData, @@ -184,25 +184,25 @@ extension ChatControllerImpl { guard let self, let videoController = self.videoRecorderValue else { return } - + guard var message else { self.recorderFeedback?.error() self.recorderFeedback = nil self.videoRecorder.set(.single(nil)) return } - + let replyMessageSubject = self.presentationInterfaceState.interfaceState.replyMessageSubject let correlationId = Int64.random(in: 0 ..< Int64.max) message = message .withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel) .withUpdatedCorrelationId(correlationId) - + var shouldAnimateMessageTransition = self.chatDisplayNode.shouldAnimateMessageTransition if self.chatLocation.threadId == nil, let channel = self.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isMonoForum, let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = self.presentationInterfaceState.renderedPeer?.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.manageDirect) { shouldAnimateMessageTransition = false } - + var usedCorrelationId = false if scheduleTime == nil, shouldAnimateMessageTransition, let extractedView = videoController.extractVideoSnapshot() { usedCorrelationId = true @@ -216,21 +216,21 @@ extension ChatControllerImpl { } else { self.videoRecorder.set(.single(nil)) } - + self.chatDisplayNode.setupSendActionOnViewUpdate({ [weak self] in if let self { self.chatDisplayNode.collapseInput() - + self.updateChatPresentationInterfaceState(animated: true, interactive: false, { $0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil).withUpdatedMediaDraftState(nil).withUpdatedPostSuggestionState(nil) } }) } }, usedCorrelationId ? correlationId : nil) - + let messages = [message] let effectiveSilentPosting = silentPosting ?? self.presentationInterfaceState.interfaceState.silentPosting let transformedMessages = self.transformEnqueueMessages(messages, silentPosting: effectiveSilentPosting, scheduleTime: scheduleTime, repeatPeriod: repeatPeriod) - + self.sendMessages(transformedMessages) } ) @@ -244,24 +244,24 @@ extension ChatControllerImpl { } } } - + func dismissMediaRecorder(_ action: ChatFinishMediaRecordingAction) { var updatedAction = action var isScheduledMessages = false if case .scheduledMessages = self.presentationInterfaceState.subject { isScheduledMessages = true } - + if let _ = self.presentationInterfaceState.slowmodeState, !isScheduledMessages { updatedAction = .preview } - + var sendImmediately = false if let _ = self.presentationInterfaceState.sendPaidMessageStars, case .send = action { updatedAction = .preview sendImmediately = true } - + if let audioRecorderValue = self.audioRecorderValue { switch action { case .pause: @@ -269,9 +269,9 @@ extension ChatControllerImpl { default: audioRecorderValue.stop() } - + self.dismissAllTooltips() - + switch updatedAction { case .dismiss: self.recorderDataDisposable.set(nil) @@ -283,7 +283,7 @@ extension ChatControllerImpl { return panelState.withUpdatedMediaRecordingState(.waitingForPreview) } }) - + var resource: LocalFileMediaResource? self.recorderDataDisposable.set( (audioRecorderValue.takenRecordedData() @@ -305,14 +305,14 @@ extension ChatControllerImpl { resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max), size: Int64(data.compressedData.count)) strongSelf.context.engine.resources.storeResourceData(id: EngineMediaResource.Id(resource!.id), data: data.compressedData) } - + let audioWaveform: AudioWaveform if let recordedMediaPreview = strongSelf.presentationInterfaceState.interfaceState.mediaDraftState, case let .audio(audio) = recordedMediaPreview { audioWaveform = audio.waveform } else { audioWaveform = AudioWaveform(bitstream: waveform, bitsPerSample: 5) } - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withUpdatedMediaDraftState(.audio( @@ -331,7 +331,7 @@ extension ChatControllerImpl { }) strongSelf.recorderFeedback = nil strongSelf.updateDownButtonVisibility() - + if sendImmediately { strongSelf.interfaceInteraction?.sendRecordedMedia(false, false) } @@ -351,20 +351,20 @@ extension ChatControllerImpl { strongSelf.recorderDataDisposable.set(nil) } else { let randomId = Int64.random(in: Int64.min ... Int64.max) - + let resource = LocalFileMediaResource(fileId: randomId) strongSelf.context.engine.resources.storeResourceData(id: EngineMediaResource.Id(resource.id), data: data.compressedData) - + let waveformBuffer: Data? = data.waveform - + let correlationId = Int64.random(in: 0 ..< Int64.max) var usedCorrelationId = false - + var shouldAnimateMessageTransition = strongSelf.chatDisplayNode.shouldAnimateMessageTransition if strongSelf.chatLocation.threadId == nil, let channel = strongSelf.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isMonoForum, let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = strongSelf.presentationInterfaceState.renderedPeer?.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.manageDirect) { shouldAnimateMessageTransition = false } - + if shouldAnimateMessageTransition, let textInputPanelNode = strongSelf.chatDisplayNode.textInputPanelNode, let micButton = textInputPanelNode.micButton { usedCorrelationId = true strongSelf.chatDisplayNode.messageTransitionNode.add(correlationId: correlationId, source: .audioMicInput(ChatMessageTransitionNodeImpl.Source.AudioMicInput(micButton: micButton)), initiated: { @@ -376,24 +376,24 @@ extension ChatControllerImpl { } else { strongSelf.audioRecorder.set(.single(nil)) } - + strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({ if let strongSelf = self { strongSelf.chatDisplayNode.collapseInput() - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { $0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil).withUpdatedPostSuggestionState(nil) } }) } }, usedCorrelationId ? correlationId : nil) - + var attributes: [EngineMessage.Attribute] = [] if viewOnce { attributes.append(AutoremoveTimeoutMessageAttribute(timeout: viewOnceTimeout, countdownBeginTime: nil)) } - + strongSelf.sendMessages([.message(text: "", attributes: attributes, inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: randomId), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: Int64(data.compressedData.count), attributes: [.Audio(isVoice: true, duration: Int(data.duration), title: nil, performer: nil, waveform: waveformBuffer)], alternativeRepresentations: [])), threadId: strongSelf.chatLocation.threadId, replyToMessageId: strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject?.subjectModel, replyToStoryId: nil, localGroupingKey: nil, correlationId: correlationId, bubbleUpEmojiOrStickersets: [])]) - + strongSelf.recorderFeedback?.tap() strongSelf.recorderFeedback = nil strongSelf.recorderDataDisposable.set(nil) @@ -411,7 +411,7 @@ extension ChatControllerImpl { self.chatDisplayNode.updateRecordedMediaDeleted(true) self.recorderDataDisposable.set(nil) } - + switch updatedAction { case .preview, .pause: if videoRecorderValue.stopVideoRecording() { @@ -456,7 +456,7 @@ extension ChatControllerImpl { } } } - + func stopMediaRecorder(pause: Bool = false) { if let audioRecorderValue = self.audioRecorderValue { if let _ = self.presentationInterfaceState.inputTextPanelState.mediaRecordingState { @@ -476,12 +476,12 @@ extension ChatControllerImpl { } } } - + func resumeMediaRecorder() { self.recorderDataDisposable.set(nil) - + self.context.sharedContext.mediaManager.playlistControl(.playback(.pause), type: nil) - + if let videoRecorderValue = self.videoRecorderValue { self.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInputTextPanelState { panelState in @@ -497,7 +497,7 @@ extension ChatControllerImpl { let proceed = { self.withAudioRecorder(resuming: true, { audioRecorder in audioRecorder.resume() - + self.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInputTextPanelState { panelState in return panelState.withUpdatedMediaRecordingState(.audio(recorder: audioRecorder, isLocked: true)) @@ -505,7 +505,7 @@ extension ChatControllerImpl { }) }) } - + let _ = (ApplicationSpecificNotice.getVoiceMessagesResumeTrimWarning(accountManager: self.context.sharedContext.accountManager) |> deliverOnMainQueue).start(next: { [weak self] count in guard let self else { @@ -536,7 +536,7 @@ extension ChatControllerImpl { }) } } - + func lockMediaRecorder() { if self.presentationInterfaceState.inputTextPanelState.mediaRecordingState != nil { self.updateChatPresentationInterfaceState(animated: true, interactive: true, { @@ -545,38 +545,38 @@ extension ChatControllerImpl { } }) } - + if let _ = self.audioRecorderValue { self.maybePresentAudioPauseTooltip() } else if let videoRecorderValue = self.videoRecorderValue { videoRecorderValue.lockVideoRecording() } } - + func deleteMediaRecording() { if let _ = self.audioRecorderValue { self.audioRecorder.set(.single(nil)) } else if let _ = self.videoRecorderValue { self.videoRecorder.set(.single(nil)) } - + self.recorderDataDisposable.set(nil) self.chatDisplayNode.updateRecordedMediaDeleted(true) self.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withUpdatedMediaDraftState(nil) } }) self.updateDownButtonVisibility() - + self.dismissAllTooltips() } - + private func maybePresentAudioPauseTooltip() { let _ = (ApplicationSpecificNotice.getVoiceMessagesPauseSuggestion(accountManager: self.context.sharedContext.accountManager) |> deliverOnMainQueue).startStandalone(next: { [weak self] pauseCounter in guard let self else { return } - + if pauseCounter >= 3 { return } else { @@ -587,14 +587,14 @@ extension ChatControllerImpl { } }) } - + private func displayPauseTooltip(text: String) { guard let layout = self.validLayout else { return } - + self.dismissAllTooltips() - + let insets = layout.insets(options: [.input]) var screenWidth = layout.size.width if layout.metrics.isTablet { @@ -604,13 +604,13 @@ extension ChatControllerImpl { screenWidth = layout.deviceMetrics.screenSize.width } } - + var sideOffset: CGFloat = 18.0 if let inputHeight = layout.inputHeight, inputHeight > 0.0 { sideOffset = 0.0 } let location = CGRect(origin: CGPoint(x: screenWidth - layout.safeInsets.right - 50.0 - sideOffset, y: layout.size.height - insets.bottom - 128.0), size: CGSize()) - + let tooltipController = TooltipScreen( account: self.context.account, sharedContext: self.context.sharedContext, @@ -630,7 +630,7 @@ extension ChatControllerImpl { ) self.present(tooltipController, in: .window(.root)) } - + private func withAudioRecorder(resuming: Bool, _ f: (ManagedAudioRecorder) -> Void) { if let audioRecorder = self.audioRecorderValue { f(audioRecorder) @@ -638,7 +638,7 @@ extension ChatControllerImpl { self.requestAudioRecorder(beginWithTone: false, existingDraft: audio) if let audioRecorder = self.audioRecorderValue { f(audioRecorder) - + if !resuming { self.recorderDataDisposable.set( (audioRecorder.takenRecordedData() @@ -646,7 +646,7 @@ extension ChatControllerImpl { next: { [weak self] data in if let strongSelf = self, let data = data { let audioWaveform = audio.waveform - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withUpdatedMediaDraftState(.audio( @@ -671,7 +671,7 @@ extension ChatControllerImpl { } } } - + func updateTrimRange(start: Double, end: Double, updatedEnd: Bool, apply: Bool) { if let videoRecorder = self.videoRecorderValue { videoRecorder.updateTrimRange(start: start, end: end, updatedEnd: updatedEnd, apply: apply) @@ -681,41 +681,59 @@ extension ChatControllerImpl { }) } } - + func sendMediaRecording( silentPosting: Bool? = nil, scheduleTime: Int32? = nil, repeatPeriod: Int32? = nil, viewOnce: Bool = false, messageEffect: ChatSendMessageEffect? = nil, - postpone: Bool = false + postpone: Bool = false, + skipWinterGramConfirmation: Bool = false ) { + if currentWinterGramSettings.voiceConfirmation && !skipWinterGramConfirmation { + let controller = textAlertController( + context: self.context, + updatedPresentationData: self.updatedPresentationData, + title: "Send Voice Message?", + text: "WinterGram confirmation is enabled.", + actions: [ + TextAlertAction(type: .genericAction, title: self.presentationData.strings.Common_Cancel, action: {}), + TextAlertAction(type: .defaultAction, title: self.presentationData.strings.Common_OK, action: { [weak self] in + self?.sendMediaRecording(silentPosting: silentPosting, scheduleTime: scheduleTime, repeatPeriod: repeatPeriod, viewOnce: viewOnce, messageEffect: messageEffect, postpone: postpone, skipWinterGramConfirmation: true) + }) + ] + ) + self.present(controller, in: .window(.root)) + return + } + self.chatDisplayNode.updateRecordedMediaDeleted(false) - + guard let recordedMediaPreview = self.presentationInterfaceState.interfaceState.mediaDraftState else { return } - + switch recordedMediaPreview { case let .audio(audio): self.audioRecorder.set(.single(nil)) - + var isScheduledMessages = false if case .scheduledMessages = self.presentationInterfaceState.subject { isScheduledMessages = true } - + if let _ = self.presentationInterfaceState.slowmodeState, !isScheduledMessages { if let rect = self.chatDisplayNode.frameForInputActionButton() { self.interfaceInteraction?.displaySlowmodeTooltip(self.chatDisplayNode.view, rect) } return } - + self.chatDisplayNode.setupSendActionOnViewUpdate({ [weak self] in if let strongSelf = self { strongSelf.chatDisplayNode.collapseInput() - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { $0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedMediaDraftState(nil).withUpdatedSendMessageEffect(nil).withUpdatedPostSuggestionState(nil) } }) @@ -723,7 +741,7 @@ extension ChatControllerImpl { strongSelf.updateDownButtonVisibility() } }, nil) - + var attributes: [EngineMessage.Attribute] = [] if viewOnce { attributes.append(AutoremoveTimeoutMessageAttribute(timeout: viewOnceTimeout, countdownBeginTime: nil)) @@ -731,7 +749,7 @@ extension ChatControllerImpl { if let messageEffect { attributes.append(EffectMessageAttribute(id: messageEffect.id)) } - + let resource: TelegramMediaResource var waveform = audio.waveform var finalDuration: Int = Int(audio.duration) @@ -745,25 +763,25 @@ extension ChatControllerImpl { } else { resource = audio.resource } - + let waveformBuffer = waveform.makeBitstream() - + let messages: [EnqueueMessage] = [.message(text: "", attributes: attributes, inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: Int64(audio.fileSize), attributes: [.Audio(isVoice: true, duration: finalDuration, title: nil, performer: nil, waveform: waveformBuffer)], alternativeRepresentations: [])), threadId: self.chatLocation.threadId, replyToMessageId: self.presentationInterfaceState.interfaceState.replyMessageSubject?.subjectModel, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])] - + let effectiveSilentPosting = silentPosting ?? self.presentationInterfaceState.interfaceState.silentPosting let transformedMessages = self.transformEnqueueMessages(messages, silentPosting: effectiveSilentPosting, scheduleTime: scheduleTime, repeatPeriod: repeatPeriod, postpone: postpone) - + guard let peerId = self.chatLocation.peerId else { return } - + let _ = (enqueueMessages(account: self.context.account, peerId: peerId, messages: transformedMessages) |> deliverOnMainQueue).startStandalone(next: { [weak self] _ in if let strongSelf = self, strongSelf.presentationInterfaceState.subject != .scheduledMessages { strongSelf.chatDisplayNode.historyNode.scrollToEndOfHistory() } }) - + donateSendMessageIntent(account: self.context.account, sharedContext: self.context.sharedContext, intentContext: .chat, peerIds: [peerId]) case .video: guard let videoRecorderValue = self.videoRecorderValue else { diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenViewOnceMediaMessage.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenViewOnceMediaMessage.swift index a40fe91b84..57c83a5246 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenViewOnceMediaMessage.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenViewOnceMediaMessage.swift @@ -121,15 +121,15 @@ import ChatMediaInputStickerGridItem extension ChatControllerImpl { func openViewOnceMediaMessage(_ message: EngineMessage) { - if self.screenCaptureManager?.isRecordingActive == true { + if self.screenCaptureManager?.isRecordingActive == true && !currentWinterGramSettings.allowScreenshots { let controller = textAlertController(context: self.context, updatedPresentationData: self.updatedPresentationData, title: nil, text: self.presentationData.strings.Chat_PlayOnceMesasge_DisableScreenCapture, actions: [TextAlertAction(type: .defaultAction, title: self.presentationData.strings.Common_OK, action: { })]) self.present(controller, in: .window(.root)) return } - + let isIncoming = message.effectivelyIncoming(self.context.account.peerId) - + var presentImpl: ((ViewController) -> Void)? let configuration = ContextController.Configuration( sources: [ @@ -157,7 +157,7 @@ extension ChatControllerImpl { ) ], initialId: 0 ) - + let contextController = makeContextController(presentationData: self.presentationData, configuration: configuration) contextController.getOverlayViews = { [weak self] in guard let self else { @@ -167,11 +167,11 @@ extension ChatControllerImpl { } self.currentContextController = contextController self.presentInGlobalOverlay(contextController) - + presentImpl = { [weak contextController] c in contextController?.present(c, in: .current) } - + let _ = self.context.sharedContext.openChatMessage(OpenChatMessageParams(context: self.context, chatLocation: nil, chatFilterTag: nil, chatLocationContextHolder: nil, message: message._asMessage(), standalone: false, reverseMessageGalleryOrder: false, navigationController: nil, dismissInput: { }, present: { _, _, _ in }, transitionNode: { _, _, _ in return nil }, addToTransitionSurface: { _ in }, openUrl: { _ in }, openPeer: { _, _ in }, callPeer: { _, _ in }, openConferenceCall: { _ in }, enqueueMessage: { _ in }, sendSticker: nil, sendEmoji: nil, setupTemporaryHiddenMedia: { _, _, _ in }, chatAvatarHiddenMedia: { _, _ in }, playlistLocation: .singleMessage(message.id))) } diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 70f4ac306d..fd2ef6f449 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -169,7 +169,7 @@ public enum NavigateToMessageLocation { case id(MessageId, NavigateToMessageParams) case index(MessageIndex) case upperBound(PeerId) - + var messageId: MessageId? { switch self { case let .id(id, _): @@ -180,7 +180,7 @@ public enum NavigateToMessageLocation { return nil } } - + var peerId: PeerId { switch self { case let .id(id, _): @@ -228,37 +228,39 @@ func calculateSlowmodeActiveUntilTimestamp(untilTimestamp: Int32?) -> Int32? { struct ScrolledToMessageId: Equatable { struct AllowedReplacementDirections: OptionSet { var rawValue: Int32 - + static let up = AllowedReplacementDirections(rawValue: 1 << 0) static let down = AllowedReplacementDirections(rawValue: 1 << 1) } - + var id: MessageId var allowedReplacementDirection: AllowedReplacementDirections } -public final class ChatControllerImpl: TelegramBaseController, ChatController, GalleryHiddenMediaTarget, UIDropInteractionDelegate { +public final class ChatControllerImpl: TelegramBaseController, ChatController, GalleryHiddenMediaTarget, UIDropInteractionDelegate { var validLayout: ContainerViewLayout? - + public weak var parentController: ViewController? public weak var customNavigationController: NavigationController? + private var skipWinterGramStickerConfirmation = false + private var skipWinterGramGifConfirmation = false let currentChatListFilter: Int32? let chatNavigationStack: [ChatNavigationStackItem] let customChatNavigationStack: [EnginePeer.Id]? - + var didSetupDropToPaste: Bool = false - + let context: AccountContext public internal(set) var chatLocation: ChatLocation public internal(set) var subject: ChatControllerSubject? var initialTextInputState: ChatTextInputState? - + var botStart: ChatControllerInitialBotStart? var attachBotStart: ChatControllerInitialAttachBotStart? var botAppStart: ChatControllerInitialBotAppStart? var mode: ChatControllerPresentationMode - + var pendingContentData: (contentData: ChatControllerImpl.ContentData, historyNode: ChatHistoryListNodeImpl)? var contentData: ChatControllerImpl.ContentData? let contentDataReady = ValuePromise(false, ignoreRepeated: true) @@ -266,60 +268,61 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G var newTopicEventsDisposable: Disposable? var didHandlePerformDismissAction: Bool = false var didInitializePersistentPeerInterfaceData: Bool = false - + var preloadNextChatPeerId: EnginePeer.Id? = nil let preloadNextChatPeerIdDisposable = MetaDisposable() - + var accountPeerDisposable: Disposable? - + let cachedDataReady = Promise() var didSetCachedDataReady = false - + let navigationActionDisposable = MetaDisposable() let messageIndexDisposable = MetaDisposable() var networkStateDisposable: Disposable? let wallpaperReady = Promise() let presentationReady = Promise() - + var presentationInterfaceState: ChatPresentationInterfaceState let presentationInterfaceStatePromise: ValuePromise public var presentationInterfaceStateSignal: Signal { return self.presentationInterfaceStatePromise.get() |> map { $0 } } - + public var selectedMessageIds: Set? { return self.presentationInterfaceState.interfaceState.selectionState?.selectedIds } - + let chatThemePromise = Promise() let chatWallpaperPromise = Promise() - + var chatTitleView: ChatNavigationBarTitleView? var leftNavigationButton: ChatNavigationButton? var rightNavigationButton: ChatNavigationButton? var secondaryRightNavigationButton: ChatNavigationButton? var chatInfoNavigationButton: ChatNavigationButton? - + var moreBarButton: MoreHeaderButton var moreInfoNavigationButton: ChatNavigationButton? - + var historyStateDisposable: Disposable? - + let galleryHiddenMesageAndMediaDisposable = MetaDisposable() let temporaryHiddenGalleryMediaDisposable = MetaDisposable() - + let galleryPresentationContext = PresentationContext() let chatBackgroundNode: WallpaperBackgroundNode public private(set) var controllerInteraction: ChatControllerInteraction? var interfaceInteraction: ChatPanelInterfaceInteraction? - + let messageContextDisposable = MetaDisposable() let controllerNavigationDisposable = MetaDisposable() let sentMessageEventsDisposable = MetaDisposable() let failedMessageEventsDisposable = MetaDisposable() let sentPeerMediaMessageEventsDisposable = MetaDisposable() + let winterGramPresenceTrackerDisposable = MetaDisposable() weak var currentFailedMessagesAlertController: ViewController? let messageActionCallbackDisposable = MetaDisposable() let messageActionUrlAuthDisposable = MetaDisposable() @@ -338,7 +341,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G var saveMediaDisposable: MetaDisposable? var giveawayStatusDisposable: MetaDisposable? var nameColorDisposable: Disposable? - + let editingMessage = ValuePromise(nil, ignoreRepeated: true) let startingBot = ValuePromise(false, ignoreRepeated: true) let unblockingPeer = ValuePromise(false, ignoreRepeated: true) @@ -347,54 +350,54 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let searchResult = Promise<(SearchMessagesResult, SearchMessagesState, SearchMessagesLocation)?>() let loadingMessage = Promise(nil) let performingInlineSearch = ValuePromise(false, ignoreRepeated: true) - + var stateServiceTasks: [AnyHashable: Disposable] = [:] - + let botCallbackAlertMessage = Promise(nil) var botCallbackAlertMessageDisposable: Disposable? - + var selectMessagePollOptionDisposables: DisposableDict? var selectPollOptionFeedback: HapticFeedback? - + var updateMessageTodoDisposables: DisposableDict? - + var resolveUrlDisposable: MetaDisposable? - + var contextQueryStates: [ChatPresentationInputQueryKind: (ChatPresentationInputQuery, Disposable)] = [:] var searchQuerySuggestionState: (ChatPresentationInputQuery?, Disposable)? var urlPreviewQueryState: (UrlPreviewState?, Disposable)? var editingUrlPreviewQueryState: (UrlPreviewState?, Disposable)? var replyMessageState: (EngineMessage.Id, Disposable)? var searchState: ChatSearchState? - + var shakeFeedback: HapticFeedback? - + var recordingModeFeedback: HapticFeedback? var recorderFeedback: HapticFeedback? var audioRecorderValue: ManagedAudioRecorder? var audioRecorder = Promise() var audioRecorderDisposable: Disposable? var audioRecorderStatusDisposable: Disposable? - + var videoRecorderValue: VideoMessageCameraScreen? var videoRecorder = Promise() var videoRecorderDisposable: Disposable? - + var recorderDataDisposable = MetaDisposable() - + var chatUnreadCountDisposable: Disposable? var buttonUnreadCountDisposable: Disposable? var chatUnreadMentionCountDisposable: Disposable? var peerInputActivitiesDisposable: Disposable? - + var peerInputActivitiesPromise = Promise<[(EnginePeer, PeerInputActivity)]>() var interactiveEmojiSyncDisposable = MetaDisposable() - + var recentlyUsedInlineBotsValue: [Peer] = [] var recentlyUsedInlineBotsDisposable: Disposable? - + var unpinMessageDisposable: MetaDisposable? - + let typingActivityPromise = Promise(false) var inputActivityDisposable: Disposable? var recordingActivityValue: ChatRecordingActivity = .none @@ -403,12 +406,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G var acquiredRecordingActivityDisposable: Disposable? let choosingStickerActivityPromise = ValuePromise(false) var choosingStickerActivityDisposable: Disposable? - + var searchDisposable: MetaDisposable? - + public let canReadHistory = ValuePromise(true, ignoreRepeated: true) public let hasBrowserOrAppInFront = Promise(false) - + var canReadHistoryValue = false { didSet { self.computedCanReadHistoryPromise.set(self.canReadHistoryValue) @@ -416,7 +419,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } var canReadHistoryDisposable: Disposable? var computedCanReadHistoryPromise = ValuePromise(false, ignoreRepeated: true) - + var chatThemeAndDarkAppearancePreviewPromise = Promise<(ChatTheme?, Bool?)>((nil, nil)) var didSetPresentationData = false var presentationData: PresentationData @@ -428,10 +431,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G var forcedTheme: PresentationTheme? var forcedNavigationBarTheme: PresentationTheme? var forcedWallpaper: TelegramWallpaper? - + var automaticMediaDownloadSettings: MediaAutoDownloadSettings var automaticMediaDownloadSettingsDisposable: Disposable? - + var disableStickerAnimationsPromise = ValuePromise(false) var disableStickerAnimationsValue = false var disableStickerAnimations: Bool { @@ -443,25 +446,25 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } var stickerSettings: ChatInterfaceStickerSettings var stickerSettingsDisposable: Disposable? - + var applicationInForegroundDisposable: Disposable? var applicationInFocusDisposable: Disposable? - + let checksTooltipDisposable = MetaDisposable() var shouldDisplayChecksTooltip = false var shouldDisplayProcessingVideoTooltip: EngineMessage.Id? - + let peerSuggestionsDisposable = MetaDisposable() let peerSuggestionsDismissDisposable = MetaDisposable() var displayedConvertToGigagroupSuggestion = false - + var checkedPeerChatServiceActions = false - + var willAppear = false var didAppear = false var enableAnimations = false var scheduledActivateInput: ChatControllerActivateInput? - + var raiseToListen: RaiseToListenManager? var voicePlaylistDidEndTimestamp: Double = 0.0 @@ -474,7 +477,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G var didDisplayGroupEmojiTip = false var didDisplaySendWhenOnlineTip = false let displaySendWhenOnlineTipDisposable = MetaDisposable() - + weak var silentPostTooltipController: TooltipController? weak var mediaRecordingModeTooltipController: TooltipController? weak var mediaRestrictedTooltipController: TooltipController? @@ -485,56 +488,56 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G weak var birthdayTooltipController: TooltipScreen? weak var scheduledVideoProcessingTooltipController: TooltipScreen? weak var guestChatMessageTooltipController: TooltipScreen? - + weak var slowmodeTooltipController: ChatSlowmodeHintController? - + weak var currentContextController: ContextController? public var visibleContextController: ViewController? { return self.currentContextController } - + weak var sendMessageActionsController: ChatSendMessageActionSheetController? var searchResultsController: ChatSearchResultsController? weak var themeScreen: ChatThemeScreen? - + weak var currentPinchController: PinchController? weak var currentPinchSourceItemNode: ListViewItemNode? - + var screenCaptureManager: ScreenCaptureDetectionManager? - + var volumeButtonsListener: VolumeButtonsListener? - + var beginMediaRecordingRequestId: Int = 0 var lockMediaRecordingRequestId: Int? - + var updateSlowmodeStatusDisposable = MetaDisposable() var updateSlowmodeStatusTimerValue: Int32? - + var isDismissed = false - + var focusOnSearchAfterAppearance: (ChatSearchDomain, String)? - + let keepPeerInfoScreenDataHotDisposable = MetaDisposable() let preloadAvatarDisposable = MetaDisposable() - + let peekData: ChatPeekTimeout? let peekTimerDisposable = MetaDisposable() - + let createVoiceChatDisposable = MetaDisposable() - + let selectAddMemberDisposable = MetaDisposable() let addMemberDisposable = MetaDisposable() let joinChannelDisposable = MetaDisposable() - + var shouldDisplayDownButton = false - + var chatLocationContextHolder: Atomic - + weak var attachmentController: AttachmentController? - + weak var currentImportMessageTooltip: UndoOverlayController? - + public var customNavigationBarContentNode: NavigationBarContentNode? public var customNavigationPanelNode: ChatControllerCustomNavigationPanelNode? public var stateUpdated: ((ContainedViewLayoutTransition) -> Void)? @@ -544,7 +547,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G public override var customData: Any? { return self.chatLocation } - + override public var customNavigationData: CustomViewControllerNavigationData? { get { if let peerId = self.chatLocation.peerId { @@ -554,54 +557,54 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + override public var interactiveNavivationGestureEdgeWidth: InteractiveTransitionGestureRecognizerEdgeWidth? { return .widthMultiplier(factor: 0.35, min: 16.0, max: 200.0) } - + var scheduledScrollToMessageId: (MessageId, NavigateToMessageParams)? - + public var purposefulAction: (() -> Void)? public var dismissPreviewing: ((Bool) -> (() -> Void))? - + var updatedClosedPinnedMessageId: ((MessageId) -> Void)? var requestedUnpinAllMessages: ((Int, MessageId) -> Void)? - + public var isSelectingMessagesUpdated: ((Bool) -> Void)? - + var translationStateDisposable: Disposable? var premiumGiftSuggestionDisposable: Disposable? - + var currentSpeechHolder: SpeechSynthesizerHolder? - + var powerSavingMonitoringDisposable: Disposable? - + var avatarNode: ChatAvatarNavigationNode? - + var performTextSelectionAction: ((Message?, Bool, NSAttributedString, [MessageTextEntity]?, TextSelectionAction) -> Void)? var performOpenURL: ((Message?, String, Promise?) -> Void)? - + var networkSpeedEventsDisposable: Disposable? - + var stickerVideoExport: MediaEditorVideoExport? - + var messageComposeController: MFMessageComposeViewController? - + weak var currentSendStarsUndoController: UndoOverlayController? var currentSendStarsUndoMessageId: EngineMessage.Id? var currentSendStarsUndoCount: Int = 0 - + weak var currentPaidMessageUndoController: UndoOverlayController? - + let initTimestamp: Double - + public var alwaysShowSearchResultsAsList: Bool = false { didSet { self.presentationInterfaceState = self.presentationInterfaceState.updatedDisplayHistoryFilterAsList(self.alwaysShowSearchResultsAsList) self.chatDisplayNode.alwaysShowSearchResultsAsList = self.alwaysShowSearchResultsAsList } } - + public var externalSearchResultsCount: Int32? { didSet { if let panelNode = self.chatDisplayNode.inputPanelNode as? ChatTagSearchInputPanelNode { @@ -609,28 +612,28 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + public var includeSavedPeersInSearchResults: Bool = false { didSet { self.chatDisplayNode.includeSavedPeersInSearchResults = self.includeSavedPeersInSearchResults } } - + public var showListEmptyResults: Bool = false { didSet { self.chatDisplayNode.showListEmptyResults = self.showListEmptyResults } } - + var layoutActionOnViewTransitionAction: (() -> Void)? - + var lastPostedScheduledMessagesToastTimestamp: Double = 0.0 var postedScheduledMessagesEventsDisposable: Disposable? - + var globalControlPanelsContext: GlobalControlPanelsContext? var globalControlPanelsContextState: GlobalControlPanelsContext.State? var globalControlPanelsContextStateDisposable: Disposable? - + public init( context: AccountContext, chatLocation: ChatLocation, @@ -648,11 +651,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G params: ChatControllerParams? = nil ) { self.initTimestamp = CFAbsoluteTimeGetCurrent() - + let _ = ChatControllerCount.modify { value in return value + 1 } - + self.context = context self.chatLocation = chatLocation self.chatLocationContextHolder = chatLocationContextHolder @@ -666,7 +669,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.currentChatListFilter = chatListFilter self.chatNavigationStack = chatNavigationStack self.customChatNavigationStack = customChatNavigationStack - + self.forcedTheme = params?.forcedTheme self.forcedNavigationBarTheme = params?.forcedNavigationBarTheme self.forcedWallpaper = params?.forcedWallpaper @@ -680,7 +683,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } self.chatBackgroundNode = createWallpaperBackgroundNode(context: context, forChatDisplay: true, useSharedAnimationPhase: useSharedAnimationPhase) self.wallpaperReady.set(self.chatBackgroundNode.isReady) - + var presentationData = context.sharedContext.currentPresentationData.with { $0 } if let forcedTheme = self.forcedTheme { presentationData = presentationData.withUpdated(theme: forcedTheme) @@ -690,11 +693,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } self.presentationData = presentationData self.automaticMediaDownloadSettings = context.sharedContext.currentAutomaticMediaDownloadSettings - + self.stickerSettings = ChatInterfaceStickerSettings() - + self.presentationInterfaceState = ChatPresentationInterfaceState(chatWallpaper: self.presentationData.chatWallpaper, theme: self.presentationData.theme, preferredGlassType: .default, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, nameDisplayOrder: self.presentationData.nameDisplayOrder, limitsConfiguration: context.currentLimitsConfiguration.with { $0 }, fontSize: self.presentationData.chatFontSize, bubbleCorners: self.presentationData.chatBubbleCorners, accountPeerId: context.account.peerId, mode: mode, chatLocation: chatLocation, subject: subject, greetingData: context.prefetchManager?.preloadedGreetingSticker, pendingUnpinnedAllMessages: false, activeGroupCallInfo: nil, hasActiveGroupCall: false, threadData: nil, isGeneralThreadClosed: nil, replyMessage: nil, accountPeerColor: nil, businessIntro: nil) - + if case let .customChatContents(customChatContents) = subject { switch customChatContents.kind { case .quickReplyMessageInput: @@ -709,9 +712,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G break } } - + self.presentationInterfaceStatePromise = ValuePromise(self.presentationInterfaceState) - + let navigationBarPresentationData: NavigationBarPresentationData? switch mode { case .inline, .standard(.embedded): @@ -719,28 +722,28 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G default: navigationBarPresentationData = NavigationBarPresentationData(presentationData: self.presentationData, hideBackground: false, hideBadge: false, style: .glass, glassStyle: .default) } - + self.moreBarButton = MoreHeaderButton(color: self.presentationData.theme.chat.inputPanel.panelControlColor) self.moreBarButton.isUserInteractionEnabled = true - + super.init(context: context, navigationBarPresentationData: navigationBarPresentationData) - + self._hasGlassStyle = true - + self.automaticallyControlPresentationContextLayout = false self.blocksBackgroundWhenInOverlay = true self.acceptsFocusWhenInOverlay = true - + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) - + self.ready.set(.never()) - + self.chatBackgroundNode.contentStatsUpdated = { [weak self] in guard let self else { return } self.updateStatusBarPresentation(animated: false) - + var preferredGlassType: ChatPresentationInterfaceState.GlassType = self.chatBackgroundNode.contentStats?.isSaturated == true ? .clear : .default if !self.presentationData.theme.overallDarkAppearance { preferredGlassType = .default @@ -755,7 +758,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.updateNavigationBarPresentation() } } - + self.scrollToTop = { [weak self] in guard let strongSelf = self, strongSelf.isNodeLoaded else { return @@ -766,22 +769,22 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.chatDisplayNode.scrollToTop() } } - + self.attemptNavigation = { [weak self] action in guard let strongSelf = self else { return true } - + if let _ = strongSelf.videoRecorderValue { return false } - + strongSelf.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() - + if strongSelf.presentVoiceMessageDiscardAlert(action: action, performAction: false) { return false } - + if case let .customChatContents(customChatContents) = strongSelf.presentationInterfaceState.subject { switch customChatContents.kind { case .hashTagSearch: @@ -801,7 +804,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G titleString = strongSelf.presentationData.strings.QuickReply_ChatRemoveAwayMessage_Title textString = strongSelf.presentationData.strings.QuickReply_ChatRemoveAwayMessage_Text } - + let alertController = textAlertController( context: strongSelf.context, title: titleString, @@ -814,16 +817,16 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G ] ) strongSelf.present(alertController, in: .window(.root)) - + return false } case let .businessLinkSetup(link): var inputText = convertMarkdownToAttributes(strongSelf.presentationInterfaceState.interfaceState.effectiveInputState.inputText) inputText = trimChatInputText(inputText) let entities = generateChatInputTextEntities(inputText, generateLinks: false) - + let message = inputText.string - + if message != link.message || entities != link.entities { let alertController = textAlertController( context: strongSelf.context, @@ -837,29 +840,29 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G ] ) strongSelf.present(alertController, in: .window(.root)) - + return false } } } - + return true } - + let controllerInteraction = ChatControllerInteraction(openMessage: { [weak self] message, params in guard let self, self.isNodeLoaded, let message = self.chatDisplayNode.historyNode.messageInCurrentHistoryView(message.id)?._asMessage() else { return false } - + if let contextController = self.currentContextController { self.present(contextController, in: .window(.root)) Queue.mainQueue().after(0.15) { contextController.dismiss(result: .dismissWithoutContent, completion: nil) } } - + let mode = params.mode - + let displayVoiceMessageDiscardAlert: () -> Bool = { [weak self] in guard let self else { return true @@ -876,15 +879,15 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } return true } - + self.commitPurposefulAction() self.dismissAllTooltips() - + self.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() - + var openMessageByAction = false var isLocation = false - + for media in message.media { if media is TelegramMediaMap { if !displayVoiceMessageDiscardAlert() { @@ -903,7 +906,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if !displayVoiceMessageDiscardAlert() { return false } - + if (file.isVoice || file.isInstantVideo) && message.minAutoremoveOrClearTimeout == viewOnceTimeout { self.openViewOnceMediaMessage(EngineMessage(message)) return false @@ -941,7 +944,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else if media is TelegramMediaGiveaway || media is TelegramMediaGiveawayResults { let progress = params.progress let presentationData = self.presentationData - + var signal = self.context.engine.payments.premiumGiveawayInfo(peerId: message.id.peerId, messageId: message.id) let disposable: MetaDisposable if let current = self.giveawayStatusDisposable { @@ -950,7 +953,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G disposable = MetaDisposable() self.giveawayStatusDisposable = disposable } - + let progressSignal = Signal { [weak self] subscriber in if let progress { progress.set(.single(true)) @@ -972,7 +975,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G |> runOn(Queue.mainQueue()) |> delay(0.25, queue: Queue.mainQueue()) let progressDisposable = progressSignal.startStrict() - + signal = signal |> afterDisposed { Queue.mainQueue().async { @@ -985,7 +988,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.displayGiveawayStatusInfo(messageId: message.id, giveawayInfo: info) } })) - + return true } else if let action = media as? TelegramMediaAction { if !displayVoiceMessageDiscardAlert() { @@ -1033,7 +1036,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + if canManageGroupCalls { let text: String if let channel = self.presentationInterfaceState.renderedPeer?.chatMainPeer as? TelegramChannel, case .broadcast = channel.info { @@ -1060,11 +1063,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.joinGroupCall(peerId: message.id.peerId, invite: nil, activeCall: EngineGroupCallDescription(id: info.id, accessHash: info.accessHash, title: info.title, scheduleTimestamp: info.scheduleTimestamp, subscribedToScheduled: info.subscribedToScheduled, isStream: info.isStream)) }, error: { [weak self] error in dismissStatus?() - + guard let self else { return } - + let text: String switch error { case .generic, .scheduledTooLate: @@ -1091,7 +1094,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return true case .messageAutoremoveTimeoutUpdated: var canSetupAutoremoveTimeout = false - + if let _ = self.presentationInterfaceState.renderedPeer?.peer as? TelegramSecretChat { canSetupAutoremoveTimeout = false } else if let group = self.presentationInterfaceState.renderedPeer?.peer as? TelegramGroup { @@ -1107,7 +1110,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G canSetupAutoremoveTimeout = true } } - + if canSetupAutoremoveTimeout { self.presentAutoremoveSetup() } @@ -1261,7 +1264,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G settingsPromise = Promise() settingsPromise.set(.single(nil) |> then(self.context.engine.privacy.requestAccountPrivacySettings() |> map(Optional.init))) } - + let controller = self.context.sharedContext.makeBirthdayAcceptSuggestionScreen(context: self.context, birthday: birthday, settings: settingsPromise, openSettings: { [weak self] in guard let self else { return @@ -1274,7 +1277,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } let _ = self.context.engine.accountData.updateBirthday(birthday: value).startStandalone() - + self.present(UndoOverlayController(presentationData: self.presentationData, content: .universal(animation: "anim_gift", scale: 0.058, colors: ["__allcolors__": UIColor.white], title: nil, text: self.presentationData.strings.SuggestBirthdate_Accept_Added, customUndoText: nil, timeout: 5.0), elevatedLayout: false, action: { _ in return true }), in: .current) @@ -1314,14 +1317,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } let transitionView = selectedNode?.0.view - + let senderName: String? if let peer = message.peers[message.id.peerId] { senderName = EnginePeer(peer).compactDisplayTitle } else { senderName = nil } - + legacyAvatarEditor(context: self.context, media: .message(message: MessageReference(message), media: image), transitionView: transitionView, senderName: senderName, present: { [weak self] c, a in self?.present(c, in: .window(.root), with: a) }, imageCompletion: { [weak self] image in @@ -1367,18 +1370,18 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + let openChatLocation = self.chatLocation var chatFilterTag: MemoryBuffer? if case let .customTag(value, _) = self.chatDisplayNode.historyNode.tag { chatFilterTag = value } - + var standalone = false if case .customChatContents = self.chatLocation { standalone = true } - + if let adAttribute = message.attributes.first(where: { $0 is AdMessageAttribute }) as? AdMessageAttribute { if let file = message.media.first(where: { $0 is TelegramMediaFile}) as? TelegramMediaFile, file.isVideo && !file.isAnimated { self.chatDisplayNode.adMessagesContext?.markAction(opaqueId: adAttribute.opaqueId, media: true, fullscreen: false) @@ -1387,7 +1390,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return true } } - + let openChatMessageParams = OpenChatMessageParams( context: context, updatedPresentationData: self.updatedPresentationData, @@ -1407,7 +1410,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let self else { return } - + if case .current = i { if c is UndoOverlayController { self.present(c, in: .current) @@ -1474,13 +1477,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.temporaryHiddenGalleryMediaDisposable.set((signal |> deliverOnMainQueue).startStrict(next: { [weak self] entry in if let self, let controllerInteraction = self.controllerInteraction { var messageIdAndMedia: [MessageId: [Media]] = [:] - + if let entry = entry as? InstantPageGalleryEntry, entry.index == centralIndex { messageIdAndMedia[message.id] = [galleryMedia] } - + controllerInteraction.hiddenMedia = messageIdAndMedia - + self.chatDisplayNode.historyNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ChatMessageItemView { itemNode.updateHiddenMedia() @@ -1495,13 +1498,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.temporaryHiddenGalleryMediaDisposable.set((signal |> deliverOnMainQueue).startStrict(next: { [weak self] messageId in if let self, let controllerInteraction = self.controllerInteraction { var messageIdAndMedia: [MessageId: [Media]] = [:] - + if let messageId = messageId { messageIdAndMedia[messageId] = [media] } - + controllerInteraction.hiddenMedia = messageIdAndMedia - + self.chatDisplayNode.historyNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ChatMessageItemView { itemNode.updateHiddenMedia() @@ -1566,13 +1569,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let self else { return } - + let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Messages.Message(id: messageId)) |> deliverOnMainQueue).startStandalone(next: { [weak self] message in guard let self, let message = message else { return } - + var mediaReference: AnyMediaReference? for media in message.media { if let image = media as? TelegramMediaImage { @@ -1587,7 +1590,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + if let mediaReference = mediaReference, let peer = message.peers[message.id.peerId] { let hasSilentPosting = peer.id != self.context.account.peerId let hasSchedule = self.presentationInterfaceState.subject != .scheduledMessages && peer.id.namespace != Namespaces.Peer.SecretChat && self.presentationInterfaceState.sendPaidMessageStars == nil @@ -1652,9 +1655,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return rect } ) - + self.controllerInteraction?.isOpeningMediaSignal = openChatMessageParams.blockInteraction.get() - + return context.sharedContext.openChatMessage(openChatMessageParams) }, openPeer: { [weak self] peer, navigation, fromMessage, source in var expandAvatar = false @@ -1682,7 +1685,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let self else { return } - + self.openMessageReactionContextMenu(message: EngineMessage(message), sourceView: sourceView, gesture: gesture, value: value) }, updateMessageReaction: { [weak self] initialMessage, reaction, force, sourceView in guard let strongSelf = self else { @@ -1705,17 +1708,17 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } } - + if !force && message.areReactionsTags(accountPeerId: strongSelf.context.account.peerId) { if case .pinnedMessages = strongSelf.subject { return } - + if !strongSelf.presentationInterfaceState.isPremium { strongSelf.presentTagPremiumPaywall() return } - + strongSelf.chatDisplayNode.historyNode.forEachItemNode { itemNode in guard let itemNode = itemNode as? ChatMessageItemView, let item = itemNode.item else { return @@ -1723,20 +1726,26 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard item.message.id == message.id else { return } - + let chosenReaction: MessageReaction.Reaction? - + switch reaction { case .default: - switch item.associatedData.defaultReaction { - case .none: - chosenReaction = nil - case let .builtin(value): - chosenReaction = .builtin(value) - case let .custom(fileId): - chosenReaction = .custom(fileId) - case .stars: - chosenReaction = .stars + let winterGramCustomReaction = currentWinterGramSettings.customDefaultReaction + if !winterGramCustomReaction.isEmpty { + // WinterGram: override the double-tap quick reaction with the chosen emoji. + chosenReaction = .builtin(winterGramCustomReaction) + } else { + switch item.associatedData.defaultReaction { + case .none: + chosenReaction = nil + case let .builtin(value): + chosenReaction = .builtin(value) + case let .custom(fileId): + chosenReaction = .custom(fileId) + case .stars: + chosenReaction = .stars + } } case let .reaction(value): switch value { @@ -1748,11 +1757,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G chosenReaction = .stars } } - + guard let chosenReaction = chosenReaction else { return } - + let tag = ReactionsMessageAttribute.messageTag(reaction: chosenReaction) if strongSelf.presentationInterfaceState.historyFilter?.customTag == tag { if let sourceView { @@ -1767,13 +1776,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } return } - + let _ = (peerMessageAllowedReactions(context: strongSelf.context, message: message, ignoreDefault: canBypassRestrictions(chatPresentationInterfaceState: strongSelf.presentationInterfaceState)) |> deliverOnMainQueue).startStandalone(next: { allowedReactions, _ in guard let strongSelf = self else { return } - + strongSelf.chatDisplayNode.historyNode.forEachItemNode { itemNode in guard let itemNode = itemNode as? ChatMessageItemView, let item = itemNode.item else { return @@ -1781,20 +1790,26 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard item.message.id == message.id else { return } - + let chosenReaction: MessageReaction.Reaction? - + switch reaction { case .default: - switch item.associatedData.defaultReaction { - case .none: - chosenReaction = nil - case let .builtin(value): - chosenReaction = .builtin(value) - case let .custom(fileId): - chosenReaction = .custom(fileId) - case .stars: - chosenReaction = .stars + let winterGramCustomReaction = currentWinterGramSettings.customDefaultReaction + if !winterGramCustomReaction.isEmpty { + // WinterGram: override the double-tap quick reaction with the chosen emoji. + chosenReaction = .builtin(winterGramCustomReaction) + } else { + switch item.associatedData.defaultReaction { + case .none: + chosenReaction = nil + case let .builtin(value): + chosenReaction = .builtin(value) + case let .custom(fileId): + chosenReaction = .custom(fileId) + case .stars: + chosenReaction = .stars + } } case let .reaction(value): switch value { @@ -1806,11 +1821,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G chosenReaction = .stars } } - + guard let chosenReaction = chosenReaction else { return } - + if case .stars = chosenReaction { if !canSendReactionsToChat(strongSelf.presentationInterfaceState) { strongSelf.displaySendReactionRestrictedToast() @@ -1821,14 +1836,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.selectPollOptionFeedback = HapticFeedback() } strongSelf.selectPollOptionFeedback?.tap() - + itemNode.awaitingAppliedReaction = (chosenReaction, { [weak itemNode] in guard let strongSelf = self else { return } if let itemNode = itemNode, let item = itemNode.item, let availableReactions = item.associatedData.availableReactions, let targetView = itemNode.targetReactionView(value: chosenReaction) { var reactionItem: ReactionItem? - + switch chosenReaction { case .builtin, .stars: for reaction in availableReactions.reactions { @@ -1867,12 +1882,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G ) } } - + if let reactionItem = reactionItem { let standaloneReactionAnimation = StandaloneReactionAnimation(genericReactionEffect: strongSelf.chatDisplayNode.historyNode.takeGenericReactionEffect()) - + strongSelf.chatDisplayNode.messageTransitionNode.addMessageStandaloneReactionAnimation(messageId: item.message.id, standaloneReactionAnimation: standaloneReactionAnimation) - + strongSelf.chatDisplayNode.addSubnode(standaloneReactionAnimation) standaloneReactionAnimation.frame = strongSelf.chatDisplayNode.bounds standaloneReactionAnimation.animateReactionSelection( @@ -1907,7 +1922,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } }) - + guard let starsContext = strongSelf.context.starsContext else { return } @@ -1923,7 +1938,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf, let balance = state?.balance else { return } - + if case let .known(reactionSettings) = reactionSettings, let starsAllowed = reactionSettings.starsAllowed, !starsAllowed { if let peer = strongSelf.presentationInterfaceState.renderedPeer?.chatMainPeer { let alertController = textAlertController( @@ -1938,7 +1953,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } return } - + if balance < StarsAmount(value: 1, nanos: 0) { let _ = (strongSelf.context.engine.payments.starsTopUpOptions() |> take(1) @@ -1949,16 +1964,16 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let starsContext = strongSelf.context.starsContext else { return } - + let purchaseScreen = strongSelf.context.sharedContext.makeStarsPurchaseScreen(context: strongSelf.context, starsContext: starsContext, options: options, purpose: .reactions(peerId: peerId, requiredStars: 1), targetPeerId: nil, customTheme: nil, completion: { result in let _ = result }) strongSelf.push(purchaseScreen) }) - + return } - + let _ = (strongSelf.context.engine.messages.sendStarsReaction(id: message.id, count: 1, privacy: nil) |> deliverOnMainQueue).startStandalone(next: { privacy in guard let strongSelf = self else { @@ -1970,10 +1985,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { var removedReaction: MessageReaction.Reaction? var messageAlreadyHasThisReaction = false - + let currentReactions = mergedMessageReactions(attributes: message.attributes, isTags: message.areReactionsTags(accountPeerId: context.account.peerId))?.reactions ?? [] var updatedReactions: [MessageReaction.Reaction] = currentReactions.filter(\.isSelected).map(\.value) - + if let index = updatedReactions.firstIndex(where: { $0 == chosenReaction }) { removedReaction = chosenReaction updatedReactions.remove(at: index) @@ -1981,7 +1996,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G updatedReactions.append(chosenReaction) messageAlreadyHasThisReaction = currentReactions.contains(where: { $0.value == chosenReaction }) } - + if removedReaction == nil { if !canSendReactionsToChat(strongSelf.presentationInterfaceState) { strongSelf.displaySendReactionRestrictedToast() @@ -1992,17 +2007,17 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G itemNode.openMessageContextMenu() return } - + if strongSelf.context.sharedContext.immediateExperimentalUISettings.disableQuickReaction { itemNode.openMessageContextMenu() return } - + guard let allowedReactions = allowedReactions else { itemNode.openMessageContextMenu() return } - + switch allowedReactions { case let .set(set): if !messageAlreadyHasThisReaction && updatedReactions.contains(where: { !set.contains($0) }) { @@ -2013,20 +2028,20 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G break } } - + if removedReaction == nil && !updatedReactions.isEmpty { if strongSelf.selectPollOptionFeedback == nil { strongSelf.selectPollOptionFeedback = HapticFeedback() } strongSelf.selectPollOptionFeedback?.tap() - + itemNode.awaitingAppliedReaction = (chosenReaction, { [weak itemNode] in guard let strongSelf = self else { return } if let itemNode = itemNode, let item = itemNode.item, let availableReactions = item.associatedData.availableReactions, let targetView = itemNode.targetReactionView(value: chosenReaction) { var reactionItem: ReactionItem? - + switch chosenReaction { case .builtin, .stars: for reaction in availableReactions.reactions { @@ -2065,12 +2080,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G ) } } - + if let reactionItem = reactionItem { let standaloneReactionAnimation = StandaloneReactionAnimation(genericReactionEffect: strongSelf.chatDisplayNode.historyNode.takeGenericReactionEffect()) - + strongSelf.chatDisplayNode.messageTransitionNode.addMessageStandaloneReactionAnimation(messageId: item.message.id, standaloneReactionAnimation: standaloneReactionAnimation) - + strongSelf.chatDisplayNode.addSubnode(standaloneReactionAnimation) standaloneReactionAnimation.frame = strongSelf.chatDisplayNode.bounds standaloneReactionAnimation.animateReactionSelection( @@ -2099,7 +2114,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) } else { strongSelf.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts(itemNode: itemNode) - + if let removedReaction = removedReaction, let targetView = itemNode.targetReactionView(value: removedReaction), shouldDisplayInlineDateReactions(message: EngineMessage(message), isPremium: strongSelf.presentationInterfaceState.isPremium, forceInline: false) { var hideRemovedReaction: Bool = false if let reactions = mergedMessageReactions(attributes: message.attributes, isTags: message.areReactionsTags(accountPeerId: context.account.peerId)) { @@ -2110,7 +2125,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + let standaloneDismissAnimation = StandaloneDismissReactionAnimation() standaloneDismissAnimation.frame = strongSelf.chatDisplayNode.bounds strongSelf.chatDisplayNode.addSubnode(standaloneDismissAnimation) @@ -2119,7 +2134,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) } } - + let mappedUpdatedReactions = updatedReactions.map { reaction -> UpdateMessageReaction in switch reaction { case let .builtin(value): @@ -2130,7 +2145,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return .stars } } - + if !strongSelf.presentationInterfaceState.isPremium && mappedUpdatedReactions.count > strongSelf.context.userLimits.maxReactionsPerMessage { let _ = (ApplicationSpecificNotice.incrementMultipleReactionsSuggestion(accountManager: strongSelf.context.sharedContext.accountManager) |> deliverOnMainQueue).startStandalone(next: { [weak self] count in @@ -2157,7 +2172,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }) } - + let _ = updateMessageReactionsInteractively(account: strongSelf.context.account, messageIds: [message.id], reactions: mappedUpdatedReactions, isLarge: false, storeAsRecentlyUsed: false).startStandalone() } } @@ -2208,7 +2223,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self, strongSelf.isNodeLoaded else { return } - + if let subject = strongSelf.subject, case .messageOptions = subject, !value { let selectedCount = strongSelf.presentationInterfaceState.interfaceState.selectionState?.selectedIds.count ?? 0 let updatedSelectedCount = selectedCount - ids.count @@ -2216,7 +2231,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } } - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withToggledSelectedMessages(ids, value: value) } }) if let selectionState = strongSelf.presentationInterfaceState.interfaceState.selectionState { let count = selectionState.selectedIds.count @@ -2241,12 +2256,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self, canSendMessagesToChat(strongSelf.presentationInterfaceState) else { return } - + var isScheduledMessages = false if case .scheduledMessages = strongSelf.presentationInterfaceState.subject { isScheduledMessages = true } - + guard !isScheduledMessages else { strongSelf.present(textAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, title: nil, text: strongSelf.presentationData.strings.ScheduledMessages_BotActionUnavailable, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) return @@ -2254,7 +2269,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({ if let strongSelf = self { strongSelf.chatDisplayNode.collapseInput() - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { $0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil).withUpdatedPostSuggestionState(nil) } }) @@ -2268,7 +2283,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let replyMessageSubject = sourceMessageId.flatMap { EngineMessageReplySubject(messageId: $0, quote: nil, innerSubject: nil) } ?? strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject?.subjectModel - + let peerId = strongSelf.chatLocation.peerId if peerId?.namespace != Namespaces.Peer.SecretChat, let interactiveEmojis = strongSelf.chatDisplayNode.interactiveEmojis, interactiveEmojis.emojis.contains(text) { strongSelf.sendMessages([.message(text: "", attributes: [], inlineStickers: [:], mediaReference: AnyMediaReference.standalone(media: TelegramMediaDice(emoji: text)), threadId: strongSelf.chatLocation.threadId, replyToMessageId: replyMessageSubject, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]) @@ -2279,21 +2294,41 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self else { return false } - + if currentWinterGramSettings.stickerConfirmation && !strongSelf.skipWinterGramStickerConfirmation { + strongSelf.present(textAlertController( + context: strongSelf.context, + updatedPresentationData: strongSelf.updatedPresentationData, + title: "Send Sticker?", + text: "WinterGram confirmation is enabled.", + actions: [ + TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}), + TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: { [weak strongSelf] in + guard let strongSelf else { + return + } + strongSelf.skipWinterGramStickerConfirmation = true + let _ = strongSelf.controllerInteraction?.sendSticker(fileReference, silentPosting, schedule, query, clearInput, sourceView, sourceRect, sourceLayer, bubbleUpEmojiOrStickersets) + strongSelf.skipWinterGramStickerConfirmation = false + }) + ] + ), in: .window(.root)) + return true + } + if let _ = strongSelf.presentationInterfaceState.slowmodeState, strongSelf.presentationInterfaceState.subject != .scheduledMessages { if let sourceView, let sourceRect { strongSelf.interfaceInteraction?.displaySlowmodeTooltip(sourceView, sourceRect) } return false } - + if let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, peer.hasBannedPermission(.banSendStickers) != nil { if !canBypassRestrictions(chatPresentationInterfaceState: strongSelf.presentationInterfaceState) { strongSelf.interfaceInteraction?.openBoostToUnrestrict() return false } } - + var attributes: [MessageAttribute] = [] if let query = query { attributes.append(EmojiSearchQueryMessageAttribute(query: query)) @@ -2310,12 +2345,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let _ = sourceView?.asyncdisplaykit_node as? ChatEmptyNodeStickerContentNode { shouldAnimateMessageTransition = true } - + strongSelf.presentPaidMessageAlertIfNeeded(completion: { [weak strongSelf] postpone in guard let strongSelf else { return } - + let addToTransitionNodeIfNeeded: () -> Void = { guard let strongSelf = self else { return @@ -2334,7 +2369,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } return current } - + return current }) }) @@ -2355,22 +2390,22 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } return current } - + return current }) }) } } } - + let replyMessageSubject = strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject - + let messages: [EnqueueMessage] = [.message(text: "", attributes: attributes, inlineStickers: [:], mediaReference: fileReference.abstract, threadId: strongSelf.chatLocation.threadId, replyToMessageId: replyMessageSubject?.subjectModel, replyToStoryId: nil, localGroupingKey: nil, correlationId: correlationId, bubbleUpEmojiOrStickersets: bubbleUpEmojiOrStickersets)] if silentPosting { strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({ if let strongSelf = self { strongSelf.chatDisplayNode.collapseInput() - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { current in var current = current current = current.updatedInterfaceState { interfaceState in @@ -2386,12 +2421,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } return current } - + return current }) } }, shouldAnimateMessageTransition ? correlationId : nil) - + addToTransitionNodeIfNeeded() let transformedMessages = strongSelf.transformEnqueueMessages(messages, silentPosting: silentPosting, postpone: postpone) strongSelf.sendMessages(transformedMessages) @@ -2399,7 +2434,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({ if let strongSelf = self { strongSelf.chatDisplayNode.collapseInput() - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { current in var current = current current = current.updatedInterfaceState { interfaceState in @@ -2415,12 +2450,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } return current } - + return current }) } }, shouldAnimateMessageTransition ? correlationId : nil) - + strongSelf.presentScheduleTimePicker(completion: { [weak self] result in if let strongSelf = self { let transformedMessages = strongSelf.transformEnqueueMessages(messages, silentPosting: result.silentPosting, scheduleTime: result.time, repeatPeriod: result.repeatPeriod, postpone: postpone) @@ -2429,7 +2464,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) } else { let messages = strongSelf.transformEnqueueMessages(messages, postpone: postpone) - + var targetThreadId: Int64? var clearMainThreadForward = false if strongSelf.chatLocation.threadId == nil, let user = strongSelf.presentationInterfaceState.renderedPeer?.peer as? TelegramUser, let botInfo = user.botInfo, botInfo.flags.contains(.hasForum), botInfo.flags.contains(.forumManagedByUser) { @@ -2453,7 +2488,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + let doSend: (Int64?) -> Void = { [weak self] overrideThreadId in guard let strongSelf = self else { return @@ -2495,7 +2530,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.sendMessages(messages.map { $0.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel) }) } - + if let targetThreadId { strongSelf.chatDisplayNode.historyNode.stopHistoryUpdates() strongSelf.updateChatLocationThread(threadId: targetThreadId, animationDirection: .right, transferInputState: true, completion: { [weak strongSelf] in @@ -2527,7 +2562,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + strongSelf.sendMessages([.message(text: text, attributes: [TextEntitiesMessageAttribute(entities: [MessageTextEntity(range: 0 ..< (text as NSString).length, type: .CustomEmoji(stickerPack: nil, fileId: file.fileId.id))])], inlineStickers: [file.fileId : file], mediaReference: nil, threadId: strongSelf.chatLocation.threadId, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: bubbleUpEmojiOrStickersets)], commit: false) } } else { @@ -2537,7 +2572,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return .text }) }) - + let _ = (ApplicationSpecificNotice.getEmojiTooltip(accountManager: strongSelf.context.sharedContext.accountManager) |> deliverOnMainQueue).startStandalone(next: { count in guard let strongSelf = self else { @@ -2545,7 +2580,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } if count < 2 { let _ = ApplicationSpecificNotice.incrementEmojiTooltip(accountManager: strongSelf.context.sharedContext.accountManager).startStandalone() - + Queue.mainQueue().after(0.5, { strongSelf.displayEmojiTooltip() }) @@ -2555,18 +2590,39 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }, sendGif: { [weak self] fileReference, sourceView, sourceRect, silentPosting, schedule in if let strongSelf = self { + if currentWinterGramSettings.gifConfirmation && !strongSelf.skipWinterGramGifConfirmation { + strongSelf.present(textAlertController( + context: strongSelf.context, + updatedPresentationData: strongSelf.updatedPresentationData, + title: "Send GIF?", + text: "WinterGram confirmation is enabled.", + actions: [ + TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}), + TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: { [weak strongSelf] in + guard let strongSelf else { + return + } + strongSelf.skipWinterGramGifConfirmation = true + let _ = strongSelf.controllerInteraction?.sendGif(fileReference, sourceView, sourceRect, silentPosting, schedule) + strongSelf.skipWinterGramGifConfirmation = false + }) + ] + ), in: .window(.root)) + return true + } + if let _ = strongSelf.presentationInterfaceState.slowmodeState, strongSelf.presentationInterfaceState.subject != .scheduledMessages { strongSelf.interfaceInteraction?.displaySlowmodeTooltip(sourceView, sourceRect) return false } - + if let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, peer.hasBannedPermission(.banSendGifs) != nil { if !canBypassRestrictions(chatPresentationInterfaceState: strongSelf.presentationInterfaceState) { strongSelf.interfaceInteraction?.openBoostToUnrestrict() return false } } - + strongSelf.presentPaidMessageAlertIfNeeded(completion: { [weak self] postpone in guard let strongSelf = self else { return @@ -2574,7 +2630,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({ if let strongSelf = self { strongSelf.chatDisplayNode.collapseInput() - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { $0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil).withUpdatedPostSuggestionState(nil) }.updatedInputMode { current in if case let .media(mode, maybeExpanded, focused) = current, maybeExpanded != nil { @@ -2585,7 +2641,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) } }, nil) - + var messages = [EnqueueMessage.message(text: "", attributes: [], inlineStickers: [:], mediaReference: fileReference.abstract, threadId: strongSelf.chatLocation.threadId, replyToMessageId: strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject?.subjectModel, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])] if silentPosting { messages = strongSelf.transformEnqueueMessages(messages, silentPosting: true) @@ -2615,16 +2671,16 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.interfaceInteraction?.displaySlowmodeTooltip(sourceView, sourceRect) return false } - + if let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, peer.hasBannedPermission(.banSendGifs) != nil { if !canBypassRestrictions(chatPresentationInterfaceState: strongSelf.presentationInterfaceState) { strongSelf.interfaceInteraction?.openBoostToUnrestrict() return false } } - + strongSelf.enqueueChatContextResult(collection, result, hideVia: true, closeMediaInput: true, silentPosting: silentPosting, resetTextInputState: resetTextInputState) - + return true }, editGif: { [weak self] file, addCaption in guard let self else { @@ -2635,14 +2691,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self else { return } - + let messageId = message.id - + guard strongSelf.presentationInterfaceState.subject != .scheduledMessages else { strongSelf.present(textAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, title: nil, text: strongSelf.presentationData.strings.ScheduledMessages_BotActionUnavailable, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) return } - + if let action = message.media.first(where: { $0 is TelegramMediaAction }) as? TelegramMediaAction, case let .starGiftPurchaseOffer(gift, amount, _, isAccepted, isDeclined) = action.action, !isAccepted && !isDeclined { guard let data = data?.makeData(), message.effectivelyIncoming(strongSelf.context.account.peerId) else { return @@ -2653,7 +2709,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let buttonType = data.withUnsafeBytes { buffer -> UInt8 in return buffer.baseAddress!.assumingMemoryBound(to: UInt8.self).pointee } - + switch buttonType { case 0: let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: message.id.peerId)) @@ -2705,7 +2761,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let buttonType = data.withUnsafeBytes { buffer -> UInt8 in return buffer.baseAddress!.assumingMemoryBound(to: UInt8.self).pointee } - + switch buttonType { case 0: strongSelf.present(textAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, title: strongSelf.presentationData.strings.Chat_EnableSharingOffer_RejectConfirmation_Title, text: strongSelf.presentationData.strings.Chat_EnableSharingOffer_RejectConfirmation_Text, actions: [ @@ -2741,7 +2797,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let buttonType = data.withUnsafeBytes { buffer -> UInt8 in return buffer.baseAddress!.assumingMemoryBound(to: UInt8.self).pointee } - + if message.effectivelyIncoming(strongSelf.context.account.peerId) { switch buttonType { case 0: @@ -2762,19 +2818,19 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf else { return } - + var timestamp: Int32? var funds: (amount: CurrencyAmount, commissionPermille: Int)? if let amount = attribute.amount { let configuration = StarsSubscriptionConfiguration.with(appConfiguration: strongSelf.context.currentAppConfiguration.with { $0 }) funds = (amount, amount.currency == .stars ? Int(configuration.channelMessageSuggestionStarsCommissionPermille) : Int(configuration.channelMessageSuggestionTonCommissionPermille)) } - + var isAdmin = false if let channel = strongSelf.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isMonoForum, let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = strongSelf.presentationInterfaceState.renderedPeer?.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.manageDirect) { isAdmin = true } - + if !isAdmin, let funds { switch funds.amount.currency { case .stars: @@ -2783,7 +2839,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let state = await (starsContext.state |> take(1) |> deliverOnMainQueue).get() balance = state?.balance } - + if let balance, funds.amount.amount > balance { guard let starsContext = strongSelf.context.starsContext else { return @@ -2798,7 +2854,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) strongSelf.push(purchaseController) }) - + return } case .ton: @@ -2807,7 +2863,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let state = await (tonContext.state |> take(1) |> deliverOnMainQueue).get() balance = state?.balance } - + if let balance, funds.amount.amount > balance { let needed = funds.amount.amount - balance var fragmentUrl = "https://fragment.com/ads/topup" @@ -2828,7 +2884,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + if attribute.timestamp == nil { let controller = ChatScheduleTimeController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, mode: .suggestPost(needsTime: true, isAdmin: isAdmin, funds: funds), style: .default, currentTime: nil, minimalTime: nil, dismissByTapOutside: true, completion: { [weak strongSelf] time in guard let strongSelf else { @@ -2841,13 +2897,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) strongSelf.view.endEditing(true) strongSelf.present(controller, in: .window(.root)) - + timestamp = Int32(Date().timeIntervalSince1970) + 1 * 60 * 60 } else { var textString: String if isAdmin { textString = strongSelf.presentationData.strings.Chat_PostSuggestion_Approve_AdminConfirmationText(message.author.flatMap(EnginePeer.init)?.compactDisplayTitle ?? "").string - + if let funds { var commissionValue: String commissionValue = "\(Double(funds.commissionPermille) * 0.1)" @@ -2856,9 +2912,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else if commissionValue.hasSuffix(".00") { commissionValue = String(commissionValue[commissionValue.startIndex ..< commissionValue.index(commissionValue.endIndex, offsetBy: -3)]) } - + textString += "\n\n" - + switch funds.amount.currency { case .stars: let displayAmount = funds.amount.amount.totalValue * Double(funds.commissionPermille) / 1000.0 @@ -2871,7 +2927,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { textString = strongSelf.presentationData.strings.Chat_PostSuggestion_Approve_UserConfirmationText } - + strongSelf.present(SuggestedPostApproveAlert(presentationData: strongSelf.presentationData, title: strongSelf.presentationData.strings.Chat_PostSuggestion_Approve_Title, text: textString, actions: [ TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Chat_PostSuggestion_Approve_Action, action: { [weak strongSelf] in guard let strongSelf else { @@ -2889,7 +2945,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G break } } - + return } else if !message.attributes.contains(where: { attribute in if let attribute = attribute as? ReplyMarkupMessageAttribute, !attribute.rows.isEmpty { @@ -2908,13 +2964,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Messages.Message(id: messageId)) |> deliverOnMainQueue).startStandalone(next: { message in guard let strongSelf = self, let message = message else { return } - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { return $0.updatedTitlePanelContext { if !$0.contains(where: { @@ -2932,12 +2988,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return $0 } }) - + let proceedWithResult: (MessageActionCallbackResult) -> Void = { [weak self] result in guard let strongSelf = self else { return } - + switch result { case .none: break @@ -2954,7 +3010,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self else { return } - + strongSelf.chatDisplayNode.dismissInput() strongSelf.effectiveNavigationController?.pushViewController(GameController(context: strongSelf.context, url: url, message: message)) } @@ -2974,7 +3030,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G botPeer = peer } } - + if let botPeer = botPeer { let _ = (ApplicationSpecificNotice.getBotGameNotice(accountManager: strongSelf.context.sharedContext.accountManager, peerId: botPeer.id) |> deliverOnMainQueue).startStandalone(next: { [weak self] value in @@ -3004,7 +3060,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + let updateProgress = { [weak self] in Queue.mainQueue().async { if let strongSelf = self { @@ -3028,7 +3084,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + let context = strongSelf.context if requiresPassword { strongSelf.messageActionCallbackDisposable.set(((strongSelf.context.engine.messages.requestMessageActionCallbackPasswordCheck(messageId: messageId, isGame: isGame, data: data) @@ -3139,7 +3195,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return $0 } }) - + strongSelf.messageActionUrlAuthDisposable.set(((strongSelf.context.engine.messages.acceptMessageActionUrlAuth(subject: subject, allowWriteAccess: allowWriteAccess, sharePhoneNumber: false) |> afterDisposed { Queue.mainQueue().async { if let strongSelf = self { @@ -3225,7 +3281,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let message = urlData.message let progress = urlData.progress let forceExternal = urlData.external ?? false - + var skipConcealedAlert = false if let author = message?.author, author.isVerified { skipConcealedAlert = true @@ -3236,7 +3292,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let message, let adAttribute = message.attributes.first(where: { $0 is AdMessageAttribute }) as? AdMessageAttribute { strongSelf.chatDisplayNode.adMessagesContext?.markAction(opaqueId: adAttribute.opaqueId, media: false, fullscreen: false) } - + if let performOpenURL = strongSelf.performOpenURL { performOpenURL(message, url, progress) } else { @@ -3255,21 +3311,21 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G switch result { case let .result(webpageResult): instantPage.progress?.set(.single(false)) - + guard let webpageResult else { self.openUrl(instantPage.url, concealed: true) return } - + if case .Loaded = webpageResult.webpage.content { let sourceLocation: InstantPageSourceLocation - + if let peerId = self.chatLocation.peerId { let (peer, isContact) = await self.context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: peerId), TelegramEngine.EngineData.Item.Peer.IsContact(id: peerId) ).get() - + if let peer { let peerType: MediaAutoDownloadPeerType switch peer { @@ -3294,7 +3350,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { sourceLocation = InstantPageSourceLocation(userLocation: .other, peerType: .channel) } - + self.push(makeInstantPageControllerImpl(context: self.context, webPage: webpageResult.webpage, anchor: instantPage.anchor, sourceLocation: sourceLocation)) } case .progress: @@ -3334,7 +3390,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if case .pinnedMessages = strongSelf.presentationInterfaceState.subject { return } - + guard strongSelf.presentationInterfaceState.subject != .scheduledMessages else { strongSelf.present(textAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, title: nil, text: strongSelf.presentationData.strings.ScheduledMessages_BotActionUnavailable, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) return @@ -3375,7 +3431,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G case .customChatContents: postAsReply = true } - + if let messageId = messageId, let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId)?._asMessage() { if let author = message.author as? TelegramUser, author.botInfo != nil { } else { @@ -3383,11 +3439,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({ if let strongSelf = self { strongSelf.chatDisplayNode.collapseInput() - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { $0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil).withUpdatedPostSuggestionState(nil).withUpdatedComposeInputState(ChatTextInputState(inputText: NSAttributedString(string: ""))).withUpdatedComposeDisableUrlPreviews([]) } }) @@ -3483,7 +3539,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let strongSelf = self { let _ = strongSelf.presentVoiceMessageDiscardAlert(action: { strongSelf.commitPurposefulAction() - + let _ = (context.account.viewTracker.peerView(peerId) |> take(1) |> map { view -> Peer? in @@ -3493,7 +3549,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let peer else { return } - + if let cachedUserData = strongSelf.contentData?.state.peerView?.cachedData as? CachedUserData, cachedUserData.callsPrivate { strongSelf.push(strongSelf.context.sharedContext.makeSendInviteLinkScreen(context: strongSelf.context, subject: .groupCall(.create), peers: [TelegramForbiddenInvitePeer( peer: EnginePeer(peer), @@ -3502,7 +3558,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G )], theme: strongSelf.presentationData.theme)) return } - + context.requestCall(peerId: peer.id, isVideo: isVideo, completion: {}) }) }) @@ -3532,23 +3588,23 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } strongSelf.commitPurposefulAction() - + var isScheduledMessages = false if case .scheduledMessages = strongSelf.presentationInterfaceState.subject { isScheduledMessages = true } - + guard !isScheduledMessages else { strongSelf.present(textAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, title: nil, text: strongSelf.presentationData.strings.ScheduledMessages_BotActionUnavailable, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) return } - + let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Messages.Message(id: messageId)) |> deliverOnMainQueue).startStandalone(next: { message in guard let strongSelf = self, let message else { return } - + for media in message.media { if let paidContent = media as? TelegramMediaPaidContent { let progressSignal = Signal { _ in @@ -3560,7 +3616,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G |> runOn(Queue.mainQueue()) |> delay(0.25, queue: Queue.mainQueue()) let progressDisposable = progressSignal.startStrict() - + strongSelf.chatDisplayNode.dismissInput() let inputData = Promise() inputData.set(BotCheckoutController.InputData.fetch(context: strongSelf.context, source: .message(message.id)) @@ -3591,7 +3647,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let invoice = TelegramMediaInvoice(title: "", description: "", photo: nil, receiptMessageId: nil, currency: "XTR", totalAmount: paidContent.amount, startParam: "", extendedMedia: .preview(dimensions: dimensions, immediateThumbnailData: immediateThumbnailData, videoDuration: nil), subscriptionPeriod: nil, flags: [], version: 0) let controller = strongSelf.context.sharedContext.makeStarsTransferScreen(context: strongSelf.context, starsContext: starsContext, invoice: invoice, source: .message(messageId), extendedMedia: paidContent.extendedMedia, inputData: starsInputData, completion: { _ in }) strongSelf.push(controller) - + progressDisposable.dispose() }) } @@ -3644,7 +3700,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self, let receiptMessageId = receiptMessageId else { return false } - + if case .info = action { strongSelf.present(BotReceiptController(context: strongSelf.context, messageId: receiptMessageId), in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) return true @@ -3686,7 +3742,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if case let .customChatContents(customChatContents) = strongSelf.presentationInterfaceState.subject, case .quickReplyMessageInput = customChatContents.kind { return .none } - + if canReplyInChat(strongSelf.presentationInterfaceState, accountPeerId: strongSelf.context.account.peerId) { return .reply } else if let channel = message.peers[message.id.peerId] as? TelegramChannel, case .broadcast = channel.info { @@ -3725,7 +3781,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self, let message = messages.filter({ $0.id == id }).first else { return } - + var actions: [ContextMenuItem] = [] actions.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.ScheduledMessages_SendNow, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Resend"), color: theme.actionSheet.primaryTextColor) @@ -3750,9 +3806,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.interfaceInteraction?.deleteMessages(messages.map { $0._asMessage() }, controller, f) } }))) - + strongSelf.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() - + let controller = makeContextController(presentationData: strongSelf.presentationData, source: .extracted(ChatMessageContextExtractedContentSource(chatController: strongSelf, chatNode: strongSelf.chatDisplayNode, engine: strongSelf.context.engine, message: message, selectAll: true)), items: .single(ContextController.Items(content: .list(actions))), recognizer: nil) strongSelf.currentContextController = controller strongSelf.forEachController({ controller in @@ -3781,9 +3837,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G notGrouped.append(message._asMessage()) } } - + let totalGroupCount = notGrouped.count + groups.count - + var maybeSelectedGroup: [Message]? for (_, group) in groups { if group.contains(where: { $0.id == id}) { @@ -3796,11 +3852,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G maybeSelectedGroup = [message] } } - + guard let selectedGroup = maybeSelectedGroup, let topMessage = selectedGroup.first else { return } - + var actions: [ContextMenuItem] = [] actions.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Conversation_MessageDialogRetry, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Resend"), color: theme.actionSheet.primaryTextColor) @@ -3838,9 +3894,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } f(.dismissWithoutContent) }))) - + strongSelf.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() - + let controller = makeContextController(presentationData: strongSelf.presentationData, source: .extracted(ChatMessageContextExtractedContentSource(chatController: strongSelf, chatNode: strongSelf.chatDisplayNode, engine: strongSelf.context.engine, message: EngineMessage(topMessage), selectAll: true)), items: .single(ContextController.Items(content: .list(actions))), recognizer: nil) strongSelf.currentContextController = controller strongSelf.forEachController({ controller in @@ -3882,12 +3938,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self, let controllerInteraction = strongSelf.controllerInteraction else { return } - + guard strongSelf.presentationInterfaceState.subject != .scheduledMessages else { strongSelf.present(textAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, title: nil, text: strongSelf.presentationData.strings.ScheduledMessages_PollUnavailable, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) return } - + if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(id)?._asMessage(), controllerInteraction.pollActionState.pollMessageIdsInProgress[id] == nil { controllerInteraction.pollActionState.pollMessageIdsInProgress[id] = opaqueIdentifiers strongSelf.chatDisplayNode.historyNode.requestMessageUpdate(id) @@ -3898,7 +3954,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G disposables = DisposableDict() strongSelf.selectMessagePollOptionDisposables = disposables } - + var shouldDisplayHiddenResultsTooltip = false if let poll = message.media.first(where: { $0 is TelegramMediaPoll }) as? TelegramMediaPoll, poll.hideResultsUntilClose { shouldDisplayHiddenResultsTooltip = true @@ -3910,7 +3966,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + let signal = strongSelf.context.engine.messages.requestMessageSelectPollOption(messageId: id, opaqueIdentifiers: opaqueIdentifiers) disposables.set((signal |> deliverOnMainQueue).startStrict(next: { resultPoll in @@ -3920,7 +3976,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let _ = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(id)?._asMessage() else { return } - + switch resultPoll.kind { case .poll: if strongSelf.selectPollOptionFeedback == nil { @@ -3932,7 +3988,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G var selectedCount = 0 var correctCount = 0 var selectedCorrectCount = 0 - + for voter in voters { if voter.selected { selectedCount += 1 @@ -3944,13 +4000,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + if correctCount == selectedCount && correctCount == selectedCorrectCount { if strongSelf.selectPollOptionFeedback == nil { strongSelf.selectPollOptionFeedback = HapticFeedback() } strongSelf.selectPollOptionFeedback?.success() - + strongSelf.chatDisplayNode.playConfettiAnimation() } else { var found = false @@ -3961,9 +4017,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.selectPollOptionFeedback = HapticFeedback() } strongSelf.selectPollOptionFeedback?.error() - + itemNode.animateQuizInvalidOptionSelected() - + if let solution = resultPoll.results.solution { for contentNode in itemNode.contentNodes { if let contentNode = contentNode as? ChatMessagePollBubbleContentNode { @@ -3977,7 +4033,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + if shouldDisplayHiddenResultsTooltip { let controller = UndoOverlayController( presentationData: strongSelf.presentationData, @@ -3993,7 +4049,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if controllerInteraction.pollActionState.pollMessageIdsInProgress.removeValue(forKey: id) != nil { strongSelf.chatDisplayNode.historyNode.requestMessageUpdate(id) } - + switch error { case .restrictedToSubscribers: strongSelf.displayPollRestrictedToast(messageId: id) @@ -4048,7 +4104,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.selectPollOptionFeedback = HapticFeedback() } strongSelf.selectPollOptionFeedback?.success() - + if controllerInteraction.pollActionState.pollMessageIdsInProgress.removeValue(forKey: id) != nil { Queue.mainQueue().after(1.0, { strongSelf.chatDisplayNode.historyNode.requestMessageUpdate(id) @@ -4082,7 +4138,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let strongSelf = self { if let node = node { strongSelf.messageTooltipController?.dismiss() - + let padding: CGFloat let timeout: Double let balancedTextLayout: Bool @@ -4101,7 +4157,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G alignment = .center innerPadding = .zero } - + let tooltipController = TooltipController(content: .text(text), baseFontSize: strongSelf.presentationData.listsFontSize.baseDisplaySize, balancedTextLayout: balancedTextLayout, alignment: alignment, isBlurred: true, timeout: timeout, dismissByTapOutside: true, dismissImmediatelyOnLayoutUpdate: true, padding: padding, innerPadding: innerPadding) strongSelf.messageTooltipController = tooltipController tooltipController.dismissed = { [weak tooltipController] _ in @@ -4169,7 +4225,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, saveInterfaceState: strongSelf.presentationInterfaceState.subject != .scheduledMessages, { $0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil).withUpdatedPostSuggestionState(nil).withUpdatedForwardMessageIds(nil).withUpdatedForwardOptionsState(nil).withUpdatedComposeInputState(ChatTextInputState(inputText: NSAttributedString(string: ""))) } }) - + if strongSelf.presentationInterfaceState.subject != .scheduledMessages && result.time != scheduleWhenOnlineTimestamp { strongSelf.openScheduledMessages() } @@ -4185,7 +4241,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard !self.presentAccountFrozenInfoIfNeeded(delay: true) else { return } - + if let _ = self.presentationInterfaceState.slowmodeState { if let rect = self.chatDisplayNode.frameForInputActionButton() { self.interfaceInteraction?.displaySlowmodeTooltip(self.chatDisplayNode.view, rect) @@ -4225,12 +4281,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let self else { return } - + if let performTextSelectionAction = self.performTextSelectionAction { performTextSelectionAction(message, canCopy, text, entities, action) return } - + switch action { case .copy: storeAttributedTextInPasteboard(text) @@ -4273,7 +4329,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let self else { return } - + let sharedDataEntries = await self.context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.translationSettings]).get() let translationSettings: TranslationSettings if let value = sharedDataEntries.entries[ApplicationSpecificSharedDataKeys.translationSettings], let parsedValue = value.get(TranslationSettings.self) { @@ -4281,16 +4337,16 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { translationSettings = .defaultSettings } - + var showTranslateIfTopical = false if let peer = self.presentationInterfaceState.renderedPeer?.chatMainPeer as? TelegramChannel, !(peer.addressName ?? "").isEmpty { showTranslateIfTopical = true } - + let (_, language) = canTranslateText(context: context, text: text.string, showTranslate: translationSettings.showTranslate, showTranslateIfTopical: showTranslateIfTopical, ignoredLanguages: translationSettings.ignoredLanguages) - + let _ = ApplicationSpecificNotice.incrementTranslationSuggestion(accountManager: context.sharedContext.accountManager, timestamp: Int32(Date().timeIntervalSince1970)).startStandalone() - + let translationConfiguration = TranslationConfiguration.with(appConfiguration: self.context.currentAppConfiguration.with { $0 }) var useSystemTranslation = false switch translationConfiguration.manual { @@ -4301,7 +4357,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G default: break } - + if useSystemTranslation { presentTranslateScreen( context: context, @@ -4335,7 +4391,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } storeMessageTextInPasteboard(text.text, entities: text.entities) - + let infoText = self.presentationData.strings.Conversation_TextCopied self.present(UndoOverlayController(presentationData: self.presentationData, content: .copy(text: infoText), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return true @@ -4366,7 +4422,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } if let currentContextController = self.currentContextController { self.currentContextController = nil - + if let transition { currentContextController.dismissWithCustomTransition(transition: transition, completion: nil) } else { @@ -4376,15 +4432,15 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } if let messageId = message?.id, let message = self.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId)?._asMessage() ?? message { var quoteData: EngineMessageReplyQuote? - + let nsRange = NSRange(location: range.lowerBound, length: range.upperBound - range.lowerBound) let quoteText = (message.text as NSString).substring(with: nsRange) - + let trimmedText = trimStringWithEntities(string: quoteText, entities: messageTextEntitiesInRange(entities: message.textEntitiesAttribute?.entities ?? [], range: nsRange, onlyQuoteable: true), maxLength: quoteMaxLength(appConfig: self.context.currentAppConfiguration.with({ $0 }))) if !trimmedText.string.isEmpty { quoteData = EngineMessageReplyQuote(text: trimmedText.string, offset: nsRange.location, entities: trimmedText.entities, media: nil) } - + let replySubject = ChatInterfaceState.ReplyMessageSubject( messageId: message.id, quote: quoteData, @@ -4502,15 +4558,15 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self else { return } - + if strongSelf.presentationInterfaceState.interfaceState.selectionState != nil { return } - + strongSelf.dismissAllTooltips() - + let context = strongSelf.context - + let dataSignal: Signal<(EnginePeer?, EngineMessage?), NoError> if let messageId = messageId { dataSignal = context.engine.data.get( @@ -4525,13 +4581,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return (peer, nil) } } - + let _ = (dataSignal |> deliverOnMainQueue).startStandalone(next: { [weak self] peer, message in guard let strongSelf = self, let peer = peer else { return } - + var isChannel = false if case let .channel(peer) = peer, case .broadcast = peer.info { isChannel = true @@ -4555,11 +4611,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Mention"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in f(.dismissWithoutContent) - + guard let strongSelf = self else { return } - + let _ = strongSelf.presentVoiceMessageDiscardAlert(action: { strongSelf.interfaceInteraction?.updateTextInputStateAndMode { current, inputMode in var inputMode = inputMode @@ -4576,18 +4632,18 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Search"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in f(.dismissWithoutContent) - + guard let strongSelf = self else { return } strongSelf.activateSearch(domain: .member(peer._asPeer())) }))) } - + strongSelf.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() - + strongSelf.canReadHistory.set(false) - + let source: ContextContentSource if let _ = peer.smallProfileImage { let galleryController = AvatarGalleryController(context: context, peer: peer, remoteEntries: nil, replaceRootController: { controller, ready in @@ -4597,7 +4653,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { source = .reference(ChatControllerContextReferenceContentSource(controller: strongSelf, sourceView: node.view, insets: .zero)) } - + let contextController = makeContextController(presentationData: strongSelf.presentationData, source: source, items: .single(ContextController.Items(content: .list(items))), gesture: gesture) contextController.dismissed = { [weak self] in self?.canReadHistory.set(true) @@ -4608,7 +4664,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self else { return } - + strongSelf.openMessageReplies(messageId: messageId, displayProgressInMessage: displayModalProgress ? nil : messageId, isChannelPost: isChannelPost, atMessage: nil, displayModalProgress: displayModalProgress) }, openReplyThreadOriginalMessage: { [weak self] message in guard let strongSelf = self else { @@ -4637,7 +4693,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self else { return } - + let _ = strongSelf.presentVoiceMessageDiscardAlert(action: { let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Messages.Message(id: id)) |> mapToSignal { message -> Signal in @@ -4658,23 +4714,23 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self else { return } - + strongSelf.chatDisplayNode.dismissInput() - + if draw { let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Messages.Message(id: messageId)) |> deliverOnMainQueue).startStandalone(next: { [weak self] message in guard let strongSelf = self, let message = message else { return } - + var mediaReference: AnyMediaReference? for m in message.media { if let image = m as? TelegramMediaImage { mediaReference = AnyMediaReference.standalone(media: image) } } - + if let mediaReference = mediaReference, let peer = message.peers[message.id.peerId] { let inputText = strongSelf.presentationInterfaceState.interfaceState.effectiveInputState.inputText legacyMediaEditor(context: strongSelf.context, peer: EnginePeer(peer), threadTitle: strongSelf.contentData?.state.threadInfo?.title, media: mediaReference, mode: .draw, initialCaption: inputText, snapshots: [], transitionCompletion: nil, getCaptionPanelView: { [weak self] in @@ -4700,12 +4756,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }, copyText: { [weak self] text in if let strongSelf = self { storeMessageTextInPasteboard(text, entities: nil) - + var infoText = presentationData.strings.Conversation_TextCopied if let peerId = strongSelf.chatLocation.peerId, peerId.isVerificationCodes && text.rangeOfCharacter(from: CharacterSet.decimalDigits.inverted) == nil { infoText = presentationData.strings.Conversation_CodeCopied } - + let presentationData = context.sharedContext.currentPresentationData.with { $0 } strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .copy(text: infoText), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return true @@ -4714,7 +4770,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }, displayUndo: { [weak self] content in if let strongSelf = self { let presentationData = context.sharedContext.currentPresentationData.with { $0 } - + strongSelf.window?.forEachController({ controller in if let controller = controller as? UndoOverlayController { controller.dismiss() @@ -4726,7 +4782,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } return true }) - + strongSelf.present(UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: false, animateInAsReplacement: false, action: { _ in return true }), in: .current) @@ -4749,9 +4805,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self, let peer = strongSelf.presentationInterfaceState.renderedPeer?.chatMainPeer, peer.id != strongSelf.context.account.peerId else { return } - + strongSelf.context.account.updateLocalInputActivity(peerId: PeerActivitySpace(peerId: messageId.peerId, category: .global), activity: .interactingWithEmoji(emoticon: emoji, messageId: messageId, interaction: interaction), isPresent: true) - + let currentTimestamp = Int32(Date().timeIntervalSince1970) let _ = (ApplicationSpecificNotice.getInteractiveEmojiSyncTip(accountManager: strongSelf.context.sharedContext.accountManager) |> deliverOnMainQueue).startStandalone(next: { [weak self] count, timestamp in @@ -4775,7 +4831,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let strongSelf = self { if !responded { strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: file, loop: true, title: nil, text: strongSelf.presentationData.strings.Conversation_InteractiveEmojiSyncTip(EnginePeer(peer).compactDisplayTitle).string, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false }), in: .current) - + let _ = ApplicationSpecificNotice.incrementInteractiveEmojiSyncTip(accountManager: strongSelf.context.sharedContext.accountManager, timestamp: currentTimestamp).startStandalone() } } @@ -4798,7 +4854,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let _ = updatePresentationThemeSettingsInteractively(accountManager: strongSelf.context.sharedContext.accountManager, { current in return current.withUpdatedLargeEmoji(true) }).startStandalone() - + strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .emoji(name: "TwoFactorSetupRememberSuccess", text: strongSelf.presentationData.strings.Conversation_LargeEmojiEnabled), elevatedLayout: false, action: { _ in return false }), in: .current) }) ]), ActionSheetItemGroup(items: [ @@ -4822,18 +4878,18 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let self else { return } - + var message: Message? if let historyMessage = self.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId)?._asMessage() { message = historyMessage } else if let panelMessage = self.chatDisplayNode.adPanelMessage, panelMessage.id == messageId { message = panelMessage } - + guard let message, let adAttribute = message.adAttribute else { return } - + var progress = progress if progress == nil { self.chatDisplayNode.historyNode.forEachVisibleMessageItemNode { itemView in @@ -4842,7 +4898,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + self.chatDisplayNode.adMessagesContext?.markAction(opaqueId: adAttribute.opaqueId, media: media, fullscreen: fullscreen) self.controllerInteraction?.openUrl(ChatControllerInteraction.OpenUrl(url: adAttribute.url, concealed: false, external: true, progress: progress)) }, adContextAction: { [weak self] message, sourceNode, gesture in @@ -4874,22 +4930,22 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let botName = self.presentationInterfaceState.renderedPeer?.peer.flatMap { EnginePeer($0) }?.compactDisplayTitle ?? "" let context = self.context let peerId = self.chatLocation.peerId - + let presentConfirmation: (String, Bool, @escaping () -> Void) -> Void = { [weak self] peerName, isChannel, completion in guard let strongSelf = self else { return } - + var attributedTitle: NSAttributedString? let attributedText: NSAttributedString - + let theme = AlertControllerTheme(presentationData: strongSelf.presentationData) if case .user = peerType { attributedTitle = nil attributedText = NSAttributedString(string: strongSelf.presentationData.strings.RequestPeer_SelectionConfirmationTitle(peerName, botName).string, font: Font.medium(17.0), textColor: theme.primaryColor, paragraphAlignment: .center) } else { attributedTitle = NSAttributedString(string: strongSelf.presentationData.strings.RequestPeer_SelectionConfirmationTitle(peerName, botName).string, font: Font.semibold(17.0), textColor: theme.primaryColor, paragraphAlignment: .center) - + var botAdminRights: TelegramChatAdminRights? switch peerType { case let .group(group): @@ -4929,13 +4985,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + let controller = richTextAlertController(context: context, title: attributedTitle, text: attributedText, actions: [TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.RequestPeer_SelectionConfirmationSend, action: { completion() })]) strongSelf.present(controller, in: .window(.root)) } - + if case let .user(requestUser) = peerType, maxQuantity > 1, requestUser.isBot == nil && requestUser.isPremium == nil { let presentationData = self.presentationData var reachedLimitImpl: ((Int32) -> Void)? @@ -4956,7 +5012,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G HapticFeedback().error() controller.present(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: presentationData.strings.RequestPeer_ReachedMaximum(limit), timeout: nil, customUndoText: nil), elevatedLayout: true, position: .bottom, animateInAsReplacement: false, action: { _ in return false }), in: .current) } - + let _ = (controller.result |> deliverOnMainQueue).startStandalone(next: { [weak controller] result in guard let controller else { @@ -4975,14 +5031,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let _ = context.engine.peers.sendBotRequestedPeer(messageId: messageId, buttonId: buttonId, requestedPeerIds: peerIds).startStandalone() controller.dismiss() }) - + self.push(controller) } else { var createNewGroupImpl: (() -> Void)? let controller = self.context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(context: self.context, filter: [.excludeRecent, .doNotSearchMessages], requestPeerType: [peerType], hasContactSelector: false, createNewGroup: { createNewGroupImpl?() }, multipleSelection: maxQuantity > 1, multipleSelectionLimit: maxQuantity > 1 ? maxQuantity : nil, hasCreation: true, immediatelyActivateMultipleSelection: maxQuantity > 1)) - + controller.peerSelected = { [weak self, weak controller] peer, _ in guard let strongSelf = self else { return @@ -5054,7 +5110,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let file else { return } - + let disposable: MetaDisposable if let current = self.saveMediaDisposable { disposable = current @@ -5083,7 +5139,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.present(UndoOverlayController(presentationData: self.presentationData, content: .actionSucceeded(title: nil, text: self.presentationData.strings.ReportAd_Hidden, cancel: nil, destructive: false), elevatedLayout: false, action: { _ in return true }), in: .current) - + var adOpaqueId: Data? self.chatDisplayNode.historyNode.forEachVisibleMessageItemNode { itemView in if let adAttribute = itemView.item?.message.adAttribute { @@ -5154,13 +5210,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return false }) self.present(controller, in: .current) - + })) }, openPremiumStatusInfo: { [weak self] peerId, sourceView, peerStatus, nameColor in guard let self else { return } - + let context = self.context let source: Signal if let peerStatus { @@ -5174,7 +5230,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G break } } - + if let reference { return context.engine.stickers.loadedStickerPack(reference: reference, forceActualized: false) |> filter { result in @@ -5202,7 +5258,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { source = .single(.profile(peerId)) } - + let _ = (source |> deliverOnMainQueue).startStandalone(next: { [weak self] source in guard let self else { @@ -5210,7 +5266,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } let controller = PremiumIntroScreen(context: self.context, source: source) controller.sourceView = sourceView - controller.containerView = self.navigationController?.view + controller.containerView = self.navigationController?.view let animationColor: UIColor switch nameColor { case let .preset(nameColor): @@ -5221,15 +5277,15 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G controller.animationColor = animationColor self.push(controller) }) - + }, openRecommendedChannelContextMenu: { [weak self] peer, sourceView, gesture in guard let self else { return } - + let chatController = self.context.sharedContext.makeChatController(context: self.context, chatLocation: .peer(id: peer.id), subject: nil, botStart: nil, mode: .standard(.previewing), params: nil) chatController.canReadHistory.set(false) - + var items: [ContextMenuItem] = [ .action(ContextMenuActionItem(text: self.presentationData.strings.Conversation_LinkDialogOpen, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ImageEnlarge"), color: theme.actionSheet.primaryTextColor) }, action: { [weak self] _, f in f(.dismissWithoutContent) @@ -5238,7 +5294,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G ] items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_SimilarChannels_Join, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Add"), color: theme.actionSheet.primaryTextColor) }, action: { [weak self] _, f in f(.dismissWithoutContent) - + guard let self else { return } @@ -5276,11 +5332,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.present(textAlertController(context: context, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) })) }))) - + self.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() - + self.canReadHistory.set(false) - + let contextController = makeContextController(presentationData: self.presentationData, source: .controller(ChatContextControllerContentSourceImpl(controller: chatController, sourceView: sourceView, passthroughTouches: true)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture) contextController.dismissed = { [weak self] in self?.canReadHistory.set(true) @@ -5350,7 +5406,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } let premiumOptions = giftOptions.filter { $0.users == 1 }.map { CachedPremiumGiftOption(months: $0.months, currency: $0.currency, amount: $0.amount, botUrl: "", storeProductId: $0.storeProductId) } - + var hasBirthday = false if let cachedUserData = self.contentData?.state.peerView?.cachedData as? CachedUserData { hasBirthday = hasBirthdayToday(cachedData: cachedUserData) @@ -5367,18 +5423,18 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let self, let peer = self.presentationInterfaceState.renderedPeer?.peer.flatMap(EnginePeer.init) else { return } - + if let removePaidMessageFeeData = self.presentationInterfaceState.removePaidMessageFeeData { guard let chatPeer = self.presentationInterfaceState.renderedPeer?.chatOrMonoforumMainPeer else { return } - + let _ = (self.context.engine.peers.getPaidMessagesRevenue(scopePeerId: peer.id, peerId: removePaidMessageFeeData.peer.id) |> deliverOnMainQueue).start(next: { [weak self] revenue in guard let self else { return } - + let controller = chatMessageRemovePaymentAlertController( context: self.context, presentationData: self.presentationData, @@ -5471,7 +5527,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.present(UndoOverlayController(presentationData: self.presentationData, content: .universal(animation: "story_expired", scale: 0.066, colors: [:], title: nil, text: self.presentationData.strings.Story_TooltipExpired, customUndoText: nil, timeout: nil), elevatedLayout: false, action: { _ in return true }), in: .current) return } - + let storyContent = SingleStoryContentContextImpl(context: self.context, storyId: storyId, readGlobally: true) let _ = (storyContent.state |> take(1) @@ -5479,7 +5535,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let self else { return } - + var transitionIn: StoryContainerScreen.TransitionIn? for i in 0 ..< 2 { if transitionIn != nil { @@ -5492,7 +5548,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } } - + if let result = itemNode.targetForStoryTransition(id: storyId) { transitionIn = StoryContainerScreen.TransitionIn( sourceView: result, @@ -5504,7 +5560,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + let storyContainerScreen = StoryContainerScreen( context: self.context, content: storyContent, @@ -5514,7 +5570,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return nil } let storyId = StoryId(peerId: peerId, id: storyIdId) - + var transitionOut: StoryContainerScreen.TransitionOut? for i in 0 ..< 2 { if transitionOut != nil { @@ -5527,7 +5583,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } } - + if let result = itemNode.targetForStoryTransition(id: storyId) { result.isHidden = true transitionOut = StoryContainerScreen.TransitionOut( @@ -5561,7 +5617,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + return transitionOut } ) @@ -5623,14 +5679,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G var incompletedIds: [Int32] = [] if value { completedIds.append(itemId) - + if self.selectPollOptionFeedback == nil { self.selectPollOptionFeedback = HapticFeedback() } self.selectPollOptionFeedback?.success() } else { incompletedIds.append(itemId) - + if self.selectPollOptionFeedback == nil { self.selectPollOptionFeedback = HapticFeedback() } @@ -5639,7 +5695,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let signal = self.context.engine.messages.requestUpdateTodoMessageItems(messageId: messageId, completedIds: completedIds, incompletedIds: incompletedIds) disposables.set((signal |> deliverOnMainQueue).startStrict(next: { todo in - + }, error: { _ in }), forKey: messageId) @@ -5648,7 +5704,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } self.dismissAllTooltips() - + if let message = self.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId)?._asMessage(), let _ = message.forwardInfo { let controller = UndoOverlayController( presentationData: self.presentationData, @@ -5660,7 +5716,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.present(controller, in: .current) return } - + guard self.presentationInterfaceState.subject != .scheduledMessages else { self.present(textAlertController(context: self.context, updatedPresentationData: self.updatedPresentationData, title: nil, text: self.presentationData.strings.ScheduledMessages_TodoUnavailable, actions: [TextAlertAction(type: .defaultAction, title: self.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) return @@ -5704,13 +5760,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G var canChange = false var canEdit = false let chatParticipant = Promise() - + if let channel = chatPeer as? TelegramChannel { canEdit = channel.hasPermission(.manageRanks) && role == .member - + if let defaultBannedRights = channel.defaultBannedRights { canChange = !defaultBannedRights.flags.contains(.banEditRank) - + if canChange { chatParticipant.set(self.context.engine.peers.fetchChannelParticipant(peerId: chatPeer.id, participantId: self.context.account.peerId)) } @@ -5728,7 +5784,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G canChange = !defaultBannedRights.flags.contains(.banEditRank) } } - + let openEdit: (EnginePeer.Id, String?, ChatRankInfoScreenRole) -> Void = { [weak self] peerId, rank, role in guard let self else { return @@ -5736,7 +5792,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let controller = self.context.sharedContext.makeChatCustomRankSetupScreen(context: self.context, peerId: chatPeer.id, participantId: peerId, rank: rank, role: role) self.push(controller) } - + if canEdit { if chatPeer is TelegramChannel { let _ = (self.context.engine.peers.fetchChannelParticipant(peerId: chatPeer.id, participantId: peer.id) @@ -5804,9 +5860,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.displayPollRestrictedToast(messageId: messageId) }, automaticMediaDownloadSettings: self.automaticMediaDownloadSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: self.stickerSettings, presentationContext: ChatPresentationContext(context: context, backgroundNode: self.chatBackgroundNode)) controllerInteraction.enableFullTranslucency = context.sharedContext.energyUsageSettings.fullTranslucency - + self.controllerInteraction = controllerInteraction - + self.navigationBar?.allowsCustomTransition = { [weak self] in guard let strongSelf = self else { return false @@ -5819,9 +5875,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } return true } - + self.chatTitleView = ChatNavigationBarTitleView(frame: CGRect()) - + self.navigationItem.titleView = self.chatTitleView self.chatTitleView?.longTapAction = { [weak self] in if let strongSelf = self, let peerView = strongSelf.contentData?.state.peerView, let peer = peerView.peers[peerView.peerId], peer.restrictionText(platform: "ios", contentSettings: strongSelf.context.currentContentSettings.with { $0 }) == nil && !strongSelf.presentationInterfaceState.isNotAccessible { @@ -5831,7 +5887,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + let chatInfoButtonItem: UIBarButtonItem switch chatLocation { case .peer, .replyThread: @@ -5840,7 +5896,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self, let peer = strongSelf.presentationInterfaceState.renderedPeer?.chatMainPeer else { return } - + let items: Signal<[ContextMenuItem], NoError> switch chatLocation { case .peer: @@ -5849,7 +5905,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G ) |> map { canViewStats -> [ContextMenuItem] in var items: [ContextMenuItem] = [] - + let openText = strongSelf.presentationData.strings.Conversation_ContextMenuOpenProfile items.append(.action(ContextMenuActionItem(text: openText, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.actionSheet.primaryTextColor) @@ -5857,7 +5913,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G f(.dismissWithoutContent) self?.navigationButtonAction(.openChatInfo(expandAvatar: true, section: nil)) }))) - + if canViewStats { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.ChannelInfo_Stats, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Statistics"), color: theme.actionSheet.primaryTextColor) @@ -5867,7 +5923,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } strongSelf.view.endEditing(true) - + let statsController: ViewController if let channel = peer as? TelegramChannel, case .group = channel.info { statsController = groupStatsController(context: context, updatedPresentationData: strongSelf.updatedPresentationData, peerId: peer.id) @@ -5883,13 +5939,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G f(.dismissWithoutContent) self?.interfaceInteraction?.beginMessageSearch(.everything, "") }))) - + return items } case let .replyThread(message): let peerId = peer.id let threadId = message.threadId - + items = context.engine.data.get( TelegramEngine.EngineData.Item.Peer.NotificationSettings(id: peerId), TelegramEngine.EngineData.Item.Peer.ThreadData(id: peer.id, threadId: threadId), @@ -5902,9 +5958,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let threadData = threadData else { return [] } - + var items: [ContextMenuItem] = [] - + var isMuted = false switch threadData.notificationSettings.muteState { case .muted: @@ -5922,7 +5978,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } isMuted = peerIsMuted } - + if !"".isEmpty { items.append(.action(ContextMenuActionItem(text: isMuted ? presentationData.strings.ChatList_Context_Unmute : presentationData.strings.ChatList_Context_Mute, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: isMuted ? "Chat/Context Menu/Unmute" : "Chat/Context Menu/Muted"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in if isMuted { @@ -5932,46 +5988,46 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) } else { var items: [ContextMenuItem] = [] - + items.append(.action(ContextMenuActionItem(text: presentationData.strings.PeerInfo_MuteFor, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Mute2d"), color: theme.contextMenu.primaryColor) }, action: { c, _ in var subItems: [ContextMenuItem] = [] - + let presetValues: [Int32] = [ 1 * 60 * 60, 8 * 60 * 60, 1 * 24 * 60 * 60, 7 * 24 * 60 * 60 ] - + for value in presetValues { subItems.append(.action(ContextMenuActionItem(text: muteForIntervalString(strings: presentationData.strings, value: value), icon: { _ in return nil }, action: { _, f in f(.default) - + let _ = context.engine.peers.updatePeerMuteSetting(peerId: peerId, threadId: threadId, muteInterval: value).startStandalone() - + self?.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_mute_for", scale: 0.066, colors: [:], title: nil, text: presentationData.strings.PeerInfo_TooltipMutedFor(mutedForTimeIntervalString(strings: presentationData.strings, value: value)).string, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) }))) } - + subItems.append(.action(ContextMenuActionItem(text: presentationData.strings.PeerInfo_MuteForCustom, icon: { _ in return nil }, action: { _, f in f(.default) - + // if let chatListController = chatListController { // openCustomMute(context: context, peerId: peerId, threadId: threadId, baseController: chatListController) // } }))) - + c?.setItems(.single(ContextController.Items(content: .list(subItems))), minHeight: nil, animated: true) }))) - + items.append(.separator) - + var isSoundEnabled = true switch threadData.notificationSettings.messageSound { case .none: @@ -5979,15 +6035,15 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G default: break } - + if case .muted = threadData.notificationSettings.muteState { items.append(.action(ContextMenuActionItem(text: presentationData.strings.PeerInfo_ButtonUnmute, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/SoundOn"), color: theme.contextMenu.primaryColor) }, action: { _, f in f(.default) - + let _ = context.engine.peers.updatePeerMuteSetting(peerId: peerId, threadId: threadId, muteInterval: nil).startStandalone() - + let iconColor: UIColor = .white self?.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_profileunmute", scale: 0.075, colors: [ "Middle.Group 1.Fill 1": iconColor, @@ -6002,9 +6058,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/SoundOn"), color: theme.contextMenu.primaryColor) }, action: { _, f in f(.default) - + let _ = context.engine.peers.updatePeerNotificationSoundInteractive(peerId: peerId, threadId: threadId, sound: .default).startStandalone() - + self?.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_sound_on", scale: 0.056, colors: [:], title: nil, text: presentationData.strings.PeerInfo_TooltipSoundEnabled, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) }))) } else { @@ -6012,18 +6068,18 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/SoundOff"), color: theme.contextMenu.primaryColor) }, action: { _, f in f(.default) - + let _ = context.engine.peers.updatePeerNotificationSoundInteractive(peerId: peerId, threadId: threadId, sound: .none).startStandalone() - + self?.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_sound_off", scale: 0.056, colors: [:], title: nil, text: presentationData.strings.PeerInfo_TooltipSoundDisabled, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) }))) } - + items.append(.action(ContextMenuActionItem(text: presentationData.strings.PeerInfo_NotificationsCustomize, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Customize"), color: theme.contextMenu.primaryColor) }, action: { _, f in f(.dismissWithoutContent) - + let _ = (context.engine.data.get( TelegramEngine.EngineData.Item.NotificationSettings.Global() ) @@ -6031,40 +6087,40 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let updatePeerSound: (PeerId, PeerMessageSound) -> Signal = { peerId, sound in return context.engine.peers.updatePeerNotificationSoundInteractive(peerId: peerId, threadId: threadId, sound: sound) |> deliverOnMainQueue } - + let updatePeerNotificationInterval: (PeerId, Int32?) -> Signal = { peerId, muteInterval in return context.engine.peers.updatePeerMuteSetting(peerId: peerId, threadId: threadId, muteInterval: muteInterval) |> deliverOnMainQueue } - + let updatePeerDisplayPreviews: (PeerId, PeerNotificationDisplayPreviews) -> Signal = { peerId, displayPreviews in return context.engine.peers.updatePeerDisplayPreviewsSetting(peerId: peerId, threadId: threadId, displayPreviews: displayPreviews) |> deliverOnMainQueue } - + let updatePeerStoriesMuted: (PeerId, PeerStoryNotificationSettings.Mute) -> Signal = { peerId, mute in return context.engine.peers.updatePeerStoriesMutedSetting(peerId: peerId, mute: mute) |> deliverOnMainQueue } - + let updatePeerStoriesHideSender: (PeerId, PeerStoryNotificationSettings.HideSender) -> Signal = { peerId, hideSender in return context.engine.peers.updatePeerStoriesHideSenderSetting(peerId: peerId, hideSender: hideSender) |> deliverOnMainQueue } - + let updatePeerStorySound: (PeerId, PeerMessageSound) -> Signal = { peerId, sound in return context.engine.peers.updatePeerStorySoundInteractive(peerId: peerId, sound: sound) |> deliverOnMainQueue } - + let defaultSound: PeerMessageSound - + if case .broadcast = channel.info { defaultSound = globalSettings.channels.sound._asMessageSound() } else { defaultSound = globalSettings.groupChats.sound._asMessageSound() } - + let canRemove = false - + let exceptionController = notificationPeerExceptionController(context: context, updatedPresentationData: nil, peer: .channel(channel), threadId: threadId, isStories: nil, canRemove: canRemove, defaultSound: defaultSound, defaultStoriesSound: defaultSound, edit: true, updatePeerSound: { peerId, sound in let _ = (updatePeerSound(peerId, sound) |> deliverOnMainQueue).startStandalone(next: { _ in @@ -6086,7 +6142,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }, updatePeerDisplayPreviews: { peerId, displayPreviews in let _ = (updatePeerDisplayPreviews(peerId, displayPreviews) |> deliverOnMainQueue).startStandalone(next: { _ in - + }) }, updatePeerStoriesMuted: { peerId, mute in let _ = (updatePeerStoriesMuted(peerId, mute) @@ -6104,14 +6160,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self?.push(exceptionController) }) }))) - + items.append(.action(ContextMenuActionItem(text: presentationData.strings.PeerInfo_MuteForever, textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Muted"), color: theme.contextMenu.destructiveColor) }, action: { _, f in f(.default) - + let _ = context.engine.peers.updatePeerMuteSetting(peerId: peerId, threadId: threadId, muteInterval: Int32.max).startStandalone() - + let iconColor: UIColor = .white self?.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_profilemute", scale: 0.075, colors: [ "Middle.Group 1.Fill 1": iconColor, @@ -6121,19 +6177,19 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G "Line.Group 1.Stroke 1": iconColor ], title: nil, text: presentationData.strings.PeerInfo_TooltipMutedForever, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) }))) - + c?.setItems(.single(ContextController.Items(content: .list(items))), minHeight: nil, animated: true) } }))) } - + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Conversation_Search, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Search"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in f(.dismissWithoutContent) self?.interfaceInteraction?.beginMessageSearch(.everything, "") }))) - + if threadId != 1 { var canOpenClose = false if channel.flags.contains(.isCreator) { @@ -6146,7 +6202,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if canOpenClose { items.append(.action(ContextMenuActionItem(text: threadData.isClosed ? presentationData.strings.ChatList_Context_ReopenTopic : presentationData.strings.ChatList_Context_CloseTopic, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: threadData.isClosed ? "Chat/Context Menu/Play": "Chat/Context Menu/Pause"), color: theme.contextMenu.primaryColor) }, action: { _, f in f(.default) - + let _ = context.engine.peers.setForumChannelTopicClosed(id: peer.id, threadId: threadId, isClosed: !threadData.isClosed).startStandalone() }))) } @@ -6157,11 +6213,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G default: items = .single([]) } - + strongSelf.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() - + strongSelf.canReadHistory.set(false) - + let source: ContextContentSource if let peer = strongSelf.presentationInterfaceState.renderedPeer?.chatMainPeer, peer.smallProfileImage != nil { let galleryController = AvatarGalleryController(context: strongSelf.context, peer: EnginePeer(peer), remoteEntries: nil, replaceRootController: { controller, ready in @@ -6171,14 +6227,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { source = .reference(ChatControllerContextReferenceContentSource(controller: strongSelf, sourceView: node.view, insets: .zero)) } - + let contextController = makeContextController(presentationData: strongSelf.presentationData, source: source, items: items |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) contextController.dismissed = { [weak self] in self?.canReadHistory.set(true) } strongSelf.presentInGlobalOverlay(contextController) } - + chatInfoButtonItem = UIBarButtonItem(customDisplayNode: avatarNode)! self.avatarNode = avatarNode case .customChatContents: @@ -6187,7 +6243,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G chatInfoButtonItem.target = self chatInfoButtonItem.action = #selector(self.rightNavigationButtonAction) self.chatInfoNavigationButton = ChatNavigationButton(action: .openChatInfo(expandAvatar: true, section: nil), buttonItem: chatInfoButtonItem) - + self.moreBarButton.setContent(.more(MoreHeaderButton.optionsCircleImage(color: self.presentationData.theme.rootController.navigationBar.buttonColor))) self.moreInfoNavigationButton = ChatNavigationButton(action: .toggleInfoPanel, buttonItem: UIBarButtonItem(customDisplayNode: self.moreBarButton)!) self.moreBarButton.contextAction = { [weak self] sourceNode, gesture in @@ -6197,7 +6253,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let peerId = self.chatLocation.peerId else { return } - + if peerId == self.context.account.peerId { PeerInfoScreenImpl.openSavedMessagesMoreMenu(context: self.context, sourceController: self, isViewingAsTopics: false, sourceView: self.navigationBar?.navigationButtonContextContainer(sourceView: sourceNode.view) ?? sourceNode.view, gesture: gesture) } else if peerId.namespace == Namespaces.Peer.CloudUser { @@ -6207,12 +6263,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } self.moreBarButton.addTarget(self, action: #selector(self.moreButtonPressed), forControlEvents: .touchUpInside) - + self.navigationItem.titleView = self.chatTitleView self.chatTitleView?.tapAction = { [weak self] in self?.navigationButtonAction(.openChatInfo(expandAvatar: false, section: nil)) } - + self.updateChatPresentationInterfaceState(animated: false, interactive: false, { state in if let botStart = botStart, case .interactive = botStart.behavior { return state.updatedBotStartPayload(botStart.payload) @@ -6220,7 +6276,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return state } }) - + self.accountPeerDisposable = (context.account.postbox.peerView(id: context.account.peerId) |> deliverOnMainQueue).startStrict(next: { [weak self] peerView in if let strongSelf = self { @@ -6230,7 +6286,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) } }) - + if let chatPeerId = chatLocation.peerId { self.nameColorDisposable = (context.engine.data.subscribe( TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId), @@ -6268,9 +6324,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) }) } - + self.reloadChatLocation(chatLocation: self.chatLocation, chatLocationContextHolder: self.chatLocationContextHolder, historyNode: self.chatDisplayNode.historyNode, apply: { $0(nil) }) - + self.botCallbackAlertMessageDisposable = (self.botCallbackAlertMessage.get() |> deliverOnMainQueue).startStrict(next: { [weak self] message in if let strongSelf = self { @@ -6317,14 +6373,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) } }) - + self.audioRecorderDisposable = (self.audioRecorder.get() |> deliverOnMainQueue).startStrict(next: { [weak self] audioRecorder in if let strongSelf = self { if strongSelf.audioRecorderValue !== audioRecorder { strongSelf.audioRecorderValue = audioRecorder strongSelf.lockOrientation = audioRecorder != nil - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInputTextPanelState { panelState in let isLocked = strongSelf.lockMediaRecordingRequestId == strongSelf.beginMediaRecordingRequestId @@ -6342,7 +6398,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }) strongSelf.audioRecorderStatusDisposable?.dispose() - + if let audioRecorder = audioRecorder { if !audioRecorder.beginWithTone { strongSelf.recorderFeedback?.impact(.light) @@ -6352,7 +6408,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G |> deliverOnMainQueue).startStrict(next: { [weak self] value in if let self, case .stopped = value { if self.presentationInterfaceState.interfaceState.mediaDraftState != nil { - + } else { self.stopMediaRecorder() } @@ -6365,14 +6421,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } }) - + self.videoRecorderDisposable = (self.videoRecorder.get() |> deliverOnMainQueue).startStrict(next: { [weak self] videoRecorder in if let strongSelf = self { if strongSelf.videoRecorderValue !== videoRecorder { let previousVideoRecorderValue = strongSelf.videoRecorderValue strongSelf.videoRecorderValue = videoRecorder - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInputTextPanelState { panelState in if let videoRecorder = videoRecorder { @@ -6386,10 +6442,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return panelState } }) - + if let videoRecorder { strongSelf.recorderFeedback?.impact(.light) - + videoRecorder.onStop = { if let strongSelf = self { strongSelf.dismissMediaRecorder(.pause) @@ -6397,24 +6453,24 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } strongSelf.chatDisplayNode.setVideoRecorder(backgroundView: videoRecorder.backgroundView) strongSelf.present(videoRecorder, in: .window(.root)) - + if strongSelf.lockMediaRecordingRequestId == strongSelf.beginMediaRecordingRequestId { videoRecorder.lockVideoRecording() } } strongSelf.updateDownButtonVisibility() - + if let previousVideoRecorderValue = previousVideoRecorderValue { previousVideoRecorderValue.discardVideo() } } } }) - + if let botStart = botStart, case .automatic = botStart.behavior { self.startBot(botStart.payload) } - + let activitySpace: PeerActivitySpace? switch self.chatLocation { case let .peer(peerId): @@ -6424,15 +6480,18 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G case .customChatContents: activitySpace = nil } - + if let activitySpace = activitySpace { self.inputActivityDisposable = (self.typingActivityPromise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in - if let strongSelf = self, strongSelf.presentationInterfaceState.interfaceState.editMessage == nil && strongSelf.presentationInterfaceState.subject != .scheduledMessages && strongSelf.presentationInterfaceState.currentSendAsPeerId == nil { + guard let strongSelf = self else { return } + let s = currentWinterGramSettings + if s.ghostModeEnabled && !s.sendUploadProgress { return } + if strongSelf.presentationInterfaceState.interfaceState.editMessage == nil && strongSelf.presentationInterfaceState.subject != .scheduledMessages && strongSelf.presentationInterfaceState.currentSendAsPeerId == nil { strongSelf.context.account.updateLocalInputActivity(peerId: activitySpace, activity: .typingText, isPresent: value) } }) - + self.choosingStickerActivityDisposable = (self.choosingStickerActivityPromise.get() |> mapToSignal { value -> Signal in if value { @@ -6449,7 +6508,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.context.account.updateLocalInputActivity(peerId: activitySpace, activity: .choosingSticker, isPresent: value) } }) - + self.recordingActivityDisposable = (self.recordingActivityPromise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in if let strongSelf = self, strongSelf.presentationInterfaceState.interfaceState.editMessage == nil && strongSelf.presentationInterfaceState.subject != .scheduledMessages && strongSelf.presentationInterfaceState.currentSendAsPeerId == nil { @@ -6465,10 +6524,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }) } - + let chatTheme: Signal = self.chatThemePromise.get() |> distinctUntilChanged - + let uploadingChatWallpaper: Signal if let peerId = self.chatLocation.peerId { uploadingChatWallpaper = self.context.account.pendingPeerMediaUploadManager.uploadingPeerMedia @@ -6483,13 +6542,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { uploadingChatWallpaper = .single(nil) } - + let chatWallpaper: Signal = combineLatest(self.chatWallpaperPromise.get(), uploadingChatWallpaper) |> map { chatWallpaper, uploadingChatWallpaper in return uploadingChatWallpaper ?? chatWallpaper } |> distinctUntilChanged - + let themeSettings = context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.presentationThemeSettings]) |> map { sharedData -> PresentationThemeSettings in let themeSettings: PresentationThemeSettings @@ -6500,7 +6559,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } return themeSettings } - + let accountManager = context.sharedContext.accountManager let currentChatTheme = Atomic<(ChatTheme?, Bool)?>(value: nil) self.presentationDataDisposable = combineLatest( @@ -6514,13 +6573,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G ).startStrict(next: { [weak self] presentationData, themeSettings, chatThemes, chatTheme, chatThemeAndDarkAppearance, chatWallpaper in if let strongSelf = self { let (chatThemePreview, darkAppearancePreview) = chatThemeAndDarkAppearance - + var chatWallpaper = chatWallpaper - + let previousTheme = strongSelf.presentationData.theme let previousStrings = strongSelf.presentationData.strings let previousChatWallpaper = strongSelf.presentationData.chatWallpaper - + var chatTheme = chatTheme if let chatThemePreview { if !chatThemePreview.isEmpty { @@ -6535,7 +6594,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if strongSelf.chatLocation.peerId == strongSelf.context.account.peerId { chatTheme = nil } - + var presentationData = presentationData var useDarkAppearance = presentationData.theme.overallDarkAppearance @@ -6563,7 +6622,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let theme = makePresentationTheme(cloudTheme: theme, dark: useDarkAppearance) { theme.forceSync = true presentationData = presentationData.withUpdated(theme: theme).withUpdated(chatWallpaper: theme.chat.defaultWallpaper) - + Queue.mainQueue().after(1.0, { theme.forceSync = false }) @@ -6576,7 +6635,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let theme = makePresentationTheme(chatTheme: chatTheme, dark: useDarkAppearance) { theme.forceSync = true presentationData = presentationData.withUpdated(theme: theme).withUpdated(chatWallpaper: theme.chat.defaultWallpaper) - + Queue.mainQueue().after(1.0, { theme.forceSync = false }) @@ -6586,51 +6645,51 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G useDarkAppearance = darkAppearancePreview let lightTheme: PresentationTheme let lightWallpaper: TelegramWallpaper - + let darkTheme: PresentationTheme let darkWallpaper: TelegramWallpaper - + if presentationData.autoNightModeTriggered { darkTheme = presentationData.theme darkWallpaper = presentationData.chatWallpaper - + var currentColors = themeSettings.themeSpecificAccentColors[themeSettings.theme.index] if let colors = currentColors, colors.baseColor == .theme { currentColors = nil } - + let themeSpecificWallpaper = (themeSettings.themeSpecificChatWallpapers[coloredThemeIndex(reference: themeSettings.theme, accentColor: currentColors)] ?? themeSettings.themeSpecificChatWallpapers[themeSettings.theme.index]) - + if let themeSpecificWallpaper = themeSpecificWallpaper { lightWallpaper = themeSpecificWallpaper } else { let theme = makePresentationTheme(mediaBox: accountManager.mediaBox, themeReference: themeSettings.theme, accentColor: currentColors?.color, bubbleColors: currentColors?.customBubbleColors ?? [], wallpaper: currentColors?.wallpaper, baseColor: currentColors?.baseColor, preview: true) ?? defaultPresentationTheme lightWallpaper = theme.chat.defaultWallpaper } - + var preferredBaseTheme: TelegramBaseTheme? if let baseTheme = themeSettings.themePreferredBaseTheme[themeSettings.theme.index], [.classic, .day].contains(baseTheme) { preferredBaseTheme = baseTheme } - + lightTheme = makePresentationTheme(mediaBox: accountManager.mediaBox, themeReference: themeSettings.theme, baseTheme: preferredBaseTheme, accentColor: currentColors?.color, bubbleColors: currentColors?.customBubbleColors ?? [], wallpaper: currentColors?.wallpaper, baseColor: currentColors?.baseColor, serviceBackgroundColor: defaultServiceBackgroundColor) ?? defaultPresentationTheme } else { lightTheme = presentationData.theme lightWallpaper = presentationData.chatWallpaper - + let automaticTheme = themeSettings.automaticThemeSwitchSetting.theme let effectiveColors = themeSettings.themeSpecificAccentColors[automaticTheme.index] let themeSpecificWallpaper = (themeSettings.themeSpecificChatWallpapers[coloredThemeIndex(reference: automaticTheme, accentColor: effectiveColors)] ?? themeSettings.themeSpecificChatWallpapers[automaticTheme.index]) - + var preferredBaseTheme: TelegramBaseTheme? if let baseTheme = themeSettings.themePreferredBaseTheme[automaticTheme.index], [.night, .tinted].contains(baseTheme) { preferredBaseTheme = baseTheme } else { preferredBaseTheme = .night } - + darkTheme = makePresentationTheme(mediaBox: accountManager.mediaBox, themeReference: automaticTheme, baseTheme: preferredBaseTheme, accentColor: effectiveColors?.color, bubbleColors: effectiveColors?.customBubbleColors ?? [], wallpaper: effectiveColors?.wallpaper, baseColor: effectiveColors?.baseColor, serviceBackgroundColor: defaultServiceBackgroundColor) ?? defaultPresentationTheme - + if let themeSpecificWallpaper = themeSpecificWallpaper { darkWallpaper = themeSpecificWallpaper } else { @@ -6648,7 +6707,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + if darkAppearancePreview { darkTheme.forceSync = true Queue.mainQueue().after(1.0, { @@ -6664,25 +6723,25 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + if let forcedWallpaper = strongSelf.forcedWallpaper { presentationData = presentationData.withUpdated(chatWallpaper: forcedWallpaper) } else if let chatWallpaper { presentationData = presentationData.withUpdated(chatWallpaper: chatWallpaper) } - + let isFirstTime = !strongSelf.didSetPresentationData strongSelf.presentationData = presentationData strongSelf.didSetPresentationData = true - + let previousChatTheme = currentChatTheme.swap((chatTheme, useDarkAppearance)) - + if isFirstTime || previousTheme != presentationData.theme || previousStrings !== presentationData.strings || presentationData.chatWallpaper != previousChatWallpaper { strongSelf.themeAndStringsUpdated() - + controllerInteraction.updatedPresentationData = strongSelf.updatedPresentationData strongSelf.presentationDataPromise.set(.single(strongSelf.presentationData)) - + if !isFirstTime && (previousChatTheme?.0 != chatTheme || previousChatTheme?.1 != useDarkAppearance) { strongSelf.presentCrossfadeSnapshot() } @@ -6690,7 +6749,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.presentationReady.set(.single(true)) } }) - + self.automaticMediaDownloadSettingsDisposable = (context.sharedContext.automaticMediaDownloadSettings |> deliverOnMainQueue).startStrict(next: { [weak self] downloadSettings in if let strongSelf = self, strongSelf.automaticMediaDownloadSettings != downloadSettings { @@ -6701,7 +6760,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } }) - + self.stickerSettingsDisposable = combineLatest(queue: Queue.mainQueue(), context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.stickerSettings]), self.disableStickerAnimationsPromise.get(), @@ -6711,12 +6770,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let value = sharedData.entries[ApplicationSpecificSharedDataKeys.stickerSettings]?.get(StickerSettings.self) { stickerSettings = value } - + var disableStickerAnimations = disableStickerAnimations if hasGroupCallOnScreen { disableStickerAnimations = true } - + let chatStickerSettings = ChatInterfaceStickerSettings(stickerSettings: stickerSettings) if let strongSelf = self, strongSelf.stickerSettings != chatStickerSettings || strongSelf.disableStickerAnimationsValue != disableStickerAnimations { strongSelf.stickerSettings = chatStickerSettings @@ -6727,7 +6786,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } }) - + var wasInForeground = true self.applicationInForegroundDisposable = (context.sharedContext.applicationBindings.applicationInForeground |> distinctUntilChanged @@ -6736,7 +6795,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if !value { strongSelf.saveInterfaceState() strongSelf.raiseToListen?.applicationResignedActive() - + strongSelf.stopMediaRecorder() } else { if !wasInForeground { @@ -6746,7 +6805,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G wasInForeground = value } }) - + if case let .peer(peerId) = chatLocation, peerId.namespace == Namespaces.Peer.SecretChat { self.applicationInFocusDisposable = (context.sharedContext.applicationBindings.applicationIsActive |> distinctUntilChanged @@ -6757,9 +6816,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.chatDisplayNode.updateIsBlurred(!value) }) } - - + + self.canReadHistoryDisposable = (combineLatest( context.sharedContext.applicationBindings.applicationInForeground, self.canReadHistory.get(), @@ -6772,31 +6831,32 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.raiseToListen?.enabled = value } }) - + self.networkStateDisposable = (context.account.networkState |> deliverOnMainQueue).startStrict(next: { [weak self] state in if let strongSelf = self, case .standard(.default) = strongSelf.presentationInterfaceState.mode { strongSelf.chatTitleView?.updateNetworkState(networkState: state, transition: .spring(duration: 0.4)) } }) - + if case let .messageOptions(_, messageIds, _) = self.subject, messageIds.count > 1 { self.updateChatPresentationInterfaceState(interactive: false, { state in return state.updatedInterfaceState({ $0.withUpdatedSelectedMessages(messageIds) }) }) } } - + required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + deinit { let _ = ChatControllerCount.modify { value in return value - 1 } - + self.historyStateDisposable?.dispose() self.messageIndexDisposable.dispose() + self.winterGramPresenceTrackerDisposable.dispose() self.navigationActionDisposable.dispose() self.galleryHiddenMesageAndMediaDisposable.dispose() self.temporaryHiddenGalleryMediaDisposable.dispose() @@ -6882,33 +6942,33 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.preloadNextChatPeerIdDisposable.dispose() self.globalControlPanelsContextStateDisposable?.dispose() } - + public func updatePresentationMode(_ mode: ChatControllerPresentationMode) { self.mode = mode self.updateChatPresentationInterfaceState(animated: false, interactive: false, { return $0.updatedMode(mode) }) } - + func animateFromPreviewing(transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring)) { guard let navigationController = self.effectiveNavigationController else { return } self.mode = .standard(.default) let completion = self.dismissPreviewing?(true) - + let initialLayout = self.validLayout let initialFrame = self.view.convert(self.view.bounds, to: navigationController.view) - + navigationController.pushViewController(self, animated: false) - + let updatedLayout = self.validLayout - + if let initialLayout, let updatedLayout, transition.isAnimated { let initialView = self.view.superview let updatedFrame = self.view.convert(self.view.bounds, to: navigationController.view) navigationController.view.addSubview(self.view) - + self.view.clipsToBounds = true self.view.frame = initialFrame self.containerLayoutUpdated(initialLayout, transition: .immediate) @@ -6920,16 +6980,16 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.chatDisplayNode.inputPanelBackgroundNode.layer.removeAllAnimations() self.chatDisplayNode.inputPanelBackgroundNode.layer.animatePosition(from: CGPoint(x: 0.0, y: self.chatDisplayNode.inputPanelNode?.frame.height ?? 45.0), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.view.layer.animate(from: 14.0, to: updatedLayout.deviceMetrics.screenCornerRadius, keyPath: "cornerRadius", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.4) - + transition.updateFrame(view: self.view, frame: updatedFrame, completion: { _ in initialView?.addSubview(self.view) self.view.clipsToBounds = false - + completion?() }) transition.updateCornerRadius(layer: self.view.layer, cornerRadius: 0.0) } - + if let navigationBar = self.navigationBar { let nodes = [ navigationBar.backButtonNode, @@ -6940,20 +7000,20 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G node.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) } } - + self.canReadHistory.set(true) - + self.updateChatPresentationInterfaceState(transition: transition, interactive: false) { state in return state.updatedMode(self.mode) } } - + var chatDisplayNode: ChatControllerNode { get { return super.displayNode as! ChatControllerNode } } - + func updateStatusBarPresentation(animated: Bool = false) { if !self.galleryPresentationContext.controllers.isEmpty, let statusBarStyle = (self.galleryPresentationContext.controllers.last?.0 as? ViewController)?.statusBar.statusBarStyle { self.statusBar.updateStatusBarStyle(statusBarStyle, animated: animated) @@ -6983,7 +7043,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + func themeAndStringsUpdated() { self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) self.updateStatusBarPresentation() @@ -7006,13 +7066,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G state = state.updatedBubbleCorners(self.presentationData.chatBubbleCorners) return state }) - + self.currentContextController?.updateTheme(presentationData: self.presentationData) } - + func updateNavigationBarPresentation() { let navigationBarTheme: NavigationBarTheme - + let presentationTheme: PresentationTheme if let forcedNavigationBarTheme = self.forcedNavigationBarTheme { presentationTheme = forcedNavigationBarTheme @@ -7021,23 +7081,23 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G presentationTheme = self.presentationData.theme navigationBarTheme = NavigationBarTheme(rootControllerTheme: self.presentationData.theme, hideBackground: false, hideBadge: false, edgeEffectColor: .clear, style: .glass, glassStyle: self.presentationInterfaceState.preferredGlassType == .clear ? .clear : .default) } - + self.navigationBar?.updatePresentationData(NavigationBarPresentationData(theme: navigationBarTheme, strings: NavigationBarStrings(presentationStrings: self.presentationData.strings)), transition: .immediate) - + self.moreBarButton.updateColor(color: presentationTheme.chat.inputPanel.panelControlColor) } - + enum PinnedReferenceMessage { struct Loaded { var id: MessageId var minId: MessageId var isScrolled: Bool } - + case ready(Loaded) case loading } - + static func topPinnedScrollReferenceMessage(historyNode: ChatHistoryListNodeImpl, scrolledToMessageId: Signal) -> Signal { return combineLatest(queue: Queue.mainQueue(), scrolledToMessageId, @@ -7047,10 +7107,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let topVisibleMessageRange, topVisibleMessageRange.isLoading { return .loading } - + let bottomVisibleMessage = topVisibleMessageRange?.lowerBound.id let topVisibleMessage = topVisibleMessageRange?.upperBound.id - + if let scrolledToMessageId = scrolledToMessageId { if let topVisibleMessage, let bottomVisibleMessage { if scrolledToMessageId.allowedReplacementDirection.contains(.up) && topVisibleMessage < scrolledToMessageId.id { @@ -7065,7 +7125,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + static func topPinnedScrollMessage(context: AccountContext, chatLocation: ChatLocation, historyNode: ChatHistoryListNodeImpl, scrolledToMessageId: Signal) -> Signal { //TODO:release move to ContentData let loadState: Signal = historyNode.historyState.get() @@ -7078,9 +7138,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } |> distinctUntilChanged - + let referenceMessage = self.topPinnedScrollReferenceMessage(historyNode: historyNode, scrolledToMessageId: scrolledToMessageId) - + return loadState |> mapToSignal { loadState in if !loadState { @@ -7090,11 +7150,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + static func topPinnedMessageSignal(context: AccountContext, chatLocation: ChatLocation, referenceMessage: Signal?) -> Signal { var pinnedPeerId: EnginePeer.Id? let threadId = chatLocation.threadId - + switch chatLocation { case let .peer(id): pinnedPeerId = id @@ -7105,10 +7165,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G default: break } - + if let peerId = pinnedPeerId { let topPinnedMessage: Signal - + func pinnedHistorySignal(anchorMessageId: MessageId?, count: Int) -> Signal { let location: ChatHistoryLocation if let anchorMessageId = anchorMessageId { @@ -7116,14 +7176,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { location = .Initial(count: count) } - + let chatLocation: ChatLocation if let threadId { chatLocation = .replyThread(message: ChatReplyThreadMessage(peerId: peerId, threadId: threadId, channelMessageId: nil, isChannelPost: false, isForumPost: true, isMonoforumPost: false, maxMessage: nil, maxReadIncomingMessageId: nil, maxReadOutgoingMessageId: nil, unreadCount: 0, initialFilledHoles: IndexSet(), initialAnchor: .automatic, isNotAvailable: false)) } else { chatLocation = .peer(id: peerId) } - + return (chatHistoryViewForLocation(ChatHistoryLocationInput(content: location, id: 0), ignoreMessagesInTimestampRange: nil, ignoreMessageIds: Set(), context: context, chatLocation: chatLocation, chatLocationContextHolder: Atomic(value: nil), scheduled: false, fixedCombinedReadStates: nil, tag: .tag(MessageTags.pinned), appendMessagesFromTheSameGroup: false, additionalData: [], orderStatistics: .combinedLocation) |> castError(Bool.self) |> mapToSignal { update -> Signal in @@ -7141,12 +7201,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) |> restartIfError } - + struct TopMessage { var message: Message var index: Int } - + let topMessage = pinnedHistorySignal(anchorMessageId: nil, count: 10) |> map { update -> TopMessage? in switch update { @@ -7160,7 +7220,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { index = viewValue.entries.count - 1 } - + return TopMessage( message: entry.message, index: index @@ -7170,25 +7230,25 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + let loadCount = 10 - + struct PinnedHistory { struct PinnedMessage { var message: Message var index: Int } - + var messages: [PinnedMessage] var totalCount: Int } - + let adjustedReplyHistory: Signal if let referenceMessage { adjustedReplyHistory = (Signal { subscriber in var referenceMessageValue: PinnedReferenceMessage? var view: ChatHistoryViewUpdate? - + let updateState: () -> Void = { guard let view = view else { return @@ -7197,7 +7257,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G subscriber.putNext(PinnedHistory(messages: [], totalCount: 0)) return } - + var messages: [PinnedHistory.PinnedMessage] = [] for i in 0 ..< viewValue.entries.count { messages.append(PinnedHistory.PinnedMessage( @@ -7206,10 +7266,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G )) } let result = PinnedHistory(messages: messages, totalCount: messages.count) - + if case let .ready(loaded) = referenceMessageValue { let referenceId = loaded.id - + if viewValue.entries.count < loadCount { subscriber.putNext(result) } else if referenceId < viewValue.entries[1].message.id { @@ -7237,10 +7297,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + var initializedView = false let viewDisposable = MetaDisposable() - + let referenceDisposable = (referenceMessage |> deliverOnMainQueue).startStrict(next: { referenceMessage in referenceMessageValue = referenceMessage @@ -7259,7 +7319,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } updateState() }) - + return ActionDisposable { //print("dispose \(unsafeBitCast(viewDisposable, to: UInt64.self))") referenceDisposable.dispose() @@ -7296,7 +7356,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + topPinnedMessage = combineLatest(queue: .mainQueue(), adjustedReplyHistory, topMessage, @@ -7304,13 +7364,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G ) |> map { pinnedMessages, topMessage, referenceMessage -> ChatPinnedMessage? in var message: ChatPinnedMessage? - + let topMessageId: MessageId if pinnedMessages.messages.isEmpty { return nil } topMessageId = topMessage?.message.id ?? pinnedMessages.messages[pinnedMessages.messages.count - 1].message.id - + if case let .ready(referenceMessage) = referenceMessage, referenceMessage.isScrolled, !pinnedMessages.messages.isEmpty, referenceMessage.id == pinnedMessages.messages[0].message.id, let topMessage = topMessage { var index = topMessage.index for message in pinnedMessages.messages { @@ -7319,7 +7379,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G break } } - + if threadId != nil { if referenceMessage.minId <= topMessage.message.id { return nil @@ -7327,7 +7387,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } return ChatPinnedMessage(message: topMessage.message, index: index, totalCount: pinnedMessages.totalCount, topMessageId: topMessageId) } - + //print("reference: \(String(describing: referenceMessage?.id.id)) entries: \(view.entries.map(\.index.id.id))") for i in 0 ..< pinnedMessages.messages.count { let entry = pinnedMessages.messages[i] @@ -7360,7 +7420,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return message } |> distinctUntilChanged - + return topPinnedMessage } else { return .single(nil) @@ -7372,9 +7432,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G func animateFromPreviousController(snapshotState: ChatControllerNode.SnapshotState) { self.storedAnimateFromSnapshotState = snapshotState } - + override public func loadDisplayNode() { self.loadDisplayNodeImpl() + self.galleryPresentationContext.view = self.view self.galleryPresentationContext.controllersUpdated = { [weak self] _ in guard let self else { @@ -7383,28 +7444,28 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.updateStatusBarPresentation() } } - + override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - + if self.willAppear { self.chatDisplayNode.historyNode.refreshPollActionsForVisibleMessages() } else { self.willAppear = true - + // Limit this to reply threads just to be safe now if case .replyThread = self.chatLocation { self.chatDisplayNode.historyNode.refocusOnUnreadMessagesIfNeeded() } } - + DispatchQueue.main.async { [weak self] in guard let self else { return } self.enableAnimations = true } - + if case let .replyThread(message) = self.chatLocation, message.isForumPost { if self.keepMessageCountersSyncrhonizedDisposable == nil { self.keepMessageCountersSyncrhonizedDisposable = self.context.engine.messages.keepMessageCountersSyncrhonized(peerId: message.peerId, threadId: message.threadId).startStrict() @@ -7421,10 +7482,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.keepSavedMessagesSyncrhonizedDisposable = self.context.engine.stickers.refreshSavedMessageTags(subPeerId: self.chatLocation.threadId.flatMap(PeerId.init)).startStrict() } } - + if let scheduledActivateInput = scheduledActivateInput, case .text = scheduledActivateInput { self.scheduledActivateInput = nil - + self.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in return state.updatedInputMode({ _ in switch scheduledActivateInput { @@ -7436,7 +7497,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) }) } - + var chatNavigationStack: [ChatNavigationStackItem] = self.chatNavigationStack if let peerId = self.chatLocation.peerId { if let summary = self.customNavigationDataSummary as? ChatControllerNavigationDataSummary { @@ -7449,7 +7510,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + if !chatNavigationStack.isEmpty, let backButtonNode = self.chatDisplayNode.navigationBar?.backButtonNode as? ContextControllerSourceNode { backButtonNode.isGestureEnabled = true backButtonNode.activated = { [weak self] gesture, _ in @@ -7469,7 +7530,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G ) } } - + if case .standard(.default) = self.mode, !"".isEmpty { let hasBrowserOrWebAppInFront: Signal = .single([]) |> then( @@ -7485,43 +7546,43 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.hasBrowserOrAppInFront.set(hasBrowserOrWebAppInFront) } } - + var returnInputViewFocus = false - + override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - + self.didAppear = true - + self.chatDisplayNode.historyNode.experimentalSnapScrollToItem = false self.chatDisplayNode.historyNode.canReadHistory.set(self.computedCanReadHistoryPromise.get()) self.chatDisplayNode.historyNode.areContentAnimationsEnabled = true - + if !self.alwaysShowSearchResultsAsList { self.chatDisplayNode.loadInputPanels(theme: self.presentationInterfaceState.theme, strings: self.presentationInterfaceState.strings, fontSize: self.presentationInterfaceState.fontSize) } - + if self.recentlyUsedInlineBotsDisposable == nil { self.recentlyUsedInlineBotsDisposable = (self.context.engine.peers.recentlyUsedInlineBots() |> deliverOnMainQueue).startStrict(next: { [weak self] peers in self?.recentlyUsedInlineBotsValue = peers.filter({ $0.1 >= 0.14 }).map({ $0.0._asPeer() }) }) } - + if case .standard(.default) = self.presentationInterfaceState.mode, self.raiseToListen == nil { self.raiseToListen = RaiseToListenManager(shouldActivate: { [weak self] in if let strongSelf = self, strongSelf.isNodeLoaded && strongSelf.canReadHistoryValue, strongSelf.presentationInterfaceState.interfaceState.editMessage == nil, strongSelf.globalControlPanelsContext?.playlistStateAndType == nil { if !strongSelf.context.sharedContext.currentMediaInputSettings.with({ $0.enableRaiseToSpeak }) { return false } - + if strongSelf.effectiveNavigationController?.topViewController !== strongSelf { return false } - + if strongSelf.presentationInterfaceState.inputTextPanelState.mediaRecordingState != nil { return false } - + if !strongSelf.traceVisibility() { return false } @@ -7531,12 +7592,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if !isTopmostChatController(strongSelf) { return false } - + if strongSelf.firstLoadedMessageToListen() != nil || strongSelf.chatDisplayNode.isTextInputPanelActive { if strongSelf.context.sharedContext.immediateHasOngoingCall { return false } - + if case .media = strongSelf.presentationInterfaceState.inputMode { return false } @@ -7557,12 +7618,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if !canSendMessagesToChat(strongSelf.presentationInterfaceState) { return } - + if let raiseToListen = strongSelf.raiseToListen { strongSelf.voicePlaylistDidEndTimestamp = CACurrentMediaTime() raiseToListen.activateBasedOnProximity(delay: 0.0) } - + if strongSelf.returnInputViewFocus { strongSelf.returnInputViewFocus = false strongSelf.chatDisplayNode.ensureInputViewFocused() @@ -7572,39 +7633,41 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self else { return } - + strongSelf.chatDisplayNode.historyNode.voicePlaylistItemChanged(previousItem, currentItem) }) } - + if let arguments = self.presentationArguments as? ChatControllerOverlayPresentationData { //TODO clear arguments self.chatDisplayNode.animateInAsOverlay(from: arguments.expandData.0, completion: { arguments.expandData.1() }) } - + if !self.didSetupDropToPaste { self.didSetupDropToPaste = true let dropInteraction = UIDropInteraction(delegate: self) self.chatDisplayNode.view.addInteraction(dropInteraction) } - + if !self.checkedPeerChatServiceActions { self.checkedPeerChatServiceActions = true - + if case let .peer(peerId) = self.chatLocation, self.screenCaptureManager == nil { if peerId.namespace == Namespaces.Peer.SecretChat { - self.screenCaptureManager = ScreenCaptureDetectionManager(check: { [weak self] in - if let strongSelf = self, strongSelf.traceVisibility() { - if strongSelf.canReadHistoryValue { - let _ = strongSelf.context.engine.messages.addSecretChatMessageScreenshot(peerId: peerId).startStandalone() + if !currentWinterGramSettings.allowScreenshots { + self.screenCaptureManager = ScreenCaptureDetectionManager(check: { [weak self] in + if let strongSelf = self, strongSelf.traceVisibility() { + if strongSelf.canReadHistoryValue { + let _ = strongSelf.context.engine.messages.addSecretChatMessageScreenshot(peerId: peerId).startStandalone() + } + return true + } else { + return false } - return true - } else { - return false - } - }) + }) + } } else if peerId.isTelegramNotifications { self.screenCaptureManager = ScreenCaptureDetectionManager(check: { [weak self] in if let strongSelf = self, strongSelf.traceVisibility() { @@ -7628,7 +7691,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let self else { return false } - + let _ = (self.context.sharedContext.mediaManager.globalMediaPlayerState |> take(1) |> deliverOnMainQueue).startStandalone(next: { [weak self] playlistStateAndType in @@ -7642,15 +7705,15 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) } } - + if case let .peer(peerId) = self.chatLocation { let _ = self.context.engine.peers.checkPeerChatServiceActions(peerId: peerId).startStandalone() - + self.context.account.viewTracker.forceUpdateCachedPeerData(peerId: peerId) self.chatDisplayNode.adMessagesContext?.activate() self.updatePreloadNextChatPeerId() } - + if self.chatLocation.peerId != nil && self.chatDisplayNode.frameForInputActionButton() != nil { let inputText = self.presentationInterfaceState.interfaceState.effectiveInputState.inputText.string if !inputText.isEmpty { @@ -7696,13 +7759,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + self.editMessageErrorsDisposable.set((self.context.account.pendingUpdateMessageManager.errors |> deliverOnMainQueue).startStrict(next: { [weak self] (_, error) in guard let strongSelf = self else { return } - + let text: String switch error { case .generic, .textTooLong, .invalidGrouping: @@ -7713,11 +7776,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.present(textAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: { })]), in: .window(.root)) })) - + if case let .peer(peerId) = self.chatLocation { let context = self.context self.keepPeerInfoScreenDataHotDisposable.set(keepPeerInfoScreenDataHot(context: context, peerId: peerId, chatLocation: self.chatLocation, chatLocationContextHolder: self.chatLocationContextHolder).startStrict()) - + if peerId.namespace == Namespaces.Peer.CloudUser { self.preloadAvatarDisposable.set((peerInfoProfilePhotosWithCache(context: context, peerId: peerId) |> mapToSignal { (complete, result) -> Signal in @@ -7734,17 +7797,17 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }).startStrict()) } } - + self.preloadAttachBotIconsDisposables = AttachmentController.preloadAttachBotIcons(context: self.context) } - + if let _ = self.focusOnSearchAfterAppearance { self.focusOnSearchAfterAppearance = nil if let searchNode = self.navigationBar?.contentNode as? ChatSearchNavigationContentNode { searchNode.activate() } } - + if let peekData = self.peekData, case let .peer(peerId) = self.chatLocation { let timestamp = Int32(Date().timeIntervalSince1970) let remainingTime = max(1, peekData.deadline - timestamp) @@ -7810,7 +7873,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G ), in: .window(.root)) })) } - + self.checksTooltipDisposable.set((self.context.engine.notices.getServerProvidedSuggestions() |> deliverOnMainQueue).startStrict(next: { [weak self] values in guard let strongSelf = self, strongSelf.chatLocation.peerId != strongSelf.context.account.peerId else { @@ -7821,33 +7884,33 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } strongSelf.shouldDisplayChecksTooltip = true })) - + if case let .peer(peerId) = self.chatLocation { self.peerSuggestionsDisposable.set((self.context.engine.notices.getPeerSpecificServerProvidedSuggestions(peerId: peerId) |> deliverOnMainQueue).startStrict(next: { [weak self] values in guard let strongSelf = self else { return } - + if !strongSelf.traceVisibility() || strongSelf.navigationController?.topViewController != strongSelf { return } - + if values.contains(.convertToGigagroup) && !strongSelf.displayedConvertToGigagroupSuggestion { strongSelf.displayedConvertToGigagroupSuggestion = true - + let attributedTitle = NSAttributedString(string: strongSelf.presentationData.strings.BroadcastGroups_LimitAlert_Title, font: Font.semibold(strongSelf.presentationData.listsFontSize.baseDisplaySize), textColor: strongSelf.presentationData.theme.actionSheet.primaryTextColor, paragraphAlignment: .center) let body = MarkdownAttributeSet(font: Font.regular(strongSelf.presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0), textColor: strongSelf.presentationData.theme.actionSheet.primaryTextColor) let bold = MarkdownAttributeSet(font: Font.semibold(strongSelf.presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0), textColor: strongSelf.presentationData.theme.actionSheet.primaryTextColor) - + let participantsLimit = strongSelf.context.currentLimitsConfiguration.with { $0 }.maxSupergroupMemberCount let text = strongSelf.presentationData.strings.BroadcastGroups_LimitAlert_Text(presentationStringsFormattedNumber(participantsLimit, strongSelf.presentationData.dateTimeFormat.groupingSeparator)).string let attributedText = parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(body: body, bold: bold, link: body, linkAttribute: { _ in return nil }), textAlignment: .center) - + let controller = richTextAlertController(context: strongSelf.context, title: attributedTitle, text: attributedText, actions: [TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_Cancel, action: { strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .info(title: nil, text: strongSelf.presentationData.strings.BroadcastGroups_LimitAlert_SettingsTip, timeout: nil, customUndoText: nil), elevatedLayout: false, action: { _ in return false }), in: .current) }), TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.BroadcastGroups_LimitAlert_LearnMore, action: { - + let context = strongSelf.context let presentationData = strongSelf.presentationData let controller = PermissionController(context: context, splashScreen: true) @@ -7864,9 +7927,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }), TextAlertAction(type: .defaultAction, title: presentationData.strings.BroadcastGroups_ConfirmationAlert_Convert, action: { [weak controller] in controller?.dismiss() - + let _ = context.engine.notices.dismissPeerSpecificServerProvidedSuggestion(peerId: peerId, suggestion: .convertToGigagroup).startStandalone() - + let _ = (convertGroupToGigagroup(account: context.account, peerId: peerId) |> deliverOnMainQueue).startStandalone(completed: { let participantsLimit = context.currentLimitsConfiguration.with { $0 }.maxSupergroupMemberCount @@ -7883,10 +7946,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } })) } - + if let scheduledActivateInput = self.scheduledActivateInput { self.scheduledActivateInput = nil - + switch scheduledActivateInput { case .text: self.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in @@ -7917,12 +7980,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { self.chatDisplayNode.historyNode.preloadPages = true } - + if let attachBotStart = self.attachBotStart { self.attachBotStart = nil self.presentAttachmentBot(botId: attachBotStart.botId, payload: attachBotStart.payload, justInstalled: attachBotStart.justInstalled) } - + if self.powerSavingMonitoringDisposable == nil { self.powerSavingMonitoringDisposable = (self.context.sharedContext.automaticMediaDownloadSettings |> mapToSignal { settings -> Signal in @@ -7933,20 +7996,20 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } var previousValueValue: Bool? - + previousValueValue = ChatListControllerImpl.sharedPreviousPowerSavingEnabled ChatListControllerImpl.sharedPreviousPowerSavingEnabled = isPowerSavingEnabled - + /*#if DEBUG previousValueValue = false #endif*/ - + if isPowerSavingEnabled != previousValueValue && previousValueValue != nil && isPowerSavingEnabled { let batteryLevel = UIDevice.current.batteryLevel if batteryLevel > 0.0 && self.view.window != nil { let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let batteryPercentage = Int(batteryLevel * 100.0) - + self.dismissAllUndoControllers() self.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "lowbattery_30", scale: 1.0, colors: [:], title: presentationData.strings.PowerSaving_AlertEnabledTitle, text: presentationData.strings.PowerSaving_AlertEnabledText("\(batteryPercentage)").string, customUndoText: presentationData.strings.PowerSaving_AlertEnabledAction, timeout: 5.0), elevatedLayout: false, action: { [weak self] action in if case .undo = action, let self { @@ -7963,10 +8026,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) } } - + override public func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - + if #available(iOS 18.0, *) { } else { //TODO:release @@ -7974,39 +8037,39 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G UIView.performWithoutAnimation { self.view.endEditing(true) } - + self.chatDisplayNode.historyNode.canReadHistory.set(.single(false)) self.saveInterfaceState() - + self.dismissAllTooltips() - + self.sendMessageActionsController?.dismiss() self.themeScreen?.dismiss() - + self.attachmentController?.dismiss() - + self.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() - + if let _ = self.peekData { self.peekTimerDisposable.set(nil) } - + if case .standard(.default) = self.mode { self.hasBrowserOrAppInFront.set(.single(false)) } - + if let _ = self.currentPaidMessageUndoController, let peerId = self.chatLocation.peerId { self.context.engine.messages.forceSendPostponedPaidMessage(peerId: peerId) } } - + func saveInterfaceState(includeScrollState: Bool = true) { if case .messageOptions = self.subject { return } - + var includeScrollState = includeScrollState - + var peerId: PeerId var threadId: Int64? switch self.chatLocation { @@ -8017,7 +8080,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G peerId = replyThreadMessage.peerId threadId = nil includeScrollState = true - + let scrollState = self.chatDisplayNode.historyNode.immediateScrollState() let _ = ChatInterfaceState.update(engine: self.context.engine, peerId: peerId, threadId: replyThreadMessage.threadId, { current in return current.withUpdatedHistoryScrollState(scrollState) @@ -8029,7 +8092,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G case .customChatContents: return } - + let timestamp = Int32(Date().timeIntervalSince1970) var interfaceState = self.presentationInterfaceState.interfaceState.withUpdatedTimestamp(timestamp) if includeScrollState { @@ -8040,24 +8103,24 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let _ = ChatInterfaceState.update(engine: self.context.engine, peerId: peerId, threadId: threadId, { _ in return interfaceState }).startStandalone() - + self.context.engine.peers.setPerstistentChatInterfaceState(peerId: peerId, state: CodableEntry(self.presentationInterfaceState.persistentData)) } - + override public func viewWillLeaveNavigation() { self.chatDisplayNode.willNavigateAway() } - + override public func inFocusUpdated(isInFocus: Bool) { self.disableStickerAnimationsPromise.set(!isInFocus) self.chatDisplayNode.inFocusUpdated(isInFocus: isInFocus) } - + func canManagePin() -> Bool { guard let peer = self.presentationInterfaceState.renderedPeer?.peer else { return false } - + var canManagePin = false if let channel = peer as? TelegramChannel { canManagePin = channel.hasPermission(.pinMessages) @@ -8075,7 +8138,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else if let _ = peer as? TelegramUser, self.presentationInterfaceState.explicitelyCanPinMessages { canManagePin = true } - + return canManagePin } @@ -8092,21 +8155,21 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } self.applyNavigationBarLayout(layout, navigationLayout: self.navigationLayout(layout: layout), additionalBackgroundHeight: self.additionalNavigationBarBackgroundHeight, additionalCutout: self.additionalNavigationBarCutout, transition: transition) } - + override public func preferredContentSizeForLayout(_ layout: ContainerViewLayout) -> CGSize? { return nil } - + public func updateIsScrollingLockedAtTop(isScrollingLockedAtTop: Bool) { self.chatDisplayNode.isScrollingLockedAtTop = isScrollingLockedAtTop } - + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { self.suspendNavigationBarLayout = true super.containerLayoutUpdated(layout, transition: transition) - + self.validLayout = layout - + switch self.presentationInterfaceState.mode { case .standard, .inline: break @@ -8118,12 +8181,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.statusBar.statusBarStyle = .Ignore } } - + var layout = layout if case .compact = layout.metrics.widthClass, let attachmentController = self.attachmentController, attachmentController.window != nil { layout = layout.withUpdatedInputHeight(nil) } - + var navigationBarTransition = transition self.chatDisplayNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition, listViewTransaction: { updateSizeAndInsets, additionalScrollDistance, scrollToTop, completion in self.chatDisplayNode.historyNode.updateLayout(transition: transition, updateSizeAndInsets: updateSizeAndInsets, additionalScrollDistance: additionalScrollDistance, scrollToTop: scrollToTop, completion: completion) @@ -8133,7 +8196,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.additionalNavigationBarHitTestSlop = hitTestSlop self.additionalNavigationBarCutout = cutout }) - + if case .compact = layout.metrics.widthClass { let hasOverlayNodes = self.context.sharedContext.mediaManager.overlayMediaManager.controller?.hasNodes ?? false if self.validLayout != nil && layout.size.width > layout.size.height && !hasOverlayNodes && self.traceVisibility() && isTopmostChatController(self) { @@ -8154,11 +8217,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } self.navigationBar?.additionalContentNode.hitTestSlop = UIEdgeInsets(top: 0.0, left: 0.0, bottom: self.additionalNavigationBarHitTestSlop, right: 0.0) } - + func updateChatPresentationInterfaceState(animated: Bool = true, interactive: Bool, saveInterfaceState: Bool = false, _ f: (ChatPresentationInterfaceState) -> ChatPresentationInterfaceState, completion: @escaping (ContainedViewLayoutTransition) -> Void = { _ in }) { self.updateChatPresentationInterfaceState(transition: animated ? .animated(duration: 0.4, curve: .spring) : .immediate, interactive: interactive, saveInterfaceState: saveInterfaceState, f, completion: completion) } - + func updateChatPresentationInterfaceState(transition: ContainedViewLayoutTransition, interactive: Bool, force: Bool = false, saveInterfaceState: Bool = false, _ f: (ChatPresentationInterfaceState) -> ChatPresentationInterfaceState, completion: @escaping (ContainedViewLayoutTransition) -> Void = { _ in }) { updateChatPresentationInterfaceStateImpl( selfController: self, @@ -8170,7 +8233,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G completion: completion ) } - + func updateItemNodesSelectionStates(animated: Bool) { self.chatDisplayNode.historyNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ChatMessageItemView { @@ -8184,7 +8247,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + func updatePollTooltipMessageState(animated: Bool) { self.chatDisplayNode.historyNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ChatMessageBubbleItemNode { @@ -8197,7 +8260,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + func updateItemNodesSearchTextHighlightStates() { var searchString: String? var resultsMessageIndices: [MessageIndex]? @@ -8218,7 +8281,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + func updateItemNodesHighlightedStates(animated: Bool) { self.chatDisplayNode.historyNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ChatMessageItemView { @@ -8226,13 +8289,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + @objc func leftNavigationButtonAction() { if let button = self.leftNavigationButton { self.navigationButtonAction(button.action) } } - + @objc func rightNavigationButtonAction() { if let button = self.rightNavigationButton { if case .standard(.previewing) = self.mode { @@ -8244,25 +8307,25 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + @objc func secondaryRightNavigationButtonAction() { if let button = self.secondaryRightNavigationButton { self.navigationButtonAction(button.action) } } - + @objc func moreButtonPressed() { self.moreBarButton.play() self.moreBarButton.contextAction?(self.moreBarButton.containerNode, nil) } - + public func beginClearHistory(type: InteractiveHistoryClearingType) { guard case let .peer(peerId) = self.chatLocation else { return } self.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) self.chatDisplayNode.historyNode.historyAppearsCleared = true - + let statusText: String if case .scheduledMessages = self.presentationInterfaceState.subject { statusText = self.presentationData.strings.Undo_ScheduledMessagesCleared @@ -8275,7 +8338,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { statusText = self.presentationData.strings.Undo_ChatCleared } - + self.present(UndoOverlayController(presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: self.context, title: NSAttributedString(string: statusText), text: nil), elevatedLayout: false, action: { [weak self] value in guard let strongSelf = self else { return false @@ -8292,11 +8355,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return false }), in: .current) } - + public func cancelSelectingMessages() { self.navigationButtonAction(.cancelMessageSelection) } - + func editMessageMediaWithMessages(_ messages: [EnqueueMessage]) { if let message = messages.first, case let .message(text, attributes, _, maybeMediaReference, _, _, _, _, _, _) = message, let mediaReference = maybeMediaReference { self.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in @@ -8307,7 +8370,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } let attributedText = chatInputStateStringWithAppliedEntities(text, entities: entities) - + var state = state if let editMessageState = state.editMessageState { state = state.updatedEditMessageState(ChatEditInterfaceMessageState(content: editMessageState.content, mediaReference: mediaReference)) @@ -8327,19 +8390,19 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + func editMessageMediaWithLegacySignals(_ signals: [Any]) { let _ = (legacyAssetPickerEnqueueMessages(context: self.context, account: self.context.account, signals: signals) |> deliverOnMainQueue).startStandalone(next: { [weak self] messages in self?.editMessageMediaWithMessages(messages.map { $0.message }) }) } - + public func presentAttachmentBot(botId: PeerId, payload: String?, justInstalled: Bool) { self.attachmentController?.dismiss(animated: true, completion: nil) self.presentAttachmentMenu(subject: .bot(id: botId, payload: payload, justInstalled: justInstalled)) } - + func displayPollSolution(solution: TelegramMediaPollResults.Solution, sourceNode: ASDisplayNode, isAutomatic: Bool) { var maybeFoundItemNode: ChatMessageItemView? self.chatDisplayNode.historyNode.forEachItemNode { itemNode in @@ -8352,16 +8415,16 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let foundItemNode = maybeFoundItemNode, let item = foundItemNode.item else { return } - + let messageId = item.message.id self.controllerInteraction?.currentPollMessageWithTooltip = messageId self.controllerInteraction?.requestMessageUpdate(messageId, false, nil) } - + public func displayPromoAnnouncement(text: String) { let psaText: String = text let psaEntities: [MessageTextEntity] = generateTextEntities(psaText, enabledTypes: .allUrl) - + var found = false self.forEachController({ controller in if let controller = controller as? TooltipScreen { @@ -8376,7 +8439,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if found { return } - + let tooltipScreen = TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .entities(text: psaText, entities: psaEntities), icon: .animation(name: "anim_infotip", delay: 0.2, tintColor: nil), location: .top, displayDuration: .custom(10.0), shouldDismissOnTouch: { point, _ in return .ignore }, openActiveTextItem: { [weak self] item, action in @@ -8426,17 +8489,17 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } }) - + self.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss() } return true }) - + self.present(tooltipScreen, in: .current) } - + func displayPsa(type: String, sourceNode: ASDisplayNode, isAutomatic: Bool) { var maybeFoundItemNode: ChatMessageItemView? self.chatDisplayNode.historyNode.forEachItemNode { itemNode in @@ -8449,7 +8512,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let foundItemNode = maybeFoundItemNode, let item = foundItemNode.item else { return } - + var psaText = self.presentationData.strings.Chat_GenericPsaTooltip let key = "Chat.PsaTooltip.\(type)" if let string = self.presentationData.strings.primaryComponent.dict[key] { @@ -8457,18 +8520,18 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else if let string = self.presentationData.strings.secondaryComponent?.dict[key] { psaText = string } - + let psaEntities: [MessageTextEntity] = generateTextEntities(psaText, enabledTypes: .allUrl) - + let messageId = item.message.id - + var found = false self.forEachController({ controller in if let controller = controller as? TooltipScreen { if controller.text == .plain(text: psaText) { found = true controller.resetDismissTimeout() - + controller.willBecomeDismissed = { [weak self] tooltipScreen in guard let strongSelf = self else { return @@ -8478,7 +8541,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.updatePollTooltipMessageState(animated: true) } } - + return false } } @@ -8487,10 +8550,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if found { self.controllerInteraction?.currentPsaMessageWithTooltip = messageId self.updatePollTooltipMessageState(animated: !isAutomatic) - + return } - + let tooltipScreen = TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .entities(text: psaText, entities: psaEntities), icon: .animation(name: "anim_infotip", delay: 0.2, tintColor: nil), location: .top, displayDuration: .custom(10.0), shouldDismissOnTouch: { point, _ in return .ignore }, openActiveTextItem: { [weak self] item, action in @@ -8540,10 +8603,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } }) - + self.controllerInteraction?.currentPsaMessageWithTooltip = messageId self.updatePollTooltipMessageState(animated: !isAutomatic) - + tooltipScreen.willBecomeDismissed = { [weak self] tooltipScreen in guard let strongSelf = self else { return @@ -8553,25 +8616,25 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.updatePollTooltipMessageState(animated: true) } } - + self.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss() } return true }) - + self.present(tooltipScreen, in: .current) } - + func transformEnqueueMessages(_ messages: [EnqueueMessage], postpone: Bool = false) -> [EnqueueMessage] { let silentPosting = self.presentationInterfaceState.interfaceState.silentPosting return transformEnqueueMessages(messages, silentPosting: silentPosting, postpone: postpone) } - + @discardableResult func dismissAllUndoControllers() -> UndoOverlayController? { var currentOverlayController: UndoOverlayController? - + self.window?.forEachController({ controller in if let controller = controller as? UndoOverlayController { currentOverlayController = controller @@ -8583,25 +8646,25 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } return true }) - + return currentOverlayController } - + func displayPremiumStickerTooltip(file: TelegramMediaFile, message: Message) { let premiumConfiguration = PremiumConfiguration.with(appConfiguration: self.context.currentAppConfiguration.with { $0 }) guard !premiumConfiguration.isPremiumDisabled else { return } - + let currentOverlayController: UndoOverlayController? = self.dismissAllUndoControllers() - + if let currentOverlayController = currentOverlayController { if case .sticker = currentOverlayController.content { return } currentOverlayController.dismissWithCommitAction() } - + var stickerPackReference: StickerPackReference? for attribute in file.attributes { if case let .Sticker(_, packReference, _) = attribute, let packReference = packReference { @@ -8609,7 +8672,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G break } } - + if let stickerPackReference = stickerPackReference { let _ = (self.context.engine.stickers.loadedStickerPack(reference: stickerPackReference, forceActualized: false) |> deliverOnMainQueue).startStandalone(next: { [weak self] stickerPack in @@ -8624,15 +8687,15 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) } } - + func displayEmojiPackTooltip(file: TelegramMediaFile, message: Message) { let premiumConfiguration = PremiumConfiguration.with(appConfiguration: self.context.currentAppConfiguration.with { $0 }) guard !premiumConfiguration.isPremiumDisabled else { return } - + var currentOverlayController: UndoOverlayController? - + self.window?.forEachController({ controller in if let controller = controller as? UndoOverlayController { currentOverlayController = controller @@ -8644,14 +8707,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } return true }) - + if let currentOverlayController = currentOverlayController { if case .sticker = currentOverlayController.content { return } currentOverlayController.dismissWithCommitAction() } - + var stickerPackReference: StickerPackReference? for attribute in file.attributes { if case let .CustomEmoji(_, _, _, packReference) = attribute { @@ -8659,18 +8722,37 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G break } } - + if let stickerPackReference = stickerPackReference { var previewIconFile: TelegramMediaFile? = file if !file.isValidForDisplay(chatPeerId: message.id.peerId) { previewIconFile = nil } - + self.presentEmojiList(references: [stickerPackReference], previewIconFile: previewIconFile) } } - + func transformEnqueueMessages(_ messages: [EnqueueMessage], silentPosting: Bool, scheduleTime: Int32? = nil, repeatPeriod: Int32? = nil, postpone: Bool = false) -> [EnqueueMessage] { + let settings = currentWinterGramSettings + let effectiveSilentPosting: Bool + switch settings.sendWithoutSound { + case .never: + effectiveSilentPosting = silentPosting + case .inGhostMode: + effectiveSilentPosting = silentPosting || settings.ghostModeEnabled + case .always: + effectiveSilentPosting = true + } + + var scheduleTime = scheduleTime + if scheduleTime == nil, settings.useScheduledMessages, case .peer = self.chatLocation, self.presentationInterfaceState.subject != .scheduledMessages { + // "Отложка": opt-in only via the dedicated useScheduledMessages toggle (independent of + // ghost mode). Routes the message through the scheduled-messages path with a near-immediate + // time so sending does not mark the chat as read. + scheduleTime = Int32(Date().timeIntervalSince1970) + 12 + } + var defaultThreadId: Int64? var defaultReplyMessageSubject: EngineMessageReplySubject? switch self.chatLocation { @@ -8688,10 +8770,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let postSuggestionState = self.presentationInterfaceState.interfaceState.postSuggestionState, let editingOriginalMessageId = postSuggestionState.editingOriginalMessageId { defaultReplyMessageSubject = EngineMessageReplySubject(messageId: editingOriginalMessageId, quote: nil, innerSubject: nil) } - + return messages.map { message in var message = message - + if let defaultReplyMessageSubject = defaultReplyMessageSubject { switch message { case let .message(text, attributes, inlineStickers, mediaReference, threadId, replyToMessageId, replyToStoryId, localGroupingKey, correlationId, bubbleUpEmojiOrStickersets): @@ -8718,7 +8800,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G message = message.withUpdatedThreadId(defaultThreadId) } } - + if case let .replyThread(replyThreadMessage) = self.chatLocation, replyThreadMessage.peerId == self.context.account.peerId { switch message { case let .message(text, attributes, inlineStickers, mediaReference, threadId, replyToMessageId, replyToStoryId, localGroupingKey, correlationId, bubbleUpEmojiOrStickersets): @@ -8727,10 +8809,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G break } } - + return message.withUpdatedAttributes { attributes in var attributes = attributes - + if let sendPaidMessageStars = self.presentationInterfaceState.sendPaidMessageStars { var effectivePostpone = postpone for i in (0 ..< attributes.count).reversed() { @@ -8741,8 +8823,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } attributes.append(PaidStarsMessageAttribute(stars: sendPaidMessageStars, postponeSending: effectivePostpone)) } - - if silentPosting || scheduleTime != nil { + + if effectiveSilentPosting || scheduleTime != nil { for i in (0 ..< attributes.count).reversed() { if attributes[i] is NotificationInfoMessageAttribute { attributes.remove(at: i) @@ -8750,14 +8832,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G attributes.remove(at: i) } } - if silentPosting { + if effectiveSilentPosting { attributes.append(NotificationInfoMessageAttribute(flags: .muted)) } if let scheduleTime { attributes.append(OutgoingScheduleInfoMessageAttribute(scheduleTime: scheduleTime, repeatPeriod: repeatPeriod)) } } - + if let channel = self.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isMonoForum { attributes.removeAll(where: { $0 is SendAsMessageAttribute }) if let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = self.presentationInterfaceState.renderedPeer?.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.manageDirect) { @@ -8791,30 +8873,30 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + func shouldDivertMessagesToScheduled(targetPeer: EnginePeer? = nil, messages: [EnqueueMessage]) -> Signal { return .single(false) } - + func sendMessages(_ messages: [EnqueueMessage], media: Bool = false, postpone: Bool = false, commit: Bool = false) { if case let .customChatContents(customChatContents) = self.subject { customChatContents.enqueueMessages(messages: messages) return } - + guard let peerId = self.chatLocation.peerId else { return } - + let _ = (self.shouldDivertMessagesToScheduled(messages: messages) |> deliverOnMainQueue).startStandalone(next: { [weak self] shouldDivert in guard let self else { return } - + var messages = messages var shouldOpenScheduledMessages = false - + if shouldDivert { messages = messages.map { message -> EnqueueMessage in return message.withUpdatedAttributes { attributes in @@ -8826,32 +8908,42 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } shouldOpenScheduledMessages = true } - + var isScheduledMessages = false if case .scheduledMessages = self.presentationInterfaceState.subject { isScheduledMessages = true } - + if commit || !isScheduledMessages { self.commitPurposefulAction() - + let _ = (enqueueMessages(account: self.context.account, peerId: peerId, messages: self.transformEnqueueMessages(messages, postpone: postpone)) |> deliverOnMainQueue).startStandalone(next: { [weak self] _ in if let strongSelf = self, strongSelf.presentationInterfaceState.subject != .scheduledMessages { strongSelf.chatDisplayNode.historyNode.scrollToEndOfHistory() } }) - + donateSendMessageIntent(account: self.context.account, sharedContext: self.context.sharedContext, intentContext: .chat, peerIds: [peerId]) - + + // WinterGram ghost toggles, applied after an explicit send action. + let wntSettings = currentWinterGramSettings + if wntSettings.shouldMarkReadAfterAction, let latestIndex = self.chatDisplayNode.historyNode.latestMessageInCurrentHistoryView()?.index { + // Reads are suppressed by ghost mode, but the user engaged: mark the chat read. + let _ = self.context.engine.messages.applyMaxReadIndexInteractively(index: latestIndex).startStandalone() + } + if wntSettings.shouldGoOfflineAfterAction { + let _ = self.context.engine.accountData.wntSendOfflinePresenceUpdate().startStandalone() + } + self.updateChatPresentationInterfaceState(interactive: true, { $0.updatedShowCommands(false) }) - + if !isScheduledMessages && shouldOpenScheduledMessages { if let layoutActionOnViewTransitionAction = self.layoutActionOnViewTransitionAction { self.layoutActionOnViewTransitionAction = nil layoutActionOnViewTransitionAction() } - + self.openScheduledMessages(force: true, completion: { _ in }) } @@ -8864,7 +8956,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }) } - + func enqueueMediaMessages( fromGallery: Bool = false, signals: [Any]?, @@ -8884,7 +8976,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.commitEnqueueMediaMessages(signals: signals, originalMediaReference: originalMediaReference, silentPosting: silentPosting, scheduleTime: scheduleTime, replyToSubject: replyToSubject, parameters: parameters, getAnimatedTransitionSource: getAnimatedTransitionSource, completion: completion) } } - + private func commitEnqueueMediaMessages( signals: [Any]?, originalMediaReference: AnyMediaReference? = nil, @@ -8902,37 +8994,37 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G animateTransition = false } } - + self.enqueueMediaMessageDisposable.set((legacyAssetPickerEnqueueMessages(context: self.context, account: self.context.account, signals: signals!, originalMediaReference: originalMediaReference) |> deliverOnMainQueue).startStrict(next: { [weak self] items in guard let strongSelf = self else { return } - + let _ = (strongSelf.shouldDivertMessagesToScheduled(messages: items.map(\.message)) |> deliverOnMainQueue).startStandalone(next: { shouldDivert in guard let strongSelf = self else { return } - + var completionImpl: (() -> Void)? = completion var usedCorrelationId: Int64? var mappedMessages: [EnqueueMessage] = [] var addedTransitions: [(Int64, [String], () -> Void)] = [] - + var groupedCorrelationIds: [Int64: Int64] = [:] - + var skipAddingTransitions = false - + if shouldDivert { skipAddingTransitions = true } if !animateTransition { skipAddingTransitions = true } - + for item in items { var message = item.message if message.groupingKey != nil { @@ -8942,7 +9034,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else if items.count > 3 { skipAddingTransitions = true } - + if let uniqueId = item.uniqueId, !item.isFile && !skipAddingTransitions { let correlationId: Int64 var addTransition = scheduleTime == nil @@ -8968,11 +9060,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G addedTransitions[index] = (correlationId, uniqueIds, completion) } } - + usedCorrelationId = correlationId completionImpl = nil } - + if let parameters { if let effect = parameters.effect { message = message.withUpdatedAttributes { attributes in @@ -8989,13 +9081,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + mappedMessages.append(message) } - + let messages = strongSelf.transformEnqueueMessages(mappedMessages, silentPosting: silentPosting, scheduleTime: scheduleTime, postpone: postpone) let replyMessageSubject = replyToSubject ?? strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject - + var targetThreadId: Int64? var clearMainThreadForward = false if strongSelf.chatLocation.threadId == nil, let user = strongSelf.presentationInterfaceState.renderedPeer?.peer as? TelegramUser, let botInfo = user.botInfo, botInfo.flags.contains(.hasForum), botInfo.flags.contains(.forumManagedByUser) { @@ -9019,12 +9111,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + let addTransitionNodes: () -> Void = { guard let strongSelf = self else { return } - + if addedTransitions.count > 1 { var transitions: [(Int64, ChatMessageTransitionNodeImpl.Source, () -> Void)] = [] for (correlationId, uniqueIds, initiated) in addedTransitions { @@ -9061,7 +9153,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + if case let .customChatContents(customChatContents) = strongSelf.presentationInterfaceState.subject, let messageLimit = customChatContents.messageLimit { if let originalHistoryView = strongSelf.chatDisplayNode.historyNode.originalHistoryView, originalHistoryView.entries.count + mappedMessages.count > messageLimit { let alertController = textAlertController( @@ -9076,19 +9168,19 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } } - + let doSend: (Int64?) -> Void = { [weak strongSelf] overrideThreadId in guard let strongSelf else { return } - + var messages = messages if let overrideThreadId { messages = messages.map { message in return message.withUpdatedThreadId(overrideThreadId) } } - + strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({ if let strongSelf = self { strongSelf.chatDisplayNode.collapseInput() @@ -9103,7 +9195,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G addTransitionNodes() strongSelf.sendMessages(messages.map { $0.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel) }, media: true) } - + if let targetThreadId { strongSelf.chatDisplayNode.historyNode.stopHistoryUpdates() strongSelf.updateChatLocationThread(threadId: targetThreadId, animationDirection: .right, transferInputState: true, completion: { [weak strongSelf] in @@ -9120,7 +9212,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { doSend(nil) } - + if let _ = scheduleTime { completion() } @@ -9132,11 +9224,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if !canSendMessagesToChat(self.presentationInterfaceState) { return } - + guard let peerId = self.chatLocation.peerId else { return } - + var isScheduledMessages = false if case .scheduledMessages = self.presentationInterfaceState.subject { isScheduledMessages = true @@ -9147,13 +9239,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } let replyMessageSubject = self.presentationInterfaceState.interfaceState.replyMessageSubject - + let sendPaidMessageStars = self.presentationInterfaceState.sendPaidMessageStars if self.context.engine.messages.enqueueOutgoingMessageWithChatContextResult(to: peerId, threadId: self.chatLocation.threadId, botId: results.botId, result: result, replyToMessageId: replyMessageSubject?.subjectModel, hideVia: hideVia, silentPosting: silentPosting, scheduleTime: scheduleTime, sendPaidMessageStars: sendPaidMessageStars, postpone: postpone) { self.chatDisplayNode.setupSendActionOnViewUpdate({ [weak self] in if let strongSelf = self { strongSelf.chatDisplayNode.collapseInput() - + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in var state = state if resetTextInputState { @@ -9177,7 +9269,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }, nil) } } - + if let scheduleTime { sendMessage(scheduleTime, silentPosting) } else if isScheduledMessages { @@ -9188,7 +9280,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G sendMessage(nil, silentPosting) } } - + func firstLoadedMessageToListen() -> Message? { var messageToListen: Message? self.chatDisplayNode.historyNode.forEachMessageInCurrentHistoryView { message in @@ -9204,9 +9296,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } return messageToListen } - + var raiseToListenActivateRecordingTimer: SwiftSignalKit.Timer? - + func activateRaiseGesture() { self.raiseToListenActivateRecordingTimer?.invalidate() self.raiseToListenActivateRecordingTimer = nil @@ -9220,21 +9312,21 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.raiseToListenActivateRecordingTimer?.start() } } - + func deactivateRaiseGesture() { self.raiseToListenActivateRecordingTimer?.invalidate() self.raiseToListenActivateRecordingTimer = nil self.dismissMediaRecorder(.pause) } - + func updateDownButtonVisibility() { let recordingMediaMessage = self.audioRecorderValue != nil || self.videoRecorderValue != nil || self.presentationInterfaceState.interfaceState.mediaDraftState != nil - + var ignoreSearchState = false if case let .customChatContents(contents) = self.subject, case .hashTagSearch = contents.kind { ignoreSearchState = true } - + if !ignoreSearchState, let search = self.presentationInterfaceState.search, let results = search.resultsState, results.messageIndices.count != 0 { var resultIndex: Int? if let currentId = results.currentId, let index = results.messageIndices.firstIndex(where: { $0.id == currentId }) { @@ -9260,7 +9352,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G ) } } - + func updateTextInputState(_ textInputState: ChatTextInputState) { self.updateChatPresentationInterfaceState(interactive: false, { state in state.updatedInterfaceState({ state in @@ -9268,7 +9360,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) }) } - + public func navigateToMessage(messageLocation: NavigateToMessageLocation, animated: Bool, forceInCurrentChat: Bool = false, dropStack: Bool = false, completion: (() -> Void)? = nil, customPresentProgress: ((ViewController, Any?) -> Void)? = nil) { let scrollPosition: ListViewScrollPosition if case .upperBound = messageLocation { @@ -9278,13 +9370,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } self.navigateToMessage(from: nil, to: messageLocation, scrollPosition: scrollPosition, rememberInStack: false, forceInCurrentChat: forceInCurrentChat, dropStack: dropStack, animated: animated, completion: completion, customPresentProgress: customPresentProgress) } - + func openStories(peerId: EnginePeer.Id, avatarHeaderNode: ChatMessageAvatarHeaderNodeImpl?, avatarNode: AvatarNode?) { if let avatarNode = avatarHeaderNode?.avatarNode ?? avatarNode { StoryContainerScreen.openPeerStories(context: self.context, peerId: peerId, parentController: self, avatarNode: avatarNode) } } - + func openPeerMention(_ name: String, navigation: ChatControllerInteractionNavigateToPeer = .default, sourceMessageId: MessageId? = nil, progress: Promise? = nil) { let _ = self.presentVoiceMessageDiscardAlert(action: { let disposable: MetaDisposable @@ -9295,7 +9387,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.resolvePeerByNameDisposable = disposable } var resolveSignal = self.context.engine.peers.resolvePeerByName(name: name, referrer: nil, ageLimit: 10) - + var cancelImpl: (() -> Void)? let presentationData = self.presentationData let progressSignal = Signal { [weak self] subscriber in @@ -9317,7 +9409,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G |> runOn(Queue.mainQueue()) |> delay(0.15, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() - + resolveSignal = resolveSignal |> afterDisposed { Queue.mainQueue().async { @@ -9337,7 +9429,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G progress?.set(.single(true)) case let .result(peer): progress?.set(.single(false)) - + if let peer { var navigation = navigation if case .default = navigation { @@ -9353,7 +9445,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G })) }) } - + func openHashtag(_ hashtag: String, peerName: String?) { let _ = self.presentVoiceMessageDiscardAlert(action: { if self.resolvePeerByNameDisposable == nil { @@ -9404,7 +9496,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G |> runOn(Queue.mainQueue()) |> delay(0.25, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() - + resolveSignal = resolveSignal |> afterDisposed { Queue.mainQueue().async { @@ -9433,7 +9525,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G })) }) } - + func shareAccountContact() { let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId)) |> mapToSignal { peer -> Signal in @@ -9481,7 +9573,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.present(alertController, in: .window(.root)) }) } - + func addPeerContact() { if let peer = self.presentationInterfaceState.renderedPeer?.chatMainPeer as? TelegramUser, let peerStatusSettings = self.presentationInterfaceState.contactStatus?.peerStatusSettings { let controller = self.context.sharedContext.makeNewContactScreen( @@ -9501,7 +9593,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.push(controller) } } - + func dismissPeerContactOptions() { guard case let .peer(peerId) = self.chatLocation else { return @@ -9518,7 +9610,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } })).startStrict()) } - + func deleteChat(reportChatSpam: Bool) { guard case let .peer(peerId) = self.chatLocation else { return @@ -9536,15 +9628,15 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G navigationController.popToRoot(animated: true) } } - + let _ = self.context.engine.privacy.requestUpdatePeerIsBlocked(peerId: peerId, isBlocked: true).startStandalone() } - + func startBot(_ payload: String?) { guard case let .peer(peerId) = self.chatLocation else { return } - + let startingBot = self.startingBot startingBot.set(true) self.editMessageDisposable.set((self.context.engine.messages.requestStartBot(botPeerId: peerId, payload: payload) |> deliverOnMainQueue |> afterDisposed({ @@ -9555,10 +9647,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } })) } - + func openResolved(result: ResolvedUrl, sourceMessageId: MessageId?, progress: Promise? = nil, forceExternal: Bool = false, concealed: Bool = false, commit: @escaping () -> Void = {}) { let urlContext: OpenURLContext - + let message = sourceMessageId.flatMap { self.chatDisplayNode.historyNode.messageInCurrentHistoryView($0)?._asMessage() } if let peerId = self.chatLocation.peerId { urlContext = .chat(peerId: peerId, message: message, updatedPresentationData: self.updatedPresentationData) @@ -9569,10 +9661,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self else { return } - + let dismissWebAppControllers: () -> Void = { } - + switch navigation { case let .chat(textInputState, subject, peekData): dismissWebAppControllers() @@ -9682,7 +9774,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self?.chatDisplayNode.dismissInput() }, contentContext: nil, progress: progress, completion: nil) } - + func openUrl( _ url: String, concealed: Bool, @@ -9696,7 +9788,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G commit: @escaping () -> Void = {} ) { self.commitPurposefulAction() - + if allowInlineWebpageResolution, let message, let webpage = message.media.first(where: { $0 is TelegramMediaWebpage }) as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content, content.url == url { if content.instantPage != nil { if let navigationController = self.navigationController as? NavigationController { @@ -9720,7 +9812,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + let _ = self.presentVoiceMessageDiscardAlert(action: { [weak self] in guard let self else { return @@ -9733,7 +9825,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.navigationActionDisposable.set(disposable) }, performAction: true) } - + func openUrlIn(_ url: String) { let actionSheet = OpenInOptionsScreen(context: self.context, updatedPresentationData: self.updatedPresentationData, item: .url(url: url), openUrl: { [weak self] url in if let strongSelf = self, let navigationController = strongSelf.effectiveNavigationController { @@ -9745,26 +9837,26 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.chatDisplayNode.dismissInput() self.push(actionSheet) } - + @available(iOSApplicationExtension 11.0, iOS 11.0, *) public func dropInteraction(_ interaction: UIDropInteraction, canHandle session: UIDropSession) -> Bool { return session.hasItemsConforming(toTypeIdentifiers: [kUTTypeImage as String]) } - + @available(iOSApplicationExtension 11.0, iOS 11.0, *) public func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: UIDropSession) -> UIDropProposal { if !canSendMessagesToChat(self.presentationInterfaceState) { return UIDropProposal(operation: .cancel) } - + //let dropLocation = session.location(in: self.chatDisplayNode.view) self.chatDisplayNode.updateDropInteraction(isActive: true) - + let operation: UIDropOperation operation = .copy return UIDropProposal(operation: operation) } - + @available(iOSApplicationExtension 11.0, iOS 11.0, *) public func dropInteraction(_ interaction: UIDropInteraction, performDrop session: UIDropSession) { session.loadObjects(ofClass: UIImage.self) { [weak self] imageItems in @@ -9772,7 +9864,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } let images = imageItems as! [UIImage] - + strongSelf.chatDisplayNode.updateDropInteraction(isActive: false) if images.count == 1, let image = images.first { let maxSide = max(image.size.width, image.size.height) @@ -9789,40 +9881,40 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.displayPasteMenu(images.map { .image($0) }) } } - + @available(iOSApplicationExtension 11.0, iOS 11.0, *) public func dropInteraction(_ interaction: UIDropInteraction, sessionDidExit session: UIDropSession) { self.chatDisplayNode.updateDropInteraction(isActive: false) } - + @available(iOSApplicationExtension 11.0, iOS 11.0, *) public func dropInteraction(_ interaction: UIDropInteraction, sessionDidEnd session: UIDropSession) { self.chatDisplayNode.updateDropInteraction(isActive: false) } - + public func beginMessageSearch(_ query: String) { self.interfaceInteraction?.beginMessageSearch(.everything, query) } - + public func beginReportSelection(reason: NavigateToChatControllerParams.ReportReason) { self.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedReportReason(ChatPresentationInterfaceState.ReportReasonData(title: reason.title, option: reason.option, message: reason.message)).updatedInterfaceState { $0.withUpdatedSelectedMessages([]) } }) } - + func displayMediaRecordingTooltip() { guard let peer = self.presentationInterfaceState.renderedPeer?.peer else { return } - + if self.birthdayTooltipController != nil { return } - + let rect: CGRect? = self.chatDisplayNode.frameForInputActionButton() - + let updatedMode: ChatTextInputMediaRecordingButtonMode = self.presentationInterfaceState.interfaceState.mediaRecordingMode - + let text: String - + var canSwitch = true if let channel = peer as? TelegramChannel { if channel.hasBannedPermission(.banSendVoice) != nil && channel.hasBannedPermission(.banSendInstantVideos) != nil { @@ -9849,7 +9941,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + if updatedMode == .audio { if canSwitch { text = self.presentationData.strings.Conversation_HoldForAudio @@ -9863,9 +9955,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G text = self.presentationData.strings.Conversation_HoldForVideoOnly } } - + self.silentPostTooltipController?.dismiss() - + if let tooltipController = self.mediaRecordingModeTooltipController { tooltipController.updateContent(.text(text), animated: true, extendTimer: true) } else if let rect { @@ -9884,7 +9976,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G })) } } - + func displaySendWhenOnlineTooltip() { guard let rect = self.chatDisplayNode.frameForInputActionButton(), self.effectiveNavigationController?.topViewController === self, let peerId = self.chatLocation.peerId else { return @@ -9893,9 +9985,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard !inputText.isEmpty else { return } - + self.sendingOptionsTooltipController?.dismiss() - + let _ = (ApplicationSpecificNotice.getSendWhenOnlineTip(accountManager: self.context.sharedContext.accountManager) |> deliverOnMainQueue).startStandalone(next: { [weak self] counter in if let strongSelf = self, counter < 3 { @@ -9916,10 +10008,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if peer.id.namespace == Namespaces.Peer.CloudUser && peer.id.id._internalGetInt64Value() == 777000 { sendWhenOnlineAvailable = false } - + if sendWhenOnlineAvailable { let _ = ApplicationSpecificNotice.incrementSendWhenOnlineTip(accountManager: strongSelf.context.sharedContext.accountManager).startStandalone() - + let tooltipController = TooltipController(content: .text(strongSelf.presentationData.strings.Conversation_SendWhenOnlineTooltip), baseFontSize: strongSelf.presentationData.listsFontSize.baseDisplaySize, timeout: 3.0, dismissByTapOutside: true, dismissImmediatelyOnLayoutUpdate: true, padding: 2.0) strongSelf.sendingOptionsTooltipController = tooltipController tooltipController.dismissed = { [weak self, weak tooltipController] _ in @@ -9938,7 +10030,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }) } - + func displaySendingOptionsTooltip() { guard let rect = self.chatDisplayNode.frameForInputActionButton(), self.effectiveNavigationController?.topViewController === self else { return @@ -9958,7 +10050,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return nil })) } - + func displayEmojiTooltip() { guard let rect = self.chatDisplayNode.frameForEmojiButton(), self.effectiveNavigationController?.topViewController === self else { return @@ -9978,7 +10070,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return nil })) } - + func displayGroupEmojiTooltip() { guard let rect = self.chatDisplayNode.frameForEmojiButton(), self.effectiveNavigationController?.topViewController === self else { return @@ -9986,33 +10078,33 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let peerId = self.chatLocation.peerId, let emojiPack = (self.contentData?.state.peerView?.cachedData as? CachedChannelData)?.emojiPack, let thumbnailFileId = emojiPack.thumbnailFileId else { return } - + let _ = (ApplicationSpecificNotice.groupEmojiPackSuggestion(accountManager: self.context.sharedContext.accountManager, peerId: peerId) |> deliverOnMainQueue).start(next: { [weak self] counter in guard let self, counter == 0 else { return } - + let _ = (self.context.engine.stickers.resolveInlineStickers(fileIds: [thumbnailFileId]) |> deliverOnMainQueue).start(next: { [weak self] files in guard let self, let emojiFile = files.values.first else { return } - + let textFont = Font.regular(self.presentationData.listsFontSize.baseDisplaySize * 14.0 / 17.0) let boldTextFont = Font.bold(self.presentationData.listsFontSize.baseDisplaySize * 14.0 / 17.0) let textColor = UIColor.white let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: textColor), linkAttribute: { _ in return nil }) - + let text = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString(self.presentationData.strings.Chat_GroupEmojiTooltip(emojiPack.title).string, attributes: markdownAttributes)) - + let range = (text.string as NSString).range(of: "#") if range.location != NSNotFound { text.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: emojiFile.fileId.id, file: emojiFile), range: range) } - + let tooltipScreen = TooltipScreen( context: self.context, account: self.context.account, @@ -10027,12 +10119,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G ) self.present(tooltipScreen, in: .current) self.emojiPackTooltipController = tooltipScreen - + let _ = ApplicationSpecificNotice.incrementGroupEmojiPackSuggestion(accountManager: self.context.sharedContext.accountManager, peerId: peerId).startStandalone() }) }) } - + private var didDisplayBirthdayTooltip = false func displayBirthdayTooltip() { guard !self.didDisplayBirthdayTooltip else { @@ -10044,9 +10136,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let rect = self.chatDisplayNode.frameForGiftButton(), self.effectiveNavigationController?.topViewController === self, let peer = self.presentationInterfaceState.renderedPeer?.peer.flatMap({ EnginePeer($0) }) else { return } - + self.didDisplayBirthdayTooltip = true - + let _ = (ApplicationSpecificNotice.dismissedBirthdayPremiumGiftTip(accountManager: self.context.sharedContext.accountManager, peerId: peer.id) |> take(1) |> deliverOnMainQueue).startStandalone(next: { [weak self] timestamp in @@ -10055,10 +10147,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let timestamp, currentTime < timestamp + 60 * 60 * 24 { return } - + let peerName = peer.compactDisplayTitle let text = self.presentationData.strings.Chat_BirthdayTooltip(peerName, peerName).string - + let tooltipScreen = TooltipScreen( context: self.context, account: self.context.account, @@ -10075,15 +10167,15 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G Queue.mainQueue().after(0.35) { self.present(tooltipScreen, in: .current) } - + let _ = ApplicationSpecificNotice.incrementDismissedBirthdayPremiumGiftTip(accountManager: self.context.sharedContext.accountManager, peerId: peer.id, timestamp: Int32(Date().timeIntervalSince1970)).startStandalone() } }) } - + func displayChecksTooltip() { self.checksTooltipController?.dismiss() - + var latestNode: (Int32, ASDisplayNode)? self.chatDisplayNode.historyNode.forEachVisibleItemNode { itemNode in if let itemNode = itemNode as? ChatMessageItemView, let item = itemNode.item, let statusNode = itemNode.getStatusNode() { @@ -10098,11 +10190,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - + if let (_, latestStatusNode) = latestNode { let bounds = latestStatusNode.view.convert(latestStatusNode.view.bounds, to: self.chatDisplayNode.view) let location = CGPoint(x: bounds.maxX - 7.0, y: bounds.minY - 11.0) - + let contentNode = ChatStatusChecksTooltipContentNode(presentationData: self.presentationData) let tooltipController = TooltipController(content: .custom(contentNode), baseFontSize: self.presentationData.listsFontSize.baseDisplaySize, timeout: 3.5, dismissByTapOutside: true, dismissImmediatelyOnLayoutUpdate: true) self.checksTooltipController = tooltipController @@ -10119,10 +10211,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G })) } } - + func displayProcessingVideoTooltip(messageId: EngineMessage.Id) { self.checksTooltipController?.dismiss() - + var latestNode: ChatMessageItemView? self.chatDisplayNode.historyNode.forEachVisibleItemNode { itemNode in if let itemNode = itemNode as? ChatMessageItemView, let item = itemNode.item { @@ -10139,11 +10231,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G latestNode = itemNode } } - + if let itemNode = latestNode, let statusNode = itemNode.getStatusNode() { let bounds = statusNode.view.convert(statusNode.view.bounds, to: self.chatDisplayNode.view) let location = CGPoint(x: bounds.midX, y: bounds.minY - 8.0) - + let tooltipController = TooltipController(content: .text(self.presentationData.strings.Chat_MessageTooltipVideoProcessing), baseFontSize: self.presentationData.listsFontSize.baseDisplaySize, balancedTextLayout: true, isBlurred: true, timeout: 4.5, dismissByTapOutside: true, dismissImmediatelyOnLayoutUpdate: true) self.checksTooltipController = tooltipController tooltipController.dismissed = { [weak self, weak tooltipController] _ in @@ -10151,7 +10243,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.checksTooltipController = nil } } - + let _ = self.chatDisplayNode.messageTransitionNode.addCustomOffsetHandler(itemNode: itemNode, update: { [weak tooltipController] offset, transition in guard let tooltipController, tooltipController.isNodeLoaded else { return false @@ -10161,10 +10253,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } containerView.bounds = containerView.bounds.offsetBy(dx: 0.0, dy: -offset) transition.animateOffsetAdditive(layer: containerView.layer, offset: offset) - + return true }) - + self.present(tooltipController, in: .current, with: TooltipControllerPresentationArguments(sourceNodeAndRect: { [weak self] in guard let self else { return nil @@ -10173,7 +10265,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }, sourceRectIsGlobal: true)) } } - + func dismissAllTooltips() { self.emojiTooltipController?.dismiss() self.sendingOptionsTooltipController?.dismiss() @@ -10186,7 +10278,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.checksTooltipController?.dismiss() self.copyProtectionTooltipController?.dismiss() self.guestChatMessageTooltipController?.dismiss() - + self.window?.forEachController({ controller in if let controller = controller as? UndoOverlayController { controller.dismissWithCommitAction() @@ -10208,28 +10300,28 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return true }) } - + func commitPurposefulAction() { if let purposefulAction = self.purposefulAction { self.purposefulAction = nil purposefulAction() } } - + public override var keyShortcuts: [KeyShortcut] { return self.keyShortcutsInternal } - + public override func joinGroupCall(peerId: PeerId, invite: String?, activeCall: EngineGroupCallDescription) { let proceed = { super.joinGroupCall(peerId: peerId, invite: invite, activeCall: activeCall) } - + let _ = self.presentVoiceMessageDiscardAlert(action: { proceed() }) } - + public func getTransitionInfo(messageId: EngineMessage.Id, media: EngineMedia) -> ((UIView) -> Void, ASDisplayNode, () -> (UIView?, UIView?))? { var selectedNode: (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? let rawMedia = media._asMedia() @@ -10251,7 +10343,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return nil } } - + public func activateInput(type: ChatControllerActivateInput) { if self.didAppear { switch type { @@ -10273,7 +10365,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.scheduledActivateInput = type } } - + func clearInputText() { self.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in if !state.interfaceState.effectiveInputState.inputText.string.isEmpty { @@ -10286,7 +10378,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }) } - + func updateSlowmodeStatus() { if let slowmodeState = self.presentationInterfaceState.slowmodeState, case let .timestamp(slowmodeActiveUntilTimestamp) = slowmodeState.variant { let timestamp = Int32(Date().timeIntervalSince1970) @@ -10316,7 +10408,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.updateSlowmodeStatusDisposable.set(nil) } } - + func openScheduledMessages(force: Bool = false, completion: @escaping (ChatControllerImpl) -> Void = { _ in }) { guard let navigationController = self.effectiveNavigationController else { return @@ -10325,12 +10417,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { return } - + var mappedChatLocation = self.chatLocation if case let .replyThread(message) = self.chatLocation, message.peerId == self.context.account.peerId { mappedChatLocation = .peer(id: self.context.account.peerId) } - + let controller = ChatControllerImpl(context: self.context, chatLocation: mappedChatLocation, subject: .scheduledMessages) controller.navigationPresentation = .modal navigationController.pushViewController(controller, completion: { [weak controller] in @@ -10341,7 +10433,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) completion(controller) } - + func openPinnedMessages(at messageId: MessageId?) { let _ = self.presentVoiceMessageDiscardAlert(action: { [weak self] in guard let self, let navigationController = self.effectiveNavigationController, navigationController.topViewController == self else { @@ -10364,10 +10456,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G navigationController.pushViewController(controller) }) } - + func performUpdatedClosedPinnedMessageId(pinnedMessageId: MessageId) { let previousClosedPinnedMessageId = self.presentationInterfaceState.interfaceState.messageActionsState.closedPinnedMessageId - + self.updateChatPresentationInterfaceState(animated: true, interactive: true, { return $0.updatedInterfaceState({ $0.withUpdatedMessageActionsState({ value in var value = value @@ -10375,7 +10467,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return value }) }) }) - + self.present( UndoOverlayController( presentationData: self.presentationData, @@ -10390,7 +10482,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self else { return true } - + switch action { case .commit: break @@ -10411,7 +10503,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G in: .current ) } - + func performRequestedUnpinAllMessages(count: Int, pinnedMessageId: MessageId) { guard let peerId = self.chatLocation.peerId else { return @@ -10420,7 +10512,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.updateChatPresentationInterfaceState(animated: true, interactive: true, { return $0.updatedPendingUnpinnedAllMessages(true) }) - + self.present( UndoOverlayController( presentationData: self.presentationData, @@ -10435,7 +10527,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self else { return true } - + switch action { case .commit: let _ = (strongSelf.context.engine.messages.requestUnpinAllMessages(peerId: peerId, threadId: strongSelf.chatLocation.threadId) @@ -10444,7 +10536,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self else { return } - + strongSelf.chatDisplayNode.historyNode.pendingUnpinnedAllMessages = false strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { return $0.updatedPendingUnpinnedAllMessages(false) @@ -10464,13 +10556,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G in: .current ) } - + func presentScheduleTimePicker(style: ChatScheduleTimeControllerStyle = .default, selectedTime: Int32? = nil, selectedRepeatPeriod: Int32? = nil, dismissByTapOutside: Bool = true, presentInOverlay: Bool = false, completion: @escaping (Int32, Int32?) -> Void) { self.presentScheduleTimePicker(style: style, selectedTime: selectedTime, selectedRepeatPeriod: selectedRepeatPeriod, dismissByTapOutside: dismissByTapOutside, presentInOverlay: presentInOverlay, completion: { result in completion(result.time, result.repeatPeriod) }) } - + func presentScheduleTimePicker(style: ChatScheduleTimeControllerStyle = .default, selectedTime: Int32? = nil, selectedRepeatPeriod: Int32? = nil, dismissByTapOutside: Bool = true, presentInOverlay: Bool = false, completion: @escaping (ChatScheduleTimeScreen.Result) -> Void) { guard let peerId = self.chatLocation.peerId else { return @@ -10488,14 +10580,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if peer.id.namespace == Namespaces.Peer.CloudUser && peer.id.id._internalGetInt64Value() == 777000 { sendWhenOnlineAvailable = false } - + let mode: ChatScheduleTimeScreen.Mode if peerId == strongSelf.context.account.peerId { mode = .reminders } else { mode = .scheduledMessages(peerId: peer.id, sendWhenOnlineAvailable: sendWhenOnlineAvailable) } - + let controller = ChatScheduleTimeScreen( context: strongSelf.context, mode: mode, @@ -10516,7 +10608,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }) } - + func presentTimerPicker(style: ChatTimerScreenStyle = .default, selectedTime: Int32? = nil, completion: @escaping (Int32) -> Void) { guard case .peer = self.chatLocation else { return @@ -10527,7 +10619,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.chatDisplayNode.dismissInput() self.present(controller, in: .window(.root)) } - + func presentVoiceMessageDiscardAlert( action: @escaping () -> Void = {}, alertAction: (() -> Void)? = nil, @@ -10571,33 +10663,33 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G ], actionLayout: .vertical ) - + self.present(alertController, in: .window(.root)) } - + return true } else if performAction { action() } return false } - + func presentAutoremoveSetup() { guard let peer = self.presentationInterfaceState.renderedPeer?.peer else { return } - + let controller = ChatTimerScreen(context: self.context, updatedPresentationData: self.updatedPresentationData, style: .default, mode: .autoremove, currentTime: self.presentationInterfaceState.autoremoveTimeout, completion: { [weak self] value in guard let strongSelf = self else { return } - + let _ = (strongSelf.context.engine.peers.setChatMessageAutoremoveTimeoutInteractively(peerId: peer.id, timeout: value == 0 ? nil : value) |> deliverOnMainQueue).startStandalone(completed: { guard let strongSelf = self else { return } - + var isOn: Bool = true var text: String? if value != 0 { @@ -10614,16 +10706,16 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.chatDisplayNode.dismissInput() self.present(controller, in: .window(.root)) } - + func presentChatRequestAdminInfo() { if let requestChatTitle = self.presentationInterfaceState.contactStatus?.peerStatusSettings?.requestChatTitle, let requestDate = self.presentationInterfaceState.contactStatus?.peerStatusSettings?.requestChatDate { let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } - + let controller = ActionSheetController(presentationData: presentationData) var items: [ActionSheetItem] = [] - + let text = presentationData.strings.Conversation_InviteRequestInfo(requestChatTitle, stringForDate(timestamp: requestDate, strings: presentationData.strings)) - + items.append(ActionSheetTextItem(title: text.string)) items.append(ActionSheetButtonItem(title: self.presentationData.strings.Conversation_InviteRequestInfoConfirm, color: .accent, action: { [weak self, weak controller] in controller?.dismissAnimated() @@ -10638,7 +10730,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.present(controller, in: .window(.root)) } } - + var crossfading = false func presentCrossfadeSnapshot() { guard !self.crossfading, let snapshotView = self.view.snapshotView(afterScreenUpdates: false) else { @@ -10652,11 +10744,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G snapshotView?.removeFromSuperview() }) } - + public func hintPlayNextOutgoingGift() { self.controllerInteraction?.playNextOutgoingGift = true } - + var effectiveNavigationController: NavigationController? { if let navigationController = self.navigationController as? NavigationController { return navigationController @@ -10671,21 +10763,21 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return nil } } - + public func activateSearch(domain: ChatSearchDomain = .everything, query: String = "") { self.focusOnSearchAfterAppearance = (domain, query) self.interfaceInteraction?.beginMessageSearch(domain, query) } - + override public func updatePossibleControllerDropContent(content: NavigationControllerDropContent?) { //self.chatDisplayNode.updateEmbeddedTitlePeekContent(content: content) } - + override public func acceptPossibleControllerDropContent(content: NavigationControllerDropContent) -> Bool { //return self.chatDisplayNode.acceptEmbeddedTitlePeekContent(content: content) return false } - + public var isSendButtonVisible: Bool { if self.presentationInterfaceState.interfaceState.editMessage != nil || self.presentationInterfaceState.interfaceState.forwardMessageIds != nil || self.presentationInterfaceState.interfaceState.composeInputState.inputText.string.count > 0 { return true @@ -10693,16 +10785,16 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return false } } - + public func playShakeAnimation() { if self.shakeFeedback == nil { self.shakeFeedback = HapticFeedback() } self.shakeFeedback?.error() - + self.chatDisplayNode.historyNodeContainer.layer.addShakeAnimation(amplitude: -6.0, decay: true) } - + public func updatePushedTransition(_ fraction: CGFloat, transition: ContainedViewLayoutTransition) { if !transition.isAnimated { self.chatDisplayNode.historyNode.layer.removeAnimation(forKey: "sublayerTransform") @@ -10710,14 +10802,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let scale: CGFloat = 1.0 - 0.06 * fraction transition.updateSublayerTransformScale(node: self.chatDisplayNode.historyNode, scale: scale) } - + public func restrictedSendingContentsText() -> String { guard let peer = self.presentationInterfaceState.renderedPeer?.peer else { return self.presentationData.strings.Chat_SendNotAllowedText } - + var itemList: [String] = [] - + let order: [TelegramChatBannedRightsFlags] = [ .banSendText, .banSendPhotos, @@ -10728,7 +10820,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G .banSendMusic, .banSendStickers ] - + for right in order { if let channel = peer as? TelegramChannel { if channel.hasBannedPermission(right) != nil { @@ -10739,7 +10831,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G continue } } - + var title: String? switch right { case .banSendText: @@ -10765,11 +10857,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G itemList.append(title) } } - + if itemList.isEmpty { return self.presentationData.strings.Chat_SendNotAllowedText } - + var itemListString = "" if #available(iOS 13.0, *) { let listFormatter = ListFormatter() @@ -10778,7 +10870,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G itemListString = value } } - + if itemListString.isEmpty { for i in 0 ..< itemList.count { if i != 0 { @@ -10787,17 +10879,17 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G itemListString.append(itemList[i]) } } - + return self.presentationData.strings.Chat_SendAllowedContentText(itemListString).string } - + func updateNextChannelToReadVisibility() { guard let contentData = self.contentData else { return } self.chatDisplayNode.historyNode.offerNextChannelToRead = contentData.state.offerNextChannelToRead && self.presentationInterfaceState.interfaceState.selectionState == nil } - + func displayGiveawayStatusInfo(messageId: EngineMessage.Id, giveawayInfo: PremiumGiveawayInfo) { presentGiveawayInfoController(context: self.context, updatedPresentationData: self.updatedPresentationData, messageId: messageId, giveawayInfo: giveawayInfo, present: { [weak self] c in guard let self else { @@ -10811,11 +10903,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.openResolved(result: .premiumGiftCode(slug: slug), sourceMessageId: messageId) }) } - + public func transferScrollingVelocity(_ velocity: CGFloat) { self.chatDisplayNode.historyNode.transferVelocity(velocity) } - + public func performScrollToTop() -> Bool { let offset = self.chatDisplayNode.historyNode.visibleContentOffset() switch offset { @@ -10826,15 +10918,15 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return true } } - + private var updateChatLocationThreadDisposable: Disposable? private(set) var isUpdatingChatLocationThread: Bool = false var currentChatSwitchDirection: ChatControllerAnimateInnerChatSwitchDirection? - + func updateChatLocationToOther(chatLocation: ChatLocation) { self.saveInterfaceState() self.chatDisplayNode.dismissTextInput() - + let updatedChatLocation: ChatLocation = chatLocation let chatLocationContextHolder = Atomic(value: nil) let historyNode = self.chatDisplayNode.createHistoryNodeForChatLocation(chatLocation: updatedChatLocation, chatLocationContextHolder: chatLocationContextHolder) @@ -10843,29 +10935,29 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let self, let historyNode else { return } - + self.currentChatSwitchDirection = nil self.chatLocation = updatedChatLocation historyNode.areContentAnimationsEnabled = true self.chatDisplayNode.prepareSwitchToChatLocation(chatLocation: chatLocation, historyNode: historyNode, animationDirection: nil) - + apply(self.didAppear ? .animated(duration: 0.4, curve: .spring) : .immediate) - + self.currentChatSwitchDirection = nil self.isUpdatingChatLocationThread = false }) } - + func updateInitialChatBotForumLocationThread(linkedForumId: EnginePeer.Id, threadId: Int64) { if self.isUpdatingChatLocationThread { assertionFailure() return } - + self.saveInterfaceState() - + self.chatDisplayNode.dismissTextInput() - + let updatedChatLocation: ChatLocation = .replyThread(message: ChatReplyThreadMessage( peerId: linkedForumId, threadId: threadId, @@ -10881,7 +10973,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G initialAnchor: .automatic, isNotAvailable: false )) - + let chatLocationContextHolder = Atomic(value: nil) let historyNode = self.chatDisplayNode.createHistoryNodeForChatLocation(chatLocation: updatedChatLocation, chatLocationContextHolder: chatLocationContextHolder) self.isUpdatingChatLocationThread = true @@ -10889,19 +10981,19 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let self, let historyNode else { return } - + self.currentChatSwitchDirection = nil self.chatLocation = updatedChatLocation historyNode.areContentAnimationsEnabled = true self.chatDisplayNode.prepareSwitchToChatLocation(chatLocation: updatedChatLocation, historyNode: historyNode, animationDirection: nil) - + apply(.animated(duration: 0.4, curve: .spring)) - + self.currentChatSwitchDirection = nil self.isUpdatingChatLocationThread = false }) } - + public func updateChatLocationThread(threadId: Int64?, animationDirection: ChatControllerAnimateInnerChatSwitchDirection? = nil, replaceInline: Bool = false, transferInputState: Bool = false, completion: (() -> Void)? = nil) { Task { @MainActor [weak self] in guard let self else { @@ -10924,11 +11016,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G completion?() return } - + var clearInputState = false if transferInputState { self.chatDisplayNode.textInputPanelNode?.ignoreInputStateUpdates = true - + var peerId: PeerId var currentThreadId: Int64? switch self.chatLocation { @@ -10940,7 +11032,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G case .customChatContents: return } - + let timestamp = Int32(Date().timeIntervalSince1970) var interfaceState = self.presentationInterfaceState.interfaceState.withUpdatedTimestamp(timestamp) let composeInputState = interfaceState.composeInputState @@ -10957,7 +11049,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return current.withUpdatedComposeInputState(ChatTextInputState()) } }).startStandalone() - + if currentThreadId == EngineMessage.newTopicThreadId { clearInputState = true } @@ -10965,11 +11057,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.saveInterfaceState() self.chatDisplayNode.dismissTextInput() } - + let updatedChatLocation: ChatLocation if let threadId { let isMonoforum = peer.isMonoForum - + updatedChatLocation = .replyThread(message: ChatReplyThreadMessage( peerId: peerId, threadId: threadId, @@ -10988,10 +11080,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { updatedChatLocation = .peer(id: peerId) } - + let navigationSnapshot = self.chatTitleView?.prepareSnapshotState() let avatarSnapshot = self.chatInfoNavigationButton?.buttonItem.customDisplayNode?.view.window != nil ? (self.chatInfoNavigationButton?.buttonItem.customDisplayNode as? ChatAvatarNavigationNode)?.prepareSnapshotState() : nil - + let chatLocationContextHolder = Atomic(value: nil) let historyNode = replaceInline ? self.chatDisplayNode.historyNode : self.chatDisplayNode.createHistoryNodeForChatLocation(chatLocation: updatedChatLocation, chatLocationContextHolder: chatLocationContextHolder) self.isUpdatingChatLocationThread = true @@ -11002,14 +11094,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let self, let historyNode else { return } - + self.currentChatSwitchDirection = animationDirection self.chatLocation = updatedChatLocation historyNode.areContentAnimationsEnabled = true self.chatDisplayNode.prepareSwitchToChatLocation(chatLocation: updatedChatLocation, historyNode: historyNode, animationDirection: animationDirection) - + apply(.animated(duration: transferInputState ? 0.3 : 0.4, curve: .spring)) - + if let navigationSnapshot, let animationDirection { let mappedAnimationDirection: ChatTitleView.AnimateFromSnapshotDirection switch animationDirection { @@ -11022,19 +11114,19 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G case .right: mappedAnimationDirection = .right } - + self.chatTitleView?.animateFromSnapshot(navigationSnapshot, direction: mappedAnimationDirection) } - + if let avatarSnapshot, self.chatInfoNavigationButton?.buttonItem.customDisplayNode?.view.window != nil { (self.chatInfoNavigationButton?.buttonItem.customDisplayNode as? ChatAvatarNavigationNode)?.animateFromSnapshot(avatarSnapshot) } - + self.currentChatSwitchDirection = nil self.isUpdatingChatLocationThread = false - + self.chatDisplayNode.textInputPanelNode?.ignoreInputStateUpdates = false - + if clearInputState { //DispatchQueue.main.async { [weak self] in // guard let self else { @@ -11043,12 +11135,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.updateChatPresentationInterfaceState(animated: false, interactive: false, { $0.updatedInterfaceState { $0.withUpdatedComposeInputState(ChatTextInputState()) } }) //} } - + completion?() }) } } - + public var contentContainerNode: ASDisplayNode { return self.chatDisplayNode.contentContainerNode } diff --git a/submodules/TelegramUI/Sources/ChatControllerContentData.swift b/submodules/TelegramUI/Sources/ChatControllerContentData.swift index 8e437535ed..ce12bfc4d4 100644 --- a/submodules/TelegramUI/Sources/ChatControllerContentData.swift +++ b/submodules/TelegramUI/Sources/ChatControllerContentData.swift @@ -22,7 +22,7 @@ extension ChatControllerImpl { let subject: ChatControllerSubject? let selectionState: ChatInterfaceSelectionState? let reportReason: ChatPresentationInterfaceState.ReportReasonData? - + init( subject: ChatControllerSubject?, selectionState: ChatInterfaceSelectionState?, @@ -32,7 +32,7 @@ extension ChatControllerImpl { self.selectionState = selectionState self.reportReason = reportReason } - + static func ==(lhs: Configuration, rhs: Configuration) -> Bool { if lhs.subject != rhs.subject { return false @@ -46,34 +46,34 @@ extension ChatControllerImpl { return true } } - + enum InfoAvatar { case peer(peer: EnginePeer, imageOverride: AvatarNodeImageOverride?, contextActionIsEnabled: Bool, accessibilityLabel: String?) case emojiStatus(content: EmojiStatusComponent.Content, contextActionIsEnabled: Bool) } - + enum PerformDismissAction { case upgraded(EnginePeer.Id) case movedToForumTopics case dismiss } - + struct NextChannelToRead: Equatable { struct ThreadData: Equatable { let id: Int64 let data: MessageHistoryThreadData - + init(id: Int64, data: MessageHistoryThreadData) { self.id = id self.data = data } } - + let peer: EnginePeer let threadData: ThreadData? let unreadCount: Int let location: TelegramEngine.NextUnreadChannelLocation - + init(peer: EnginePeer, threadData: ThreadData?, unreadCount: Int, location: TelegramEngine.NextUnreadChannelLocation) { self.peer = peer self.threadData = threadData @@ -81,7 +81,7 @@ extension ChatControllerImpl { self.location = location } } - + struct State { var peerView: PeerView? var threadInfo: EngineMessageHistoryThread.Info? @@ -124,7 +124,7 @@ extension ChatControllerImpl { var starGiftsAvailable: Bool = false var performDismissAction: PerformDismissAction? var savedMessagesTopicPeer: EnginePeer? - + var keyboardButtonsMessage: Message? var pinnedMessageId: EngineMessage.Id? var pinnedMessage: ChatPinnedMessage? @@ -133,7 +133,7 @@ extension ChatControllerImpl { var callsPrivate: Bool = false var activeGroupCallInfo: ChatActiveGroupCallInfo? var slowmodeState: ChatSlowmodeState? - + var suggestPremiumGift: Bool = false var translationState: ChatPresentationTranslationState? var voiceMessagesAvailable: Bool = true @@ -148,19 +148,19 @@ extension ChatControllerImpl { var viewForumAsMessages: Bool = false var hasTopics: Bool = false var canStopIncomingStreamingMessage: Bool = false - + var preloadNextChatPeerId: EnginePeer.Id? } - + private let presentationData: PresentationData - + private var peerDisposable: Disposable? private var titleDisposable: Disposable? private var preloadSavedMessagesChatsDisposable: Disposable? - + private var preloadHistoryPeerId: PeerId? private let preloadHistoryPeerIdDisposable = MetaDisposable() - + private var nextChannelToReadDisposable: Disposable? private let chatAdditionalDataDisposable = MetaDisposable() private var premiumOrStarsRequiredDisposable: Disposable? @@ -168,19 +168,19 @@ extension ChatControllerImpl { private var cachedDataDisposable: Disposable? private var premiumGiftSuggestionDisposable: Disposable? private var translationStateDisposable: Disposable? - + private let isPeerInfoReady = ValuePromise(false, ignoreRepeated: true) private let isChatLocationInfoReady = ValuePromise(false, ignoreRepeated: true) private let isCachedDataReady = ValuePromise(false, ignoreRepeated: true) - + let chatLocation: ChatLocation let chatLocationInfoData: ChatLocationInfoData - + private(set) var state: State = State() var initialInterfaceState: (interfaceState: ChatInterfaceState, editMessage: Message?)? var initialNavigationBadge: String? var initialPersistentPeerData: ChatPresentationInterfaceState.PersistentPeerData? - + var overlayTitle: String? { var title: String? if let threadInfo = self.state.threadInfo { @@ -192,25 +192,25 @@ extension ChatControllerImpl { } return title } - + let isReady = Promise() var onUpdated: ((State) -> Void)? - + let scrolledToMessageId = ValuePromise(nil, ignoreRepeated: true) var scrolledToMessageIdValue: ScrolledToMessageId? = nil { didSet { self.scrolledToMessageId.set(self.scrolledToMessageIdValue) } } - + var historyNavigationStack = ChatHistoryNavigationStack() - + let chatThemePromise = Promise() let chatWallpaperPromise = Promise() - + private(set) var inviteRequestsContext: PeerInvitationImportersContext? private var inviteRequestsDisposable: Disposable? - + init( context: AccountContext, chatLocation: ChatLocation, @@ -227,14 +227,14 @@ extension ChatControllerImpl { ) { self.chatLocation = chatLocation self.presentationData = presentationData - + self.inviteRequestsContext = inviteRequestsContext - + let strings = self.presentationData.strings - + let chatLocationPeerId: PeerId? = chatLocation.peerId let peerId = chatLocationPeerId - + switch chatLocation { case .peer: self.chatLocationInfoData = .peer(Promise()) @@ -255,7 +255,7 @@ extension ChatControllerImpl { case .customChatContents: self.chatLocationInfoData = .customChatContents } - + if let peerId = chatLocation.peerId, peerId != context.account.peerId { switch initialSubject { case .pinnedMessages, .scheduledMessages, .messageOptions: @@ -264,7 +264,7 @@ extension ChatControllerImpl { self.state.navigationUserInfo = PeerInfoNavigationSourceTag(peerId: peerId, threadId: chatLocation.threadId) } } - + let managingBot: Signal if let peerId = chatLocation.peerId, peerId.namespace == Namespaces.Peer.CloudUser { managingBot = context.engine.data.subscribe( @@ -281,7 +281,7 @@ extension ChatControllerImpl { guard let botPeer else { return nil } - + return ChatManagingBot(bot: botPeer, isPaused: result.isPaused, canReply: result.canReply, settingsUrl: result.manageUrl) } } @@ -289,13 +289,13 @@ extension ChatControllerImpl { } else { managingBot = .single(nil) } - + if case let .peer(peerView) = self.chatLocationInfoData, let peerId = peerId { peerView.set(context.account.viewTracker.peerView(peerId)) var onlineMemberCount: Signal<(total: Int32?, recent: Int32?), NoError> = .single((nil, nil)) var hasScheduledMessages: Signal = .single(false) var hasTopics: Signal = .single(false) - + if peerId.namespace == Namespaces.Peer.CloudChannel { let recentOnlineSignal: Signal<(total: Int32?, recent: Int32?), NoError> = peerView.get() |> map { view -> Bool? in @@ -331,7 +331,7 @@ extension ChatControllerImpl { } onlineMemberCount = recentOnlineSignal } - + var isScheduledOrPinnedMessages = false switch initialSubject { case .scheduledMessages, .pinnedMessages, .messageOptions: @@ -339,7 +339,7 @@ extension ChatControllerImpl { default: break } - + if chatLocation.peerId != nil, !isScheduledOrPinnedMessages, peerId.namespace != Namespaces.Peer.SecretChat { hasScheduledMessages = peerView.get() |> take(1) @@ -353,7 +353,7 @@ extension ChatControllerImpl { } } } - + if chatLocation.threadId != nil { hasTopics = .single(true) } else { @@ -363,7 +363,7 @@ extension ChatControllerImpl { } } } - + var displayedCountSignal: Signal = .single(nil) var subtitleTextSignal: Signal = .single(nil) if case .pinnedMessages = initialSubject { @@ -382,10 +382,10 @@ extension ChatControllerImpl { } } |> distinctUntilChanged - + let peers = context.account.postbox.multiplePeersView(peerIds) |> take(1) - + switch info { case let .forward(forward): subtitleTextSignal = combineLatest(peers, forward.options, displayedCountSignal) @@ -476,7 +476,7 @@ extension ChatControllerImpl { |> distinctUntilChanged } } - + let hasPeerInfo: Signal if peerId == context.account.peerId { hasPeerInfo = .single(true) @@ -486,7 +486,7 @@ extension ChatControllerImpl { } else { hasPeerInfo = .single(true) } - + enum MessageOptionsTitleInfo { case reply(hasQuote: Bool) } @@ -508,7 +508,7 @@ extension ChatControllerImpl { } else { messageOptionsTitleInfo = .single(nil) } - + var savedMessagesChatsTip: Signal = .single(false) if case .peer(context.account.peerId) = chatLocation { let hasSavedChats = context.engine.messages.savedMessagesHasPeersOtherThanSaved() @@ -527,7 +527,7 @@ extension ChatControllerImpl { |> distinctUntilChanged } } - + self.titleDisposable = (combineLatest( queue: Queue.mainQueue(), peerView.get(), @@ -543,14 +543,14 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + let previousState = strongSelf.state - + var isScheduledMessages = false if case .scheduledMessages = configuration.subject { isScheduledMessages = true } - + if case let .messageOptions(_, _, info) = configuration.subject { if case .reply = info { let titleContent: ChatTitleContent @@ -559,7 +559,7 @@ extension ChatControllerImpl { } else { titleContent = .custom(title: [ChatTitleContent.TitleTextItem(id: AnyHashable(0), content: .text(strings.Chat_TitleReply))], subtitle: subtitleText, isEnabled: false) } - + strongSelf.state.chatTitleContent = titleContent } else if case .link = info { strongSelf.state.chatTitleContent = .custom(title: [ChatTitleContent.TitleTextItem(id: AnyHashable(0), content: .text(strings.Chat_TitleLinkOptions))], subtitle: subtitleText, isEnabled: false) @@ -581,7 +581,7 @@ extension ChatControllerImpl { items.append(ChatTitleContent.TitleTextItem(id: AnyHashable("selection_2"), content: .text(String(rawText[range.upperBound ..< rawText.endIndex])))) } } - + strongSelf.state.chatTitleContent = .custom(title: items, subtitle: nil, isEnabled: false) } else { if let reportReason = configuration.reportReason { @@ -612,9 +612,9 @@ extension ChatControllerImpl { if savedMessagesChatsTip { customSubtitle = strings.Chat_SavedMessagesStatusViewAsChats } - + strongSelf.state.chatTitleContent = .peer(peerView: ChatTitleContent.PeerData(peerView: peerView), customTitle: nil, customSubtitle: customSubtitle, onlineMemberCount: onlineMemberCount, isScheduledMessages: isScheduledMessages, isMuted: nil, customMessageCount: nil, hidePeerStatus: false, isEnabled: hasPeerInfo) - + let imageOverride: AvatarNodeImageOverride? if context.account.peerId == peer.id { imageOverride = .savedMessagesIcon @@ -627,29 +627,29 @@ extension ChatControllerImpl { } else { imageOverride = nil } - + let infoContextActionIsEnabled: Bool if case .standard(.previewing) = mode { infoContextActionIsEnabled = false } else { infoContextActionIsEnabled = peer.restrictionText(platform: "ios", contentSettings: context.currentContentSettings.with { $0 }) == nil } - + strongSelf.state.infoAvatar = .peer( peer: EnginePeer(peer), imageOverride: imageOverride, contextActionIsEnabled: infoContextActionIsEnabled, accessibilityLabel: strings.Conversation_ContextMenuOpenProfile ) - + strongSelf.state.storyStats = peerView.storyStats } } - + strongSelf.isPeerInfoReady.set(true) strongSelf.onUpdated?(previousState) }) - + let threadInfo: Signal if let threadId = chatLocation.threadId { threadInfo = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Messages.ThreadInfo(peerId: peerId, threadId: threadId)) @@ -660,7 +660,7 @@ extension ChatControllerImpl { } else { threadInfo = .single(nil) } - + let hasSearchTags: Signal if let peerId = chatLocation.peerId, peerId == context.account.peerId { hasSearchTags = context.engine.data.subscribe( @@ -673,14 +673,14 @@ extension ChatControllerImpl { } else { hasSearchTags = .single(false) } - + let hasSavedChats: Signal if case .peer(context.account.peerId) = chatLocation { hasSavedChats = context.engine.messages.savedMessagesHasPeersOtherThanSaved() } else { hasSavedChats = .single(false) } - + let isPremiumRequiredForMessaging: Signal if let peerId = chatLocation.peerId { isPremiumRequiredForMessaging = context.engine.peers.subscribeIsPremiumRequiredForMessaging(id: peerId) @@ -688,14 +688,14 @@ extension ChatControllerImpl { } else { isPremiumRequiredForMessaging = .single(false) } - + let adMessage: Signal if let adMessagesContext { adMessage = adMessagesContext.state |> map { $0.messages.first } } else { adMessage = .single(nil) } - + let displayedPeerVerification: Signal if let peerId = chatLocation.peerId { displayedPeerVerification = ApplicationSpecificNotice.displayedPeerVerification(accountManager: context.sharedContext.accountManager, peerId: peerId) @@ -703,9 +703,9 @@ extension ChatControllerImpl { } else { displayedPeerVerification = .single(false) } - + let globalPrivacySettings = context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.GlobalPrivacy()) - + let canStopIncomingStreamingMessage: Signal = .single(false) /*if let peerId = chatLocation.peerId { let key = PeerAndThreadId(peerId: peerId, threadId: chatLocation.threadId) @@ -742,9 +742,9 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + let previousState = strongSelf.state - + if strongSelf.state.peerView === peerView && strongSelf.state.hasScheduledMessages == hasScheduledMessages && strongSelf.state.hasTopics == hasTopics @@ -757,11 +757,11 @@ extension ChatControllerImpl { && adMessage?.id == strongSelf.state.adMessage?.id { return } - + strongSelf.state.hasScheduledMessages = hasScheduledMessages strongSelf.state.hasTopics = hasTopics strongSelf.state.canStopIncomingStreamingMessage = canStopIncomingStreamingMessage - + var upgradedToPeerId: PeerId? var movedToForumTopics = false if let previous = strongSelf.state.peerView, let group = previous.peers[previous.peerId] as? TelegramGroup, group.migrationReference == nil, let updatedGroup = peerView.peers[peerView.peerId] as? TelegramGroup, let migrationReference = updatedGroup.migrationReference { @@ -773,7 +773,7 @@ extension ChatControllerImpl { movedToForumTopics = true } } - + var shouldDismiss = false if let previous = strongSelf.state.peerView, let group = previous.peers[previous.peerId] as? TelegramGroup, group.membership != .Removed, let updatedGroup = peerView.peers[peerView.peerId] as? TelegramGroup, updatedGroup.membership == .Removed { shouldDismiss = true @@ -782,7 +782,7 @@ extension ChatControllerImpl { } else if let previous = strongSelf.state.peerView, let secretChat = previous.peers[previous.peerId] as? TelegramSecretChat, case .active = secretChat.embeddedState, let updatedSecretChat = peerView.peers[peerView.peerId] as? TelegramSecretChat, case .terminated = updatedSecretChat.embeddedState { shouldDismiss = true } - + var wasGroupChannel: Bool? if let previousPeerView = strongSelf.state.peerView, let info = (previousPeerView.peers[previousPeerView.peerId] as? TelegramChannel)?.info { if case .group = info { @@ -814,7 +814,7 @@ extension ChatControllerImpl { strongSelf.chatAdditionalDataDisposable.set(nil) } } - + var peerIsMuted = false if let notificationSettings = peerView.notificationSettings as? TelegramPeerNotificationSettings { if case let .muted(until) = notificationSettings.muteState, until >= Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) { @@ -891,7 +891,7 @@ extension ChatControllerImpl { } } contactStatus = ChatContactStatus(canAddContact: false, peerStatusSettings: cachedData.peerStatusSettings, invitedBy: invitedBy, managingBot: managingBot) - + if let channel = peerView.peers[peerView.peerId] as? TelegramChannel { if channel.isMonoForum { if let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = peerView.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.manageDirect) { @@ -916,7 +916,7 @@ extension ChatControllerImpl { } } } - + var peers = SimpleDictionary() peers[peer.id] = peer if let associatedPeerId = peer.associatedPeerId, let associatedPeer = peerView.peers[associatedPeerId] { @@ -924,16 +924,16 @@ extension ChatControllerImpl { } renderedPeer = RenderedPeer(peerId: peer.id, peers: peers, associatedMedia: peerView.media) } - + var isNotAccessible: Bool = false if let cachedChannelData = peerView.cachedData as? CachedChannelData { isNotAccessible = cachedChannelData.isNotAccessible } - + if firstTime && isNotAccessible { context.account.viewTracker.forceUpdateCachedPeerData(peerId: peerView.peerId) } - + var hasBots: Bool = false var hasBotCommands: Bool = false var botMenuButton: BotMenuButton = .commands @@ -1010,22 +1010,22 @@ extension ChatControllerImpl { } } } - + if let channel = peerView.peers[peerView.peerId] as? TelegramChannel, channel.flags.contains(.displayForumAsTabs) { strongSelf.state.viewForumAsMessages = true } else if let cachedData = peerView.cachedData as? CachedChannelData { strongSelf.state.viewForumAsMessages = cachedData.viewForumAsMessages.knownValue ?? false } - + let isArchived: Bool = peerView.groupId == Namespaces.PeerGroup.archive - + var explicitelyCanPinMessages: Bool = false if let cachedUserData = peerView.cachedData as? CachedUserData { explicitelyCanPinMessages = cachedUserData.canPinMessages } else if peerView.peerId == context.account.peerId { explicitelyCanPinMessages = true } - + let preloadHistoryPeerId = peerMonoforumId// ?? peerDiscussionId if strongSelf.preloadHistoryPeerId != preloadHistoryPeerId { strongSelf.preloadHistoryPeerId = preloadHistoryPeerId @@ -1038,24 +1038,24 @@ extension ChatControllerImpl { strongSelf.preloadHistoryPeerIdDisposable.set(nil) } } - + var appliedBoosts: Int32? var boostsToUnrestrict: Int32? if let cachedChannelData = peerView.cachedData as? CachedChannelData { appliedBoosts = cachedChannelData.appliedBoosts boostsToUnrestrict = cachedChannelData.boostsToUnrestrict } - + if strongSelf.premiumOrStarsRequiredDisposable == nil, sendPaidMessageStars != nil, let peerId = chatLocation.peerId { strongSelf.premiumOrStarsRequiredDisposable = ((context.engine.peers.isPremiumRequiredToContact([peerId]) |> then(.complete() |> suspendAwareDelay(60.0, queue: Queue.concurrentDefaultQueue()))) |> restart).startStandalone() } - + var adMessage = adMessage if let peer = peerView.peers[peerView.peerId] as? TelegramUser, peer.botInfo != nil { } else { adMessage = nil } - + strongSelf.state.isNotAccessible = isNotAccessible strongSelf.state.contactStatus = contactStatus strongSelf.state.hasBots = hasBots @@ -1072,7 +1072,7 @@ extension ChatControllerImpl { strongSelf.state.canStopIncomingStreamingMessage = canStopIncomingStreamingMessage strongSelf.state.autoremoveTimeout = autoremoveTimeout strongSelf.state.currentSendAsPeerId = currentSendAsPeerId - strongSelf.state.copyProtectionEnabled = copyProtectionEnabled + strongSelf.state.copyProtectionEnabled = copyProtectionEnabled && !currentWinterGramSettings.disableCopyProtection strongSelf.state.myCopyProtectionEnabled = myCopyProtectionEnabled strongSelf.state.hasSearchTags = hasSearchTags strongSelf.state.isPremiumRequiredForMessaging = isPremiumRequiredForMessaging @@ -1087,7 +1087,7 @@ extension ChatControllerImpl { strongSelf.state.adMessage = adMessage strongSelf.state.peerVerification = peerVerification strongSelf.state.starGiftsAvailable = starGiftsAvailable - + strongSelf.state.renderedPeer = renderedPeer strongSelf.state.adMessage = adMessage @@ -1114,11 +1114,11 @@ extension ChatControllerImpl { guard let strongSelf else { return } - + let previousState = strongSelf.state var isUpdated = false - + if !strongSelf.state.offerNextChannelToRead { strongSelf.state.offerNextChannelToRead = true isUpdated = true @@ -1136,9 +1136,9 @@ extension ChatControllerImpl { } let nextPeerId = nextPeer?.id - + strongSelf.state.preloadNextChatPeerId = nextPeerId - + if isUpdated { strongSelf.onUpdated?(previousState) } @@ -1158,11 +1158,11 @@ extension ChatControllerImpl { guard let strongSelf else { return } - + let previousState = strongSelf.state var isUpdated = false - + if !strongSelf.state.offerNextChannelToRead { strongSelf.state.offerNextChannelToRead = true isUpdated = true @@ -1180,16 +1180,16 @@ extension ChatControllerImpl { } let nextPeerId = nextPeer?.peer.id - + strongSelf.state.preloadNextChatPeerId = nextPeerId - + if isUpdated { strongSelf.onUpdated?(previousState) } }) } } - + if let upgradedToPeerId { strongSelf.state.performDismissAction = .upgraded(upgradedToPeerId) } else if movedToForumTopics { @@ -1197,18 +1197,18 @@ extension ChatControllerImpl { } else if shouldDismiss { strongSelf.state.performDismissAction = .dismiss } - + strongSelf.isChatLocationInfoReady.set(true) strongSelf.onUpdated?(previousState) }) - + if peerId == context.account.peerId { self.preloadSavedMessagesChatsDisposable?.dispose() self.preloadSavedMessagesChatsDisposable = context.engine.messages.savedMessagesPeerListHead().start() } } else if case let .replyThread(messagePromise) = self.chatLocationInfoData, let peerId = peerId { self.isPeerInfoReady.set(true) - + let replyThreadType: ChatTitleContent.ReplyThreadType var replyThreadId: Int64? switch chatLocation { @@ -1229,9 +1229,9 @@ extension ChatControllerImpl { case .customChatContents: replyThreadType = .replies } - + let peerView = context.account.viewTracker.peerView(peerId) - + let messageAndTopic = messagePromise.get() |> mapToSignal { message -> Signal<(message: Message?, threadData: MessageHistoryThreadData?, messageCount: Int), NoError> in guard let replyThreadId = replyThreadId else { @@ -1257,14 +1257,14 @@ extension ChatControllerImpl { return (message, threadData, messageCount) } } - + let savedMessagesPeerId: PeerId? if case let .replyThread(replyThreadMessage) = chatLocation, (replyThreadMessage.peerId == context.account.peerId || replyThreadMessage.isMonoforumPost) { savedMessagesPeerId = PeerId(replyThreadMessage.threadId) } else { savedMessagesPeerId = nil } - + let savedMessagesPeer: Signal<(peer: EnginePeer?, messageCount: Int, presence: EnginePeer.Presence?, isMonoforumFeeRemoved: Bool)?, NoError> if let savedMessagesPeerId { let threadPeerId = savedMessagesPeerId @@ -1298,7 +1298,7 @@ extension ChatControllerImpl { } else { savedMessagesPeer = .single(nil) } - + var isScheduledOrPinnedMessages = false switch initialSubject { case .scheduledMessages, .pinnedMessages, .messageOptions: @@ -1306,7 +1306,7 @@ extension ChatControllerImpl { default: break } - + var hasScheduledMessages: Signal = .single(false) if chatLocation.peerId != nil, !isScheduledOrPinnedMessages, peerId.namespace != Namespaces.Peer.SecretChat { let chatLocationContextHolder = chatLocationContextHolder @@ -1332,7 +1332,7 @@ extension ChatControllerImpl { } } } - + var onlineMemberCount: Signal<(total: Int32?, recent: Int32?), NoError> = .single((nil, nil)) if peerId.namespace == Namespaces.Peer.CloudChannel { let recentOnlineSignal: Signal<(total: Int32?, recent: Int32?), NoError> = peerView @@ -1369,7 +1369,7 @@ extension ChatControllerImpl { } onlineMemberCount = recentOnlineSignal } - + let hasSearchTags: Signal if let peerId = chatLocation.peerId, peerId == context.account.peerId { hasSearchTags = context.engine.data.subscribe( @@ -1382,14 +1382,14 @@ extension ChatControllerImpl { } else { hasSearchTags = .single(false) } - + let hasSavedChats: Signal if case .peer(context.account.peerId) = chatLocation { hasSavedChats = context.engine.messages.savedMessagesHasPeersOtherThanSaved() } else { hasSavedChats = .single(false) } - + let isPremiumRequiredForMessaging: Signal if let peerId = chatLocation.peerId { isPremiumRequiredForMessaging = context.engine.peers.subscribeIsPremiumRequiredForMessaging(id: peerId) @@ -1397,9 +1397,9 @@ extension ChatControllerImpl { } else { isPremiumRequiredForMessaging = .single(false) } - + let globalPrivacySettings = context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.GlobalPrivacy()) - + let canStopIncomingStreamingMessage: Signal = .single(false) /*if let peerId = chatLocation.peerId { let key = PeerAndThreadId(peerId: peerId, threadId: chatLocation.threadId) @@ -1414,7 +1414,7 @@ extension ChatControllerImpl { } else { canStopIncomingStreamingMessage = .single(false) }*/ - + self.peerDisposable = (combineLatest(queue: Queue.mainQueue(), peerView, messageAndTopic, @@ -1432,17 +1432,17 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + let previousState = strongSelf.state - + strongSelf.state.hasScheduledMessages = hasScheduledMessages - + if let channel = peerView.peers[peerView.peerId] as? TelegramChannel, channel.flags.contains(.displayForumAsTabs) { strongSelf.state.viewForumAsMessages = true } else if let cachedData = peerView.cachedData as? CachedChannelData { strongSelf.state.viewForumAsMessages = cachedData.viewForumAsMessages.knownValue ?? false } - + var renderedPeer: RenderedPeer? var contactStatus: ChatContactStatus? var copyProtectionEnabled = false @@ -1479,7 +1479,7 @@ extension ChatControllerImpl { } } contactStatus = ChatContactStatus(canAddContact: false, peerStatusSettings: cachedData.peerStatusSettings, invitedBy: invitedBy, managingBot: managingBot) - + if let channel = peerView.peers[peerView.peerId] as? TelegramChannel { if channel.isMonoForum { if let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = peerView.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.manageDirect) { @@ -1494,7 +1494,7 @@ extension ChatControllerImpl { } } } - + var peers = SimpleDictionary() peers[peer.id] = peer if let associatedPeerId = peer.associatedPeerId, let associatedPeer = peerView.peers[associatedPeerId] { @@ -1502,10 +1502,10 @@ extension ChatControllerImpl { } renderedPeer = RenderedPeer(peerId: peer.id, peers: peers, associatedMedia: peerView.media) } - + strongSelf.state.hasTopics = true strongSelf.state.canStopIncomingStreamingMessage = canStopIncomingStreamingMessage - + if let savedMessagesPeerId { var peerPresences: [PeerId: PeerPresence] = [:] if let presence = savedMessagesPeer?.presence { @@ -1520,7 +1520,7 @@ extension ChatControllerImpl { peerPresences: peerPresences, cachedData: nil ) - + var customMessageCount: Int? var customSubtitle: String? customSubtitle = nil @@ -1532,11 +1532,11 @@ extension ChatControllerImpl { } else { customMessageCount = savedMessagesPeer?.messageCount ?? 0 } - + strongSelf.state.chatTitleContent = .peer(peerView: mappedPeerData, customTitle: nil, customSubtitle: customSubtitle, onlineMemberCount: (nil, nil), isScheduledMessages: false, isMuted: false, customMessageCount: customMessageCount, hidePeerStatus: false, isEnabled: true) - + strongSelf.state.peerView = peerView - + let imageOverride: AvatarNodeImageOverride? if context.account.peerId == savedMessagesPeerId { imageOverride = .myNotesIcon @@ -1549,7 +1549,7 @@ extension ChatControllerImpl { } else { imageOverride = nil } - + if let peer = savedMessagesPeer?.peer { var infoContextActionIsEnabled = false if case .standard(.previewing) = mode { @@ -1564,7 +1564,7 @@ extension ChatControllerImpl { accessibilityLabel: strings.Conversation_ContextMenuOpenProfile ) } - + var currentSendAsPeerId: PeerId? if let peer = peerView.peers[peerView.peerId] as? TelegramChannel, let cachedData = peerView.cachedData as? CachedChannelData { if peer.isMonoForum { @@ -1577,7 +1577,7 @@ extension ChatControllerImpl { currentSendAsPeerId = cachedData.sendAsPeerId } } - + var removePaidMessageFeeData: ChatPresentationInterfaceState.RemovePaidMessageFeeData? if let savedMessagesPeer, !savedMessagesPeer.isMonoforumFeeRemoved, let peer = savedMessagesPeer.peer, let channel = peerView.peers[peerView.peerId] as? TelegramChannel, let sendPaidMessageStars = channel.sendPaidMessageStars, channel.isMonoForum { if let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = peerView.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.manageDirect) { @@ -1587,7 +1587,7 @@ extension ChatControllerImpl { ) } } - + strongSelf.state.renderedPeer = renderedPeer strongSelf.state.savedMessagesTopicPeer = savedMessagesPeer?.peer strongSelf.state.hasSearchTags = hasSearchTags @@ -1597,7 +1597,7 @@ extension ChatControllerImpl { strongSelf.state.removePaidMessageFeeData = removePaidMessageFeeData } else { let message = messageAndTopic.message - + var count = 0 if let message = message { for attribute in message.attributes { @@ -1607,7 +1607,7 @@ extension ChatControllerImpl { } } } - + var peerIsMuted = false if let threadData = messageAndTopic.threadData { if case let .muted(until) = threadData.notificationSettings.muteState, until >= Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) { @@ -1618,7 +1618,7 @@ extension ChatControllerImpl { peerIsMuted = true } } - + if let threadInfo = messageAndTopic.threadData?.info { var customSubtitle: String? if messageAndTopic.messageCount == 0, let peer = peerView.peers[peerView.peerId] as? TelegramUser { @@ -1626,9 +1626,9 @@ extension ChatControllerImpl { customSubtitle = strongSelf.presentationData.strings.Chat_GenericForuThreadStatus } } - + strongSelf.state.chatTitleContent = .peer(peerView: ChatTitleContent.PeerData(peerView: peerView), customTitle: threadInfo.title, customSubtitle: customSubtitle, onlineMemberCount: onlineMemberCount, isScheduledMessages: false, isMuted: peerIsMuted, customMessageCount: messageAndTopic.messageCount == 0 ? nil : messageAndTopic.messageCount, hidePeerStatus: true, isEnabled: true) - + let avatarContent: EmojiStatusComponent.Content if chatLocation.threadId == 1 { avatarContent = .image(image: PresentationResourcesChat.chatGeneralThreadIcon(strongSelf.presentationData.theme), tintColor: nil) @@ -1637,7 +1637,7 @@ extension ChatControllerImpl { } else { avatarContent = .topic(title: String(threadInfo.title.prefix(1)), color: threadInfo.iconColor, size: CGSize(width: 32.0, height: 32.0)) } - + var infoContextActionIsEnabled = false if case .standard(.previewing) = mode { infoContextActionIsEnabled = false @@ -1651,7 +1651,7 @@ extension ChatControllerImpl { } else { strongSelf.state.chatTitleContent = .replyThread(type: replyThreadType, count: count) } - + var wasGroupChannel: Bool? if let previousPeerView = strongSelf.state.peerView, let info = (previousPeerView.peers[previousPeerView.peerId] as? TelegramChannel)?.info { if case .group = info { @@ -1669,7 +1669,7 @@ extension ChatControllerImpl { } } let firstTime = strongSelf.state.peerView == nil - + if wasGroupChannel != isGroupChannel { if let isGroupChannel = isGroupChannel, isGroupChannel { let (recentDisposable, _) = context.peerChannelMemberCategoriesContextsManager.recent(engine: context.engine, accountPeerId: context.account.peerId, peerId: peerView.peerId, updated: { _ in }) @@ -1682,10 +1682,10 @@ extension ChatControllerImpl { strongSelf.chatAdditionalDataDisposable.set(nil) } } - + strongSelf.state.peerView = peerView strongSelf.state.threadInfo = messageAndTopic.threadData?.info - + var peerDiscussionId: PeerId? var peerMonoforumId: PeerId? var peerGeoLocation: PeerGeoLocation? @@ -1699,7 +1699,7 @@ extension ChatControllerImpl { } } else { peerMonoforumId = peer.linkedMonoforumId - + currentSendAsPeerId = cachedData.sendAsPeerId if case .group = peer.info { peerGeoLocation = cachedData.peerGeoLocation @@ -1709,16 +1709,16 @@ extension ChatControllerImpl { } } } - + var isNotAccessible: Bool = false if let cachedChannelData = peerView.cachedData as? CachedChannelData { isNotAccessible = cachedChannelData.isNotAccessible } - + if firstTime && isNotAccessible { context.account.viewTracker.forceUpdateCachedPeerData(peerId: peerView.peerId) } - + var hasBots: Bool = false if let peer = peerView.peers[peerView.peerId] { if let cachedGroupData = peerView.cachedData as? CachedGroupData { @@ -1731,16 +1731,16 @@ extension ChatControllerImpl { } } } - + let isArchived: Bool = peerView.groupId == Namespaces.PeerGroup.archive - + var explicitelyCanPinMessages: Bool = false if let cachedUserData = peerView.cachedData as? CachedUserData { explicitelyCanPinMessages = cachedUserData.canPinMessages } else if peerView.peerId == context.account.peerId { explicitelyCanPinMessages = true } - + let preloadHistoryPeerId = peerMonoforumId// ?? peerDiscussionId if strongSelf.preloadHistoryPeerId != preloadHistoryPeerId { strongSelf.preloadHistoryPeerId = preloadHistoryPeerId @@ -1750,18 +1750,18 @@ extension ChatControllerImpl { strongSelf.preloadHistoryPeerIdDisposable.set(nil) } } - + var appliedBoosts: Int32? var boostsToUnrestrict: Int32? if let cachedChannelData = peerView.cachedData as? CachedChannelData { appliedBoosts = cachedChannelData.appliedBoosts boostsToUnrestrict = cachedChannelData.boostsToUnrestrict } - + if strongSelf.premiumOrStarsRequiredDisposable == nil, sendPaidMessageStars != nil, let peerId = chatLocation.peerId { strongSelf.premiumOrStarsRequiredDisposable = ((context.engine.peers.isPremiumRequiredToContact([peerId]) |> then(.complete() |> suspendAwareDelay(60.0, queue: Queue.concurrentDefaultQueue()))) |> restart).startStandalone() } - + strongSelf.state.renderedPeer = renderedPeer strongSelf.state.isNotAccessible = isNotAccessible strongSelf.state.contactStatus = contactStatus @@ -1774,7 +1774,7 @@ extension ChatControllerImpl { strongSelf.state.explicitelyCanPinMessages = explicitelyCanPinMessages strongSelf.state.hasScheduledMessages = hasScheduledMessages strongSelf.state.currentSendAsPeerId = currentSendAsPeerId - strongSelf.state.copyProtectionEnabled = copyProtectionEnabled + strongSelf.state.copyProtectionEnabled = copyProtectionEnabled && !currentWinterGramSettings.disableCopyProtection strongSelf.state.hasSearchTags = hasSearchTags strongSelf.state.isPremiumRequiredForMessaging = isPremiumRequiredForMessaging strongSelf.state.hasSavedChats = hasSavedChats @@ -1784,7 +1784,7 @@ extension ChatControllerImpl { strongSelf.state.sendPaidMessageStars = sendPaidMessageStars strongSelf.state.alwaysShowGiftButton = alwaysShowGiftButton strongSelf.state.disallowedGifts = disallowedGifts - + if let replyThreadId, let channel = renderedPeer?.peer as? TelegramChannel, channel.isForumOrMonoForum, strongSelf.nextChannelToReadDisposable == nil { strongSelf.nextChannelToReadDisposable = (combineLatest(queue: .mainQueue(), context.engine.peers.getNextUnreadForumTopic(peerId: channel.id, topicId: Int32(clamping: replyThreadId)), @@ -1795,11 +1795,11 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + let previousState = strongSelf.state var isUpdated = false - + if !strongSelf.state.offerNextChannelToRead { strongSelf.state.offerNextChannelToRead = true isUpdated = true @@ -1815,14 +1815,14 @@ extension ChatControllerImpl { strongSelf.state.nextChannelToReadDisplayName = nextChatSuggestionTip >= 3 isUpdated = true } - + if isUpdated { strongSelf.onUpdated?(previousState) } }) } } - + strongSelf.isChatLocationInfoReady.set(true) strongSelf.onUpdated?(previousState) }) @@ -1830,9 +1830,9 @@ extension ChatControllerImpl { self.titleDisposable?.dispose() self.titleDisposable = nil self.isPeerInfoReady.set(true) - + let peerView: Signal = .single(nil) - + if case let .customChatContents(customChatContents) = initialSubject { switch customChatContents.kind { case .hashTagSearch: @@ -1853,21 +1853,21 @@ extension ChatControllerImpl { } else { linkUrl = link.url } - + self.state.chatTitleContent = .custom(title: [ChatTitleContent.TitleTextItem(id: AnyHashable(0), content: .text(link.title ?? strings.Business_Links_EditLinkTitle))], subtitle: linkUrl, isEnabled: false) } } else { self.state.chatTitleContent = .custom(title: [ChatTitleContent.TitleTextItem(id: AnyHashable(0), content: .text(" "))], subtitle: nil, isEnabled: false) } - + self.peerDisposable = (peerView |> deliverOnMainQueue).startStrict(next: { [weak self] peerView in guard let self else { return } - + let previousState = self.state - + var renderedPeer: RenderedPeer? if let peerView, let peer = peerView.peers[peerView.peerId] { var peers = SimpleDictionary() @@ -1876,7 +1876,7 @@ extension ChatControllerImpl { peers[associatedPeer.id] = associatedPeer } renderedPeer = RenderedPeer(peerId: peer.id, peers: peers, associatedMedia: peerView.media) - + self.state.infoAvatar = .peer( peer: EnginePeer(peer), imageOverride: nil, @@ -1886,15 +1886,15 @@ extension ChatControllerImpl { } else { self.state.infoAvatar = nil } - + self.state.peerView = peerView self.state.renderedPeer = renderedPeer - + self.isChatLocationInfoReady.set(true) self.onUpdated?(previousState) }) } - + let initialData = historyNode.initialData |> take(1) |> deliverOnMainQueue @@ -1902,7 +1902,7 @@ extension ChatControllerImpl { guard let strongSelf = self, let combinedInitialData else { return } - + let previousState = strongSelf.state if let opaqueState = (combinedInitialData.initialData?.storedInterfaceState).flatMap(_internal_decodeStoredChatInterfaceState) { @@ -1916,7 +1916,7 @@ extension ChatControllerImpl { var slowmodeState: ChatSlowmodeState? if let cachedData = combinedInitialData.cachedData as? CachedChannelData { pinnedMessageId = cachedData.pinnedMessageId - + var canBypassRestrictions = false if let boostsToUnrestrict = cachedData.boostsToUnrestrict, let appliedBoosts = cachedData.appliedBoosts, appliedBoosts >= boostsToUnrestrict { canBypassRestrictions = true @@ -1941,7 +1941,7 @@ extension ChatControllerImpl { } } else if let _ = combinedInitialData.cachedData as? CachedSecretChatData { } - + if let channel = combinedInitialData.initialData?.peer as? TelegramChannel { if channel.hasBannedPermission(.banSendVoice) != nil && channel.hasBannedPermission(.banSendInstantVideos) != nil { interfaceState = interfaceState.withUpdatedMediaRecordingMode(.audio) @@ -1967,7 +1967,7 @@ extension ChatControllerImpl { } } } - + if case let .replyThread(replyThreadMessageId) = chatLocation { if let channel = combinedInitialData.initialData?.peer as? TelegramChannel, channel.isForumOrMonoForum { pinnedMessageId = nil @@ -1975,7 +1975,7 @@ extension ChatControllerImpl { pinnedMessageId = replyThreadMessageId.effectiveTopId } } - + var pinnedMessage: ChatPinnedMessage? if let pinnedMessageId = pinnedMessageId { if let cachedDataMessages = combinedInitialData.cachedDataMessages { @@ -1984,12 +1984,12 @@ extension ChatControllerImpl { } } } - + var buttonKeyboardMessage = combinedInitialData.buttonKeyboardMessage if let buttonKeyboardMessageValue = buttonKeyboardMessage, buttonKeyboardMessageValue.isRestricted(platform: "ios", contentSettings: context.currentContentSettings.with({ $0 })) { buttonKeyboardMessage = nil } - + strongSelf.state.pinnedMessageId = pinnedMessageId strongSelf.state.pinnedMessage = pinnedMessage strongSelf.state.keyboardButtonsMessage = buttonKeyboardMessage @@ -1998,23 +1998,23 @@ extension ChatControllerImpl { strongSelf.state.callsPrivate = callsPrivate strongSelf.state.activeGroupCallInfo = activeGroupCallInfo strongSelf.state.slowmodeState = slowmodeState - + var initialEditMessage: Message? if let editMessage = interfaceState.editMessage, let message = combinedInitialData.initialData?.associatedMessages[editMessage.messageId] { initialEditMessage = message } - + strongSelf.initialInterfaceState = (interfaceState, initialEditMessage) } else { strongSelf.initialInterfaceState = (ChatInterfaceState(), nil) } - + if let readStateData = combinedInitialData.readStateData { if case let .peer(peerId) = chatLocation, let peerReadStateData = readStateData[peerId], let notificationSettings = peerReadStateData.notificationSettings { - + let inAppSettings = context.sharedContext.currentInAppNotificationSettings.with { $0 } let (count, _) = renderedTotalUnreadCount(inAppSettings: inAppSettings, totalUnreadState: peerReadStateData.totalState ?? ChatListTotalUnreadState(absoluteCounters: [:], filteredCounters: [:])) - + var globalRemainingUnreadChatCount = count if !notificationSettings.isRemovedFromTotalUnreadCount(default: false) && peerReadStateData.unreadCount > 0 { if case .messages = inAppSettings.totalUnreadCountDisplayCategory { @@ -2028,10 +2028,10 @@ extension ChatControllerImpl { } } } - + strongSelf.onUpdated?(previousState) } - + let initialPersistentPeerData: Signal if let peerId = chatLocation.peerId { initialPersistentPeerData = context.engine.peers.getPerstistentChatInterfaceState(peerId: peerId) @@ -2050,7 +2050,7 @@ extension ChatControllerImpl { self.initialPersistentPeerData = value } |> map { _ -> Bool in true } - + self.isReady.set(combineLatest(queue: .mainQueue(), [ self.isPeerInfoReady.get(), self.isChatLocationInfoReady.get(), @@ -2065,7 +2065,7 @@ extension ChatControllerImpl { |> filter { $0 } |> take(1) |> distinctUntilChanged) - + self.buttonKeyboardMessageDisposable?.dispose() self.buttonKeyboardMessageDisposable = historyNode.buttonKeyboardMessage.startStrict(next: { [weak self] message in guard let strongSelf = self else { @@ -2085,7 +2085,7 @@ extension ChatControllerImpl { strongSelf.onUpdated?(previousState) } }) - + if let peerId = chatLocation.peerId { let customEmojiAvailable: Signal = context.engine.data.subscribe( TelegramEngine.EngineData.Item.Peer.SecretChatLayer(id: peerId) @@ -2094,11 +2094,11 @@ extension ChatControllerImpl { guard let layer = layer else { return true } - + return layer >= 144 } |> distinctUntilChanged - + let isForum = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) |> map { peer -> Bool in if case let .channel(channel) = peer { @@ -2108,7 +2108,7 @@ extension ChatControllerImpl { } } |> distinctUntilChanged - + let threadData: Signal let forumTopicData: Signal if let threadId = chatLocation.threadId { @@ -2148,19 +2148,19 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + let previousState = strongSelf.state - + let currentTime = Int32(Date().timeIntervalSince1970) var suggest = true if let timestamp, currentTime < timestamp + 60 * 60 * 24 { suggest = false } strongSelf.state.suggestPremiumGift = suggest - + strongSelf.onUpdated?(previousState) }) - + var baseLanguageCode = self.presentationData.strings.baseLanguageCode if baseLanguageCode.contains("-") { baseLanguageCode = baseLanguageCode.components(separatedBy: "-").first ?? baseLanguageCode @@ -2169,13 +2169,13 @@ extension ChatControllerImpl { |> map { peer -> Bool in return peer?.isPremium ?? false } |> distinctUntilChanged - + let isHidden = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.TranslationHidden(id: peerId)) |> distinctUntilChanged - + let hasAutoTranslate = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.AutoTranslateEnabled(id: peerId)) |> distinctUntilChanged - + self.translationStateDisposable?.dispose() self.translationStateDisposable = (combineLatest( queue: .concurrentDefaultQueue(), @@ -2206,15 +2206,15 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + let previousState = strongSelf.state - + strongSelf.state.translationState = chatTranslationState - + strongSelf.onUpdated?(previousState) }) } - + let premiumGiftOptions: Signal<[CachedPremiumGiftOption], NoError> = .single([]) |> then( context.engine.payments.premiumGiftCodeOptions(peerId: peerId, onlyCached: true) @@ -2222,13 +2222,13 @@ extension ChatControllerImpl { return options.filter { $0.users == 1 }.map { CachedPremiumGiftOption(months: $0.months, currency: $0.currency, amount: $0.amount, botUrl: "", storeProductId: $0.storeProductId) } } ) - + let isTopReplyThreadMessageShown: Signal = historyNode.isTopReplyThreadMessageShown.get() |> distinctUntilChanged - + let hasPendingMessages: Signal let chatLocationPeerId = chatLocation.peerId - + if let chatLocationPeerId = chatLocationPeerId { hasPendingMessages = context.account.pendingMessageManager.pendingMessageCount |> mapToSignal { pendingMessageCount -> Signal in @@ -2242,7 +2242,7 @@ extension ChatControllerImpl { } else { hasPendingMessages = .single(false) } - + let topPinnedMessage: Signal if let subject = initialSubject { switch subject { @@ -2254,7 +2254,7 @@ extension ChatControllerImpl { } else { topPinnedMessage = ChatControllerImpl.topPinnedScrollMessage(context: context, chatLocation: chatLocation, historyNode: historyNode, scrolledToMessageId: self.scrolledToMessageId.get()) } - + let cachedData: Signal<(CachedPeerData?, [MessageId: Message]), NoError> if let peerId = chatLocation.peerId { cachedData = context.account.postbox.combinedView(keys: [ @@ -2268,7 +2268,7 @@ extension ChatControllerImpl { } else { cachedData = .single((nil, [:])) } - + self.cachedDataDisposable?.dispose() self.cachedDataDisposable = combineLatest(queue: .mainQueue(), cachedData, @@ -2284,11 +2284,11 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + let previousState = strongSelf.state - + let (cachedData, messages) = cachedDataAndMessages - + if cachedData != nil { var chatTheme: ChatTheme? = nil var chatWallpaper: TelegramWallpaper? @@ -2301,11 +2301,11 @@ extension ChatControllerImpl { chatTheme = cachedData.chatTheme chatWallpaper = cachedData.wallpaper } - + strongSelf.chatThemePromise.set(.single(chatTheme)) strongSelf.chatWallpaperPromise.set(.single(chatWallpaper)) } - + var pinnedMessageId: MessageId? var peerIsBlocked: Bool = false var callsAvailable: Bool = false @@ -2343,7 +2343,7 @@ extension ChatControllerImpl { inviteRequestsPending = cachedData.inviteRequestsPending } else if let _ = cachedData as? CachedSecretChatData { } - + var pinnedMessage: ChatPinnedMessage? switch chatLocation { case let .replyThread(replyThreadMessage): @@ -2369,7 +2369,7 @@ extension ChatControllerImpl { pinnedMessageId = nil pinnedMessage = nil } - + var pinnedMessageUpdated = false if let current = strongSelf.state.pinnedMessage, let updated = pinnedMessage { if current != updated { @@ -2378,11 +2378,11 @@ extension ChatControllerImpl { } else if (strongSelf.state.pinnedMessage != nil) != (pinnedMessage != nil) { pinnedMessageUpdated = true } - + let callsDataUpdated = strongSelf.state.callsAvailable != callsAvailable || strongSelf.state.callsPrivate != callsPrivate - + let voiceMessagesAvailableUpdated = strongSelf.state.voiceMessagesAvailable != voiceMessagesAvailable - + var canManageInvitations = false if let channel = strongSelf.state.renderedPeer?.peer as? TelegramChannel, channel.flags.contains(.isCreator) || (channel.adminRights?.rights.contains(.canInviteUsers) == true) { canManageInvitations = true @@ -2393,7 +2393,7 @@ extension ChatControllerImpl { canManageInvitations = true } } - + if canManageInvitations, let inviteRequestsPending = inviteRequestsPending, inviteRequestsPending >= 0 { if strongSelf.inviteRequestsContext == nil { let inviteRequestsContext = context.engine.peers.peerInvitationImporters(peerId: peerId, subject: .requests(query: nil)) @@ -2407,19 +2407,19 @@ extension ChatControllerImpl { } }) } - + if chatLocation.threadId == nil { if strongSelf.inviteRequestsDisposable == nil, let inviteRequestsContext = strongSelf.inviteRequestsContext { strongSelf.inviteRequestsDisposable = combineLatest(queue: Queue.mainQueue(), inviteRequestsContext.state, ApplicationSpecificNotice.dismissedInvitationRequests(accountManager: context.sharedContext.accountManager, peerId: peerId)).startStrict(next: { [weak strongSelf] requestsState, dismissedInvitationRequests in guard let strongSelf else { return } - + let previousState = strongSelf.state - + strongSelf.state.requestsState = requestsState strongSelf.state.dismissedInvitationRequests = dismissedInvitationRequests - + strongSelf.onUpdated?(previousState) }) } @@ -2432,11 +2432,11 @@ extension ChatControllerImpl { strongSelf.state.requestsState = nil strongSelf.state.dismissedInvitationRequests = [] } - + var isUpdated = false if strongSelf.state.pinnedMessageId != pinnedMessageId || strongSelf.state.pinnedMessage != pinnedMessage || strongSelf.state.peerIsBlocked != peerIsBlocked || pinnedMessageUpdated || callsDataUpdated || voiceMessagesAvailableUpdated || strongSelf.state.slowmodeState != slowmodeState || strongSelf.state.activeGroupCallInfo != activeGroupCallInfo || customEmojiAvailable != strongSelf.state.customEmojiAvailable || threadData != strongSelf.state.threadData || forumTopicData != strongSelf.state.forumTopicData || premiumGiftOptions != strongSelf.state.premiumGiftOptions { isUpdated = true - + strongSelf.state.pinnedMessage = pinnedMessage strongSelf.state.pinnedMessageId = pinnedMessageId strongSelf.state.activeGroupCallInfo = activeGroupCallInfo @@ -2451,9 +2451,9 @@ extension ChatControllerImpl { strongSelf.state.premiumGiftOptions = premiumGiftOptions strongSelf.state.slowmodeState = slowmodeState } - + strongSelf.isCachedDataReady.set(true) - + if isUpdated { strongSelf.onUpdated?(previousState) } @@ -2462,7 +2462,7 @@ extension ChatControllerImpl { self.isCachedDataReady.set(true) } } - + deinit { self.peerDisposable?.dispose() self.titleDisposable?.dispose() diff --git a/submodules/TelegramUI/Sources/ChatControllerForwardMessages.swift b/submodules/TelegramUI/Sources/ChatControllerForwardMessages.swift index ededfd221f..d2f81c05df 100644 --- a/submodules/TelegramUI/Sources/ChatControllerForwardMessages.swift +++ b/submodules/TelegramUI/Sources/ChatControllerForwardMessages.swift @@ -10,6 +10,7 @@ import TextFormat import UndoUI import ChatInterfaceState import PremiumUI +import TelegramUIPreferences import ReactionSelectionNode import TopMessageReactions import ChatMessagePaymentAlertController @@ -80,13 +81,13 @@ extension ChatControllerImpl { } return true } - + var hasAction = false let premiumConfiguration = PremiumConfiguration.with(appConfiguration: strongSelf.context.currentAppConfiguration.with { $0 }) if !premiumConfiguration.isPremiumDisabled { hasAction = true } - + controller.present(UndoOverlayController(presentationData: presentationData, content: .premiumPaywall(title: nil, text: presentationData.strings.Chat_ToastMessagingRestrictedToPremium_Text(peer.compactDisplayTitle).string, customUndoText: hasAction ? presentationData.strings.Chat_ToastMessagingRestrictedToPremium_Action : nil, timeout: nil, linkAction: { _ in }), elevatedLayout: false, animateInAsReplacement: true, action: { [weak controller] action in guard let self, let controller else { @@ -102,7 +103,7 @@ extension ChatControllerImpl { } controller.multiplePeersSelected = { [weak self, weak controller] peers, peerMap, messageText, mode, forwardOptions, _ in let peerIds = peers.map { $0.id } - + let _ = (context.engine.data.get( EngineDataMap( peerIds.map(TelegramEngine.EngineData.Item.Peer.SendPaidMessageStars.init(id:)) @@ -116,7 +117,7 @@ extension ChatControllerImpl { return } let renderedPeers = renderedPeers.compactMap({ $0 }) - + var count: Int32 = Int32(messages.count) if messageText.string.count > 0 { count += 1 @@ -129,14 +130,14 @@ extension ChatControllerImpl { chargingPeers.append(peer) } } - + let proceed = { [weak self, weak controller] in guard let strongSelf = self, let strongController = controller else { return } - + strongController.dismiss() - + var result: [EnqueueMessage] = [] if messageText.string.count > 0 { let inputText = convertMarkdownToAttributes(messageText) @@ -151,29 +152,29 @@ extension ChatControllerImpl { } } } - + var attributes: [EngineMessage.Attribute] = [] - attributes.append(ForwardOptionsMessageAttribute(hideNames: forwardOptions?.hideNames == true, hideCaptions: forwardOptions?.hideCaptions == true)) - + attributes.append(ForwardOptionsMessageAttribute(hideNames: forwardOptions?.hideNames == true || currentWinterGramSettings.forwardWithoutAuthor, hideCaptions: forwardOptions?.hideCaptions == true)) + result.append(contentsOf: messages.map { message -> EnqueueMessage in return .forward(source: message.id, threadId: nil, grouping: .auto, attributes: attributes, correlationId: nil) }) - + let commit: ([EnqueueMessage]) -> Void = { result in guard let strongSelf = self else { return } var result = result - + strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: true, { $0.updatedInterfaceState({ $0.withoutSelectionState() }).updatedSearch(nil) }) - + var correlationIds: [Int64] = [] for i in 0 ..< result.count { let correlationId = Int64.random(in: Int64.min ... Int64.max) correlationIds.append(correlationId) result[i] = result[i].withUpdatedCorrelationId(correlationId) } - + let targetPeersShouldDivertSignals: [Signal<(EnginePeer, Bool), NoError>] = peers.map { peer -> Signal<(EnginePeer, Bool), NoError> in return strongSelf.shouldDivertMessagesToScheduled(targetPeer: peer, messages: result) |> map { shouldDivert -> (EnginePeer, Bool) in @@ -186,9 +187,9 @@ extension ChatControllerImpl { guard let strongSelf = self else { return } - + var displayConvertingTooltip = false - + var displayPeers: [EnginePeer] = [] for (peer, shouldDivert) in targetPeersShouldDivert { var peerMessages = result @@ -203,7 +204,7 @@ extension ChatControllerImpl { } } } - + if let maybeAmount = sendPaidMessageStars[peer.id], let amount = maybeAmount { peerMessages = peerMessages.map { message -> EnqueueMessage in return message.withUpdatedAttributes { attributes in @@ -213,7 +214,7 @@ extension ChatControllerImpl { } } } - + let _ = (enqueueMessages(account: strongSelf.context.account, peerId: peer.id, messages: peerMessages) |> deliverOnMainQueue).startStandalone(next: { messageIds in if let strongSelf = self { @@ -238,7 +239,7 @@ extension ChatControllerImpl { |> deliverOnMainQueue).startStrict()) } }) - + if case let .secretChat(secretPeer) = peer { if let peer = peerMap[secretPeer.regularPeerId] { displayPeers.append(peer) @@ -247,7 +248,7 @@ extension ChatControllerImpl { displayPeers.append(peer) } } - + let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } let text: String var savedMessages = false @@ -273,20 +274,20 @@ extension ChatControllerImpl { text = "" } } - + let reactionItems: Signal<[ReactionItem], NoError> if savedMessages && messages.count > 0 { reactionItems = tagMessageReactions(context: strongSelf.context, subPeerId: nil) } else { reactionItems = .single([]) } - + let _ = (reactionItems |> deliverOnMainQueue).startStandalone(next: { [weak strongSelf] reactionItems in guard let strongSelf else { return } - + strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: savedMessages, text: text), elevatedLayout: false, position: savedMessages && messages.count > 0 ? .top : .bottom, animateInAsReplacement: true, action: { action in if savedMessages, let self, action == .info { let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId)) @@ -303,12 +304,12 @@ extension ChatControllerImpl { return false }, additionalView: (savedMessages && messages.count > 0) ? chatShareToSavedMessagesAdditionalView(strongSelf, reactionItems: reactionItems, correlationIds: correlationIds) : nil), in: .current) }) - + if displayConvertingTooltip { } }) } - + switch mode { case .generic: commit(result) @@ -327,7 +328,7 @@ extension ChatControllerImpl { commit(transformedMessages) } } - + if totalAmount.value > 0 { let controller = chatMessagePaymentAlertController( context: nil, @@ -355,16 +356,16 @@ extension ChatControllerImpl { } let peerId = peer.id let accountPeerId = strongSelf.context.account.peerId - + if resetCurrent { strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: true, { $0.updatedInterfaceState({ $0.withUpdatedForwardMessageIds(nil).withUpdatedForwardOptionsState(nil) }) }) } - + var isPinnedMessages = false if case .pinnedMessages = strongSelf.presentationInterfaceState.subject { isPinnedMessages = true } - + var hasNotOwnMessages = false for message in messages { if message.id.peerId == accountPeerId && message.forwardInfo == nil { @@ -372,7 +373,7 @@ extension ChatControllerImpl { hasNotOwnMessages = true } } - + if case .peer(peerId) = strongSelf.chatLocation, strongSelf.parentController == nil, !isPinnedMessages { strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: true, { $0.updatedInterfaceState({ $0.withUpdatedForwardMessageIds(messages.map { $0.id }).withUpdatedForwardOptionsState(ChatInterfaceForwardOptionsState(hideNames: !hasNotOwnMessages, hideCaptions: false, unhideNamesOnCaptionChange: false)).withoutSelectionState() }).updatedSearch(nil) }) strongSelf.updateItemNodesSearchTextHighlightStates() @@ -382,27 +383,27 @@ extension ChatControllerImpl { Queue.mainQueue().after(0.88) { strongSelf.chatDisplayNode.hapticFeedback.success() } - + let reactionItems: Signal<[ReactionItem], NoError> if messages.count > 0 { reactionItems = tagMessageReactions(context: strongSelf.context, subPeerId: nil) } else { reactionItems = .single([]) } - + var correlationIds: [Int64] = [] let mappedMessages = messages.map { message -> EnqueueMessage in let correlationId = Int64.random(in: Int64.min ... Int64.max) correlationIds.append(correlationId) return .forward(source: message.id, threadId: nil, grouping: .auto, attributes: [], correlationId: correlationId) } - + let _ = (reactionItems |> deliverOnMainQueue).startStandalone(next: { [weak strongSelf] reactionItems in guard let strongSelf else { return } - + let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: true, text: messages.count == 1 ? presentationData.strings.Conversation_ForwardTooltip_SavedMessages_One : presentationData.strings.Conversation_ForwardTooltip_SavedMessages_Many), elevatedLayout: false, position: .top, animateInAsReplacement: true, action: { [weak self] value in if case .info = value, let strongSelf = self { @@ -411,7 +412,7 @@ extension ChatControllerImpl { guard let strongSelf = self, let peer = peer, let navigationController = strongSelf.effectiveNavigationController else { return } - + strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), keepStack: .always, purposefulAction: {}, peekData: nil, forceOpenChat: true)) }) return true @@ -419,7 +420,7 @@ extension ChatControllerImpl { return false }, additionalView: messages.count > 0 ? chatShareToSavedMessagesAdditionalView(strongSelf, reactionItems: reactionItems, correlationIds: correlationIds) : nil), in: .current) }) - + let _ = (enqueueMessages(account: strongSelf.context.account, peerId: peerId, messages: mappedMessages) |> deliverOnMainQueue).startStandalone(next: { messageIds in if let strongSelf = self { @@ -473,14 +474,14 @@ extension ChatControllerImpl { if let strongSelf = self { let proceed: (ChatController) -> Void = { chatController in strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: true, { $0.updatedInterfaceState({ $0.withoutSelectionState() }) }) - + let navigationController: NavigationController? if let parentController = strongSelf.parentController { navigationController = (parentController.navigationController as? NavigationController) } else { navigationController = strongSelf.effectiveNavigationController } - + if let navigationController = navigationController { var viewControllers = navigationController.viewControllers if threadId != nil { @@ -489,7 +490,7 @@ extension ChatControllerImpl { viewControllers.insert(chatController, at: viewControllers.count - 1) } navigationController.setViewControllers(viewControllers, animated: false) - + strongSelf.controllerNavigationDisposable.set((chatController.ready.get() |> SwiftSignalKit.filter { $0 } |> take(1) diff --git a/submodules/TelegramUI/Sources/ChatHistoryEntriesForView.swift b/submodules/TelegramUI/Sources/ChatHistoryEntriesForView.swift index 8bda922c31..ccbc150288 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryEntriesForView.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryEntriesForView.swift @@ -16,10 +16,10 @@ import TelegramStringFormatting struct ChatHistoryEntriesForViewState { private var messageStableIdToLocalId: [UInt32: Int64] = [:] - + init() { } - + mutating func messageGroupStableId(messageStableId: UInt32, groupId: Int64, isLocal: Bool) -> Int64 { if isLocal { self.messageStableIdToLocalId[messageStableId] = groupId @@ -64,7 +64,7 @@ func chatHistoryEntriesForView( pinToTopStableId: EngineMessage.StableId? ) -> ([ChatHistoryEntry], ChatHistoryEntriesForViewState) { var currentState = currentState - + if historyAppearsCleared { return ([], currentState) } @@ -106,10 +106,10 @@ func chatHistoryEntriesForView( } } } - + var joinMessage: Message? if (associatedData.subject?.isService ?? false) { - + } else { if let peer = chatPeer as? TelegramChannel, case .broadcast = peer.info, case .member = peer.participationStatus, !peer.flags.contains(.isCreator) { joinMessage = Message( @@ -140,21 +140,25 @@ func chatHistoryEntriesForView( ) } } - + var count = 0 loop: for entry in view.entries { var message = entry.message var isRead = entry.isRead - + var pinToTop = false if message.stableId == pinToTopStableId { pinToTop = true } - + if pendingRemovedMessages.contains(message.id) { continue } - + + if !currentWinterGramSettings.shadowBanIds.isEmpty, let author = message.author, currentWinterGramSettings.shadowBanIds.contains(author.id.id._internalGetInt64Value()) { + continue + } + if case let .replyThread(replyThreadMessage) = location, replyThreadMessage.isForumPost { for media in message.media { if let action = media as? TelegramMediaAction { @@ -192,13 +196,13 @@ func chatHistoryEntriesForView( } } } - + count += 1 - + if let customThreadOutgoingReadState = customThreadOutgoingReadState { isRead = customThreadOutgoingReadState >= message.id } - + if let customChannelDiscussionReadState = customChannelDiscussionReadState { attibuteLoop: for i in 0 ..< message.attributes.count { if let attribute = message.attributes[i] as? ReplyThreadMessageAttribute { @@ -213,15 +217,15 @@ func chatHistoryEntriesForView( } } } - + if skipViewOnceMedia, let minAutoremoveOrClearTimeout = message.minAutoremoveOrClearTimeout { if minAutoremoveOrClearTimeout <= 60 { continue loop } } - + var contentTypeHint: ChatMessageEntryContentType = .generic - + for media in message.media { if media is TelegramMediaDice { contentTypeHint = .animatedEmoji @@ -235,12 +239,12 @@ func chatHistoryEntriesForView( } } } - + var adminRank: CachedChannelAdminRank? if let author = message.author { adminRank = adminRanks[author.id] } - + if presentationData.largeEmoji, message.media.isEmpty { if messageIsEligibleForLargeCustomEmoji(EngineMessage(message)) { contentTypeHint = .animatedEmoji @@ -250,7 +254,7 @@ func chatHistoryEntriesForView( contentTypeHint = .animatedEmoji } } - + if groupMessages || reverseGroupedMessages { if let messageGroupingKey = message.groupingKey { let selection: ChatHistoryMessageSelection @@ -259,33 +263,33 @@ func chatHistoryEntriesForView( } else { selection = .none } - + var isCentered = false if case let .messageOptions(_, _, info) = associatedData.subject, case let .link(link) = info { isCentered = link.isCentered } - + let attributes = ChatMessageEntryAttributes(rank: adminRank, isContact: entry.attributes.authorIsContact, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[message.id], isPlaying: message.index == associatedData.currentlyPlayingMessageId, isCentered: isCentered, authorStoryStats: message.author.flatMap { view.peerStoryStats[$0.id] }, displayContinueThreadFooter: false, pinToTop: pinToTop) - + let groupStableId = currentState.messageGroupStableId(messageStableId: message.stableId, groupId: messageGroupingKey, isLocal: Namespaces.Message.allLocal.contains(message.id.namespace)) var found = false for i in 0 ..< entries.count { if case let .MessageEntry(currentMessage, _, currentIsRead, currentLocation, currentSelection, currentAttributes) = entries[i], let currentGroupingKey = currentMessage.groupingKey, currentState.messageGroupStableId(messageStableId: currentMessage.stableId, groupId: currentGroupingKey, isLocal: Namespaces.Message.allLocal.contains(currentMessage.id.namespace)) == groupStableId { found = true - + var currentMessages: [(Message, Bool, ChatHistoryMessageSelection, ChatMessageEntryAttributes, MessageHistoryEntryLocation?)] = [] - + currentMessages.append((currentMessage, currentIsRead, currentSelection, currentAttributes, currentLocation)) if reverseGroupedMessages { currentMessages.insert((message, isRead, selection, attributes, entry.location), at: 0) } else { currentMessages.append((message, isRead, selection, attributes, entry.location)) } - + entries[i] = .MessageGroupEntry(groupStableId, currentMessages, presentationData) } else if case let .MessageGroupEntry(currentGroupStableId, currentMessages, _) = entries[i], currentGroupStableId == groupStableId { found = true - + var currentMessages = currentMessages if reverseGroupedMessages { currentMessages.insert((message, isRead, selection, attributes, entry.location), at: 0) @@ -305,12 +309,12 @@ func chatHistoryEntriesForView( } else { selection = .none } - + var isCentered = false if case let .messageOptions(_, _, info) = associatedData.subject, case let .link(link) = info { isCentered = link.isCentered } - + entries.append(.MessageEntry(message, presentationData, isRead, entry.location, selection, ChatMessageEntryAttributes(rank: adminRank, isContact: entry.attributes.authorIsContact, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[message.id], isPlaying: message.index == associatedData.currentlyPlayingMessageId, isCentered: isCentered, authorStoryStats: message.author.flatMap { view.peerStoryStats[$0.id] }, displayContinueThreadFooter: false, pinToTop: pinToTop))) } } else { @@ -320,14 +324,14 @@ func chatHistoryEntriesForView( } else { selection = .none } - + entries.append(.MessageEntry(message, presentationData, isRead, entry.location, selection, ChatMessageEntryAttributes(rank: adminRank, isContact: entry.attributes.authorIsContact, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[message.id], isPlaying: message.index == associatedData.currentlyPlayingMessageId, isCentered: false, authorStoryStats: message.author.flatMap { view.peerStoryStats[$0.id] }, displayContinueThreadFooter: false, pinToTop: pinToTop))) } } - + if !groupMessages && reverseGroupedMessages { var flatEntries: [ChatHistoryEntry] = [] - + for entry in entries { switch entry { case let .MessageGroupEntry(_, messages, presentationData): @@ -340,7 +344,7 @@ func chatHistoryEntriesForView( } entries = flatEntries } - + var addBotForumHeader = false if location.threadId == nil, let user = chatPeer as? TelegramUser, let botInfo = user.botInfo, botInfo.flags.contains(.hasForum), botInfo.flags.contains(.forumManagedByUser), !entries.isEmpty, !view.holeEarlier, !view.isLoading { addBotForumHeader = true @@ -367,7 +371,7 @@ func chatHistoryEntriesForView( } } } - + let insertPendingProcessingMessage: ([Message], Int) -> Void = { messages, index in let serviceMessage = Message( stableId: UInt32.max - messages[0].stableId, @@ -397,7 +401,7 @@ func chatHistoryEntriesForView( ) entries.insert(.MessageEntry(serviceMessage, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false, authorStoryStats: nil, displayContinueThreadFooter: false, pinToTop: false)), at: index) } - + for i in (0 ..< entries.count).reversed() { switch entries[i] { case let .MessageEntry(message, _, _, _, _, _): @@ -420,7 +424,7 @@ func chatHistoryEntriesForView( break } } - + if let lowerTimestamp = view.entries.last?.message.timestamp, let upperTimestamp = view.entries.first?.message.timestamp { if let joinMessage { var insertAtPosition: Int? @@ -439,7 +443,7 @@ func chatHistoryEntriesForView( } } } - + if let maxReadIndex = view.maxReadIndex, includeUnreadEntry { var i = 0 let unreadEntry: ChatHistoryEntry = .UnreadEntry(maxReadIndex, presentationData) @@ -459,7 +463,7 @@ func chatHistoryEntriesForView( i += 1 } } - + var addedThreadHead = false if case let .replyThread(replyThreadMessage) = location, !replyThreadMessage.isForumPost, view.earlierId == nil, !view.holeEarlier, !view.isLoading, !isMusicPlaylist { loop: for entry in view.additionalData { @@ -467,9 +471,9 @@ func chatHistoryEntriesForView( case let .message(id, messages) where id == replyThreadMessage.effectiveTopId: if !messages.isEmpty { let selection: ChatHistoryMessageSelection = .none - + let topMessage = messages[0] - + var hasTopicCreated = false inner: for media in topMessage.media { if let action = media as? TelegramMediaAction { @@ -482,12 +486,12 @@ func chatHistoryEntriesForView( } } } - + var adminRank: CachedChannelAdminRank? if let author = topMessage.author { adminRank = adminRanks[author.id] } - + var contentTypeHint: ChatMessageEntryContentType = .generic if presentationData.largeEmoji, topMessage.media.isEmpty { if messageIsEligibleForLargeCustomEmoji(EngineMessage(topMessage)) { @@ -498,7 +502,7 @@ func chatHistoryEntriesForView( contentTypeHint = .animatedEmoji } } - + addedThreadHead = true if messages.count > 1, let groupingKey = messages[0].groupingKey { var groupMessages: [(Message, Bool, ChatHistoryMessageSelection, ChatMessageEntryAttributes, MessageHistoryEntryLocation?)] = [] @@ -511,7 +515,7 @@ func chatHistoryEntriesForView( entries.insert(.MessageEntry(messages[0], presentationData, false, nil, selection, ChatMessageEntryAttributes(rank: adminRank, isContact: false, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[messages[0].id], isPlaying: false, isCentered: false, authorStoryStats: messages[0].author.flatMap { view.peerStoryStats[$0.id] }, displayContinueThreadFooter: false, pinToTop: false)), at: 0) } } - + if !replyThreadMessage.isForumPost { let replyCount = view.entries.isEmpty ? 0 : 1 entries.insert(.ReplyCountEntry(messages[0].index, replyThreadMessage.isChannelPost, replyCount, presentationData), at: 1) @@ -523,7 +527,7 @@ func chatHistoryEntriesForView( } } } - + if includeChatInfoEntry { if view.earlierId == nil, !view.isLoading { var chatPeer: Peer? @@ -551,7 +555,7 @@ func chatHistoryEntriesForView( entries.insert(.ChatInfoEntry(.botInfo(title: "", text: "", photo: nil, video: nil, peer: peer, managedByBot: EnginePeer(managedByBot)), presentationData), at: 0) } else if let peerStatusSettings = cachedPeerData.peerStatusSettings, peerStatusSettings.registrationDate != nil || peerStatusSettings.phoneCountry != nil { if peerStatusSettings.flags.contains(.canAddContact) || peerStatusSettings.flags.contains(.canReport) || peerStatusSettings.flags.contains(.canBlock) { - + if let chatPeer, let photoChangeDate = peerStatusSettings.photoChangeDate, photoChangeDate > 0 { let timeText = stringForIntervalSinceUpdateAction(strings: presentationData.strings, value: photoChangeDate) let text = presentationData.strings.Chat_NonContactUser_UpdatedPhoto(timeText) @@ -591,7 +595,7 @@ func chatHistoryEntriesForView( ) entries.insert(.MessageEntry(message, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false, authorStoryStats: nil, displayContinueThreadFooter: false, pinToTop: false)), at: 0) } - + if let chatPeer, let nameChangeDate = peerStatusSettings.nameChangeDate, nameChangeDate > 0 { let timeText = stringForIntervalSinceUpdateAction(strings: presentationData.strings, value: nameChangeDate) let text = presentationData.strings.Chat_NonContactUser_UpdatedName(timeText) @@ -678,7 +682,7 @@ func chatHistoryEntriesForView( } } } - + if !dynamicAdMessages.isEmpty { assert(entries.sorted() == entries) for message in dynamicAdMessages { @@ -752,7 +756,7 @@ func chatHistoryEntriesForView( entities.append(MessageTextEntity(range: range.lowerBound ..< range.upperBound, type: .TextMention(peerId: context.account.peerId))) } }) - + let message = Message( stableId: UInt32.max - 1001, stableVersion: 0, @@ -782,7 +786,7 @@ func chatHistoryEntriesForView( entries.append(.MessageEntry(message, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false, authorStoryStats: nil, displayContinueThreadFooter: false, pinToTop: false))) } } - + if let subject = associatedData.subject, case let .customChatContents(customChatContents) = subject, case let .quickReplyMessageInput(_, shortcutType) = customChatContents.kind, case .generic = shortcutType { if !view.isLoading && view.laterId == nil && !view.entries.isEmpty { for i in 0 ..< 2 { @@ -809,7 +813,7 @@ func chatHistoryEntriesForView( entities.append(MessageTextEntity(range: range.lowerBound ..< range.upperBound, type: .TextMention(peerId: context.account.peerId))) } }) - + let message = Message( stableId: UInt32.max - 1001 - UInt32(i), stableVersion: 0, @@ -840,11 +844,11 @@ func chatHistoryEntriesForView( } } } - + if isMusicPlaylist && entries.count == 1 { return ([], currentState) } - + if reverse { return (entries.reversed(), currentState) } else { diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index e58c57e392..6400b57974 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -25,6 +25,7 @@ import TelegramNotices import ReactionListContextMenuContent import TelegramUIPreferences import TranslateUI +import PromptUI import DebugSettingsUI import ChatPresentationInterfaceState import Pasteboard @@ -37,7 +38,7 @@ import ChatMessageItemView import ChatMessageBubbleItemNode import AdsInfoScreen import AdsReportScreen - + private struct MessageContextMenuData { let starStatus: Bool? let canReply: Bool @@ -55,7 +56,7 @@ func canEditMessage(context: AccountContext, limitsConfiguration: EngineConfigur private func canEditMessage(accountPeerId: EnginePeer.Id, limitsConfiguration: EngineConfiguration.Limits, message: EngineRawMessage, reschedule: Bool = false) -> Bool { var hasEditRights = false var unlimitedInterval = reschedule - + if message.id.namespace == Namespaces.Message.ScheduledCloud { if let peer = message.peers[message.id.peerId], let channel = peer as? TelegramChannel { switch channel.info { @@ -116,9 +117,9 @@ private func canEditMessage(accountPeerId: EnginePeer.Id, limitsConfiguration: E } } } - + var hasUneditableAttributes = false - + if hasEditRights { for attribute in message.attributes { if let _ = attribute as? InlineBotMessageAttribute { @@ -132,7 +133,7 @@ private func canEditMessage(accountPeerId: EnginePeer.Id, limitsConfiguration: E if message.forwardInfo != nil { hasUneditableAttributes = true } - + for media in message.media { if let file = media as? TelegramMediaFile { if file.isSticker || file.isAnimatedSticker || file.isInstantVideo { @@ -173,7 +174,7 @@ private func canEditMessage(accountPeerId: EnginePeer.Id, limitsConfiguration: E unlimitedInterval = true } } - + if !hasUneditableAttributes || reschedule { if canPerformEditingActions(limits: limitsConfiguration._asLimits(), accountPeerId: accountPeerId, message: message, unlimitedInterval: unlimitedInterval) { return true @@ -266,7 +267,7 @@ private func canViewReadStats(message: EngineRawMessage, participantCount: Int?, if user.flags.contains(.isSupport) { return false } - + if !isPremium { let premiumConfiguration = PremiumConfiguration.with(appConfiguration: appConfig) if premiumConfiguration.isPremiumDisabled { @@ -295,7 +296,7 @@ func canReplyInChat(_ chatPresentationInterfaceState: ChatPresentationInterfaceS guard let peer = chatPresentationInterfaceState.renderedPeer?.peer else { return false } - + if case .scheduledMessages = chatPresentationInterfaceState.subject { return false } @@ -319,7 +320,7 @@ func canReplyInChat(_ chatPresentationInterfaceState: ChatPresentationInterfaceS return false } } - + if let channel = peer as? TelegramChannel, channel.isForumOrMonoForum { if let threadData = chatPresentationInterfaceState.threadData { if threadData.isClosed { @@ -331,14 +332,14 @@ func canReplyInChat(_ chatPresentationInterfaceState: ChatPresentationInterfaceS } else if threadData.isOwnedByMe { canManage = true } - + if !canManage { return false } } } } - + var canReply = false switch chatPresentationInterfaceState.chatLocation { case .peer: @@ -392,9 +393,9 @@ func messageMediaEditingOptions(message: EngineRawMessage) -> MessageMediaEditin return [] } } - + var options: MessageMediaEditingOptions = [] - + for media in message.media { if let _ = media as? TelegramMediaImage { options.formUnion([.imageOrVideo, .file]) @@ -428,11 +429,11 @@ func messageMediaEditingOptions(message: EngineRawMessage) -> MessageMediaEditin options.formUnion([.imageOrVideo, .file]) } } - + if message.groupingKey != nil { options.remove(.file) } - + return options } @@ -468,12 +469,12 @@ func updatedChatEditInterfaceMessageState(context: AccountContext, state: ChatPr content = .media(mediaOptions: messageMediaEditingOptions(message: message)) } updated = updated.updatedEditMessageState(ChatEditInterfaceMessageState(content: content, mediaReference: nil)) - + var previewState: (UrlPreviewState?, Disposable)? if let (updatedEditingUrlPreviewState, _) = urlPreviewStateForInputText(updated.interfaceState.editMessage?.inputState.inputText, context: context, currentQuery: nil, forPeerId: state.chatLocation.peerId) { previewState = (updatedEditingUrlPreviewState, EmptyDisposable) } - + return ( updated, previewState @@ -492,7 +493,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState return .single(ContextController.Items(content: .list([]))) } } - + var isEmbeddedMode = false if case .standard(.embedded) = chatPresentationInterfaceState.mode { isEmbeddedMode = true @@ -518,30 +519,30 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState let message = messages[0] let presentationData = context.sharedContext.currentPresentationData.with { $0 } - + var actions: [ContextMenuItem] = [] - + if adAttribute.sponsorInfo != nil || adAttribute.additionalInfo != nil { actions.append(.action(ContextMenuActionItem(text: presentationData.strings.Chat_ContextMenu_AdSponsorInfo, textColor: .primary, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Channels"), color: theme.actionSheet.primaryTextColor) }, iconSource: nil, action: { c, _ in var subItems: [ContextMenuItem] = [] - + subItems.append(.action(ContextMenuActionItem(text: presentationData.strings.Common_Back, textColor: .primary, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) }, iconSource: nil, iconPosition: .left, action: { c, _ in c?.popItems() }))) - + subItems.append(.separator) - + if let sponsorInfo = adAttribute.sponsorInfo { subItems.append(.action(ContextMenuActionItem(text: sponsorInfo, textColor: .primary, textLayout: .multiline, textFont: .custom(font: Font.regular(floor(presentationData.listsFontSize.baseDisplaySize * 0.8)), height: nil, verticalOffset: nil), badge: nil, icon: { theme in return nil }, iconSource: nil, action: { [weak controllerInteraction] c, _ in c?.dismiss(completion: { UIPasteboard.general.string = sponsorInfo - + let content: UndoOverlayContent = .copy(text: presentationData.strings.Chat_ContextMenu_AdSponsorInfoCopied) controllerInteraction?.displayUndo(content) }) @@ -553,18 +554,18 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }, iconSource: nil, action: { [weak controllerInteraction] c, _ in c?.dismiss(completion: { UIPasteboard.general.string = additionalInfo - + let content: UndoOverlayContent = .copy(text: presentationData.strings.Chat_ContextMenu_AdSponsorInfoCopied) controllerInteraction?.displayUndo(content) }) }))) } - + c?.pushItems(items: .single(ContextController.Items(content: .list(subItems)))) }))) actions.append(.separator) } - + if adAttribute.canReport { actions.append(.action(ContextMenuActionItem(text: presentationData.strings.Chat_ContextMenu_AboutAd, textColor: .primary, textLayout: .twoLinesMax, textFont: .custom(font: Font.regular(presentationData.listsFontSize.baseDisplaySize - 1.0), height: nil, verticalOffset: nil), badge: nil, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.actionSheet.primaryTextColor) @@ -572,12 +573,12 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState f(.dismissWithoutContent) controllerInteraction.navigationController()?.pushViewController(AdsInfoScreen(context: context, mode: .channel)) }))) - + actions.append(.action(ContextMenuActionItem(text: presentationData.strings.Chat_ContextMenu_ReportAd, textColor: .primary, textLayout: .twoLinesMax, textFont: .custom(font: Font.regular(presentationData.listsFontSize.baseDisplaySize - 1.0), height: nil, verticalOffset: nil), badge: nil, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Restrict"), color: theme.actionSheet.primaryTextColor) }, iconSource: nil, action: { _, f in f(.default) - + let _ = (context.engine.messages.reportAdMessage(opaqueId: adAttribute.opaqueId, option: nil) |> deliverOnMainQueue).start(next: { result in if case let .options(title, options) = result { @@ -601,9 +602,9 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } }) }))) - + actions.append(.separator) - + actions.append(.action(ContextMenuActionItem(text: presentationData.strings.Chat_ContextMenu_RemoveAd, textColor: .primary, textLayout: .twoLinesMax, textFont: .custom(font: Font.regular(presentationData.listsFontSize.baseDisplaySize - 1.0), height: nil, verticalOffset: nil), badge: nil, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.primaryTextColor) }, iconSource: nil, action: { c, _ in @@ -618,7 +619,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState f(.dismissWithoutContent) controllerInteraction.navigationController()?.pushViewController(AdInfoScreen(context: context, forceDark: false)) }))) - + let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) if !chatPresentationInterfaceState.isPremium && !premiumConfiguration.isPremiumDisabled { actions.append(.action(ContextMenuActionItem(text: presentationData.strings.SponsoredMessageMenu_Hide, textColor: .primary, textLayout: .twoLinesMax, textFont: .custom(font: Font.regular(presentationData.listsFontSize.baseDisplaySize - 1.0), height: nil, verticalOffset: nil), badge: nil, icon: { theme in @@ -637,9 +638,9 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }) }))) } - + actions.append(.separator) - + if chatPresentationInterfaceState.copyProtectionEnabled { } else { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_ContextMenuCopy, icon: { theme in @@ -655,7 +656,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState restrictedText = attribute.platformText(platform: "ios", contentSettings: context.currentContentSettings.with { $0 }) ?? "" } } - + if let restrictedText = restrictedText { storeMessageTextInPasteboard(restrictedText, entities: nil) } else { @@ -671,29 +672,29 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState storeMessageTextInPasteboard(message.text, entities: messageEntities) } } - + Queue.mainQueue().after(0.2, { let content: UndoOverlayContent = .copy(text: chatPresentationInterfaceState.strings.Conversation_MessageCopied) controllerInteraction.displayUndo(content) }) - + f(.default) }))) } - + if let author = message.author, let addressName = author.addressName { let link = "https://t.me/\(addressName)" actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_ContextMenuCopyLink, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in UIPasteboard.general.string = link - + let presentationData = context.sharedContext.currentPresentationData.with { $0 } - + Queue.mainQueue().after(0.2, { controllerInteraction.displayUndo(.linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied)) }) - + f(.default) }))) } @@ -701,7 +702,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState return .single(ContextController.Items(content: .list(actions))) } - + var loadStickerSaveStatus: EngineMedia.Id? var loadCopyMediaResource: TelegramMediaResource? var isAction = false @@ -739,19 +740,19 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } } - + var canReply = canReplyInChat(chatPresentationInterfaceState, accountPeerId: context.account.peerId) var canPin = false let canSelect = !isAction - + let message = messages[0] - + if case .peer = chatPresentationInterfaceState.chatLocation, let channel = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isForumOrMonoForum { if message.threadId == nil { canReply = false } } - + if Namespaces.Message.allNonRegular.contains(message.id.namespace) || message.id.peerId.isRepliesOrVerificationCodes { canReply = false canPin = false @@ -785,11 +786,11 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState canReply = false canPin = false } - + if isGiveawayServiceMessage { canReply = false } - + if let peer = messages[0].peers[messages[0].id.peerId] { if peer.isDeleted { canPin = false @@ -799,11 +800,11 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState canReply = false } } - + if !canSendMessagesToChat(chatPresentationInterfaceState) && (chatPresentationInterfaceState.copyProtectionEnabled || message.isCopyProtected()) { canReply = false } - + for media in messages[0].media { if let story = media as? TelegramMediaStory { if let story = message.associatedStories[story.storyId], story.data.isEmpty { @@ -813,31 +814,31 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } } - + var loadStickerSaveStatusSignal: Signal = .single(nil) if let loadStickerSaveStatus = loadStickerSaveStatus { loadStickerSaveStatusSignal = context.engine.stickers.isStickerSaved(id: loadStickerSaveStatus) |> map(Optional.init) } - + var loadResourceStatusSignal: Signal = .single(nil) if let loadCopyMediaResource = loadCopyMediaResource { loadResourceStatusSignal = context.engine.resources.status(resource: EngineMediaResource(loadCopyMediaResource)) |> take(1) |> map(Optional.init) } - + let loadLimits = context.engine.data.get( TelegramEngine.EngineData.Item.Configuration.Limits(), TelegramEngine.EngineData.Item.Configuration.App() ) - + struct InfoSummaryData { var linkedDiscusionPeerId: EnginePeerCachedInfoItem var canViewStats: Bool var participantCount: Int? var messageReadStatsAreHidden: Bool? - + init(linkedDiscusionPeerId: EnginePeerCachedInfoItem, canViewStats: Bool, participantCount: Int?, messageReadStatsAreHidden: Bool?) { self.linkedDiscusionPeerId = linkedDiscusionPeerId self.canViewStats = canViewStats @@ -845,7 +846,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState self.messageReadStatsAreHidden = messageReadStatsAreHidden } } - + let infoSummaryData = context.engine.data.get( TelegramEngine.EngineData.Item.Peer.LinkedDiscussionPeerId(id: messages[0].id.peerId), TelegramEngine.EngineData.Item.Peer.CanViewStats(id: messages[0].id.peerId), @@ -876,9 +877,9 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState return readCounters.isOutgoingMessageIndexRead(message.index) } } - + let isScheduled = chatPresentationInterfaceState.subject == .scheduledMessages - + let dataSignal: Signal<(MessageContextMenuData, [EngineMessage.Id: ChatUpdatingMessageMedia], InfoSummaryData, AppConfiguration, Bool, Int32, AvailableReactions?, TranslationSettings, LoggingSettings, NotificationSoundList?, EnginePeer?), NoError> = combineLatest( loadLimits, loadStickerSaveStatusSignal, @@ -901,21 +902,21 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState let message = messages[0] canEdit = canEditMessage(context: context, limitsConfiguration: limitsConfiguration, message: message) } - + let translationSettings: TranslationSettings if let current = sharedData.entries[ApplicationSpecificSharedDataKeys.translationSettings]?.get(TranslationSettings.self) { translationSettings = current } else { translationSettings = TranslationSettings.defaultSettings } - + let loggingSettings: LoggingSettings if let current = sharedData.entries[SharedDataKeys.loggingSettings]?.get(LoggingSettings.self) { loggingSettings = current } else { loggingSettings = LoggingSettings.defaultSettings } - + var messageActions = messageActions if isEmbeddedMode { messageActions = ChatAvailableMessageActions( @@ -931,7 +932,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState messageActions.setTag = false messageActions.editTags = Set() } - + let data = MessageContextMenuData( starStatus: stickerSaveStatus, canReply: canReply, @@ -941,10 +942,10 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState resourceStatus: resourceStatus, messageActions: messageActions ) - + return (data, updatingMessageMedia, infoSummaryData, appConfig, isMessageRead, messageViewsPrivacyTips, availableReactions, translationSettings, loggingSettings, notificationSoundList, accountPeer) } - + return dataSignal |> deliverOnMainQueue |> map { data, updatingMessageMedia, infoSummaryData, appConfig, isMessageRead, messageViewsPrivacyTips, availableReactions, translationSettings, loggingSettings, notificationSoundList, accountPeer -> ContextController.Items in @@ -983,7 +984,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState if case .pinnedMessages = chatPresentationInterfaceState.subject { isPinnedMessages = true } - + if let starStatus = data.starStatus { var isPremiumSticker = false for media in messages[0].media { @@ -1001,7 +1002,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }))) } } - + if data.messageActions.options.contains(.rateCall) { var callId: CallId? var isVideo: Bool = false @@ -1023,23 +1024,23 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState if let accessHash = Int64(accessHash) { callId = CallId(id: id, accessHash: accessHash) } - + actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Call_ShareStats, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in f(.dismissWithoutContent) - + let controller = context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(context: context, filter: [.onlyWriteable, .excludeDisabled], selectForumThreads: true)) controller.peerSelected = { [weak controller] peer, _ in let peerId = peer.id - + if let strongController = controller { strongController.dismiss() - + let id = Int64.random(in: Int64.min ... Int64.max) let file = TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: LocalFileReferenceMediaResource(localFilePath: logPath, randomId: id), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: nil, attributes: [.FileName(fileName: "CallStats.log")], alternativeRepresentations: []) let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) - + let _ = enqueueMessages(account: context.account, peerId: peerId, messages: [message]).startStandalone() } } @@ -1059,7 +1060,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }))) } } - + var audioTranscription: AudioTranscriptionMessageAttribute? var didRateAudioTranscription = false for attribute in message.attributes { @@ -1069,7 +1070,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState break } } - + var hasRateTranscription = false if hasExpandedAudioTranscription, let audioTranscription = audioTranscription, !didRateAudioTranscription { hasRateTranscription = true @@ -1077,35 +1078,35 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState guard let context = context else { return } - + let _ = context.engine.messages.rateAudioTranscription(messageId: message.id, id: audioTranscription.id, isGood: value).startStandalone() - + let presentationData = context.sharedContext.currentPresentationData.with { $0 } let content: UndoOverlayContent = .info(title: nil, text: presentationData.strings.Chat_AudioTranscriptionFeedbackTip, timeout: nil, customUndoText: nil) controllerInteraction.displayUndo(content) }), false), at: 0) actions.insert(.separator, at: 1) } - + if !hasRateTranscription && message.minAutoremoveOrClearTimeout == nil { for media in message.effectiveMedia { if let file = media as? TelegramMediaFile, let size = file.size, size < 1 * 1024 * 1024, let duration = file.duration, duration < 60, (["audio/mpeg", "audio/mp3", "audio/mpeg3", "audio/ogg"] as [String]).contains(file.mimeType.lowercased()) { let fileName = file.fileName ?? "Tone" - + var isAlreadyAdded = false if let notificationSoundList = notificationSoundList, notificationSoundList.sounds.contains(where: { $0.file.fileId == file.fileId }) { isAlreadyAdded = true } - + if !isAlreadyAdded { let presentationData = context.sharedContext.currentPresentationData.with { $0 } actions.append(.action(ContextMenuActionItem(text: presentationData.strings.Chat_SaveForNotifications, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/DownloadTone"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in f(.default) - + let presentationData = context.sharedContext.currentPresentationData.with { $0 } - + let settings = NotificationSoundSettings.extract(from: context.currentAppConfiguration.with({ $0 })) if size > settings.maxSize { controllerInteraction.displayUndo(.info(title: presentationData.strings.Notifications_UploadError_TooLarge_Title, text: presentationData.strings.Notifications_UploadError_TooLarge_Text(dataSizeString(Int64(settings.maxSize), formatting: DataSizeStringFormatting(presentationData: presentationData))).string, timeout: nil, customUndoText: nil)) @@ -1125,7 +1126,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } } - + var isDownloading = false let resourceAvailable: Bool if let resourceStatus = data.resourceStatus { @@ -1140,7 +1141,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } else { resourceAvailable = false } - + if !isPremium && isDownloading { var isLargeFile = false for media in message.effectiveMedia { @@ -1170,7 +1171,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState actions.append(.separator) } } - + if data.messageActions.options.contains(.sendGift), !message.id.peerId.isTelegramNotifications { let sendGiftTitle: String var isIncoming = message.effectivelyIncoming(context.account.peerId) @@ -1196,12 +1197,12 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState f(.dismissWithoutContent) }))) } - + var isReplyThreadHead = false if case let .replyThread(replyThreadMessage) = chatPresentationInterfaceState.chatLocation { isReplyThreadHead = messages[0].id == replyThreadMessage.effectiveTopId } - + if !isPinnedMessages, !isReplyThreadHead, data.canReply { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_ContextMenuReply, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Reply"), color: theme.actionSheet.primaryTextColor) @@ -1213,7 +1214,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }) }))) } - + if data.messageActions.options.contains(.sendScheduledNow) { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.ScheduledMessages_SendNow, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Resend"), color: theme.actionSheet.primaryTextColor) @@ -1221,7 +1222,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState if messages.contains(where: { $0.pendingProcessingAttribute != nil }) { c?.dismiss(completion: { let presentationData = context.sharedContext.currentPresentationData.with { $0 } - + controllerInteraction.presentController(textAlertController( context: context, title: presentationData.strings.Chat_ScheduledForceSendProcessingVideo_Title, @@ -1241,7 +1242,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } }))) } - + if data.messageActions.options.contains(.editScheduledTime) { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.ScheduledMessages_EditTime, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Schedule"), color: theme.actionSheet.primaryTextColor) @@ -1250,7 +1251,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState f(.dismissWithoutContent) }))) } - + var messageText: String = "" for message in messages { if !message.text.isEmpty { @@ -1262,7 +1263,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } } - + for attribute in message.attributes { if hasExpandedAudioTranscription, let attribute = attribute as? AudioTranscriptionMessageAttribute { if !messageText.isEmpty { @@ -1272,7 +1273,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState break } } - + var isPoll = false if messageText.isEmpty { for media in message.media { @@ -1287,7 +1288,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } } - + let message = messages[0] var richMessageMarkdown: String? if let richTextAttribute = message.attributes.first(where: { $0 is RichTextMessageAttribute }) as? RichTextMessageAttribute { @@ -1306,7 +1307,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState isImage = true } } - + let isCopyProtected = chatPresentationInterfaceState.copyProtectionEnabled || message.isCopyProtected() if !messageText.isEmpty || richMessageMarkdown != nil || (resourceAvailable && isImage) || diceEmoji != nil { if !isExpired { @@ -1341,7 +1342,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState messageText = attribute.text } } - + if let restrictedText = restrictedText { storeMessageTextInPasteboard(restrictedText, entities: nil) } else { @@ -1357,7 +1358,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState storeMessageTextInPasteboard(messageText, entities: messageEntities) } } - + Queue.mainQueue().after(0.2, { let content: UndoOverlayContent = .copy(text: chatPresentationInterfaceState.strings.Conversation_MessageCopied) controllerInteraction.displayUndo(content) @@ -1375,7 +1376,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState copyTextWithEntities() } else { UIPasteboard.general.image = image - + Queue.mainQueue().after(0.2, { let content: UndoOverlayContent = .copy(text: chatPresentationInterfaceState.strings.Conversation_ImageCopied) controllerInteraction.displayUndo(content) @@ -1402,12 +1403,86 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }))) } } - + + if let editHistory = message.attributes.first(where: { $0 is WinterGramEditHistoryAttribute }) as? WinterGramEditHistoryAttribute, !editHistory.revisions.isEmpty { + actions.append(.action(ContextMenuActionItem(text: "Edit History (\(editHistory.revisions.count))", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.actionSheet.primaryTextColor) + }, action: { c, f in + c?.dismiss(completion: { + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let sheet = ActionSheetController(presentationData: presentationData) + var items: [ActionSheetItem] = [] + items.append(ActionSheetTextItem(title: "Edit History")) + for revision in editHistory.revisions.reversed() { + let dateText = humanReadableStringForTimestamp(strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, timestamp: revision.timestamp, alwaysShowTime: true, allowYesterday: true, format: nil).string + items.append(ActionSheetButtonItem(title: "\(dateText)\n\(revision.text)", action: { [weak sheet] in + UIPasteboard.general.string = revision.text + sheet?.dismissAnimated() + })) + } + sheet.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak sheet] in + sheet?.dismissAnimated() + }) + ]) + ]) + controllerInteraction.presentController(sheet, nil) + }) + let _ = f + }))) + } + + 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) + }, action: { _, f in + UIPasteboard.general.string = "\(message.id.id)" + f(.default) + }))) + } + + let trimmedNumeric = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedNumeric.count >= 5 && trimmedNumeric.count <= 16, let numericId = Int64(trimmedNumeric), numericId > 0 { + actions.append(.action(ContextMenuActionItem(text: "Open Profile #\(numericId)", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/User"), color: theme.actionSheet.primaryTextColor) + }, action: { c, _ in + c?.dismiss(completion: { + let peerId = EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(numericId)) + let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) + |> deliverOnMainQueue).startStandalone(next: { peer in + if let peer = peer { + controllerInteraction.openPeer(peer, .info(nil), nil, .default) + } else { + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + controllerInteraction.presentController(textAlertController(context: context, title: "WinterGram", text: "No cached profile for ID \(numericId). You can only open users already known to the app.", actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), nil) + } + }) + }) + }))) + } + + if !message.text.isEmpty && message.media.isEmpty { + actions.append(.action(ContextMenuActionItem(text: "Edit Locally", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.actionSheet.primaryTextColor) + }, action: { c, _ in + c?.dismiss(completion: { + let controller = promptController(context: context, text: "Edit Locally", subtitle: "This change stays on your device only — it is not sent and adds no edited mark.", value: message.text, characterLimit: 4096, apply: { newValue in + if let newValue, !newValue.isEmpty { + let _ = winterGramEditMessageLocally(postbox: context.account.postbox, messageId: message.id, text: newValue).startStandalone() + } + }) + controllerInteraction.presentController(controller, nil) + }) + }))) + } + var showTranslateIfTopical = false if let peer = chatPresentationInterfaceState.renderedPeer?.chatMainPeer as? TelegramChannel, !(peer.addressName ?? "").isEmpty { showTranslateIfTopical = true } - + var (canTranslate, _) = canTranslateText(context: context, text: messageText, showTranslate: translationSettings.showTranslate, showTranslateIfTopical: showTranslateIfTopical, ignoredLanguages: translationSettings.ignoredLanguages) if let peerId = chatPresentationInterfaceState.chatLocation.peerId, peerId.namespace == Namespaces.Peer.SecretChat { canTranslate = false @@ -1422,18 +1497,18 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState messageEntities = attribute.entities } } - + controllerInteraction.performTextSelectionAction(message, !isCopyProtected, NSAttributedString(string: messageText), messageEntities, .translate) f(.default) }))) } - + if isSpeakSelectionEnabled() && !messageText.isEmpty { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_ContextMenuSpeak, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Message"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in var text = messageText - + var translateToLang: String? if let translationState = chatPresentationInterfaceState.translationState, translationState.isEnabled { translateToLang = translationState.toLang @@ -1443,14 +1518,14 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } else if let translateToLang, let translation = message.attributes.first(where: { ($0 as? TranslationMessageAttribute)?.toLang == translateToLang }) as? TranslationMessageAttribute, !translation.text.isEmpty { text = translation.text } - + controllerInteraction.performTextSelectionAction(message, !isCopyProtected, NSAttributedString(string: text), nil, .speak) f(.default) }))) } } } - + if resourceAvailable, !message.containsSecretMedia && !isCopyProtected { var mediaReference: AnyMediaReference? var isVideo = false @@ -1479,7 +1554,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }))) } } - + var downloadableMediaResourceInfos: [String] = [] for media in message.effectiveMedia { if let file = media as? TelegramMediaFile { @@ -1494,7 +1569,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } } - + if !isCopyProtected { for media in message.effectiveMedia { if let file = media as? TelegramMediaFile { @@ -1510,7 +1585,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } } - + if (loggingSettings.logToFile || loggingSettings.logToConsole) && !downloadableMediaResourceInfos.isEmpty { actions.append(.action(ContextMenuActionItem(text: "Send Logs", icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Message"), color: theme.actionSheet.primaryTextColor) @@ -1521,7 +1596,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState f(.default) }))) } - + var threadId: Int64? var threadMessageCount: Int = 0 if case .peer = chatPresentationInterfaceState.chatLocation, let channel = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramChannel, case .group = channel.info { @@ -1545,7 +1620,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } } - + if let _ = threadId, !isPinnedMessages { let text: String if threadMessageCount != 0 { @@ -1561,14 +1636,14 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }) }))) } - + let isMigrated: Bool if chatPresentationInterfaceState.renderedPeer?.peer is TelegramChannel && message.id.peerId.namespace == Namespaces.Peer.CloudGroup { isMigrated = true } else { isMigrated = false } - + var activePoll: TelegramMediaPoll? var activeTodo: TelegramMediaTodo? for media in message.media { @@ -1580,7 +1655,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState activeTodo = todo } } - + if data.canEdit && !isPinnedMessages && !isMigrated { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_MessageDialogEdit, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.actionSheet.primaryTextColor) @@ -1595,7 +1670,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } }))) } - + if let message = messages.first, message.id.namespace == Namespaces.Message.Cloud, let channel = message.peers[message.id.peerId] as? TelegramChannel, channel.isMonoForum { var canSuggestPost = true for media in message.media { @@ -1603,7 +1678,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState canSuggestPost = false } } - + if canSuggestPost { if message.attributes.contains(where: { $0 is SuggestedPostMessageAttribute }) { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Chat_ContextMenu_SuggestedPost_EditMessage, icon: { theme in @@ -1638,7 +1713,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } } - + if let activePoll = activePoll, let voters = activePoll.results.voters { var hasSelected = false for result in voters { @@ -1655,7 +1730,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }))) } } - + if let activeTodo { var maxTodoItemsCount: Int = 30 if let data = context.currentAppConfiguration.with({ $0 }).data { @@ -1663,7 +1738,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState maxTodoItemsCount = Int(value) } } - + var canAppend = false if activeTodo.items.count < maxTodoItemsCount && (activeTodo.flags.contains(.othersCanAppend) || message.author?.id == context.account.peerId) { canAppend = true @@ -1677,7 +1752,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }))) } } - + var canPin = data.canPin if case let .replyThread(message) = chatPresentationInterfaceState.chatLocation { if !message.isForumPost { @@ -1687,7 +1762,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState if isMigrated { canPin = false } - + if canPin { var pinnedSelectedMessageId: EngineMessage.Id? for message in messages { @@ -1696,7 +1771,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState break } } - + if let pinnedSelectedMessageId = pinnedSelectedMessageId { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_Unpin, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Unpin"), color: theme.actionSheet.primaryTextColor) @@ -1711,7 +1786,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }))) } } - + if let activePoll, messages[0].forwardInfo == nil { var canStopPoll = false if !messages[0].flags.contains(.Incoming) { @@ -1731,12 +1806,12 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } } - + if hasEditRights { canStopPoll = true } } - + if canStopPoll { let stopPollAction: String switch activePoll.kind { @@ -1753,7 +1828,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }))) } } - + if let message = messages.first, message.id.namespace == Namespaces.Message.Cloud, let channel = message.peers[message.id.peerId] as? TelegramChannel, !channel.isMonoForum, !(message.media.first is TelegramMediaAction), !isReplyThreadHead, !isMigrated { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_ContextMenuCopyLink, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.actionSheet.primaryTextColor) @@ -1769,9 +1844,9 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState |> deliverOnMainQueue).startStandalone(next: { link in if let link = link { UIPasteboard.general.string = link - + let presentationData = context.sharedContext.currentPresentationData.with { $0 } - + var warnAboutPrivate = false if case .peer = chatPresentationInterfaceState.chatLocation { if channel.addressName == nil { @@ -1790,11 +1865,11 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState f(.default) }))) } - + var isUnremovableAction = false if messages.count == 1 { let message = messages[0] - + var hasAutoremove = false for attribute in message.attributes { if let _ = attribute as? AutoremoveTimeoutMessageAttribute { @@ -1805,7 +1880,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState break } } - + if !hasAutoremove { for media in message.media { if let action = media as? TelegramMediaAction { @@ -1824,7 +1899,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } if message.id.peerId.namespace == Namespaces.Peer.SecretChat { - + } else if let file = media as? TelegramMediaFile, !isCopyProtected { if file.isVideo { if file.isAnimated && !file.isVideoSticker { @@ -1867,7 +1942,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } } - + var editStickerFile: TelegramMediaFile? for media in messages[0].media { if let file = media as? TelegramMediaFile, file.isSticker && !file.isPremiumSticker { @@ -1883,7 +1958,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState interfaceInteraction.editSticker(editStickerFile) }))) } - + if data.messageActions.options.contains(.viewStickerPack) { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.StickerPack_ViewPack, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Sticker"), color: theme.actionSheet.primaryTextColor) @@ -1903,7 +1978,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }))) } } - + if data.messageActions.options.contains(.report) { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_ContextMenuReport, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Report"), color: theme.actionSheet.primaryTextColor) @@ -1917,7 +1992,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState interfaceInteraction.blockMessageAuthor(message, controller) }))) } - + var clearCacheAsDelete = false var hasViewStats = false if let channel = message.peers[message.id.peerId] as? TelegramChannel, case .broadcast = channel.info, !isMigrated { @@ -1931,7 +2006,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState forwards = attribute.count } } - + if infoSummaryData.canViewStats, forwards >= 1 || views >= 100 { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_ContextViewStats, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Statistics"), color: theme.actionSheet.primaryTextColor) @@ -1942,10 +2017,10 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }))) hasViewStats = true } - + clearCacheAsDelete = true } - + if !hasViewStats, messages[0].forwardInfo == nil { for media in message.media { if let poll = media as? TelegramMediaPoll, message.id.namespace == Namespaces.Message.Cloud, poll.pollId.namespace == Namespaces.Media.CloudPoll, poll.results.canViewStats { @@ -1961,13 +2036,13 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } } - + if message.id.namespace == Namespaces.Message.Cloud, let channel = message.peers[message.id.peerId] as? TelegramChannel, case .broadcast = channel.info, canEditFactCheck(appConfig: appConfig) { var canAddFactCheck = true if message.media.contains(where: { $0 is TelegramMediaAction || $0 is TelegramMediaGiveaway }) { canAddFactCheck = false } - + if canAddFactCheck { let sortedMessages = messages.sorted(by: { $0.id < $1.id }) let hasFactCheck = sortedMessages[0].factCheckAttribute != nil @@ -1986,7 +2061,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }))) } } - + if isReplyThreadHead { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_ViewInChannel, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/GoToMessage"), color: theme.actionSheet.primaryTextColor) @@ -2046,7 +2121,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState if message.attributes.contains(where: { $0 is PublishedSuggestedPostMessageAttribute }) && message.timestamp > Int32(Date().timeIntervalSince1970) - 60 * 60 * 24 { iconName = "Chat/Context Menu/DeletePaid" } - + actions.append(.action(ContextMenuActionItem(text: title, textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: iconName), color: theme.actionSheet.destructiveActionTextColor) }, action: { controller, f in @@ -2092,7 +2167,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }))) } } - + var canViewStats = false var canViewAuthor = false if let channel = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isMonoForum, let associatedPeerId = channel.associatedPeerId { @@ -2107,7 +2182,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } else if let messageReadStatsAreHidden = infoSummaryData.messageReadStatsAreHidden, !messageReadStatsAreHidden { canViewStats = canViewReadStats(message: message, participantCount: infoSummaryData.participantCount, isMessageRead: isMessageRead, isPremium: isPremium, appConfig: appConfig) } - + var reactionCount = 0 for reaction in mergedMessageReactionsAndPeers(accountPeerId: context.account.peerId, accountPeer: nil, message: message).reactions { reactionCount += Int(reaction.count) @@ -2117,7 +2192,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState reactionCount = 0 } } - + let isEdited = message.attributes.contains(where: { attribute in if let attribute = attribute as? EditedMessageAttribute, !attribute.isHidden, attribute.date != 0 { return true @@ -2132,7 +2207,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }) }), false), at: 0) } - + if let peer = message.peers[message.id.peerId], (canViewStats || reactionCount != 0) { var hasReadReports = false if let channel = peer as? TelegramChannel { @@ -2160,7 +2235,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState if !actions.isEmpty { actions.insert(.separator, at: 0) } - + var readStats = readStats if !(hasReadReports || reactionCount != 0) { readStats = MessageReadStats(reactionCount: 0, peers: [], readTimestamps: [:]) @@ -2180,10 +2255,10 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }) } else if (stats != nil && !stats!.peers.isEmpty) || reactionCount != 0 { var tip: ContextController.Tip? - + let presentationData = context.sharedContext.currentPresentationData.with { $0 } let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) - + if !premiumConfiguration.isPremiumDisabled { if customReactionEmojiPacks.count == 1, let firstCustomEmojiReaction = firstCustomEmojiReaction { tip = .animatedEmoji( @@ -2206,7 +2281,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }) } } - + var displayReadTimestamps = false if let stats, !stats.readTimestamps.isEmpty { displayReadTimestamps = true @@ -2221,7 +2296,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState if allItemsHaveTimestamp { displayReadTimestamps = true } - + let deleteReaction: ((EnginePeer, MessageReaction.Reaction) -> Void)? if let channel = message.peers[message.id.peerId] as? TelegramChannel, channel.hasPermission(.deleteAllMessages) { deleteReaction = { [weak c] peer, _ in @@ -2261,18 +2336,18 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }), false), at: 0) } } - + if isEdited { if !actions.isEmpty { actions.insert(.separator, at: 0) } actions.insert(.custom(ChatReadReportContextItem(context: context, message: message, hasReadReports: false, isEdit: true, stats: MessageReadStats(reactionCount: 0, peers: [], readTimestamps: [:]), action: nil), false), at: 0) } - + if !actions.isEmpty, case .separator = actions[0] { actions.removeFirst() } - + if let message = messages.first, case let .customChatContents(customChatContents) = chatPresentationInterfaceState.subject { switch customChatContents.kind { case .hashTagSearch: @@ -2293,7 +2368,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState restrictedText = attribute.platformText(platform: "ios", contentSettings: context.currentContentSettings.with { $0 }) ?? "" } } - + if let restrictedText = restrictedText { storeMessageTextInPasteboard(restrictedText, entities: nil) } else { @@ -2309,16 +2384,16 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState storeMessageTextInPasteboard(message.text, entities: messageEntities) } } - + Queue.mainQueue().after(0.2, { let content: UndoOverlayContent = .copy(text: chatPresentationInterfaceState.strings.Conversation_MessageCopied) controllerInteraction.displayUndo(content) }) - + f(.default) }))) } - + if message.id.namespace == Namespaces.Message.QuickReplyCloud { if data.canEdit { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_MessageDialogEdit, icon: { theme in @@ -2330,7 +2405,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }))) } } - + if message.id.id < Int32.max - 1000 { if !actions.isEmpty { actions.append(.separator) @@ -2339,7 +2414,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.actionSheet.destructiveActionTextColor) }, action: { [weak customChatContents] _, f in f(.dismissWithoutContent) - + guard let customChatContents else { return } @@ -2350,12 +2425,12 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState actions.removeAll() } } - + for media in message.media { if let poll = media as? TelegramMediaPoll, message.id.namespace == Namespaces.Message.Cloud, poll.pollId.namespace == Namespaces.Media.CloudPoll { var restrictionText: String = "" let peerName: String = chatPresentationInterfaceState.renderedPeer?.peer.flatMap(EnginePeer.init)?.compactDisplayTitle ?? "" - + if !poll.countries.isEmpty { let locale = localeWithStrings(chatPresentationInterfaceState.strings) let countryNames = poll.countries.map { id in @@ -2388,7 +2463,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } else if poll.restrictToSubscribers { restrictionText = chatPresentationInterfaceState.strings.Chat_Poll_Restriction_Subscribers(peerName).string } - + if !restrictionText.isEmpty { actions.append(.separator) let noAction: ((ContextMenuActionItem.Action) -> Void)? = nil @@ -2399,7 +2474,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState break } } - + return ContextController.Items(content: .list(actions), tip: nil) } } @@ -2408,16 +2483,16 @@ func canPerformEditingActions(limits: LimitsConfiguration, accountPeerId: Engine if message.id.peerId == accountPeerId { return true } - + if unlimitedInterval { return true } - + let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) if Int64(message.timestamp) + Int64(limits.maxMessageEditingInterval) > Int64(timestamp) { return true } - + return false } @@ -2428,7 +2503,7 @@ private func canPerformDeleteActions(limits: LimitsConfiguration, accountPeerId: if message.id.peerId.namespace == Namespaces.Peer.SecretChat { return true } - + if !message.flags.contains(.Incoming) { let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) if message.id.peerId.namespace == Namespaces.Peer.CloudUser { @@ -2441,7 +2516,7 @@ private func canPerformDeleteActions(limits: LimitsConfiguration, accountPeerId: } } } - + return false } @@ -2462,7 +2537,7 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Engi } else { isPremium = false } - + var optionsMap: [EngineMessage.Id: ChatAvailableMessageActionOptions] = [:] var banPeer: EngineRawPeer? var banPeers: [EngineRawPeer] = [] @@ -2472,10 +2547,10 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Engi var isCopyProtected = false var isShareProtected = false var isExternalShareProtected = false - + var setTag = false var commonTags: Set? - + func getPeer(_ peerId: EnginePeer.Id) -> EngineRawPeer? { if let maybePeer = peerMap[peerId], let peer = maybePeer { return peer._asPeer() @@ -2485,7 +2560,7 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Engi return nil } } - + func getMessage(_ messageId: EngineMessage.Id) -> EngineRawMessage? { if let maybeMessage = messageMap[messageId], let message = maybeMessage { return message._asMessage() @@ -2495,7 +2570,7 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Engi return nil } } - + func isPeerCopyProtected(_ peerId: EnginePeer.Id) -> Bool? { let copyProtection = copyProtectionMap[peerId] let myCopyProtection = myCopyProtectionMap[peerId] @@ -2505,7 +2580,7 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Engi return nil } } - + for id in messageIds { let isScheduled = id.namespace == Namespaces.Message.ScheduledCloud if optionsMap[id] == nil { @@ -2514,7 +2589,7 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Engi if let message = getMessage(id) { if message.areReactionsTags(accountPeerId: accountPeerId) { setTag = true - + var messageReactions = Set() if let reactionsAttribute = mergedMessageReactions(attributes: message.attributes, isTags: message.areReactionsTags(accountPeerId: accountPeerId)) { for reaction in reactionsAttribute.reactions { @@ -2530,15 +2605,15 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Engi commonTags = messageReactions } } - + if message.isCopyProtected() || message.containsSecretMedia { isCopyProtected = true } - + if isPeerCopyProtected(message.id.peerId) == true { isCopyProtected = true } - + for media in message.media { if let invoice = media as? TelegramMediaInvoice, let _ = invoice.extendedMedia { isShareProtected = true @@ -2616,7 +2691,7 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Engi } else if banPeer?.id != message.author?.id { banPeer = nil } - + if !banPeers.contains(where: { $0.id == author.id }) { banPeers.append(author) } @@ -2627,7 +2702,7 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Engi } else if banPeer?.id != message.author?.id { banPeer = nil } - + if !banPeers.contains(where: { $0.id == author.id }) { banPeers.append(author) } @@ -2692,7 +2767,7 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Engi if user.botInfo != nil { canDeleteGlobally = false } - + let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) if isDice && Int64(message.timestamp) + 60 * 60 * 24 > Int64(timestamp) { canDeleteGlobally = false @@ -2728,7 +2803,7 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Engi } } } - + if !isNonRemovableServiceAction { optionsMap[id]!.insert(.deleteGlobally) } @@ -2739,12 +2814,12 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Engi optionsMap[id]!.insert(.deleteLocally) } } - + if !isShareProtected && !isExternalShareProtected { optionsMap[id]!.insert(.externalShare) } } - + if !optionsMap.isEmpty { var reducedOptions = optionsMap.values.first! for value in optionsMap.values { @@ -2753,12 +2828,12 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Engi if hadPersonalIncoming && optionsMap.values.contains(where: { $0.contains(.deleteGlobally) }) && !reducedOptions.contains(.deleteGlobally) { reducedOptions.insert(.unsendPersonal) } - + if !isPremium { setTag = false commonTags = nil } - + return ChatAvailableMessageActions(options: reducedOptions, banAuthor: banPeer.flatMap(EnginePeer.init), banAuthors: banPeers.map(EnginePeer.init), disableDelete: disableDelete, isCopyProtected: isCopyProtected, setTag: setTag, editTags: commonTags ?? Set()) } else { return ChatAvailableMessageActions(options: [], banAuthor: nil, banAuthors: [], disableDelete: false, isCopyProtected: isCopyProtected, setTag: false, editTags: Set()) @@ -2769,12 +2844,12 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Engi final class ChatDeleteMessageContextItem: ContextMenuCustomItem { fileprivate let timestamp: Double fileprivate let action: (ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void - + init(timestamp: Double, action: @escaping (ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void) { self.timestamp = timestamp self.action = action } - + func node(presentationData: PresentationData, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) -> ContextMenuCustomNode { return ChatDeleteMessageContextItemNode(presentationData: presentationData, item: self, getController: getController, actionSelected: actionSelected) } @@ -2787,16 +2862,16 @@ private final class ChatDeleteMessageContextItemNode: ASDisplayNode, ContextMenu private let presentationData: PresentationData private let getController: () -> ContextControllerProtocol? private let actionSelected: (ContextMenuActionResult) -> Void - + private let backgroundNode: ASDisplayNode private let textNode: ImmediateTextNode private let statusNode: ImmediateTextNode private let iconNode: ASImageNode private let textIconNode: ASImageNode private let buttonNode: HighlightTrackingButtonNode - + private var timer: SwiftSignalKit.Timer? - + private var pointerInteraction: PointerInteraction? var isActionEnabled: Bool { @@ -2808,20 +2883,20 @@ private final class ChatDeleteMessageContextItemNode: ASDisplayNode, ContextMenu self.presentationData = presentationData self.getController = getController self.actionSelected = actionSelected - + let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize) let subtextFont = Font.regular(presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0) - + self.backgroundNode = ASDisplayNode() self.backgroundNode.isAccessibilityElement = false self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor - + self.textNode = ImmediateTextNode() self.textNode.isAccessibilityElement = false self.textNode.isUserInteractionEnabled = false self.textNode.displaysAsynchronously = false self.textNode.attributedText = NSAttributedString(string: presentationData.strings.Conversation_ContextMenuDelete, font: textFont, textColor: presentationData.theme.contextMenu.destructiveColor) - + self.textNode.maximumNumberOfLines = 1 let statusNode = ImmediateTextNode() statusNode.isAccessibilityElement = false @@ -2830,78 +2905,78 @@ private final class ChatDeleteMessageContextItemNode: ASDisplayNode, ContextMenu statusNode.attributedText = NSAttributedString(string: stringForRemainingTime(Int32(max(0.0, self.item.timestamp - Date().timeIntervalSince1970)), strings: presentationData.strings), font: subtextFont, textColor: presentationData.theme.contextMenu.destructiveColor) statusNode.maximumNumberOfLines = 1 self.statusNode = statusNode - + self.buttonNode = HighlightTrackingButtonNode() self.buttonNode.isAccessibilityElement = true self.buttonNode.accessibilityLabel = presentationData.strings.VoiceChat_StopRecording - + self.iconNode = ASImageNode() self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: presentationData.theme.actionSheet.destructiveActionTextColor) - + self.textIconNode = ASImageNode() self.textIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/SelfExpiring"), color: presentationData.theme.actionSheet.destructiveActionTextColor) - + super.init() - + self.addSubnode(self.backgroundNode) self.addSubnode(self.textNode) self.addSubnode(self.statusNode) self.addSubnode(self.iconNode) self.addSubnode(self.textIconNode) self.addSubnode(self.buttonNode) - + self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) } - + deinit { self.timer?.invalidate() } - + override func didLoad() { super.didLoad() - + self.pointerInteraction = PointerInteraction(node: self.buttonNode, style: .hover, willEnter: { }, willExit: { }) - + let timer = SwiftSignalKit.Timer(timeout: 0.5, repeat: true, completion: { [weak self] in self?.updateTime(transition: .immediate) }, queue: Queue.mainQueue()) self.timer = timer timer.start() } - + private var validLayout: CGSize? func updateTime(transition: ContainedViewLayoutTransition) { guard let size = self.validLayout else { return } - + let subtextFont = Font.regular(self.presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0) self.statusNode.attributedText = NSAttributedString(string: stringForRemainingTime(Int32(max(0.0, self.item.timestamp - Date().timeIntervalSince1970)), strings: presentationData.strings), font: subtextFont, textColor: presentationData.theme.contextMenu.destructiveColor) - + let sideInset: CGFloat = 18.0 let statusSize = self.statusNode.updateLayout(CGSize(width: size.width - sideInset - 32.0 + 4.0, height: .greatestFiniteMagnitude)) transition.updateFrameAdditive(node: self.statusNode, frame: CGRect(origin: CGPoint(x: self.statusNode.frame.minX, y: self.statusNode.frame.minY), size: statusSize)) } - + func updateLayout(constrainedWidth: CGFloat, constrainedHeight: CGFloat) -> (CGSize, (CGSize, ContainedViewLayoutTransition) -> Void) { let sideInset: CGFloat = 18.0 let iconSideInset: CGFloat = 12.0 let verticalInset: CGFloat = 12.0 - + let iconSize: CGSize = self.iconNode.image?.size ?? CGSize(width: 10.0, height: 10.0) let textIconSize: CGSize = self.textIconNode.image?.size ?? CGSize(width: 2.0, height: 2.0) - + let standardIconWidth: CGFloat = 32.0 var rightTextInset: CGFloat = sideInset if !iconSize.width.isZero { rightTextInset = max(iconSize.width, standardIconWidth) + iconSideInset + sideInset } - + let textSize = self.textNode.updateLayout(CGSize(width: constrainedWidth - sideInset - rightTextInset, height: .greatestFiniteMagnitude)) let statusSize = self.statusNode.updateLayout(CGSize(width: constrainedWidth - sideInset - rightTextInset - textIconSize.width + 2.0, height: .greatestFiniteMagnitude)) - + let verticalSpacing: CGFloat = 2.0 let combinedTextHeight = textSize.height + verticalSpacing + statusSize.height return (CGSize(width: max(textSize.width, statusSize.width) + sideInset + rightTextInset, height: verticalInset * 2.0 + combinedTextHeight), { size, transition in @@ -2909,33 +2984,33 @@ private final class ChatDeleteMessageContextItemNode: ASDisplayNode, ContextMenu let verticalOrigin = floor((size.height - combinedTextHeight) / 2.0) let textFrame = CGRect(origin: CGPoint(x: sideInset + 42.0, y: verticalOrigin), size: textSize) transition.updateFrameAdditive(node: self.textNode, frame: textFrame) - + transition.updateFrame(node: self.textIconNode, frame: CGRect(origin: CGPoint(x: sideInset + 42.0, y: verticalOrigin + verticalSpacing + textSize.height + floorToScreenPixels((statusSize.height - textIconSize.height) / 2.0) + 1.0), size: textIconSize)) transition.updateFrameAdditive(node: self.statusNode, frame: CGRect(origin: CGPoint(x: sideInset + 42.0 + textIconSize.width + 2.0, y: verticalOrigin + verticalSpacing + textSize.height), size: statusSize)) - + if !iconSize.width.isZero { transition.updateFrameAdditive(node: self.iconNode, frame: CGRect(origin: CGPoint(x: iconSideInset + 12.0, y: floor((size.height - iconSize.height) / 2.0)), size: iconSize)) } - + transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))) transition.updateFrame(node: self.buttonNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))) }) } - + func updateTheme(presentationData: PresentationData) { self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor - + let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize) let subtextFont = Font.regular(presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0) - + self.textNode.attributedText = NSAttributedString(string: self.textNode.attributedText?.string ?? "", font: textFont, textColor: presentationData.theme.contextMenu.primaryColor) self.statusNode.attributedText = NSAttributedString(string: self.statusNode.attributedText?.string ?? "", font: subtextFont, textColor: presentationData.theme.contextMenu.secondaryColor) } - + @objc private func buttonPressed() { self.performAction() } - + func performAction() { guard let controller = self.getController() else { return @@ -2944,18 +3019,18 @@ private final class ChatDeleteMessageContextItemNode: ASDisplayNode, ContextMenu self?.actionSelected(result) }) } - + func setIsHighlighted(_ value: Bool) { } - + func canBeHighlighted() -> Bool { return self.isActionEnabled } - + func updateIsHighlighted(isHighlighted: Bool) { self.setIsHighlighted(isHighlighted) } - + func actionNode(at point: CGPoint) -> ContextActionNodeProtocol { return self } @@ -3034,7 +3109,7 @@ private final class ChatMessageAuthorContextItemNode: ASDisplayNode, ContextMenu self.addSubnode(self.buttonNode) self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) - + self.buttonNode.isUserInteractionEnabled = false self.disposable = (item.context.engine.messages.requestMessageAuthor(id: item.message.id) @@ -3081,7 +3156,7 @@ private final class ChatMessageAuthorContextItemNode: ASDisplayNode, ContextMenu let rightTextInset: CGFloat //let avatarsWidth: CGFloat = 32.0 let avatarsWidth: CGFloat = 0 - + verticalInset = 12.0 rightTextInset = sideInset + 36.0 @@ -3089,7 +3164,7 @@ private final class ChatMessageAuthorContextItemNode: ASDisplayNode, ContextMenu let textFont = Font.regular(floor(13.0 * (self.presentationData.listsFontSize.baseDisplaySize / 17.0))) let boldTextFont = Font.semibold(floor(13.0 * (self.presentationData.listsFontSize.baseDisplaySize / 17.0))) - + let animatePositions = true if let peer = self.peer { @@ -3112,12 +3187,12 @@ private final class ChatMessageAuthorContextItemNode: ASDisplayNode, ContextMenu let combinedTextHeight = textSize.height return (CGSize(width: calculatedWidth, height: verticalInset * 2.0 + combinedTextHeight), { size, transition in self.validLayout = (calculatedWidth: calculatedWidth, size: size) - + let positionTransition: ContainedViewLayoutTransition = animatePositions ? transition : .immediate - + let verticalOrigin = floor((size.height - combinedTextHeight) / 2.0) let textFrame = CGRect(origin: CGPoint(x: sideInset + avatarsWidth + 2.0, y: verticalOrigin), size: textSize) - + positionTransition.updateFrameAdditive(node: self.textNode, frame: textFrame) transition.updateAlpha(node: self.textNode, alpha: self.peer == nil ? 0.0 : 1.0) @@ -3162,11 +3237,11 @@ private final class ChatMessageAuthorContextItemNode: ASDisplayNode, ContextMenu } private var actionTemporarilyDisabled: Bool = false - + func canBeHighlighted() -> Bool { return self.isActionEnabled } - + func updateIsHighlighted(isHighlighted: Bool) { self.setIsHighlighted(isHighlighted) } @@ -3199,7 +3274,7 @@ private final class ChatMessageAuthorContextItemNode: ASDisplayNode, ContextMenu func setIsHighlighted(_ value: Bool) { } - + func actionNode(at point: CGPoint) -> ContextActionNodeProtocol { return self } @@ -3253,7 +3328,7 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus private var disposable: Disposable? private var currentStats: MessageReadStats? - + private var customEmojiPacksDisposable: Disposable? private var customEmojiPacks: [StickerPackCollectionInfo] = [] private var firstCustomEmojiReaction: TelegramMediaFile? @@ -3318,17 +3393,17 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus self.addSubnode(self.buttonNode) self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) - + var reactionCount = 0 var customEmojiFiles = Set() for reaction in mergedMessageReactionsAndPeers(accountPeerId: item.context.account.peerId, accountPeer: nil, message: self.item.message).reactions { reactionCount += Int(reaction.count) - + if case let .custom(fileId) = reaction.value { customEmojiFiles.insert(fileId) } } - + if !customEmojiFiles.isEmpty { self.customEmojiPacksDisposable = (item.context.engine.stickers.resolveInlineStickers(fileIds: Array(customEmojiFiles)) |> mapToSignal { customEmoji -> Signal<([StickerPackCollectionInfo], TelegramMediaFile?), NoError> in @@ -3342,7 +3417,7 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus if firstCustomEmojiReaction == nil { firstCustomEmojiReaction = file } - + existingIds.insert(id) stickerPackSignals.append(item.context.engine.stickers.loadedStickerPack(reference: packReference, forceActualized: false) |> filter { result in @@ -3397,7 +3472,7 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus } }) } - + if !self.item.isEdit { item.context.account.viewTracker.updateReactionsForMessageIds(messageIds: [item.message.id], force: true) } @@ -3439,7 +3514,7 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus let sideInset: CGFloat = 18.0 let verticalInset: CGFloat let rightTextInset: CGFloat - + if self.item.message.id.peerId.namespace == Namespaces.Peer.CloudUser { verticalInset = 7.0 rightTextInset = 8.0 @@ -3453,18 +3528,18 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus let calculatedWidth = min(constrainedWidth, 250.0) let textFont = Font.regular(self.presentationData.listsFontSize.baseDisplaySize) - + var reactionCount = 0 for reaction in mergedMessageReactionsAndPeers(accountPeerId: self.item.context.account.peerId, accountPeer: nil, message: self.item.message).reactions { reactionCount += Int(reaction.count) } - + var showReadBadge = false var animatePositions = true if let currentStats = self.currentStats { reactionCount = currentStats.reactionCount - + if currentStats.peers.isEmpty { if self.item.isEdit, let attribute = self.item.message.attributes.first(where: { $0 is EditedMessageAttribute }) as? EditedMessageAttribute, !attribute.isHidden, attribute.date != 0 { let dateText = humanReadableStringForTimestamp(strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, timestamp: attribute.date, alwaysShowTime: true, allowYesterday: true, format: HumanReadableStringFormat( @@ -3481,7 +3556,7 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus return PresentationStrings.FormattedString(string: self.presentationData.strings.Chat_PrivateMessageEditTimestamp_YesterdayAt(value).string, ranges: []) } )).string - + self.textNode.attributedText = NSAttributedString(string: dateText, font: Font.regular(floor(self.presentationData.listsFontSize.baseDisplaySize * 0.8)), textColor: self.presentationData.theme.contextMenu.primaryColor) } else if self.item.message.id.peerId.namespace == Namespaces.Peer.CloudUser { let text = NSAttributedString(string: self.presentationData.strings.Chat_ContextMenuReadDate_ReadAvailablePrefix, font: Font.regular(floor(self.presentationData.listsFontSize.baseDisplaySize * 0.8)), textColor: self.presentationData.theme.contextMenu.primaryColor) @@ -3505,7 +3580,7 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus } } } - + self.textNode.attributedText = NSAttributedString(string: text, font: textFont, textColor: self.presentationData.theme.contextMenu.secondaryColor) } } @@ -3524,7 +3599,7 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus return PresentationStrings.FormattedString(string: self.presentationData.strings.Chat_PrivateMessageSeenTimestamp_YesterdayAt(value).string, ranges: []) } )).string - + self.textNode.attributedText = NSAttributedString(string: dateText, font: Font.regular(floor(self.presentationData.listsFontSize.baseDisplaySize * 0.8)), textColor: self.presentationData.theme.contextMenu.primaryColor) } else { if reactionCount != 0 { @@ -3561,7 +3636,7 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus let textSize = self.textNode.updateLayout(CGSize(width: calculatedWidth - sideInset - rightTextInset - iconSize.width - 4.0, height: .greatestFiniteMagnitude)) let placeholderTextSize = self.placeholderCalculationTextNode.updateLayout(CGSize(width: calculatedWidth - sideInset - rightTextInset - iconSize.width - 4.0, height: .greatestFiniteMagnitude)) - + var badgeTextSize: CGSize? if showReadBadge { let badgeBackground: UIImageView @@ -3573,7 +3648,7 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus self.badgeBackground = badgeBackground self.view.addSubview(badgeBackground) } - + let badgeText: ImmediateTextNode if let current = self.badgeText { badgeText = current @@ -3583,9 +3658,9 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus self.badgeText = badgeText self.addSubnode(badgeText) } - + badgeText.attributedText = NSAttributedString(string: self.presentationData.strings.Chat_ContextMenuReadDate_ReadAvailableBadge, font: Font.regular(self.presentationData.listsFontSize.baseDisplaySize * 11.0 / 17.0), textColor: self.presentationData.theme.contextMenu.primaryColor) - + badgeTextSize = badgeText.updateLayout(CGSize(width: calculatedWidth - sideInset - rightTextInset - iconSize.width - 4.0 - textSize.width - 12.0, height: 100.0)) } else { if let badgeBackground = self.badgeBackground { @@ -3601,29 +3676,29 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus let combinedTextHeight = textSize.height return (CGSize(width: calculatedWidth, height: verticalInset * 2.0 + combinedTextHeight), { size, transition in self.validLayout = (calculatedWidth: calculatedWidth, size: size) - + let positionTransition: ContainedViewLayoutTransition = animatePositions ? transition : .immediate - + let verticalOrigin = floor((size.height - combinedTextHeight) / 2.0) let textFrame = CGRect(origin: CGPoint(x: sideInset + 42.0, y: verticalOrigin), size: textSize) - + positionTransition.updateFrameAdditive(node: self.textNode, frame: textFrame) transition.updateAlpha(node: self.textNode, alpha: self.currentStats == nil ? 0.0 : 1.0) - + if let badgeTextSize, let badgeText = self.badgeText, let badgeBackground = self.badgeBackground { let backgroundSideInset: CGFloat = 5.0 let backgroundVerticalInset: CGFloat = 3.0 let badgeTextFrame = CGRect(origin: CGPoint(x: textFrame.maxX + 5.0 + backgroundSideInset, y: textFrame.minY + floor((textFrame.height - badgeTextSize.height) * 0.5)), size: badgeTextSize) positionTransition.updateFrameAdditive(node: badgeText, frame: badgeTextFrame) transition.updateAlpha(node: badgeText, alpha: self.currentStats == nil ? 0.0 : 1.0) - + let badgeBackgroundFrame = badgeTextFrame.insetBy(dx: -backgroundSideInset, dy: -backgroundVerticalInset).offsetBy(dx: 0.0, dy: 1.0) - + if badgeBackground.image?.size.height != ceil(badgeBackgroundFrame.height) { badgeBackground.image = generateStretchableFilledCircleImage(diameter: ceil(badgeBackgroundFrame.height), color: .white, strokeColor: nil, strokeWidth: nil, backgroundColor: nil)?.withRenderingMode(.alwaysTemplate) } badgeBackground.tintColor = self.presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.05) - + positionTransition.updateFrame(view: badgeBackground, frame: badgeBackgroundFrame) transition.updateAlpha(layer: badgeBackground.layer, alpha: self.currentStats == nil ? 0.0 : 1.0) } @@ -3681,7 +3756,7 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus } } avatarsContent = self.avatarsContext.update(peers: avatarsPeers, animated: false) - + if self.item.message.id.peerId.namespace == Namespaces.Peer.CloudUser { placeholderAvatarsContent = self.avatarsContext.updatePlaceholder(color: shimmeringForegroundColor, count: 0, animated: false) } else { @@ -3716,11 +3791,11 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus } private var actionTemporarilyDisabled: Bool = false - + func canBeHighlighted() -> Bool { return self.isActionEnabled } - + func updateIsHighlighted(isHighlighted: Bool) { self.setIsHighlighted(isHighlighted) } @@ -3770,7 +3845,7 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus func setIsHighlighted(_ value: Bool) { } - + func actionNode(at point: CGPoint) -> ContextActionNodeProtocol { return self } @@ -3818,7 +3893,7 @@ private final class ChatRateTranscriptionContextItemNode: ASDisplayNode, Context private let backgroundNode: ASDisplayNode private let textNode: ImmediateTextNode - + private let upButtonImageNode: ASImageNode private let downButtonImageNode: ASImageNode private let upButtonNode: HighlightableButtonNode @@ -3843,18 +3918,18 @@ private final class ChatRateTranscriptionContextItemNode: ASDisplayNode, Context self.textNode.displaysAsynchronously = false self.textNode.attributedText = NSAttributedString(string: self.presentationData.strings.Chat_AudioTranscriptionRateAction, font: textFont, textColor: presentationData.theme.contextMenu.secondaryColor) self.textNode.maximumNumberOfLines = 1 - + self.upButtonImageNode = ASImageNode() self.upButtonImageNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ThumbsDown"), color: presentationData.theme.contextMenu.primaryColor, backgroundColor: nil) self.upButtonImageNode.isUserInteractionEnabled = false - + self.downButtonImageNode = ASImageNode() self.downButtonImageNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ThumbsUp"), color: presentationData.theme.contextMenu.primaryColor, backgroundColor: nil) self.downButtonImageNode.isUserInteractionEnabled = false - + self.upButtonNode = HighlightableButtonNode() self.upButtonNode.addSubnode(self.upButtonImageNode) - + self.downButtonNode = HighlightableButtonNode() self.downButtonNode.addSubnode(self.downButtonImageNode) @@ -3862,10 +3937,10 @@ private final class ChatRateTranscriptionContextItemNode: ASDisplayNode, Context self.addSubnode(self.backgroundNode) self.addSubnode(self.textNode) - + self.addSubnode(self.upButtonNode) self.addSubnode(self.downButtonNode) - + self.upButtonNode.addTarget(self, action: #selector(self.upPressed), forControlEvents: .touchUpInside) self.downButtonNode.addTarget(self, action: #selector(self.downPressed), forControlEvents: .touchUpInside) } @@ -3876,12 +3951,12 @@ private final class ChatRateTranscriptionContextItemNode: ASDisplayNode, Context override func didLoad() { super.didLoad() } - + @objc private func upPressed() { self.action(true) self.getController()?.dismiss(completion: nil) } - + @objc private func downPressed() { self.action(false) self.getController()?.dismiss(completion: nil) @@ -3900,14 +3975,14 @@ private final class ChatRateTranscriptionContextItemNode: ASDisplayNode, Context let verticalOrigin = verticalInset let textFrame = CGRect(origin: CGPoint(x: floor((size.width - textSize.width) / 2.0), y: verticalOrigin), size: textSize) transition.updateFrameAdditive(node: self.textNode, frame: textFrame) - + let buttonArea = CGRect(origin: CGPoint(x: 0.0, y: size.height - 35.0 - 6.0), size: CGSize(width: size.width, height: 35.0)) - + self.upButtonNode.frame = CGRect(origin: CGPoint(x: buttonArea.minX, y: buttonArea.minY), size: CGSize(width: floor(buttonArea.size.width / 2.0), height: buttonArea.height)) self.downButtonNode.frame = CGRect(origin: CGPoint(x: buttonArea.minX + floor(buttonArea.size.width / 2.0), y: buttonArea.minY), size: CGSize(width: floor(buttonArea.size.width / 2.0), height: buttonArea.height)) - + let spacing: CGFloat = 56.0 - + if let image = self.upButtonImageNode.image { self.upButtonImageNode.frame = CGRect(origin: CGPoint(x: floor(buttonArea.width / 2.0) - floor(spacing / 2.0) - image.size.width, y: floor((buttonArea.height - image.size.height) / 2.0)), size: image.size) } @@ -3928,11 +4003,11 @@ private final class ChatRateTranscriptionContextItemNode: ASDisplayNode, Context self.textNode.attributedText = NSAttributedString(string: self.textNode.attributedText?.string ?? "", font: textFont, textColor: presentationData.theme.contextMenu.primaryColor) } - + func canBeHighlighted() -> Bool { return self.isActionEnabled } - + func updateIsHighlighted(isHighlighted: Bool) { self.setIsHighlighted(isHighlighted) } @@ -3946,7 +4021,7 @@ private final class ChatRateTranscriptionContextItemNode: ASDisplayNode, Context func setIsHighlighted(_ value: Bool) { } - + func actionNode(at point: CGPoint) -> ContextActionNodeProtocol { return self } diff --git a/submodules/TelegramUI/Sources/OpenUrl.swift b/submodules/TelegramUI/Sources/OpenUrl.swift index 6c964bad99..f0c199859d 100644 --- a/submodules/TelegramUI/Sources/OpenUrl.swift +++ b/submodules/TelegramUI/Sources/OpenUrl.swift @@ -10,6 +10,7 @@ import UrlEscaping import PassportUI import UrlHandling import OpenInExternalAppUI +import SettingsUI import BrowserUI import OverlayStatusController import PresentationDataUtils @@ -38,11 +39,11 @@ public func isOAuthUrl(_ url: URL) -> Bool { guard let query = url.query, let params = QueryParameters(query), ["oauth", "resolve"].contains(url.host) else { return false } - + let domain = params["domain"] let startApp = params["startapp"] let token = params["token"] - + var valid = false if url.host == "resolve" { if domain == "oauth", let _ = startApp { @@ -53,7 +54,7 @@ public func isOAuthUrl(_ url: URL) -> Bool { valid = true } } - + return valid } @@ -61,7 +62,7 @@ public func parseSecureIdUrl(_ url: URL) -> ParsedSecureIdUrl? { guard let query = url.query, let params = QueryParameters(query), ["passport", "resolve"].contains(url.host) else { return nil } - + let domain = params["domain"] let botId = params["bot_id"].flatMap(Int64.init) let scope = params["scope"] @@ -75,7 +76,7 @@ public func parseSecureIdUrl(_ url: URL) -> ParsedSecureIdUrl? { if let nonceValue = params["nonce"], let data = nonceValue.data(using: .utf8) { opaqueNonce = data } - + let valid: Bool if url.host == "resolve" { if domain == "telegrampassport" { @@ -86,7 +87,7 @@ public func parseSecureIdUrl(_ url: URL) -> ParsedSecureIdUrl? { } else { valid = true } - + if valid { if let botId = botId, let scope = scope, let publicKey = publicKey, let callbackUrl = callbackUrl { if scope.hasPrefix("{") && scope.hasSuffix("}") { @@ -97,11 +98,11 @@ public func parseSecureIdUrl(_ url: URL) -> ParsedSecureIdUrl? { } else if opaquePayload.isEmpty { return nil } - + return ParsedSecureIdUrl(peerId: EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(botId)), scope: scope, publicKey: publicKey, callbackUrl: callbackUrl, opaquePayload: opaquePayload, opaqueNonce: opaqueNonce) } } - + return nil } @@ -248,7 +249,7 @@ private func handleInternetUrl( if urlScheme == "tonsite" { isInternetUrl = true } - + if isInternetUrl { if let host = parsedUrl.host, telegramMeHosts.contains(host) { handleInternalUrl(parsedUrl.absoluteString) @@ -263,19 +264,19 @@ private func handleInternetUrl( let accountSettings = accountSettingsEntry?.get(AccountWebBrowserSettings.self) ?? AccountWebBrowserSettings.defaultSettings return (localSettings, accountSettings) } - + let _ = (settings |> deliverOnMainQueue).startStandalone(next: { settings in let localSettings = settings.0 let accountSettings = settings.1 - + var isTonSite = false if let host = parsedUrl.host, host.lowercased().hasSuffix(".ton") { isTonSite = true } else if let scheme = parsedUrl.scheme, scheme.lowercased().hasPrefix("tonsite") { isTonSite = true } - + var isExceptedDomain = false let host = ".\((parsedUrl.host ?? "").lowercased())" let exceptions = accountSettings.openExternalBrowser ? accountSettings.inAppExceptions : accountSettings.externalExceptions @@ -285,7 +286,7 @@ private func handleInternetUrl( break } } - + let shouldOpenInApp: Bool if isTonSite { shouldOpenInApp = true @@ -294,7 +295,7 @@ private func handleInternetUrl( } else { shouldOpenInApp = !isExceptedDomain } - + if shouldOpenInApp { let controller = BrowserScreen(context: context, subject: .webPage(url: parsedUrl.absoluteString)) navigationController?.pushViewController(controller) @@ -324,21 +325,21 @@ private func handleInternetUrl( private struct QueryParameters { private let map: [String: [String?]] let items: [URLQueryItem] - + init?(_ query: String) { guard let components = URLComponents(string: "/?" + query) else { return nil } let queryItems = components.queryItems ?? [] self.items = queryItems - + var map: [String: [String?]] = [:] for item in queryItems { map[item.name, default: []].append(item.value) } self.map = map } - + subscript(_ name: String) -> String? { return self.map[name]?.first ?? nil } @@ -373,18 +374,18 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur context.sharedContext.applicationBindings.openUrl(url) return } - + guard let canonicalUrl = canonicalExternalUrl(from: url) else { return } - + if canonicalUrl.scheme == "mailto" { context.sharedContext.applicationBindings.openUrl(url) return } - + var parsedUrl = canonicalUrl - + if let host = parsedUrl.host?.lowercased() { if host == "itunes.apple.com" { if context.sharedContext.applicationBindings.canOpenUrl(parsedUrl.absoluteString) { @@ -404,7 +405,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur } } } - + let handleResolvedUrl = makeResolvedUrlHandler( context: context, presentationData: presentationData, @@ -415,7 +416,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur context: context, resolvedHandler: handleResolvedUrl ) - + let continueHandling: () -> Void = { if let scheme = parsedUrl.scheme, (scheme == "tg" || scheme == context.sharedContext.applicationBindings.appSpecificScheme) { if parsedUrl.host == "tonsite" { @@ -424,10 +425,49 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur } } } - + if let scheme = parsedUrl.scheme, (scheme == "tg" || scheme == context.sharedContext.applicationBindings.appSpecificScheme) { var convertedUrl: String? let host = parsedUrl.host?.lowercased() ?? "" + // WinterGram settings deep links: wnt://wintergram (menu) and wnt://wintergram/
(subtab). + if host == "wintergram" { + let pathName = parsedUrl.path.replacingOccurrences(of: "/", with: "").lowercased() + let queryName = parsedUrl.query.flatMap { QueryParameters($0)?["section"] }?.lowercased() + let sectionName = !pathName.isEmpty ? pathName : queryName + let controller: ViewController + if let sectionName, let section = WinterGramSettingsSection(deepLinkName: sectionName) { + controller = winterGramSettingsController(context: context, category: section) + } else { + controller = winterGramMainSettingsController(context: context) + } + navigationController?.pushViewController(controller) + return + } + // WinterGram: wnt://profile opens the current account's own profile; wnt://profile?id= + // opens that user's profile (resolved from the local cache — works for known peers). + if host == "profile" { + let targetPeerId: EnginePeer.Id + if let idString = parsedUrl.query.flatMap({ QueryParameters($0)?["id"] }), let idValue = Int64(idString) { + targetPeerId = EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(idValue)) + } else { + targetPeerId = context.account.peerId + } + let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: targetPeerId)) + |> deliverOnMainQueue).start(next: { peer in + guard let peer = peer else { + return + } + if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { + navigationController?.pushViewController(controller) + } + }) + return + } + // WinterGram: wnt://deletedmessages opens the saved-deleted-messages breakdown (pie chart). + if host == "deletedmessages" { + navigationController?.pushViewController(winterGramDeletedMessagesController(context: context)) + return + } if let query = parsedUrl.query, let params = QueryParameters(query) { switch host { case "localpeer": @@ -478,7 +518,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur let pass = params["pass"] let secret = params["secret"] let secretHost = params["host"] - + if let server, !server.isEmpty, let port, let _ = Int32(port) { var queryItems: [URLQueryItem] = [ URLQueryItem(name: "proxy", value: server), @@ -512,10 +552,10 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur return } let controller = SecureIdAuthController(context: context, mode: .form(peerId: secureId.peerId, scope: secureId.scope, publicKey: secureId.publicKey, callbackUrl: secureId.callbackUrl, opaquePayload: secureId.opaquePayload, opaqueNonce: secureId.opaqueNonce)) - + if let navigationController = navigationController { context.sharedContext.applicationBindings.dismissNativeController() - + navigationController.view.window?.endEditing(true) context.sharedContext.applicationBindings.getWindowHost()?.present(controller, on: .root, blockInteraction: false, completion: {}) } @@ -601,7 +641,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur let channelId = params["channel"].flatMap(Int64.init) let postId = params["post"].flatMap(Int32.init) let threadId = params["thread"].flatMap(Int64.init) - + if let channelId { if let postId { if let threadId { @@ -687,7 +727,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur default: break } - + if host == "resolve" { var phone: String? var domain: String? @@ -710,7 +750,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur var referrer: String? var albumId: Int64? var collectionId: Int64? - + for queryItem in params.items { if let value = queryItem.value { switch queryItem.name { @@ -774,7 +814,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur } } } - + if let phone = phone { var queryItems: [URLQueryItem] = [] if let text { @@ -808,7 +848,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur } else if let collectionId { path += "/c/\(collectionId)" } - + var queryItems: [URLQueryItem] = [] if let startApp { queryItems.append(URLQueryItem(name: "startapp", value: startApp.isEmpty ? "" : startApp)) @@ -832,7 +872,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur } else if let attach { queryItems.append(URLQueryItem(name: "attach", value: attach)) } - + if let startAttach { queryItems.append(URLQueryItem(name: "startattach", value: startAttach.isEmpty ? nil : startAttach)) if let choose { @@ -851,7 +891,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur if direct { queryItems.append(URLQueryItem(name: "direct", value: nil)) } - + convertedUrl = makeTelegramUrl(path, queryItems: queryItems) } } @@ -868,10 +908,10 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur case "restore_purchases": let statusController = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil)) context.sharedContext.presentGlobalController(statusController, nil) - + context.inAppPurchaseManager?.restorePurchases(completion: { [weak statusController] result in statusController?.dismiss() - + let text: String? switch result { case let .succeed(serverProvided): @@ -982,7 +1022,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur break } } - + if let convertedUrl { handleInternalUrl(convertedUrl) } else if let path = parsedUrl.host { @@ -990,7 +1030,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur } return } - + handleInternetUrl( parsedUrl: parsedUrl, originalUrl: url, @@ -1000,7 +1040,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur handleInternalUrl: handleInternalUrl ) } - + if let scheme = parsedUrl.scheme, internetSchemes.contains(scheme) { if let host = parsedUrl.host, telegramMeHosts.contains(host) { continueHandling() diff --git a/submodules/TelegramUI/Sources/SharedWakeupManager.swift b/submodules/TelegramUI/Sources/SharedWakeupManager.swift index 97de6187fc..df9ae258a7 100644 --- a/submodules/TelegramUI/Sources/SharedWakeupManager.swift +++ b/submodules/TelegramUI/Sources/SharedWakeupManager.swift @@ -9,6 +9,7 @@ import AccountContext import UniversalMediaPlayer import TelegramAudio import TelegramPresentationData +import TelegramUIPreferences private struct AccountTasks { let stateSynchronization: Bool @@ -19,7 +20,7 @@ private struct AccountTasks { let activeCalls: Bool let watchTasks: Bool let userInterfaceInUse: Bool - + var isEmpty: Bool { if self.stateSynchronization { return false @@ -66,10 +67,10 @@ public final class SharedWakeupManager { private let endBackgroundTask: (UIBackgroundTaskIdentifier) -> Void private let backgroundTimeRemaining: () -> Double private let acquireIdleExtension: () -> Disposable? - + private var enableBackgroundTasks: Bool = false private let presentationData: () -> PresentationData? - + private var inForeground: Bool = false private var hasActiveAudioSession: Bool = false private var activeExplicitExtensionTimer: SwiftSignalKit.Timer? @@ -77,7 +78,7 @@ public final class SharedWakeupManager { private var allowBackgroundTimeExtensionDeadline: Double? private var allowBackgroundTimeExtensionDeadlineTimer: SwiftSignalKit.Timer? private var isInBackgroundExtension: Bool = false - + private var accountSettingsDisposable: Disposable? private var inForegroundDisposable: Disposable? private var hasActiveAudioSessionDisposable: Disposable? @@ -87,13 +88,13 @@ public final class SharedWakeupManager { private var currentTask: (UIBackgroundTaskIdentifier, Double, SwiftSignalKit.Timer)? private var currentExternalCompletion: (() -> Void, SwiftSignalKit.Timer)? private var currentExternalCompletionValidationTimer: SwiftSignalKit.Timer? - + private var managedPausedInBackgroundPlayer: Disposable? private var keepIdleDisposable: Disposable? private var silenceAudioRenderer: MediaPlayerAudioRenderer? - + private var accountsAndTasks: [(Account, Bool, AccountTasks)] = [] - + private var pendingMediaUploadsByKey: [PendingMediaUploadKey: Float] = [:] private var backgroundProcessingTaskProgressByKey: [PendingMediaUploadKey: Float] = [:] private var nextBackgroundProcessingTaskId: Int = 0 @@ -113,13 +114,13 @@ public final class SharedWakeupManager { public init(beginBackgroundTask: @escaping (String, @escaping () -> Void) -> UIBackgroundTaskIdentifier?, endBackgroundTask: @escaping (UIBackgroundTaskIdentifier) -> Void, backgroundTimeRemaining: @escaping () -> Double, acquireIdleExtension: @escaping () -> Disposable?, activeAccounts: Signal<(primary: Account?, accounts: [(AccountRecordId, Account)]), NoError>, liveLocationPolling: Signal, watchTasks: Signal, inForeground: Signal, hasActiveAudioSession: Signal, notificationManager: SharedNotificationManager?, mediaManager: MediaManager, callManager: PresentationCallManager?, accountUserInterfaceInUse: @escaping (AccountRecordId) -> Signal, presentationData: @escaping () -> PresentationData?) { assert(Queue.mainQueue().isCurrent()) - + self.beginBackgroundTask = beginBackgroundTask self.endBackgroundTask = endBackgroundTask self.backgroundTimeRemaining = backgroundTimeRemaining self.acquireIdleExtension = acquireIdleExtension self.presentationData = presentationData - + self.accountSettingsDisposable = (activeAccounts |> mapToSignal { activeAccounts -> Signal in guard let account = activeAccounts.primary else { @@ -142,7 +143,7 @@ public final class SharedWakeupManager { } self.enableBackgroundTasks = isEnabled }) - + self.inForegroundDisposable = (inForeground |> deliverOnMainQueue).startStrict(next: { [weak self] value in guard let strongSelf = self else { @@ -167,7 +168,7 @@ public final class SharedWakeupManager { strongSelf.updateBackgroundProcessingTaskStateFromPendingStoryUploads() strongSelf.checkTasks() }) - + self.hasActiveAudioSessionDisposable = (hasActiveAudioSession |> deliverOnMainQueue).startStrict(next: { [weak self] value in guard let strongSelf = self else { @@ -176,7 +177,7 @@ public final class SharedWakeupManager { strongSelf.hasActiveAudioSession = value strongSelf.checkTasks() }) - + self.managedPausedInBackgroundPlayer = combineLatest(queue: .mainQueue(), mediaManager.activeGlobalMediaPlayerAccountId, inForeground).startStrict(next: { [weak mediaManager] accountAndActive, inForeground in guard let mediaManager = mediaManager else { return @@ -185,7 +186,7 @@ public final class SharedWakeupManager { mediaManager.audioSession.dropAll() } }) - + self.tasksDisposable = (activeAccounts |> deliverOnMainQueue |> mapToSignal { primary, accounts -> Signal<[(Account, Bool, AccountTasks)], NoError> in @@ -204,13 +205,13 @@ public final class SharedWakeupManager { return hasActiveMedia && hasActiveAudioSession } |> distinctUntilChanged - + let hasActiveCalls = (callManager?.currentCallSignal ?? .single(nil)) |> map { call in return call?.context.account.id == account.id } |> distinctUntilChanged - + let hasActiveGroupCalls = (callManager?.currentGroupCallSignal ?? .single(nil)) |> map { call -> Bool in guard let call else { @@ -224,39 +225,39 @@ public final class SharedWakeupManager { } } |> distinctUntilChanged - + let keepUpdatesForCalls = combineLatest(queue: .mainQueue(), hasActiveCalls, hasActiveGroupCalls) |> map { hasActiveCalls, hasActiveGroupCalls -> Bool in return hasActiveCalls || hasActiveGroupCalls } |> distinctUntilChanged - + let isPlayingBackgroundActiveCall = combineLatest(queue: .mainQueue(), hasActiveCalls, hasActiveGroupCalls, hasActiveAudioSession) |> map { hasActiveCalls, hasActiveGroupCalls, hasActiveAudioSession -> Bool in return (hasActiveCalls || hasActiveGroupCalls) && hasActiveAudioSession } |> distinctUntilChanged - + let hasActiveAudio = combineLatest(queue: .mainQueue(), isPlayingBackgroundAudio, isPlayingBackgroundActiveCall) |> map { isPlayingBackgroundAudio, isPlayingBackgroundActiveCall in return isPlayingBackgroundAudio || isPlayingBackgroundActiveCall } |> distinctUntilChanged - + let hasActiveLiveLocationPolling = liveLocationPolling |> map { id in return id == account.id } |> distinctUntilChanged - + let hasWatchTasks = watchTasks |> map { id in return id == account.id } |> distinctUntilChanged - + let userInterfaceInUse = accountUserInterfaceInUse(account.id) - + return combineLatest(queue: .mainQueue(), account.importantTasksRunning, notificationManager?.isPollingState(accountId: account.id) ?? .single(false), hasActiveAudio, keepUpdatesForCalls, hasActiveLiveLocationPolling, hasWatchTasks, userInterfaceInUse) |> map { importantTasksRunning, isPollingState, hasActiveAudio, keepUpdatesForCalls, hasActiveLiveLocationPolling, hasWatchTasks, userInterfaceInUse -> (Account, Bool, AccountTasks) in return (account, primary?.id == account.id, AccountTasks(stateSynchronization: isPollingState, importantTasks: importantTasksRunning, backgroundLocation: hasActiveLiveLocationPolling, backgroundDownloads: false, backgroundAudio: hasActiveAudio, activeCalls: keepUpdatesForCalls, watchTasks: hasWatchTasks, userInterfaceInUse: userInterfaceInUse)) @@ -271,7 +272,7 @@ public final class SharedWakeupManager { strongSelf.accountsAndTasks = accountsAndTasks strongSelf.checkTasks() }) - + self.pendingMediaUploadsDisposable = (activeAccounts |> deliverOnMainQueue |> mapToSignal { _, accounts -> Signal<[PendingMediaUploadKey: Float], NoError> in @@ -308,7 +309,7 @@ public final class SharedWakeupManager { strongSelf.pendingMediaUploadsByKey = pendingMediaUploadsByKey strongSelf.updateBackgroundProcessingTaskStateFromPendingMediaUploads() }) - + self.pendingStoryUploadsDisposable = (activeAccounts |> deliverOnMainQueue |> mapToSignal { _, accounts -> Signal<[PendingStoryUploadKey: PendingStoryUploadStatus], NoError> in @@ -343,7 +344,7 @@ public final class SharedWakeupManager { return } strongSelf.pendingStoryUploadStatusesByKey = pendingStoryUploadStatusesByKey - + var pendingStoryUploadsByKey: [PendingStoryUploadKey: Float] = [:] pendingStoryUploadsByKey.reserveCapacity(pendingStoryUploadStatusesByKey.count) for (key, status) in pendingStoryUploadStatusesByKey { @@ -353,7 +354,7 @@ public final class SharedWakeupManager { strongSelf.updateBackgroundProcessingTaskStateFromPendingStoryUploads() }) } - + deinit { self.accountSettingsDisposable?.dispose() self.inForegroundDisposable?.dispose() @@ -370,12 +371,12 @@ public final class SharedWakeupManager { self.endBackgroundTask(taskId) } } - + private func updateBackgroundProcessingTaskStateFromPendingMediaUploads() { if !self.enableBackgroundTasks { return } - + let shouldHaveTask = !self.pendingMediaUploadsByKey.isEmpty && !self.inForeground let hadTask = self.backgroundProcessingTaskId != nil @@ -411,12 +412,12 @@ public final class SharedWakeupManager { } } } - + private func updateBackgroundProcessingTaskStateFromPendingStoryUploads() { if !self.enableBackgroundTasks { return } - + let shouldHaveTask = !self.pendingStoryUploadStatusesByKey.isEmpty && !self.inForeground let hadTask = self.backgroundStoryProcessingTaskId != nil @@ -452,14 +453,14 @@ public final class SharedWakeupManager { } } } - + private func cancelUploadingMessagesForCurrentTask() { let keys = Array(self.pendingMediaUploadsByKey.keys) if keys.isEmpty { Logger.shared.log("Wakeup", "BG task external cancel: no pending uploads to delete") return } - + var messageIdsByAccount: [AccountRecordId: [EngineMessage.Id]] = [:] for key in keys { if messageIdsByAccount[key.accountId] == nil { @@ -467,13 +468,13 @@ public final class SharedWakeupManager { } messageIdsByAccount[key.accountId]?.append(key.messageId) } - + for key in keys { self.pendingMediaUploadsByKey.removeValue(forKey: key) } - + Logger.shared.log("Wakeup", "BG task external cancel: deleting \(keys.count) uploading messages across \(messageIdsByAccount.count) accounts") - + for (accountId, messageIds) in messageIdsByAccount { guard let account = self.accountsAndTasks.first(where: { $0.0.id == accountId })?.0 else { Logger.shared.log("Wakeup", "BG task external cancel: missing account \(accountId.int64), skip \(messageIds.count) messages") @@ -483,14 +484,14 @@ public final class SharedWakeupManager { let _ = TelegramEngine(account: account).messages.deleteMessagesInteractively(messageIds: messageIds, type: .forLocalPeer).startStandalone() } } - + private func cancelUploadingStoriesForCurrentTask() { let keys = Array(self.pendingStoryUploadsByKey.keys) if keys.isEmpty { Logger.shared.log("Wakeup", "Story BG task external cancel: no pending uploads to cancel") return } - + var stableIdsByAccount: [AccountRecordId: [Int32]] = [:] for key in keys { if stableIdsByAccount[key.accountId] == nil { @@ -498,14 +499,14 @@ public final class SharedWakeupManager { } stableIdsByAccount[key.accountId]?.append(key.stableId) } - + for key in keys { self.pendingStoryUploadsByKey.removeValue(forKey: key) self.pendingStoryUploadStatusesByKey.removeValue(forKey: key) } - + Logger.shared.log("Wakeup", "Story BG task external cancel: cancelling \(keys.count) uploading stories across \(stableIdsByAccount.count) accounts") - + for (accountId, stableIds) in stableIdsByAccount { guard let account = self.accountsAndTasks.first(where: { $0.0.id == accountId })?.0 else { Logger.shared.log("Wakeup", "Story BG task external cancel: missing account \(accountId.int64), skip \(stableIds.count) stories") @@ -518,7 +519,7 @@ public final class SharedWakeupManager { } } } - + private func startBackgroundProcessingTaskIfNeeded() { guard #available(iOS 26.0, *) else { return @@ -532,14 +533,14 @@ public final class SharedWakeupManager { guard let presentationData = self.presentationData() else { return } - + let baseAppBundleId = Bundle.main.bundleIdentifier! let uploadTaskId = "\(baseAppBundleId).upload.message\(self.nextBackgroundProcessingTaskId)" self.nextBackgroundProcessingTaskId += 1 self.backgroundProcessingTaskProgressByKey = [:] self.backgroundProcessingTaskLaunched = false self.backgroundProcessingTaskCancellationRequestedByApp = false - + BGTaskScheduler.shared.register(forTaskWithIdentifier: uploadTaskId, using: nil, launchHandler: { [weak self] task in guard let task = task as? BGContinuedProcessingTask else { return @@ -549,7 +550,7 @@ public final class SharedWakeupManager { task.setTaskCompleted(success: true) return } - + Task { @MainActor [weak self] in guard let self else { return @@ -558,12 +559,12 @@ public final class SharedWakeupManager { self.backgroundProcessingTaskLaunched = true } } - + var wasExpired = false - + task.expirationHandler = { [weak self] in wasExpired = true - + Queue.mainQueue().async { guard let self else { return @@ -590,21 +591,21 @@ public final class SharedWakeupManager { } } } - + Task { @MainActor [weak self] in guard let self else { task.updateTitle(task.title, subtitle: presentationData.strings.BackgroundTasks_MediaFinished) task.setTaskCompleted(success: true) return } - + var foregroundCancellationRequested = false - + while true { if wasExpired { break } - + if self.backgroundProcessingTaskId != task.identifier || self.pendingMediaUploadsByKey.isEmpty { self.backgroundProcessingTaskProgressByKey = [:] task.updateTitle(task.title, subtitle: presentationData.strings.BackgroundTasks_MediaFinished) @@ -618,7 +619,7 @@ public final class SharedWakeupManager { } return } - + if self.inForeground { if !foregroundCancellationRequested { foregroundCancellationRequested = true @@ -634,11 +635,11 @@ public final class SharedWakeupManager { } else { foregroundCancellationRequested = false } - + if self.backgroundProcessingTaskId != task.identifier { return } - + var currentKeys = Set() for (key, progress) in self.pendingMediaUploadsByKey { currentKeys.insert(key) @@ -652,39 +653,39 @@ public final class SharedWakeupManager { for key in self.backgroundProcessingTaskProgressByKey.keys where !currentKeys.contains(key) { self.backgroundProcessingTaskProgressByKey[key] = 1.0 } - + let progressPrecision: Int64 = 1000 let totalItemCount = max(1, self.backgroundProcessingTaskProgressByKey.count) let totalUnitCount = Int64(totalItemCount) * progressPrecision - + var completedUnitCount: Int64 = 0 for progress in self.backgroundProcessingTaskProgressByKey.values { completedUnitCount += Int64((progress * Float(progressPrecision)).rounded(.down)) } completedUnitCount = min(totalUnitCount, max(0, completedUnitCount)) - + task.progress.totalUnitCount = totalUnitCount task.progress.completedUnitCount = completedUnitCount - + let title: String = presentationData.strings.BackgroundTasks_UploadingMedia(Int32(self.pendingMediaUploadsByKey.count)) if task.title != title { task.updateTitle(title, subtitle: presentationData.strings.BackgroundTasks_MediaSubtitle) } - + try await Task.sleep(for: .seconds(1.0)) } } }) - + let title: String = presentationData.strings.BackgroundTasks_UploadingMedia(Int32(self.pendingMediaUploadsByKey.count)) - + let request = BGContinuedProcessingTaskRequest( identifier: uploadTaskId, title: title, subtitle: presentationData.strings.BackgroundTasks_MediaSubtitle ) request.strategy = .fail - + do { try BGTaskScheduler.shared.submit(request) self.backgroundProcessingTaskId = uploadTaskId @@ -694,7 +695,7 @@ public final class SharedWakeupManager { Logger.shared.log("Wakeup", "BGTaskScheduler submit error: \(e)") } } - + private func startBackgroundStoryProcessingTaskIfNeeded() { guard #available(iOS 26.0, *) else { return @@ -708,14 +709,14 @@ public final class SharedWakeupManager { guard let presentationData = self.presentationData() else { return } - + let baseAppBundleId = Bundle.main.bundleIdentifier! let uploadTaskId = "\(baseAppBundleId).upload.story\(self.nextBackgroundStoryProcessingTaskId)" self.nextBackgroundStoryProcessingTaskId += 1 self.backgroundStoryProcessingTaskProgressByKey = [:] self.backgroundStoryProcessingTaskLaunched = false self.backgroundStoryProcessingTaskCancellationRequestedByApp = false - + BGTaskScheduler.shared.register(forTaskWithIdentifier: uploadTaskId, using: nil, launchHandler: { [weak self] task in guard let task = task as? BGContinuedProcessingTask else { return @@ -725,7 +726,7 @@ public final class SharedWakeupManager { task.setTaskCompleted(success: true) return } - + Task { @MainActor [weak self] in guard let self else { return @@ -734,12 +735,12 @@ public final class SharedWakeupManager { self.backgroundStoryProcessingTaskLaunched = true } } - + var wasExpired = false - + task.expirationHandler = { [weak self] in wasExpired = true - + Queue.mainQueue().async { guard let self else { return @@ -766,23 +767,23 @@ public final class SharedWakeupManager { } } } - + Task { @MainActor [weak self] in guard let self else { task.updateTitle(task.title, subtitle: presentationData.strings.BackgroundTasks_StoryFinished) task.setTaskCompleted(success: true) return } - + var foregroundCancellationRequested = false var currentDisplayedTitle: String? var currentDisplayedSubtitle: String? - + while true { if wasExpired { break } - + if self.backgroundStoryProcessingTaskId != task.identifier || self.pendingStoryUploadStatusesByKey.isEmpty { self.backgroundStoryProcessingTaskProgressByKey = [:] task.updateTitle(task.title, subtitle: presentationData.strings.BackgroundTasks_StoryFinished) @@ -796,7 +797,7 @@ public final class SharedWakeupManager { } return } - + if self.inForeground { if !foregroundCancellationRequested { foregroundCancellationRequested = true @@ -812,11 +813,11 @@ public final class SharedWakeupManager { } else { foregroundCancellationRequested = false } - + if self.backgroundStoryProcessingTaskId != task.identifier { return } - + var currentKeys = Set() for (key, status) in self.pendingStoryUploadStatusesByKey { currentKeys.insert(key) @@ -830,20 +831,20 @@ public final class SharedWakeupManager { for key in self.backgroundStoryProcessingTaskProgressByKey.keys where !currentKeys.contains(key) { self.backgroundStoryProcessingTaskProgressByKey[key] = 1.0 } - + let progressPrecision: Int64 = 1000 let totalItemCount = max(1, self.backgroundStoryProcessingTaskProgressByKey.count) let totalUnitCount = Int64(totalItemCount) * progressPrecision - + var completedUnitCount: Int64 = 0 for progress in self.backgroundStoryProcessingTaskProgressByKey.values { completedUnitCount += Int64((progress * Float(progressPrecision)).rounded(.down)) } completedUnitCount = min(totalUnitCount, max(0, completedUnitCount)) - + task.progress.totalUnitCount = totalUnitCount task.progress.completedUnitCount = completedUnitCount - + let title: String = presentationData.strings.BackgroundTasks_UploadingStories(Int32(self.pendingStoryUploadsByKey.count)) let subtitle: String if self.pendingStoryUploadStatusesByKey.values.contains(where: { $0.phase == .processing }) { @@ -856,12 +857,12 @@ public final class SharedWakeupManager { currentDisplayedTitle = title currentDisplayedSubtitle = subtitle } - + try await Task.sleep(for: .seconds(1.0)) } } }) - + let title: String = presentationData.strings.BackgroundTasks_UploadingStories(Int32(self.pendingStoryUploadsByKey.count)) let subtitle: String if self.pendingStoryUploadStatusesByKey.values.contains(where: { $0.phase == .processing }) { @@ -869,7 +870,7 @@ public final class SharedWakeupManager { } else { subtitle = presentationData.strings.BackgroundTasks_StorySubtitle } - + let request = BGContinuedProcessingTaskRequest( identifier: uploadTaskId, title: title, @@ -879,7 +880,7 @@ public final class SharedWakeupManager { /*if BGTaskScheduler.supportedResources.contains(.gpu) { request.requiredResources = .gpu }*/ - + do { try BGTaskScheduler.shared.submit(request) self.backgroundStoryProcessingTaskId = uploadTaskId @@ -889,11 +890,11 @@ public final class SharedWakeupManager { Logger.shared.log("Wakeup", "Story BGTaskScheduler submit error: \(e)") } } - + func allowBackgroundTimeExtension(timeout: Double, extendNow: Bool = false) { let shouldCheckTasks = self.allowBackgroundTimeExtensionDeadline == nil self.allowBackgroundTimeExtensionDeadline = CFAbsoluteTimeGetCurrent() + timeout - + self.allowBackgroundTimeExtensionDeadlineTimer?.invalidate() self.allowBackgroundTimeExtensionDeadlineTimer = SwiftSignalKit.Timer(timeout: timeout, repeat: false, completion: { [weak self] in guard let strongSelf = self else { @@ -904,7 +905,7 @@ public final class SharedWakeupManager { strongSelf.checkTasks() }, queue: .mainQueue()) self.allowBackgroundTimeExtensionDeadlineTimer?.start() - + if extendNow { if self.activeExplicitExtensionTimer == nil { let activeExplicitExtensionTimer = SwiftSignalKit.Timer(timeout: 20.0, repeat: false, completion: { [weak self] in @@ -921,7 +922,7 @@ public final class SharedWakeupManager { }, queue: .mainQueue()) self.activeExplicitExtensionTimer = activeExplicitExtensionTimer activeExplicitExtensionTimer.start() - + self.activeExplicitExtensionTask = self.beginBackgroundTask("explicit-extension") { [weak self, weak activeExplicitExtensionTimer] in guard let self, let activeExplicitExtensionTimer else { return @@ -942,7 +943,7 @@ public final class SharedWakeupManager { self.checkTasks() } } - + func replaceCurrentExtensionWithExternalTime(completion: @escaping () -> Void, timeout: Double) { if let (currentCompletion, timer) = self.currentExternalCompletion { currentCompletion() @@ -964,7 +965,7 @@ public final class SharedWakeupManager { }, queue: Queue.mainQueue()) self.currentExternalCompletion = (completion, timer) timer.start() - + self.currentExternalCompletionValidationTimer?.invalidate() let validationTimer = SwiftSignalKit.Timer(timeout: 1.0, repeat: false, completion: { [weak self] in guard let strongSelf = self else { @@ -978,10 +979,10 @@ public final class SharedWakeupManager { validationTimer.start() self.checkTasks() } - + func checkTasks() { var hasTasksForBackgroundExtension = false - + var hasActiveCalls = false var pendingMessageCount = 0 for (_, _, tasks) in self.accountsAndTasks { @@ -990,16 +991,16 @@ public final class SharedWakeupManager { } pendingMessageCount += tasks.importantTasks.pendingMessageCount } - + var endTaskAfterTransactionsComplete: UIBackgroundTaskIdentifier? - + if self.inForeground || self.hasActiveAudioSession || hasActiveCalls { if let (completion, timer) = self.currentExternalCompletion { self.currentExternalCompletion = nil completion() timer.invalidate() } - + if let (taskId, _, timer) = self.currentTask { self.currentTask = nil timer.invalidate() @@ -1013,7 +1014,7 @@ public final class SharedWakeupManager { break } } - + if !hasTasksForBackgroundExtension && self.currentExternalCompletionValidationTimer == nil { if let (completion, timer) = self.currentExternalCompletion { self.currentExternalCompletion = nil @@ -1021,16 +1022,16 @@ public final class SharedWakeupManager { timer.invalidate() } } - + if self.activeExplicitExtensionTimer != nil { hasTasksForBackgroundExtension = true } - + let canBeginBackgroundExtensionTasks = self.allowBackgroundTimeExtensionDeadline.flatMap({ CFAbsoluteTimeGetCurrent() < $0 }) ?? false if hasTasksForBackgroundExtension { if canBeginBackgroundExtensionTasks { var endTaskId: UIBackgroundTaskIdentifier? - + let currentTime = CFAbsoluteTimeGetCurrent() if let (taskId, startTime, timer) = self.currentTask { if startTime < currentTime + 1.0 { @@ -1039,23 +1040,23 @@ public final class SharedWakeupManager { endTaskId = taskId } } - + if self.currentTask == nil { var actualTaskId: UIBackgroundTaskIdentifier? let handleExpiration: () -> Void = { [weak self] in guard let strongSelf = self else { return } - + if let actualTaskId { strongSelf.endBackgroundTask(actualTaskId) - + if let (taskId, _, timer) = strongSelf.currentTask, taskId == actualTaskId { timer.invalidate() strongSelf.currentTask = nil } } - + strongSelf.isInBackgroundExtension = false strongSelf.checkTasks() } @@ -1068,24 +1069,24 @@ public final class SharedWakeupManager { }, queue: Queue.mainQueue()) self.currentTask = (taskId, currentTime, timer) timer.start() - + endTaskId.flatMap(self.endBackgroundTask) - + self.isInBackgroundExtension = true } } } } else if let (taskId, _, timer) = self.currentTask { self.currentTask = nil - + timer.invalidate() - + endTaskAfterTransactionsComplete = taskId - + self.isInBackgroundExtension = false } } - + if pendingMessageCount != 0 && !self.inForeground { if self.keepIdleDisposable == nil { self.keepIdleDisposable = self.acquireIdleExtension() @@ -1096,9 +1097,9 @@ public final class SharedWakeupManager { keepIdleDisposable.dispose() } } - + self.updateAccounts(hasTasks: hasTasksForBackgroundExtension, endTaskAfterTransactionsComplete: endTaskAfterTransactionsComplete) - + /*if !self.inForeground && pendingMessageCount != 0 && !self.hasActiveAudioSession { if self.silenceAudioRenderer == nil { let audioSession = AVAudioSession() @@ -1108,7 +1109,7 @@ public final class SharedWakeupManager { audioSession: .custom({ control in let _ = try? audioSession.setActive(true) control.activate() - + return EmptyDisposable }), forAudioVideoMessage: false, @@ -1131,26 +1132,27 @@ public final class SharedWakeupManager { silenceAudioRenderer.stop() }*/ } - + private func updateAccounts(hasTasks: Bool, endTaskAfterTransactionsComplete: UIBackgroundTaskIdentifier?) { let hasBackgroundLocationTask = self.accountsAndTasks.contains(where: { $0.2.backgroundLocation }) - + if self.inForeground || self.hasActiveAudioSession || self.isInBackgroundExtension || self.backgroundProcessingTaskId != nil || self.backgroundStoryProcessingTaskId != nil || hasBackgroundLocationTask || (hasTasks && self.currentExternalCompletion != nil) || self.activeExplicitExtensionTimer != nil || self.silenceAudioRenderer != nil { Logger.shared.log("Wakeup", "enableBeginTransactions: true (active)") - + for (account, primary, tasks) in self.accountsAndTasks { account.postbox.setCanBeginTransactions(true) - + if (self.inForeground && primary) || !tasks.isEmpty || (self.activeExplicitExtensionTimer != nil && primary) { account.shouldBeServiceTaskMaster.set(.single(.always)) } else { account.shouldBeServiceTaskMaster.set(.single(.never)) } account.shouldExplicitelyKeepWorkerConnections.set(.single(tasks.backgroundAudio || tasks.backgroundLocation || tasks.importantTasks.pendingStoryCount != 0 || tasks.importantTasks.pendingMessageCount != 0)) - account.shouldKeepOnlinePresence.set(.single(primary && self.inForeground)) + let ghostOnline = currentWinterGramSettings.suppressesOnlinePresence + account.shouldKeepOnlinePresence.set(.single(primary && self.inForeground && !ghostOnline)) account.shouldKeepBackgroundDownloadConnections.set(.single(tasks.backgroundDownloads)) } - + if let endTaskAfterTransactionsComplete { self.endBackgroundTask(endTaskAfterTransactionsComplete) } @@ -1160,11 +1162,11 @@ public final class SharedWakeupManager { enableBeginTransactions = true } Logger.shared.log("Wakeup", "enableBeginTransactions: \(enableBeginTransactions)") - + final class CompletionObservationState { var isCompleted: Bool = false var remainingAccounts: [AccountRecordId] - + init(remainingAccounts: [AccountRecordId]) { self.remainingAccounts = remainingAccounts } @@ -1187,7 +1189,7 @@ public final class SharedWakeupManager { } } } - + for (account, _, _) in self.accountsAndTasks { let accountId = account.id account.postbox.setCanBeginTransactions(enableBeginTransactions, afterTransactionIfRunning: { @@ -1197,7 +1199,7 @@ public final class SharedWakeupManager { account.shouldKeepOnlinePresence.set(.single(false)) account.shouldKeepBackgroundDownloadConnections.set(.single(false)) } - + checkCompletionState(nil) } } diff --git a/submodules/TelegramUI/Sources/WinterGramTopBanner.swift b/submodules/TelegramUI/Sources/WinterGramTopBanner.swift new file mode 100644 index 0000000000..39b6b2fef8 --- /dev/null +++ b/submodules/TelegramUI/Sources/WinterGramTopBanner.swift @@ -0,0 +1,59 @@ +import UIKit +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` +// 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 { + private let bannerImageView = UIImageView() + private var style: WinterGramTopBannerStyle = .off + private var currentImageName: String? + + // wnt-banner.png is 1034×250. + private let bannerAspect: CGFloat = 1034.0 / 250.0 + + public init() { + super.init(frame: .zero) + self.isUserInteractionEnabled = false + self.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + self.bannerImageView.contentMode = .scaleAspectFit + self.bannerImageView.image = UIImage(bundleImageName: "WntGramBanner") + self.addSubview(self.bannerImageView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func update(style: WinterGramTopBannerStyle, imageName: String, text: String) { + self.style = style + self.isHidden = style == .off + let resolvedImageName = imageName.isEmpty ? "WntGramBanner" : imageName + if self.bannerImageView.image == nil || self.currentImageName != resolvedImageName { + self.bannerImageView.image = UIImage(bundleImageName: resolvedImageName) ?? UIImage(bundleImageName: "WntGramBanner") + self.currentImageName = resolvedImageName + } + self.setNeedsLayout() + } + + public override func layoutSubviews() { + super.layoutSubviews() + + guard self.style != .off else { + return + } + + let bannerHeight: CGFloat = 24.0 + let bannerWidth = floor(bannerHeight * self.bannerAspect) + + // Vertically centre the banner in the top safe-area band (status bar / Dynamic Island region). + let topInset = self.safeAreaInsets.top + let bannerY = max(2.0, (topInset - bannerHeight) / 2.0 + 2.0) + let bannerX = floor((self.bounds.width - bannerWidth) / 2.0) + + self.bannerImageView.frame = CGRect(x: bannerX, y: bannerY, width: bannerWidth, height: bannerHeight) + } +} diff --git a/submodules/TelegramUIPreferences/Sources/WinterGramGhostLogic.swift b/submodules/TelegramUIPreferences/Sources/WinterGramGhostLogic.swift new file mode 100644 index 0000000000..cec7afed37 --- /dev/null +++ b/submodules/TelegramUIPreferences/Sources/WinterGramGhostLogic.swift @@ -0,0 +1,42 @@ +import Foundation + +// Pure, dependency-free ghost-mode decision logic. +// +// This file deliberately imports nothing beyond Foundation so it can be compiled +// and unit-tested standalone (see Tests/WinterGram/WinterGramGhostLogicTests.swift), +// without pulling in TelegramCore / the Bazel module graph. WinterGramSettings' +// computed gate properties forward to these functions, so the tests exercise the +// exact rules the app ships. + +public enum WinterGramGhostLogic { + /// Read receipts must be withheld (the other side won't see "read"). + public static func suppressesReadReceipts(ghostModeEnabled: Bool, sendReadReceipts: Bool) -> Bool { + return ghostModeEnabled && !sendReadReceipts + } + + /// Online presence must be withheld (appear offline). + public static func suppressesOnlinePresence(ghostModeEnabled: Bool, sendOnlineStatus: Bool) -> Bool { + return ghostModeEnabled && !sendOnlineStatus + } + + /// Typing / upload activity must be withheld. + public static func suppressesTypingStatus(ghostModeEnabled: Bool, sendUploadProgress: Bool) -> Bool { + return ghostModeEnabled && !sendUploadProgress + } + + /// Story views must be withheld (don't mark stories seen). + public static func suppressesStoryViews(ghostModeEnabled: Bool, sendReadStories: Bool) -> Bool { + return ghostModeEnabled && !sendReadStories + } + + /// When reads are suppressed but the user took an explicit action (sent a message), + /// the active chat should still be marked read. + public static func shouldMarkReadAfterAction(ghostModeEnabled: Bool, sendReadReceipts: Bool, markReadAfterAction: Bool) -> Bool { + return suppressesReadReceipts(ghostModeEnabled: ghostModeEnabled, sendReadReceipts: sendReadReceipts) && markReadAfterAction + } + + /// After an action forces the client online, immediately drop back to offline. + public static func shouldGoOfflineAfterAction(ghostModeEnabled: Bool, sendOfflineAfterOnline: Bool) -> Bool { + return ghostModeEnabled && sendOfflineAfterOnline + } +} diff --git a/submodules/TelegramUIPreferences/Sources/WinterGramOnlineTracker.swift b/submodules/TelegramUIPreferences/Sources/WinterGramOnlineTracker.swift new file mode 100644 index 0000000000..31362d85e6 --- /dev/null +++ b/submodules/TelegramUIPreferences/Sources/WinterGramOnlineTracker.swift @@ -0,0 +1,75 @@ +import Foundation + +// WinterGram online/last-seen tracker storage. +// +// Records online<->offline transitions for peers while their chat is open. Backed by +// UserDefaults (this is a local, best-effort log, not synced). Each peer keeps the most +// recent `maxEntriesPerPeer` transitions. Used by the recorder in ChatController and the +// viewer in SettingsUI. + +private let trackedPeersKey = "wnt_onlineTrackedPeers" +private func logKey(_ peerId: Int64) -> String { return "wnt_onlineLog_\(peerId)" } +private let maxEntriesPerPeer = 100 + +public struct WinterGramPresenceEntry: Equatable { + public let timestamp: Int32 + public let isOnline: Bool + public init(timestamp: Int32, isOnline: Bool) { + self.timestamp = timestamp + self.isOnline = isOnline + } +} + +/// Append a transition for `peerId` if it differs from the last recorded status. +public func winterGramRecordPresence(peerId: Int64, isOnline: Bool, timestamp: Int32 = Int32(Date().timeIntervalSince1970)) { + let defaults = UserDefaults.standard + var entries = winterGramPresenceLog(peerId: peerId) + if let last = entries.last, last.isOnline == isOnline { + return + } + entries.append(WinterGramPresenceEntry(timestamp: timestamp, isOnline: isOnline)) + if entries.count > maxEntriesPerPeer { + entries.removeFirst(entries.count - maxEntriesPerPeer) + } + // Serialize as "timestamp:0/1" strings. + let encoded = entries.map { "\($0.timestamp):\($0.isOnline ? 1 : 0)" } + defaults.set(encoded, forKey: logKey(peerId)) + + var tracked = Set(defaults.array(forKey: trackedPeersKey) as? [Int64] ?? (defaults.array(forKey: trackedPeersKey) as? [NSNumber])?.map { $0.int64Value } ?? []) + if !tracked.contains(peerId) { + tracked.insert(peerId) + defaults.set(Array(tracked).map { NSNumber(value: $0) }, forKey: trackedPeersKey) + } +} + +public func winterGramPresenceLog(peerId: Int64) -> [WinterGramPresenceEntry] { + guard let raw = UserDefaults.standard.array(forKey: logKey(peerId)) as? [String] else { + return [] + } + return raw.compactMap { item in + let parts = item.split(separator: ":") + guard parts.count == 2, let ts = Int32(parts[0]) else { + return nil + } + return WinterGramPresenceEntry(timestamp: ts, isOnline: parts[1] == "1") + } +} + +/// All peer ids that have a recorded log, most-recently-active first. +public func winterGramTrackedPeerIds() -> [Int64] { + let defaults = UserDefaults.standard + let ids = (defaults.array(forKey: trackedPeersKey) as? [NSNumber])?.map { $0.int64Value } ?? [] + return ids.sorted { a, b in + let la = winterGramPresenceLog(peerId: a).last?.timestamp ?? 0 + let lb = winterGramPresenceLog(peerId: b).last?.timestamp ?? 0 + return la > lb + } +} + +public func winterGramClearPresenceLog() { + let defaults = UserDefaults.standard + for id in winterGramTrackedPeerIds() { + defaults.removeObject(forKey: logKey(id)) + } + defaults.removeObject(forKey: trackedPeersKey) +} diff --git a/submodules/TelegramUIPreferences/Sources/WinterGramSettings.swift b/submodules/TelegramUIPreferences/Sources/WinterGramSettings.swift index 05ec0bbd38..1b83bdb7c4 100644 --- a/submodules/TelegramUIPreferences/Sources/WinterGramSettings.swift +++ b/submodules/TelegramUIPreferences/Sources/WinterGramSettings.swift @@ -2,26 +2,26 @@ import Foundation import TelegramCore import SwiftSignalKit -public enum WinterGramSendWithoutSound: Int32, Codable { +public enum WinterGramSendWithoutSound: Int32 { case never = 0 case inGhostMode = 1 case always = 2 } -public enum WinterGramPeerIdDisplay: Int32, Codable { +public enum WinterGramPeerIdDisplay: Int32 { case hidden = 0 case telegramApi = 1 case botApi = 2 } -public enum WinterGramTranslationProvider: Int32, Codable { +public enum WinterGramTranslationProvider: Int32 { case telegram = 0 case google = 1 case yandex = 2 case system = 3 } -public enum WinterGramWebviewPlatform: Int32, Codable { +public enum WinterGramWebviewPlatform: Int32 { case auto = 0 case ios = 1 case android = 2 @@ -29,13 +29,22 @@ public enum WinterGramWebviewPlatform: Int32, Codable { case desktop = 4 } -public enum WinterGramIconPack: Int32, Codable { +public enum WinterGramIconPack: Int32 { case wintergram = 0 case ayugram = 1 case exteragram = 2 case telegram = 3 } +// Style of the persistent top-center branding pill shown around the Dynamic Island / notch. +public enum WinterGramTopBannerStyle: Int32 { + case off = 0 + case solid = 1 + case glass = 2 + case gradient = 3 + case outline = 4 +} + public struct WinterGramLiquidGlass: Codable, Equatable { public var enabled: Bool public var transparency: Double @@ -110,6 +119,98 @@ public struct WinterGramLiquidGlass: Codable, Equatable { } } +public struct WinterGramSpoofPreset: Codable, Equatable { + public var name: String + public var deviceModel: String + public var appVersion: String + + public init(name: String, deviceModel: String, appVersion: String) { + self.name = name + self.deviceModel = deviceModel + self.appVersion = appVersion + } +} + +public struct WinterGramStashPrivacySettings: Codable, Equatable { + public var profilePhoto: Bool + public var phoneNumber: Bool + public var presence: Bool + public var forwards: Bool + public var voiceCalls: Bool + public var birthday: Bool + public var giftsAutoSave: Bool + public var bio: Bool + public var savedMusic: Bool + public var groupInvitations: Bool + + public static var defaultSettings: WinterGramStashPrivacySettings { + return WinterGramStashPrivacySettings( + profilePhoto: true, + phoneNumber: false, + presence: false, + forwards: false, + voiceCalls: false, + birthday: false, + giftsAutoSave: false, + bio: false, + savedMusic: false, + groupInvitations: false + ) + } + + public init(profilePhoto: Bool, phoneNumber: Bool, presence: Bool, forwards: Bool, voiceCalls: Bool, birthday: Bool, giftsAutoSave: Bool, bio: Bool, savedMusic: Bool, groupInvitations: Bool) { + self.profilePhoto = profilePhoto + self.phoneNumber = phoneNumber + self.presence = presence + self.forwards = forwards + self.voiceCalls = voiceCalls + self.birthday = birthday + self.giftsAutoSave = giftsAutoSave + self.bio = bio + self.savedMusic = savedMusic + self.groupInvitations = groupInvitations + } + + public var hasAny: Bool { + return self.profilePhoto || self.phoneNumber || self.presence || self.forwards || self.voiceCalls || self.birthday || self.giftsAutoSave || self.bio || self.savedMusic || self.groupInvitations + } +} + +public struct WinterGramVisualGift: Codable, Equatable { + public var id: String + public var gift: StarGift + public var fromPeer: EnginePeer? + + public init(id: String, gift: StarGift, fromPeer: EnginePeer?) { + self.id = id + self.gift = gift + self.fromPeer = fromPeer + } + + private enum CodingKeys: String, CodingKey { + case id + case gift + case fromPeerId + } + + public init(from decoder: any Decoder) throws { + 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 + // nil on decode, to be resolved lazily by the consumer when a peer is actually needed. + self.fromPeer = nil + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.gift, forKey: .gift) + try container.encodeIfPresent(self.fromPeer?.id, forKey: .fromPeerId) + } +} + public struct WinterGramSettings: Codable, Equatable { // Ghost mode & privacy public var ghostModeEnabled: Bool @@ -127,13 +228,19 @@ public struct WinterGramSettings: Codable, Equatable { public var saveDeletedMessages: Bool public var saveMessagesHistory: Bool public var saveForBots: Bool + public var saveSelfDestructMessages: Bool public var hideFromBlocked: Bool public var semiTransparentDeletedMessages: Bool + // Show the deletion time next to the deleted-message marker emoji. + public var showDeletedTime: Bool // Hidden archive ("ААрхив") public var stashedPeerIds: [Int64] public var stashMuteNotifications: Bool public var stashAutoMarkRead: Bool + public var stashPrivacy: WinterGramStashPrivacySettings + // Optional passcode (digits) required to open the hidden stash; empty = no passcode. + public var stashPasscode: String // Anti-features public var disableAds: Bool @@ -142,15 +249,21 @@ public struct WinterGramSettings: Codable, Equatable { public var disableStories: Bool public var hidePremiumStatuses: Bool public var disableOpenLinkWarning: Bool + public var disableCopyProtection: Bool + public var forwardWithoutAuthor: Bool + public var allowScreenshots: Bool // Confirmations public var stickerConfirmation: Bool public var gifConfirmation: Bool public var voiceConfirmation: Bool + public var confirmStoryView: Bool // Message decorations public var showMessageSeconds: Bool public var showPeerId: WinterGramPeerIdDisplay + public var showRegistrationDate: Bool + public var hideEditedMark: Bool public var deletedMark: String public var editedMark: String public var recentStickersCount: Int32 @@ -163,6 +276,22 @@ public struct WinterGramSettings: Codable, Equatable { public var webviewSpoofPlatform: WinterGramWebviewPlatform public var increaseWebviewHeight: Bool + // Session device / app-version spoofing (applied at next launch). nil means real value. + public var spoofDeviceModel: String? + public var spoofAppVersion: String? + public var spoofPresets: [WinterGramSpoofPreset] + public var visualGifts: [WinterGramVisualGift] + + // Custom Telegram API credentials (applied at next launch). nil means the built-in values. + public var customApiId: Int32? + public var customApiHash: String? + + // Emoji used for the double-tap quick reaction; empty means Telegram's configured one. + public var customDefaultReaction: String + + // Log online/offline transitions of peers whose chats you open. + public var trackOnlineStatus: Bool + // Appearance & customization public var liquidGlass: WinterGramLiquidGlass public var materialDesign: Bool @@ -175,6 +304,9 @@ public struct WinterGramSettings: Codable, Equatable { public var appIcon: String public var iconPack: WinterGramIconPack public var showOnlyAddedEmojisAndStickers: Bool + public var useDefaultBranding: Bool + public var topBannerStyle: WinterGramTopBannerStyle + public var topBannerName: String public static var defaultSettings: WinterGramSettings { return WinterGramSettings( @@ -191,22 +323,32 @@ public struct WinterGramSettings: Codable, Equatable { saveDeletedMessages: true, saveMessagesHistory: true, saveForBots: false, + saveSelfDestructMessages: false, hideFromBlocked: false, - semiTransparentDeletedMessages: false, + semiTransparentDeletedMessages: true, + showDeletedTime: true, stashedPeerIds: [], stashMuteNotifications: true, stashAutoMarkRead: false, + stashPrivacy: .defaultSettings, + stashPasscode: "", disableAds: true, localPremium: false, shadowBanIds: [], disableStories: false, hidePremiumStatuses: false, disableOpenLinkWarning: false, + disableCopyProtection: false, + forwardWithoutAuthor: false, + allowScreenshots: false, stickerConfirmation: false, gifConfirmation: false, voiceConfirmation: false, + confirmStoryView: false, showMessageSeconds: false, showPeerId: .botApi, + showRegistrationDate: false, + hideEditedMark: false, deletedMark: "🧹", editedMark: "", recentStickersCount: 100, @@ -214,6 +356,14 @@ public struct WinterGramSettings: Codable, Equatable { translationProvider: .telegram, webviewSpoofPlatform: .auto, increaseWebviewHeight: false, + spoofDeviceModel: nil, + spoofAppVersion: nil, + spoofPresets: [], + visualGifts: [], + customApiId: nil, + customApiHash: nil, + customDefaultReaction: "", + trackOnlineStatus: false, liquidGlass: .defaultSettings, materialDesign: true, avatarCornerRadius: 50, @@ -224,7 +374,10 @@ public struct WinterGramSettings: Codable, Equatable { monoFont: nil, appIcon: "WinterGramDark", iconPack: .wintergram, - showOnlyAddedEmojisAndStickers: false + showOnlyAddedEmojisAndStickers: false, + useDefaultBranding: false, + topBannerStyle: .solid, + topBannerName: "WntGramBanner" ) } @@ -242,22 +395,32 @@ public struct WinterGramSettings: Codable, Equatable { saveDeletedMessages: Bool, saveMessagesHistory: Bool, saveForBots: Bool, + saveSelfDestructMessages: Bool, hideFromBlocked: Bool, semiTransparentDeletedMessages: Bool, + showDeletedTime: Bool, stashedPeerIds: [Int64], stashMuteNotifications: Bool, stashAutoMarkRead: Bool, + stashPrivacy: WinterGramStashPrivacySettings, + stashPasscode: String, disableAds: Bool, localPremium: Bool, shadowBanIds: [Int64], disableStories: Bool, hidePremiumStatuses: Bool, disableOpenLinkWarning: Bool, + disableCopyProtection: Bool, + forwardWithoutAuthor: Bool, + allowScreenshots: Bool, stickerConfirmation: Bool, gifConfirmation: Bool, voiceConfirmation: Bool, + confirmStoryView: Bool, showMessageSeconds: Bool, showPeerId: WinterGramPeerIdDisplay, + showRegistrationDate: Bool, + hideEditedMark: Bool, deletedMark: String, editedMark: String, recentStickersCount: Int32, @@ -265,6 +428,14 @@ public struct WinterGramSettings: Codable, Equatable { translationProvider: WinterGramTranslationProvider, webviewSpoofPlatform: WinterGramWebviewPlatform, increaseWebviewHeight: Bool, + spoofDeviceModel: String?, + spoofAppVersion: String?, + spoofPresets: [WinterGramSpoofPreset], + visualGifts: [WinterGramVisualGift], + customApiId: Int32?, + customApiHash: String?, + customDefaultReaction: String, + trackOnlineStatus: Bool, liquidGlass: WinterGramLiquidGlass, materialDesign: Bool, avatarCornerRadius: Int32, @@ -275,7 +446,10 @@ public struct WinterGramSettings: Codable, Equatable { monoFont: String?, appIcon: String, iconPack: WinterGramIconPack, - showOnlyAddedEmojisAndStickers: Bool + showOnlyAddedEmojisAndStickers: Bool, + useDefaultBranding: Bool, + topBannerStyle: WinterGramTopBannerStyle, + topBannerName: String ) { self.ghostModeEnabled = ghostModeEnabled self.sendReadReceipts = sendReadReceipts @@ -290,22 +464,32 @@ public struct WinterGramSettings: Codable, Equatable { self.saveDeletedMessages = saveDeletedMessages self.saveMessagesHistory = saveMessagesHistory self.saveForBots = saveForBots + self.saveSelfDestructMessages = saveSelfDestructMessages self.hideFromBlocked = hideFromBlocked self.semiTransparentDeletedMessages = semiTransparentDeletedMessages + self.showDeletedTime = showDeletedTime self.stashedPeerIds = stashedPeerIds self.stashMuteNotifications = stashMuteNotifications self.stashAutoMarkRead = stashAutoMarkRead + self.stashPrivacy = stashPrivacy + self.stashPasscode = stashPasscode self.disableAds = disableAds self.localPremium = localPremium self.shadowBanIds = shadowBanIds self.disableStories = disableStories self.hidePremiumStatuses = hidePremiumStatuses self.disableOpenLinkWarning = disableOpenLinkWarning + self.disableCopyProtection = disableCopyProtection + self.forwardWithoutAuthor = forwardWithoutAuthor + self.allowScreenshots = allowScreenshots self.stickerConfirmation = stickerConfirmation self.gifConfirmation = gifConfirmation self.voiceConfirmation = voiceConfirmation + self.confirmStoryView = confirmStoryView self.showMessageSeconds = showMessageSeconds self.showPeerId = showPeerId + self.showRegistrationDate = showRegistrationDate + self.hideEditedMark = hideEditedMark self.deletedMark = deletedMark self.editedMark = editedMark self.recentStickersCount = recentStickersCount @@ -313,6 +497,14 @@ public struct WinterGramSettings: Codable, Equatable { self.translationProvider = translationProvider self.webviewSpoofPlatform = webviewSpoofPlatform self.increaseWebviewHeight = increaseWebviewHeight + self.spoofDeviceModel = spoofDeviceModel + self.spoofAppVersion = spoofAppVersion + self.spoofPresets = spoofPresets + self.visualGifts = visualGifts + self.customApiId = customApiId + self.customApiHash = customApiHash + self.customDefaultReaction = customDefaultReaction + self.trackOnlineStatus = trackOnlineStatus self.liquidGlass = liquidGlass self.materialDesign = materialDesign self.avatarCornerRadius = avatarCornerRadius @@ -324,6 +516,9 @@ public struct WinterGramSettings: Codable, Equatable { self.appIcon = appIcon self.iconPack = iconPack self.showOnlyAddedEmojisAndStickers = showOnlyAddedEmojisAndStickers + self.useDefaultBranding = useDefaultBranding + self.topBannerStyle = topBannerStyle + self.topBannerName = topBannerName } public init(from decoder: Decoder) throws { @@ -338,34 +533,58 @@ public struct WinterGramSettings: Codable, Equatable { self.sendOfflineAfterOnline = try container.decodeIfPresent(Bool.self, forKey: "sendOfflineAfterOnline") ?? defaults.sendOfflineAfterOnline self.markReadAfterAction = try container.decodeIfPresent(Bool.self, forKey: "markReadAfterAction") ?? defaults.markReadAfterAction self.useScheduledMessages = try container.decodeIfPresent(Bool.self, forKey: "useScheduledMessages") ?? defaults.useScheduledMessages - self.sendWithoutSound = try container.decodeIfPresent(WinterGramSendWithoutSound.self, forKey: "sendWithoutSound") ?? defaults.sendWithoutSound + self.sendWithoutSound = WinterGramSendWithoutSound(rawValue: try container.decodeIfPresent(Int32.self, forKey: "sendWithoutSound") ?? defaults.sendWithoutSound.rawValue) ?? defaults.sendWithoutSound self.suggestGhostBeforeStory = try container.decodeIfPresent(Bool.self, forKey: "suggestGhostBeforeStory") ?? defaults.suggestGhostBeforeStory self.saveDeletedMessages = try container.decodeIfPresent(Bool.self, forKey: "saveDeletedMessages") ?? defaults.saveDeletedMessages self.saveMessagesHistory = try container.decodeIfPresent(Bool.self, forKey: "saveMessagesHistory") ?? defaults.saveMessagesHistory self.saveForBots = try container.decodeIfPresent(Bool.self, forKey: "saveForBots") ?? defaults.saveForBots + self.saveSelfDestructMessages = try container.decodeIfPresent(Bool.self, forKey: "saveSelfDestructMessages") ?? defaults.saveSelfDestructMessages self.hideFromBlocked = try container.decodeIfPresent(Bool.self, forKey: "hideFromBlocked") ?? defaults.hideFromBlocked self.semiTransparentDeletedMessages = try container.decodeIfPresent(Bool.self, forKey: "semiTransparentDeletedMessages") ?? defaults.semiTransparentDeletedMessages + self.showDeletedTime = try container.decodeIfPresent(Bool.self, forKey: "showDeletedTime") ?? defaults.showDeletedTime self.stashedPeerIds = try container.decodeIfPresent([Int64].self, forKey: "stashedPeerIds") ?? defaults.stashedPeerIds self.stashMuteNotifications = try container.decodeIfPresent(Bool.self, forKey: "stashMuteNotifications") ?? defaults.stashMuteNotifications self.stashAutoMarkRead = try container.decodeIfPresent(Bool.self, forKey: "stashAutoMarkRead") ?? defaults.stashAutoMarkRead + if let legacyProfilePhoto = try container.decodeIfPresent(Bool.self, forKey: "stashHideProfilePhotoFromPeer") { + var migrated = defaults.stashPrivacy + migrated.profilePhoto = legacyProfilePhoto + self.stashPrivacy = try container.decodeIfPresent(WinterGramStashPrivacySettings.self, forKey: "stashPrivacy") ?? migrated + } else { + self.stashPrivacy = try container.decodeIfPresent(WinterGramStashPrivacySettings.self, forKey: "stashPrivacy") ?? defaults.stashPrivacy + } + self.stashPasscode = try container.decodeIfPresent(String.self, forKey: "stashPasscode") ?? defaults.stashPasscode self.disableAds = try container.decodeIfPresent(Bool.self, forKey: "disableAds") ?? defaults.disableAds self.localPremium = try container.decodeIfPresent(Bool.self, forKey: "localPremium") ?? defaults.localPremium self.shadowBanIds = try container.decodeIfPresent([Int64].self, forKey: "shadowBanIds") ?? defaults.shadowBanIds self.disableStories = try container.decodeIfPresent(Bool.self, forKey: "disableStories") ?? defaults.disableStories self.hidePremiumStatuses = try container.decodeIfPresent(Bool.self, forKey: "hidePremiumStatuses") ?? defaults.hidePremiumStatuses self.disableOpenLinkWarning = try container.decodeIfPresent(Bool.self, forKey: "disableOpenLinkWarning") ?? defaults.disableOpenLinkWarning + self.disableCopyProtection = try container.decodeIfPresent(Bool.self, forKey: "disableCopyProtection") ?? defaults.disableCopyProtection + self.forwardWithoutAuthor = try container.decodeIfPresent(Bool.self, forKey: "forwardWithoutAuthor") ?? defaults.forwardWithoutAuthor + self.allowScreenshots = try container.decodeIfPresent(Bool.self, forKey: "allowScreenshots") ?? defaults.allowScreenshots self.stickerConfirmation = try container.decodeIfPresent(Bool.self, forKey: "stickerConfirmation") ?? defaults.stickerConfirmation self.gifConfirmation = try container.decodeIfPresent(Bool.self, forKey: "gifConfirmation") ?? defaults.gifConfirmation self.voiceConfirmation = try container.decodeIfPresent(Bool.self, forKey: "voiceConfirmation") ?? defaults.voiceConfirmation + self.confirmStoryView = try container.decodeIfPresent(Bool.self, forKey: "confirmStoryView") ?? defaults.confirmStoryView self.showMessageSeconds = try container.decodeIfPresent(Bool.self, forKey: "showMessageSeconds") ?? defaults.showMessageSeconds - self.showPeerId = try container.decodeIfPresent(WinterGramPeerIdDisplay.self, forKey: "showPeerId") ?? defaults.showPeerId + self.showPeerId = WinterGramPeerIdDisplay(rawValue: try container.decodeIfPresent(Int32.self, forKey: "showPeerId") ?? defaults.showPeerId.rawValue) ?? defaults.showPeerId + self.showRegistrationDate = try container.decodeIfPresent(Bool.self, forKey: "showRegistrationDate") ?? defaults.showRegistrationDate + self.hideEditedMark = try container.decodeIfPresent(Bool.self, forKey: "hideEditedMark") ?? defaults.hideEditedMark self.deletedMark = try container.decodeIfPresent(String.self, forKey: "deletedMark") ?? defaults.deletedMark self.editedMark = try container.decodeIfPresent(String.self, forKey: "editedMark") ?? defaults.editedMark self.recentStickersCount = try container.decodeIfPresent(Int32.self, forKey: "recentStickersCount") ?? defaults.recentStickersCount self.translateMessages = try container.decodeIfPresent(Bool.self, forKey: "translateMessages") ?? defaults.translateMessages - self.translationProvider = try container.decodeIfPresent(WinterGramTranslationProvider.self, forKey: "translationProvider") ?? defaults.translationProvider - self.webviewSpoofPlatform = try container.decodeIfPresent(WinterGramWebviewPlatform.self, forKey: "webviewSpoofPlatform") ?? defaults.webviewSpoofPlatform + self.translationProvider = WinterGramTranslationProvider(rawValue: try container.decodeIfPresent(Int32.self, forKey: "translationProvider") ?? defaults.translationProvider.rawValue) ?? defaults.translationProvider + self.webviewSpoofPlatform = WinterGramWebviewPlatform(rawValue: try container.decodeIfPresent(Int32.self, forKey: "webviewSpoofPlatform") ?? defaults.webviewSpoofPlatform.rawValue) ?? defaults.webviewSpoofPlatform self.increaseWebviewHeight = try container.decodeIfPresent(Bool.self, forKey: "increaseWebviewHeight") ?? defaults.increaseWebviewHeight + self.spoofDeviceModel = try container.decodeIfPresent(String.self, forKey: "spoofDeviceModel") + self.spoofAppVersion = try container.decodeIfPresent(String.self, forKey: "spoofAppVersion") + self.spoofPresets = try container.decodeIfPresent([WinterGramSpoofPreset].self, forKey: "spoofPresets") ?? defaults.spoofPresets + self.visualGifts = try container.decodeIfPresent([WinterGramVisualGift].self, forKey: "visualGifts") ?? defaults.visualGifts + self.customApiId = try container.decodeIfPresent(Int32.self, forKey: "customApiId") + self.customApiHash = try container.decodeIfPresent(String.self, forKey: "customApiHash") + self.customDefaultReaction = try container.decodeIfPresent(String.self, forKey: "customDefaultReaction") ?? defaults.customDefaultReaction + self.trackOnlineStatus = try container.decodeIfPresent(Bool.self, forKey: "trackOnlineStatus") ?? defaults.trackOnlineStatus self.liquidGlass = try container.decodeIfPresent(WinterGramLiquidGlass.self, forKey: "liquidGlass") ?? defaults.liquidGlass self.materialDesign = try container.decodeIfPresent(Bool.self, forKey: "materialDesign") ?? defaults.materialDesign self.avatarCornerRadius = try container.decodeIfPresent(Int32.self, forKey: "avatarCornerRadius") ?? defaults.avatarCornerRadius @@ -375,8 +594,11 @@ public struct WinterGramSettings: Codable, Equatable { self.customFont = try container.decodeIfPresent(String.self, forKey: "customFont") self.monoFont = try container.decodeIfPresent(String.self, forKey: "monoFont") self.appIcon = try container.decodeIfPresent(String.self, forKey: "appIcon") ?? defaults.appIcon - self.iconPack = try container.decodeIfPresent(WinterGramIconPack.self, forKey: "iconPack") ?? defaults.iconPack + self.iconPack = WinterGramIconPack(rawValue: try container.decodeIfPresent(Int32.self, forKey: "iconPack") ?? defaults.iconPack.rawValue) ?? defaults.iconPack self.showOnlyAddedEmojisAndStickers = try container.decodeIfPresent(Bool.self, forKey: "showOnlyAddedEmojisAndStickers") ?? defaults.showOnlyAddedEmojisAndStickers + self.useDefaultBranding = try container.decodeIfPresent(Bool.self, forKey: "useDefaultBranding") ?? defaults.useDefaultBranding + self.topBannerStyle = WinterGramTopBannerStyle(rawValue: try container.decodeIfPresent(Int32.self, forKey: "topBannerStyle") ?? defaults.topBannerStyle.rawValue) ?? defaults.topBannerStyle + self.topBannerName = try container.decodeIfPresent(String.self, forKey: "topBannerName") ?? defaults.topBannerName } public func encode(to encoder: Encoder) throws { @@ -389,34 +611,52 @@ public struct WinterGramSettings: Codable, Equatable { try container.encode(self.sendOfflineAfterOnline, forKey: "sendOfflineAfterOnline") try container.encode(self.markReadAfterAction, forKey: "markReadAfterAction") try container.encode(self.useScheduledMessages, forKey: "useScheduledMessages") - try container.encode(self.sendWithoutSound, forKey: "sendWithoutSound") + try container.encode(self.sendWithoutSound.rawValue, forKey: "sendWithoutSound") try container.encode(self.suggestGhostBeforeStory, forKey: "suggestGhostBeforeStory") try container.encode(self.saveDeletedMessages, forKey: "saveDeletedMessages") try container.encode(self.saveMessagesHistory, forKey: "saveMessagesHistory") try container.encode(self.saveForBots, forKey: "saveForBots") + try container.encode(self.saveSelfDestructMessages, forKey: "saveSelfDestructMessages") try container.encode(self.hideFromBlocked, forKey: "hideFromBlocked") try container.encode(self.semiTransparentDeletedMessages, forKey: "semiTransparentDeletedMessages") + try container.encode(self.showDeletedTime, forKey: "showDeletedTime") try container.encode(self.stashedPeerIds, forKey: "stashedPeerIds") try container.encode(self.stashMuteNotifications, forKey: "stashMuteNotifications") try container.encode(self.stashAutoMarkRead, forKey: "stashAutoMarkRead") + try container.encode(self.stashPrivacy, forKey: "stashPrivacy") + try container.encode(self.stashPasscode, forKey: "stashPasscode") try container.encode(self.disableAds, forKey: "disableAds") try container.encode(self.localPremium, forKey: "localPremium") try container.encode(self.shadowBanIds, forKey: "shadowBanIds") try container.encode(self.disableStories, forKey: "disableStories") try container.encode(self.hidePremiumStatuses, forKey: "hidePremiumStatuses") try container.encode(self.disableOpenLinkWarning, forKey: "disableOpenLinkWarning") + try container.encode(self.disableCopyProtection, forKey: "disableCopyProtection") + try container.encode(self.forwardWithoutAuthor, forKey: "forwardWithoutAuthor") + try container.encode(self.allowScreenshots, forKey: "allowScreenshots") try container.encode(self.stickerConfirmation, forKey: "stickerConfirmation") try container.encode(self.gifConfirmation, forKey: "gifConfirmation") try container.encode(self.voiceConfirmation, forKey: "voiceConfirmation") + try container.encode(self.confirmStoryView, forKey: "confirmStoryView") try container.encode(self.showMessageSeconds, forKey: "showMessageSeconds") - try container.encode(self.showPeerId, forKey: "showPeerId") + try container.encode(self.showPeerId.rawValue, forKey: "showPeerId") + try container.encode(self.showRegistrationDate, forKey: "showRegistrationDate") + try container.encode(self.hideEditedMark, forKey: "hideEditedMark") try container.encode(self.deletedMark, forKey: "deletedMark") try container.encode(self.editedMark, forKey: "editedMark") try container.encode(self.recentStickersCount, forKey: "recentStickersCount") try container.encode(self.translateMessages, forKey: "translateMessages") - try container.encode(self.translationProvider, forKey: "translationProvider") - try container.encode(self.webviewSpoofPlatform, forKey: "webviewSpoofPlatform") + try container.encode(self.translationProvider.rawValue, forKey: "translationProvider") + try container.encode(self.webviewSpoofPlatform.rawValue, forKey: "webviewSpoofPlatform") try container.encode(self.increaseWebviewHeight, forKey: "increaseWebviewHeight") + try container.encodeIfPresent(self.spoofDeviceModel, forKey: "spoofDeviceModel") + try container.encodeIfPresent(self.spoofAppVersion, forKey: "spoofAppVersion") + try container.encode(self.spoofPresets, forKey: "spoofPresets") + try container.encode(self.visualGifts, forKey: "visualGifts") + try container.encodeIfPresent(self.customApiId, forKey: "customApiId") + try container.encodeIfPresent(self.customApiHash, forKey: "customApiHash") + try container.encode(self.customDefaultReaction, forKey: "customDefaultReaction") + try container.encode(self.trackOnlineStatus, forKey: "trackOnlineStatus") try container.encode(self.liquidGlass, forKey: "liquidGlass") try container.encode(self.materialDesign, forKey: "materialDesign") try container.encode(self.avatarCornerRadius, forKey: "avatarCornerRadius") @@ -426,10 +666,15 @@ public struct WinterGramSettings: Codable, Equatable { try container.encodeIfPresent(self.customFont, forKey: "customFont") try container.encodeIfPresent(self.monoFont, forKey: "monoFont") try container.encode(self.appIcon, forKey: "appIcon") - try container.encode(self.iconPack, forKey: "iconPack") + try container.encode(self.iconPack.rawValue, forKey: "iconPack") try container.encode(self.showOnlyAddedEmojisAndStickers, forKey: "showOnlyAddedEmojisAndStickers") + try container.encode(self.useDefaultBranding, forKey: "useDefaultBranding") + try container.encode(self.topBannerStyle.rawValue, forKey: "topBannerStyle") + try container.encode(self.topBannerName, forKey: "topBannerName") } + + public func isShadowBanned(_ peerId: Int64) -> Bool { return self.shadowBanIds.contains(peerId) } @@ -438,6 +683,41 @@ public struct WinterGramSettings: Codable, Equatable { return self.stashedPeerIds.contains(peerId) } + // MARK: - Ghost-mode decision helpers (pure, unit-testable) + + // These forward to WinterGramGhostLogic (a pure, standalone-testable file). + + /// Read receipts must be withheld (the other side won't see "read"). + public var suppressesReadReceipts: Bool { + return WinterGramGhostLogic.suppressesReadReceipts(ghostModeEnabled: self.ghostModeEnabled, sendReadReceipts: self.sendReadReceipts) + } + + /// Online presence must be withheld (appear offline). + public var suppressesOnlinePresence: Bool { + return WinterGramGhostLogic.suppressesOnlinePresence(ghostModeEnabled: self.ghostModeEnabled, sendOnlineStatus: self.sendOnlineStatus) + } + + /// Typing / upload activity must be withheld. + public var suppressesTypingStatus: Bool { + return WinterGramGhostLogic.suppressesTypingStatus(ghostModeEnabled: self.ghostModeEnabled, sendUploadProgress: self.sendUploadProgress) + } + + /// Story views must be withheld (don't mark stories seen). + public var suppressesStoryViews: Bool { + return WinterGramGhostLogic.suppressesStoryViews(ghostModeEnabled: self.ghostModeEnabled, sendReadStories: self.sendReadStories) + } + + /// When reads are suppressed but the user took an explicit action (sent a message), + /// the active chat should still be marked read. + public var shouldMarkReadAfterAction: Bool { + return WinterGramGhostLogic.shouldMarkReadAfterAction(ghostModeEnabled: self.ghostModeEnabled, sendReadReceipts: self.sendReadReceipts, markReadAfterAction: self.markReadAfterAction) + } + + /// After an action forces the client online, immediately drop back to offline. + public var shouldGoOfflineAfterAction: Bool { + return WinterGramGhostLogic.shouldGoOfflineAfterAction(ghostModeEnabled: self.ghostModeEnabled, sendOfflineAfterOnline: self.sendOfflineAfterOnline) + } + public var shouldSendWithoutSound: Bool { switch self.sendWithoutSound { case .never: @@ -487,5 +767,89 @@ public func observeWinterGramSettings(accountManager: AccountManager deliverOnMainQueue).start(next: { settings in setCurrentWinterGramSettings(settings) + // Bridge branding to standard UserDefaults so the early-launch intro (Obj-C, + // runs before the settings store is up) can pick the title synchronously. + UserDefaults.standard.set(settings.useDefaultBranding, forKey: "wnt_useDefaultBranding") + // Bridge session spoof values to standard UserDefaults so the network stack, which is + // initialized at launch before the settings store is up, can read them synchronously. + // These take effect on the next launch. + if let spoofDeviceModel = settings.spoofDeviceModel, !spoofDeviceModel.isEmpty { + UserDefaults.standard.set(spoofDeviceModel, forKey: "wnt_spoofDeviceModel") + } else { + UserDefaults.standard.removeObject(forKey: "wnt_spoofDeviceModel") + } + if let spoofAppVersion = settings.spoofAppVersion, !spoofAppVersion.isEmpty { + UserDefaults.standard.set(spoofAppVersion, forKey: "wnt_spoofAppVersion") + } else { + UserDefaults.standard.removeObject(forKey: "wnt_spoofAppVersion") + } + // Custom API credentials (applied at next launch). Both must be set to take effect. + if let customApiId = settings.customApiId, customApiId != 0, let customApiHash = settings.customApiHash, !customApiHash.isEmpty { + UserDefaults.standard.set(Int(customApiId), forKey: "wnt_customApiId") + UserDefaults.standard.set(customApiHash, forKey: "wnt_customApiHash") + } else { + UserDefaults.standard.removeObject(forKey: "wnt_customApiId") + UserDefaults.standard.removeObject(forKey: "wnt_customApiHash") + } + let webviewPlatform: String? + switch settings.webviewSpoofPlatform { + case .auto: + webviewPlatform = nil + case .ios: + webviewPlatform = "ios" + case .android: + webviewPlatform = "android" + case .macos: + webviewPlatform = "macos" + case .desktop: + webviewPlatform = "tdesktop" + } + setCurrentWinterGramCoreSettings(WinterGramCoreSettings( + saveDeletedMessages: settings.saveDeletedMessages, + saveMessageEditHistory: settings.saveMessagesHistory, + saveForBots: settings.saveForBots, + saveSelfDestructMessages: settings.saveSelfDestructMessages, + allowScreenshots: settings.allowScreenshots, + webviewPlatform: webviewPlatform + )) }) } + +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 + } +} + +// 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 + } +} + +// 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" + } + if isWinterGramOfficialPeer(peer) { + if case .channel = peer { + return "WntGramDeveloperBadge" + } + return "WinterGramSnowflake" + } + return nil +} diff --git a/submodules/TelegramUIPreferences/Sources/WinterGramStashPrivacy.swift b/submodules/TelegramUIPreferences/Sources/WinterGramStashPrivacy.swift new file mode 100644 index 0000000000..67d1e294bf --- /dev/null +++ b/submodules/TelegramUIPreferences/Sources/WinterGramStashPrivacy.swift @@ -0,0 +1,57 @@ +import Foundation +import TelegramCore +import SwiftSignalKit + +// WinterGram: when a chat is moved into / out of the Hidden Archive we can add (or remove) the peer +// in the "exceptions" of the selected privacy categories. Only `.enableEveryone` is touched: this +// mirrors Telegram's "Everybody Except..." behavior and does not change other privacy categories. +// +// `stashed == true` adds the peer to every enabled exception list; `false` removes it from all lists. +public func winterGramApplyStashPrivacy(engine: TelegramEngine, peerId: EnginePeer.Id, stashed: Bool, privacySettings: WinterGramStashPrivacySettings) -> Signal { + return combineLatest( + engine.privacy.requestAccountPrivacySettings() |> take(1), + engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) + ) + |> mapToSignal { settings, maybePeer -> Signal in + guard let peer = maybePeer else { + return .complete() + } + + let privacyPeer = SelectivePrivacyPeer(peer: peer, participantCount: nil) + var updates: [Signal] = [] + + func adjust(_ current: SelectivePrivacySettings, type: UpdateSelectiveAccountPrivacySettingsType, enabled: Bool) { + guard case let .enableEveryone(disableFor) = current else { + return + } + var newDisableFor = disableFor + if stashed && enabled { + if newDisableFor[peerId] == nil { + newDisableFor[peerId] = privacyPeer + } + } else { + newDisableFor.removeValue(forKey: peerId) + } + if newDisableFor != disableFor { + updates.append(engine.privacy.updateSelectiveAccountPrivacySettings(type: type, settings: .enableEveryone(disableFor: newDisableFor))) + } + } + + adjust(settings.profilePhoto, type: .profilePhoto, enabled: privacySettings.profilePhoto) + adjust(settings.phoneNumber, type: .phoneNumber, enabled: privacySettings.phoneNumber) + adjust(settings.presence, type: .presence, enabled: privacySettings.presence) + adjust(settings.forwards, type: .forwards, enabled: privacySettings.forwards) + adjust(settings.voiceCalls, type: .voiceCalls, enabled: privacySettings.voiceCalls) + adjust(settings.birthday, type: .birthday, enabled: privacySettings.birthday) + adjust(settings.giftsAutoSave, type: .giftsAutoSave, enabled: privacySettings.giftsAutoSave) + adjust(settings.bio, type: .bio, enabled: privacySettings.bio) + adjust(settings.savedMusic, type: .savedMusic, enabled: privacySettings.savedMusic) + adjust(settings.groupInvitations, type: .groupInvitations, enabled: privacySettings.groupInvitations) + + if updates.isEmpty { + return .complete() + } + return combineLatest(updates) + |> ignoreValues + } +} diff --git a/submodules/TextFormat/Sources/GenerateTextEntities.swift b/submodules/TextFormat/Sources/GenerateTextEntities.swift index ea64fb5e9d..2103ac785c 100644 --- a/submodules/TextFormat/Sources/GenerateTextEntities.swift +++ b/submodules/TextFormat/Sources/GenerateTextEntities.swift @@ -69,7 +69,7 @@ private enum CurrentEntityType { case hashtag case phoneNumber case timecode - + var type: EnabledEntityTypes { switch self { case .command: @@ -88,11 +88,11 @@ private enum CurrentEntityType { public struct EnabledEntityTypes: OptionSet { public var rawValue: Int32 - + public init(rawValue: Int32) { self.rawValue = rawValue } - + public static let command = EnabledEntityTypes(rawValue: 1 << 0) public static let mention = EnabledEntityTypes(rawValue: 1 << 1) public static let hashtag = EnabledEntityTypes(rawValue: 1 << 2) @@ -101,7 +101,7 @@ public struct EnabledEntityTypes: OptionSet { public static let timecode = EnabledEntityTypes(rawValue: 1 << 5) public static let external = EnabledEntityTypes(rawValue: 1 << 6) public static let internalUrl = EnabledEntityTypes(rawValue: 1 << 7) - + public static let all: EnabledEntityTypes = [.command, .mention, .hashtag, .allUrl, .phoneNumber] } @@ -114,7 +114,7 @@ private func commitEntity(_ utf16: String.UTF16View, _ type: CurrentEntityType, for entity in entities { if entity.range.overlaps(indexRange) { if case .Spoiler = entity.type { - + } else { overlaps = true break @@ -135,7 +135,7 @@ private func commitEntity(_ utf16: String.UTF16View, _ type: CurrentEntityType, case .timecode: entityType = .Custom(type: ApplicationSpecificEntityType.Timecode) } - + if case .timecode = type { if let mediaDuration = mediaDuration, let timecode = parseTimecodeString(String(utf16[range])), timecode <= mediaDuration { entities.append(MessageTextEntity(range: indexRange, type: entityType)) @@ -181,7 +181,7 @@ public func generateChatInputTextEntities(_ text: NSAttributedString, maxAnimate } } }) - + if generateLinks { for entity in generateTextEntities(text.string, enabledTypes: .allUrl) { if case .Url = entity.type { @@ -189,10 +189,10 @@ public func generateChatInputTextEntities(_ text: NSAttributedString, maxAnimate } } } - + while true { var hadReductions = false - + scan: for i in 0 ..< entities.count { if case .BlockQuote = entities[i].type { inner: for j in 0 ..< entities.count { @@ -203,25 +203,25 @@ public func generateChatInputTextEntities(_ text: NSAttributedString, maxAnimate if entities[i].range.upperBound == entities[j].range.lowerBound || entities[i].range.lowerBound == entities[j].range.upperBound { entities[i].range = min(entities[i].range.lowerBound, entities[j].range.lowerBound) ..< max(entities[i].range.upperBound, entities[j].range.upperBound) entities.remove(at: j) - + hadReductions = true break scan } } } - + break scan } } - + if !hadReductions { break } } - + while true { var hadReductions = false - + scan: for i in 0 ..< entities.count { if case let .Pre(language) = entities[i].type { inner: for j in 0 ..< entities.count { @@ -232,30 +232,30 @@ public func generateChatInputTextEntities(_ text: NSAttributedString, maxAnimate if entities[i].range.upperBound == entities[j].range.lowerBound || entities[i].range.lowerBound == entities[j].range.upperBound { entities[i].range = min(entities[i].range.lowerBound, entities[j].range.lowerBound) ..< max(entities[i].range.upperBound, entities[j].range.upperBound) entities.remove(at: j) - + hadReductions = true break scan } } } - + break scan } } - + if !hadReductions { break } } - + return entities } public func generateTextEntities(_ text: String, enabledTypes: EnabledEntityTypes, currentEntities: [MessageTextEntity] = []) -> [MessageTextEntity] { var entities: [MessageTextEntity] = currentEntities - + let utf16 = text.utf16 - + var detector: NSDataDetector? if enabledTypes.contains(.phoneNumber) && (enabledTypes.contains(.allUrl) || enabledTypes.contains(.internalUrl)) { detector = dataAndPhoneNumberDetector @@ -264,9 +264,9 @@ public func generateTextEntities(_ text: String, enabledTypes: EnabledEntityType } else if enabledTypes.contains(.allUrl) || enabledTypes.contains(.internalUrl) { detector = dataDetector } - + let delimiterSet = enabledTypes.contains(.external) ? externalIdentifierDelimiterSet : identifierDelimiterSet - + if let detector = detector { detector.enumerateMatches(in: text, options: [], range: NSMakeRange(0, utf16.count), using: { result, _, _ in if let result = result { @@ -294,7 +294,7 @@ public func generateTextEntities(_ text: String, enabledTypes: EnabledEntityType } } } - + type = .Url } else { type = .PhoneNumber @@ -305,10 +305,10 @@ public func generateTextEntities(_ text: String, enabledTypes: EnabledEntityType } }) } - + var index = utf16.startIndex var currentEntity: (CurrentEntityType, Range)? - + var previousScalar: UnicodeScalar? while index != utf16.endIndex { let c = utf16[index] @@ -346,7 +346,7 @@ public func generateTextEntities(_ text: String, enabledTypes: EnabledEntityType } currentEntity = (.hashtag, index ..< index) } - + if notFound { if let (type, range) = currentEntity { switch type { @@ -380,16 +380,16 @@ public func generateTextEntities(_ text: String, enabledTypes: EnabledEntityType if let (type, range) = currentEntity { commitEntity(utf16, type, range, enabledTypes, &entities) } - + return entities } public func addLocallyGeneratedEntities(_ text: String, enabledTypes: EnabledEntityTypes, entities: [MessageTextEntity], mediaDuration: Double? = nil) -> [MessageTextEntity]? { var resultEntities = entities - + var hasDigits = false var hasColons = false - + let detectPhoneNumbers = enabledTypes.contains(.phoneNumber) let detectTimecodes = enabledTypes.contains(.timecode) if detectPhoneNumbers || detectTimecodes { @@ -409,7 +409,7 @@ public func addLocallyGeneratedEntities(_ text: String, enabledTypes: EnabledEnt } } } - + if hasDigits || hasColons { if let phoneNumberDetector = phoneNumberDetector, detectPhoneNumbers { let utf16 = text.utf16 @@ -428,10 +428,10 @@ public func addLocallyGeneratedEntities(_ text: String, enabledTypes: EnabledEnt if hasColons && detectTimecodes { let utf16 = text.utf16 let delimiterSet = timecodeDelimiterSet - + var index = utf16.startIndex var currentEntity: (CurrentEntityType, Range)? - + var previousScalar: UnicodeScalar? while index != utf16.endIndex { let c = utf16[index] @@ -446,7 +446,7 @@ public func addLocallyGeneratedEntities(_ text: String, enabledTypes: EnabledEnt currentEntity = (.timecode, index ..< index) } } - + if notFound { if let (type, range) = currentEntity { switch type { @@ -469,7 +469,23 @@ public func addLocallyGeneratedEntities(_ text: String, enabledTypes: EnabledEnt } } } - + + // WinterGram: link "id" / "@id" (>= 6 digits) to the user's profile via + // wnt://profile?id=. Overrides any overlapping mention/url entity the @ may have produced. + if let regex = winterGramIdMentionRegex { + let nsText = text as NSString + regex.enumerateMatches(in: text, options: [], range: NSRange(location: 0, length: nsText.length), using: { match, _, _ in + guard let match = match, match.numberOfRanges >= 2 else { + return + } + let fullRange = match.range + let digits = nsText.substring(with: match.range(at: 1)) + let entityRange = fullRange.location ..< (fullRange.location + fullRange.length) + resultEntities.removeAll(where: { $0.range.overlaps(entityRange) }) + resultEntities.append(MessageTextEntity(range: entityRange, type: .TextUrl(url: "wnt://profile?id=\(digits)"))) + }) + } + if resultEntities.count != entities.count { return resultEntities } else { @@ -477,6 +493,9 @@ public func addLocallyGeneratedEntities(_ text: String, enabledTypes: EnabledEnt } } +// WinterGram: matches "id" or "@id" (>= 6 digits) not preceded by a word character. +private let winterGramIdMentionRegex: NSRegularExpression? = try? NSRegularExpression(pattern: "(? Double? { if let string = string, string.rangeOfCharacter(from: validTimecodeSet.inverted) == nil { let components = string.components(separatedBy: ":") diff --git a/submodules/TranslateUI/Sources/ChatTranslation.swift b/submodules/TranslateUI/Sources/ChatTranslation.swift index f7d16c25be..7933a46bdb 100644 --- a/submodules/TranslateUI/Sources/ChatTranslation.swift +++ b/submodules/TranslateUI/Sources/ChatTranslation.swift @@ -13,13 +13,13 @@ public struct ChatTranslationState: Codable { case toLang case isEnabled } - + public let baseLang: String public let fromLang: String public let timestamp: Int32? public let toLang: String? public let isEnabled: Bool - + public init( baseLang: String, fromLang: String, @@ -33,17 +33,17 @@ public struct ChatTranslationState: Codable { self.toLang = toLang self.isEnabled = isEnabled } - + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - + self.baseLang = try container.decode(String.self, forKey: .baseLang) self.fromLang = try container.decode(String.self, forKey: .fromLang) self.timestamp = try container.decodeIfPresent(Int32.self, forKey: .timestamp) self.toLang = try container.decodeIfPresent(String.self, forKey: .toLang) self.isEnabled = try container.decode(Bool.self, forKey: .isEnabled) } - + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -63,7 +63,7 @@ public struct ChatTranslationState: Codable { isEnabled: self.isEnabled ) } - + public func withIsEnabled(_ isEnabled: Bool) -> ChatTranslationState { return ChatTranslationState( baseLang: self.baseLang, @@ -85,7 +85,7 @@ private func cachedChatTranslationState(engine: TelegramEngine, peerId: EnginePe key = EngineDataBuffer(length: 8) key.setInt64(0, value: peerId.id._internalGetInt64Value()) } - + return engine.data.subscribe(TelegramEngine.EngineData.Item.ItemCache.Item(collectionId: ApplicationSpecificItemCacheCollectionId.translationState, id: key)) |> map { entry -> ChatTranslationState? in return entry?.get(ChatTranslationState.self) @@ -102,7 +102,7 @@ private func updateChatTranslationState(engine: TelegramEngine, peerId: EnginePe key = EngineDataBuffer(length: 8) key.setInt64(0, value: peerId.id._internalGetInt64Value()) } - + if let state { return engine.itemCache.put(collectionId: ApplicationSpecificItemCacheCollectionId.translationState, id: key, item: state) } else { @@ -120,7 +120,7 @@ public func updateChatTranslationStateInteractively(engine: TelegramEngine, peer key = EngineDataBuffer(length: 8) key.setInt64(0, value: peerId.id._internalGetInt64Value()) } - + return engine.data.get(TelegramEngine.EngineData.Item.ItemCache.Item(collectionId: ApplicationSpecificItemCacheCollectionId.translationState, id: key)) |> map { entry -> ChatTranslationState? in return entry?.get(ChatTranslationState.self) @@ -161,7 +161,7 @@ public func translateMessageIds(context: AccountContext, messageIds: [EngineMess if let translation = message.attributes.first(where: { $0 is TranslationMessageAttribute }) as? TranslationMessageAttribute, translation.toLang == toLang { continue } - + if !message.text.isEmpty { if !messageIdsSet.contains(messageId) { messageIdsToTranslate.append(messageId) @@ -185,7 +185,7 @@ public func translateMessageIds(context: AccountContext, messageIds: [EngineMess } } } - + let translationConfiguration = TranslationConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) var enableLocalIfPossible = false switch translationConfiguration.auto { @@ -196,6 +196,18 @@ public func translateMessageIds(context: AccountContext, messageIds: [EngineMess default: break } + // WinterGram: route through the chosen provider. For anything other than the + // native Telegram backend we hand the texts to the installed external service. + switch currentWinterGramSettings.translationProvider { + case .telegram: + break + case .google, .yandex: + enableLocalIfPossible = true + case .system: + if #available(iOS 18.0, *) { + enableLocalIfPossible = true + } + } return context.engine.messages.translateMessages(messageIds: messageIdsToTranslate, fromLang: fromLang, toLang: toLang, enableLocalIfPossible: enableLocalIfPossible) |> `catch` { _ -> Signal in return .complete() @@ -207,13 +219,13 @@ public func chatTranslationState(context: AccountContext, peerId: EnginePeer.Id, if peerId.id == EnginePeer.Id.Id._internalFromInt64Value(777000) { return .single(nil) } - + guard canTranslateChats(context: context) else { return .single(nil) } - + let loggingEnabled = context.sharedContext.immediateExperimentalUISettings.logLanguageRecognition - + if #available(iOS 12.0, *) { var baseLang = context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode let rawSuffix = "-raw" @@ -229,10 +241,12 @@ public func chatTranslationState(context: AccountContext, peerId: EnginePeer.Id, context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.AutoTranslateEnabled(id: peerId)) ) |> mapToSignal { settings, autoTranslateEnabled in - if !settings.translateChats && !autoTranslateEnabled { + // WinterGram: "Message Translation" makes the translate panel available in every chat. + let winterGramAutoTranslate = currentWinterGramSettings.translateMessages + if !settings.translateChats && !autoTranslateEnabled && !winterGramAutoTranslate { return .single(nil) } - + var dontTranslateLanguages = Set() if let ignoredLanguages = settings.ignoredLanguages { dontTranslateLanguages = Set(ignoredLanguages) @@ -242,7 +256,7 @@ public func chatTranslationState(context: AccountContext, peerId: EnginePeer.Id, dontTranslateLanguages.insert(language) } } - + return cachedChatTranslationState(engine: context.engine, peerId: peerId, threadId: threadId) |> mapToSignal { cached in let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) @@ -262,7 +276,7 @@ public func chatTranslationState(context: AccountContext, peerId: EnginePeer.Id, |> take(1) |> map { messageHistoryView, _, _ -> ChatTranslationState? in let messages = messageHistoryView.entries.map(\.message) - + if loggingEnabled { Logger.shared.log("ChatTranslation", "Start language recognizing for \(peerId)") } @@ -300,15 +314,15 @@ public func chatTranslationState(context: AccountContext, peerId: EnginePeer.Id, } } } - + if message.text.count < 10 { continue } - + languageRecognizer.processString(text) let hypotheses = languageRecognizer.languageHypotheses(withMaximum: 4) languageRecognizer.reset() - + let filteredLanguages = hypotheses.filter { supportedTranslationLanguages.contains(normalizeTranslationLanguage($0.key.rawValue)) }.sorted(by: { $0.value > $1.value }) if let language = filteredLanguages.first { let fromLang = normalizeTranslationLanguage(language.key.rawValue) @@ -325,7 +339,7 @@ public func chatTranslationState(context: AccountContext, peerId: EnginePeer.Id, break } } - + var mostFrequent: (String, Int)? for (lang, count) in fromLangs { if let current = mostFrequent { @@ -340,16 +354,17 @@ public func chatTranslationState(context: AccountContext, peerId: EnginePeer.Id, if loggingEnabled { Logger.shared.log("ChatTranslation", "Ended with: \(fromLang)") } - + let isEnabled: Bool if let currentIsEnabled = cached?.isEnabled { isEnabled = currentIsEnabled - } else if autoTranslateEnabled { + } else if autoTranslateEnabled || winterGramAutoTranslate { + // WinterGram: auto-translate incoming foreign-language messages. isEnabled = true } else { isEnabled = false } - + let state = ChatTranslationState( baseLang: baseLang, fromLang: fromLang, diff --git a/submodules/TranslateUI/Sources/Translate.swift b/submodules/TranslateUI/Sources/Translate.swift index 38152a40bc..c273509d4b 100644 --- a/submodules/TranslateUI/Sources/Translate.swift +++ b/submodules/TranslateUI/Sources/Translate.swift @@ -5,6 +5,7 @@ import SwiftSignalKit import AccountContext import NaturalLanguage import TelegramCore +import TelegramUIPreferences import SwiftUI import Translation import Combine @@ -148,7 +149,7 @@ public func effectiveIgnoredTranslationLanguages(context: AccountContext, ignore if baseLang.hasSuffix(rawSuffix) { baseLang = String(baseLang.dropLast(rawSuffix.count)) } - + var dontTranslateLanguages = Set() if let ignoredLanguages = ignoredLanguages { dontTranslateLanguages = Set(ignoredLanguages) @@ -206,26 +207,26 @@ public func canTranslateText(context: AccountContext, text: String, showTranslat default: break } - + let showTranslate = showTranslate && translateButtonAvailable - + if #available(iOS 12.0, *) { if context.sharedContext.immediateExperimentalUISettings.disableLanguageRecognition { return (true, nil) } - + let dontTranslateLanguages = effectiveIgnoredTranslationLanguages(context: context, ignoredLanguages: ignoredLanguages) - + let text = String(text.prefix(64)) languageRecognizer.processString(text) let hypotheses = languageRecognizer.languageHypotheses(withMaximum: 3) languageRecognizer.reset() - + var supportedTranslationLanguages = supportedTranslationLanguages if !showTranslate && showTranslateIfTopical { supportedTranslationLanguages = ["uk", "ru"] } - + let filteredLanguages = hypotheses.filter { supportedTranslationLanguages.contains(normalizeTranslationLanguage($0.key.rawValue)) }.sorted(by: { $0.value > $1.value }) if let language = filteredLanguages.first { let languageCode = normalizeTranslationLanguage(language.key.rawValue) @@ -260,12 +261,12 @@ private struct TranslationViewImpl: View { @State private var configuration: TranslationSession.Configuration? @ObservedObject var externalCondition: ExternalTranslationTrigger private let taskContainer: Atomic - + init(externalCondition: ExternalTranslationTrigger, taskContainer: Atomic) { self.externalCondition = externalCondition self.taskContainer = taskContainer } - + var body: some View { Text("ABC") .onChange(of: self.externalCondition.shouldInvalidate) { _ in @@ -276,7 +277,7 @@ private struct TranslationViewImpl: View { return nil } } - + if let firstTaskLanguagePair { if let configuration = self.configuration, configuration.source?.languageCode?.identifier == firstTaskLanguagePair.0, configuration.target?.languageCode?.identifier == firstTaskLanguagePair.1 { self.configuration?.invalidate() @@ -297,11 +298,11 @@ private struct TranslationViewImpl: View { return nil } } - + guard let task else { return } - + do { var nextClientIdentifier: Int = 0 var clientIdentifierMap: [String: AnyHashable] = [:] @@ -311,7 +312,7 @@ private struct TranslationViewImpl: View { clientIdentifierMap["\(id)"] = key return TranslationSession.Request(sourceText: value, clientIdentifier: "\(id)") } - + let responses = try await session.translations(from: translationRequests) var resultMap: [AnyHashable: String] = [:] for response in responses { @@ -319,13 +320,13 @@ private struct TranslationViewImpl: View { resultMap[originalKey] = "\(response.targetText)" } } - + task.completion(resultMap) } catch let e { print("Translation error: \(e)") task.completion(nil) } - + let firstTaskLanguagePair = self.taskContainer.with { taskContainer -> (String, String)? in if let firstTask = taskContainer.tasks.first { return (firstTask.fromLang, firstTask.toLang) @@ -333,7 +334,7 @@ private struct TranslationViewImpl: View { return nil } } - + if let firstTaskLanguagePair { if let configuration = self.configuration, configuration.source?.languageCode?.identifier == firstTaskLanguagePair.0, configuration.target?.languageCode?.identifier == firstTaskLanguagePair.1 { self.configuration?.invalidate() @@ -356,7 +357,7 @@ public final class ExperimentalInternalTranslationServiceImpl: ExperimentalInter let fromLang: String let toLang: String let completion: ([AnyHashable: String]?) -> Void - + init(id: Int, texts: [AnyHashable: String], fromLang: String, toLang: String, completion: @escaping ([AnyHashable: String]?) -> Void) { self.id = id self.texts = texts @@ -365,31 +366,31 @@ public final class ExperimentalInternalTranslationServiceImpl: ExperimentalInter self.completion = completion } } - + fileprivate final class TranslationTaskContainer { var tasks: [TranslationTask] = [] - + init() { } } - + private final class Impl { private let hostingController: UIViewController - + private let taskContainer = Atomic(value: TranslationTaskContainer()) private let taskTrigger = ExternalTranslationTrigger() - + private var nextId: Int = 0 - + init(view: UIView) { self.hostingController = UIHostingController(rootView: TranslationViewImpl( externalCondition: self.taskTrigger, taskContainer: self.taskContainer )) - + view.addSubview(self.hostingController.view) } - + func translate(texts: [AnyHashable: String], fromLang: String, toLang: String, onResult: @escaping ([AnyHashable: String]?) -> Void) -> Disposable { let id = self.nextId self.nextId += 1 @@ -405,7 +406,7 @@ public final class ExperimentalInternalTranslationServiceImpl: ExperimentalInter )) } self.taskTrigger.shouldInvalidate += 1 - + return ActionDisposable { [weak self] in Queue.mainQueue().async { guard let self else { @@ -418,15 +419,15 @@ public final class ExperimentalInternalTranslationServiceImpl: ExperimentalInter } } } - + private let impl: QueueLocalObject - + public init(view: UIView) { self.impl = QueueLocalObject(queue: .mainQueue(), generate: { return Impl(view: view) }) } - + public func translate(texts: [AnyHashable: String], fromLang: String, toLang: String) -> Signal<[AnyHashable: String]?, NoError> { return self.impl.signalWith { impl, subscriber in return impl.translate(texts: texts, fromLang: fromLang, toLang: toLang, onResult: { result in @@ -448,7 +449,7 @@ func alternativeTranslateText(text: String, fromLang: String?, toLang: String) - languageRecognizer.processString(text) let hypotheses = languageRecognizer.languageHypotheses(withMaximum: 3) languageRecognizer.reset() - + let filteredLanguages = hypotheses.filter { supportedTranslationLanguages.contains(normalizeTranslationLanguage($0.key.rawValue)) }.sorted(by: { $0.value > $1.value }) if let language = filteredLanguages.first { let languageCode = normalizeTranslationLanguage(language.key.rawValue) @@ -457,7 +458,7 @@ func alternativeTranslateText(text: String, fromLang: String?, toLang: String) - effectiveFromLang = "en" } } - + var uri = "https://translate.goo" uri += "gleapis.com/transl" uri += "ate_a" @@ -466,52 +467,52 @@ func alternativeTranslateText(text: String, fromLang: String?, toLang: String) - uri += "&tl=\(toLang.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" uri += "&dt=t&ie=UTF-8&oe=UTF-8&otf=1&ssel=0&tsel=0&kc=7&dt=at&dt=bd&dt=ex&dt=ld&dt=md&dt=qca&dt=rw&dt=rm&dt=ss&q=" uri += text.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" - + guard let url = URL(string: uri) else { subscriber.putError(.generic) return } - + var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue(getRandomUserAgent(), forHTTPHeaderField: "User-Agent") request.setValue("application/json", forHTTPHeaderField: "Content-Type") - + task = URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { print("Translation failed: \(error.localizedDescription)") subscriber.putError(.generic) return } - + guard let httpResponse = response as? HTTPURLResponse else { subscriber.putError(.generic) return } - + if httpResponse.statusCode != 200 { print("Translation failed with status code: \(httpResponse.statusCode)") let isRateLimit = httpResponse.statusCode == 429 subscriber.putError(isRateLimit ? .limitExceeded : .generic) return } - + guard let data = data else { subscriber.putError(.generic) return } - + do { guard let jsonArray = try JSONSerialization.jsonObject(with: data) as? [Any] else { subscriber.putError(.generic) return } - + guard let translationArray = jsonArray.first as? [Any] else { subscriber.putError(.generic) return } - + var result = "" for element in translationArray { if let translationBlock = element as? [Any], @@ -521,11 +522,11 @@ func alternativeTranslateText(text: String, fromLang: String?, toLang: String) - result += blockText } } - + if text.hasPrefix("\n") { result = "\n" + result } - + subscriber.putNext((result, [])) subscriber.putCompletion() } catch { @@ -547,3 +548,68 @@ func getRandomUserAgent() -> String { ] return userAgents.randomElement() ?? userAgents[0] } + +// WinterGram: routes message translation through the provider chosen in settings. +// Installed globally as `engineExperimentalInternalTranslationService`; the engine calls +// it (with a batch of texts) whenever `enableLocalIfPossible` is set, which +// ChatTranslation turns on for any non-Telegram provider. `.telegram` never reaches +// here (the engine uses its native API path instead). +public final class WinterGramTranslationService: ExperimentalInternalTranslationService { + // Stored as the (un-gated) protocol; the concrete iOS 18+ impl is only constructed + // inside an availability check. + private let appleService: ExperimentalInternalTranslationService? + + public init(view: UIView?) { + if #available(iOS 18.0, *), let view { + self.appleService = ExperimentalInternalTranslationServiceImpl(view: view) + } else { + self.appleService = nil + } + } + + public func translate(texts: [AnyHashable: String], fromLang: String, toLang: String) -> Signal<[AnyHashable: String]?, NoError> { + switch currentWinterGramSettings.translationProvider { + case .system: + if let appleService = self.appleService { + return appleService.translate(texts: texts, fromLang: fromLang, toLang: toLang) + } + // No on-device translation available: signal failure so the UI can report it. + return .single(nil) + case .google, .yandex: + // Both currently use the free Google endpoint (no API key required); Yandex + // without a key is not reliably reachable, so it shares the Google backend. + return winterGramHttpTranslate(texts: texts, fromLang: fromLang, toLang: toLang) + case .telegram: + return .single(nil) + } + } +} + +// Translates a batch of texts via the free Google endpoint, preserving the keys. +private func winterGramHttpTranslate(texts: [AnyHashable: String], fromLang: String, toLang: String) -> Signal<[AnyHashable: String]?, NoError> { + let perText: [Signal<(AnyHashable, String)?, NoError>] = texts.map { key, text in + return alternativeTranslateText(text: text, fromLang: fromLang.isEmpty ? nil : fromLang, toLang: toLang) + |> map { result -> (AnyHashable, String)? in + if let result { + return (key, result.0) + } + return nil + } + |> `catch` { _ -> Signal<(AnyHashable, String)?, NoError> in + return .single(nil) + } + } + if perText.isEmpty { + return .single([:]) + } + return combineLatest(perText) + |> map { results -> [AnyHashable: String]? in + var mapped: [AnyHashable: String] = [:] + for entry in results { + if let (key, value) = entry { + mapped[key] = value + } + } + return mapped + } +} diff --git a/submodules/UrlHandling/Sources/UrlHandling.swift b/submodules/UrlHandling/Sources/UrlHandling.swift index 5a92356531..eb4ef00748 100644 --- a/submodules/UrlHandling/Sources/UrlHandling.swift +++ b/submodules/UrlHandling/Sources/UrlHandling.swift @@ -13,7 +13,7 @@ private let baseTelegramMePaths = [ "t.me", "telegram.dog" ] private let telegramWebShortLinkHosts = [ - "a.t.me", + "a.t.me", "k.t.me", "z.t.me" ] @@ -43,7 +43,7 @@ public func isTelegramWebShortLink(_ url: String) -> Bool { extension ResolvedBotAdminRights { init?(_ string: String) { var rawValue: UInt32 = 0 - + let components = string.lowercased().components(separatedBy: "+") if components.contains("change_info") { rawValue |= ResolvedBotAdminRights.changeInfo.rawValue @@ -81,7 +81,7 @@ extension ResolvedBotAdminRights { if components.contains("anonymous") { rawValue |= ResolvedBotAdminRights.canBeAnonymous.rawValue } - + if rawValue != 0 { self.init(rawValue: rawValue) } else { @@ -95,7 +95,7 @@ public enum ParsedInternalPeerUrlParameter { case live case id(Int32) } - + case botStart(String) case groupBotStart(String, ResolvedBotAdminRights?) case channelBotStart(String, ResolvedBotAdminRights?) @@ -120,13 +120,13 @@ public enum ParsedInternalUrl { case name(String) case id(EnginePeer.Id) } - + public enum UrlMessageSubject { case timecode(Double) case todoItem(Int32) case pollOption(String) } - + case peer(UrlPeerReference, ParsedInternalPeerUrlParameter?) case peerId(EnginePeer.Id) case privateMessage(messageId: EngineMessage.Id, threadId: Int32?, subject: UrlMessageSubject?) @@ -179,11 +179,11 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, context: Accou } if !pathComponents.isEmpty && !pathComponents[0].isEmpty { let peerName: String = pathComponents[0] - + if query.hasPrefix("tonsite/") { return .externalUrl(url: "tonsite://" + String(query[query.index(query.startIndex, offsetBy: "tonsite/".count)...])) } - + if pathComponents[0].hasPrefix("+") || pathComponents[0].hasPrefix("%20") { let component = pathComponents[0].replacingOccurrences(of: "%20", with: "+") if component.rangeOfCharacter(from: CharacterSet(charactersIn: "0123456789+").inverted) == nil { @@ -203,7 +203,7 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, context: Accou } } } - + return .phone(component.replacingOccurrences(of: "+", with: ""), attach, startAttach, text) } else { return .join(String(component.dropFirst())) @@ -211,6 +211,28 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, context: Accou } if pathComponents.count == 1 { if let queryItems = components.queryItems { + if peerName == "chat" || peerName == "user" { + for queryItem in queryItems { + if queryItem.name == "id", let value = queryItem.value, let idValue = Int64(value) { + let namespace: EnginePeer.Id.Namespace + let normalizedId: Int64 + if idValue < 0 { + let abs = -idValue + if abs > 1000000000000 { + namespace = Namespaces.Peer.CloudChannel + normalizedId = abs - 1000000000000 + } else { + namespace = Namespaces.Peer.CloudGroup + normalizedId = abs + } + } else { + namespace = Namespaces.Peer.CloudUser + normalizedId = idValue + } + return .peerId(EnginePeer.Id(namespace: namespace, id: EnginePeer.Id.Id._internalFromInt64Value(normalizedId))) + } + } + } if peerName == "socks" || peerName == "proxy" { var server: String? var port: String? @@ -237,7 +259,7 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, context: Accou } } } - + if let server = server, !server.isEmpty, let port = port, let portValue = Int32(port) { return .proxy(host: server, port: portValue, username: user, password: pass, secret: secret) } @@ -512,7 +534,7 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, context: Accou } } } - + if let url = url { return .share(url: url, text: text, to: nil) } @@ -732,7 +754,7 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, context: Accou } } } - + let peerId = EnginePeer.Id(namespace: Namespaces.Peer.CloudChannel, id: EnginePeer.Id.Id._internalFromInt64Value(channelId)) if boost { return .peer(.id(peerId), .boost) @@ -774,7 +796,7 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, context: Accou } } } - + if pathComponents.count >= 3, let subMessageId = Int32(pathComponents[2]) { return .peer(.name(peerName), .replyThread(value, subMessageId)) } else if let threadId = threadId { @@ -891,13 +913,13 @@ private func resolveInternalUrl(context: AccountContext, url: ParsedInternalUrl) resolvedPeer = .single(.result(nil)) } } - + return resolvedPeer |> mapToSignal { result -> Signal in guard case let .result(peer) = result else { return .single(.progress) } - + if let peer = peer { if let parameter = parameter { switch parameter { @@ -1015,7 +1037,7 @@ private func resolveInternalUrl(context: AccountContext, url: ParsedInternalUrl) } case let .replyThread(id, replyId): let replyThreadMessageId = EngineMessage.Id(peerId: peer.id, namespace: Namespaces.Message.Cloud, id: id) - + if case let .channel(channel) = peer, channel.isForumOrMonoForum { return context.engine.peers.fetchForumChannelTopic(id: channel.id, threadId: Int64(replyThreadMessageId.id)) |> map { result -> ResolveInternalUrlResult in @@ -1065,7 +1087,7 @@ private func resolveInternalUrl(context: AccountContext, url: ParsedInternalUrl) |> then(.single(.result(.story(peerId: peer.id, id: id))))) } case .boost: - return .single(.progress) + return .single(.progress) |> then( combineLatest( context.engine.peers.getChannelBoostStatus(peerId: peer.id), @@ -1341,7 +1363,7 @@ private func resolveInternalUrl(context: AccountContext, url: ParsedInternalUrl) } } } - + return context.engine.stickers.resolveInlineStickers(fileIds: customEmojiIds) |> mapToSignal { _ -> Signal in return .single(.result(.messageLink(link: result))) @@ -1424,7 +1446,7 @@ public func parseProxyUrl(sharedContext: SharedAccountContext, url: String) -> ( return (host, port, username, password, secret) } } - + return nil } @@ -1445,7 +1467,7 @@ public func parseStickerPackUrl(sharedContext: SharedAccountContext, url: String return name } } - + return nil } @@ -1466,7 +1488,7 @@ public func parseWallpaperUrl(sharedContext: SharedAccountContext, url: String) return wallpaper } } - + return nil } @@ -1487,7 +1509,7 @@ public func parseAdUrl(sharedContext: SharedAccountContext, context: AccountCont return internalUrl } } - + return nil } @@ -1510,15 +1532,15 @@ private struct UrlHandlingConfiguration { static var defaultValue: UrlHandlingConfiguration { return UrlHandlingConfiguration(domains: [], urlAuthDomains: []) } - + public let domains: [String] public let urlAuthDomains: [String] - + fileprivate init(domains: [String], urlAuthDomains: [String]) { self.domains = domains self.urlAuthDomains = urlAuthDomains } - + static func with(appConfiguration: AppConfiguration) -> UrlHandlingConfiguration { if let data = appConfiguration.data { let urlAuthDomains = data["url_auth_domains"] as? [String] ?? [] @@ -1532,13 +1554,13 @@ private struct UrlHandlingConfiguration { public func resolveUrlImpl(context: AccountContext, peerId: EnginePeer.Id?, url: String, skipUrlAuth: Bool) -> Signal { let schemes = ["http://", "https://", ""] - + return ApplicationSpecificNotice.getSecretChatLinkPreviews(accountManager: context.sharedContext.accountManager) |> mapToSignal { linkPreviews -> Signal in return context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.App(), TelegramEngine.EngineData.Item.Configuration.Links()) |> mapToSignal { appConfiguration, linksConfiguration -> Signal in let urlHandlingConfiguration = UrlHandlingConfiguration.with(appConfiguration: appConfiguration) - + var skipUrlAuth = skipUrlAuth if let peerId = peerId, peerId.namespace == Namespaces.Peer.SecretChat { if let linkPreviews = linkPreviews, linkPreviews { @@ -1546,7 +1568,7 @@ public func resolveUrlImpl(context: AccountContext, peerId: EnginePeer.Id?, url: skipUrlAuth = true } } - + var url = url if !url.contains("://") && !url.hasPrefix("tel:") && !url.hasPrefix("mailto:") && !url.hasPrefix("calshow:") { if !(url.hasPrefix("http") || url.hasPrefix("https")) { @@ -1557,7 +1579,7 @@ public func resolveUrlImpl(context: AccountContext, peerId: EnginePeer.Id?, url: } } } - + if let urlValue = URL(string: url), let host = urlValue.host?.lowercased() { if urlHandlingConfiguration.domains.contains(host), var components = URLComponents(string: url) { components.scheme = "https" @@ -1569,7 +1591,7 @@ public func resolveUrlImpl(context: AccountContext, peerId: EnginePeer.Id?, url: return .single(.result(.urlAuth(url))) } } - + if isTelegramWebShortLink(url) { return .single(.result(.externalUrl(url))) } @@ -1669,13 +1691,13 @@ public func cleanDomain(url: String) -> (domain: String, fullUrl: String) { private func hTmeParseDuration(_ durationStr: String) -> Int { // Optional hours, optional minutes, optional seconds let pattern = "^(?:(\\d+)h)?(?:(\\d+)m)?(?:(\\d+)s)?$" - + // Attempt to create the regex guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { // If regex creation fails, fallback to integer parsing return Int(durationStr) ?? 0 } - + // Search for a match let range = NSRange(durationStr.startIndex..., in: durationStr) if let match = regex.firstMatch(in: durationStr, options: [], range: range) { @@ -1683,7 +1705,7 @@ private func hTmeParseDuration(_ durationStr: String) -> Int { let hoursRange = match.range(at: 1) let minutesRange = match.range(at: 2) let secondsRange = match.range(at: 3) - + // Helper to safely extract integer from a matched range func intValue(_ nsRange: NSRange) -> Int { guard nsRange.location != NSNotFound, @@ -1692,14 +1714,14 @@ private func hTmeParseDuration(_ durationStr: String) -> Int { } return Int(durationStr[substringRange]) ?? 0 } - + let hours = intValue(hoursRange) let minutes = intValue(minutesRange) let seconds = intValue(secondsRange) - + return hours * 3600 + minutes * 60 + seconds } - + // If the string didn't match the pattern, parse it as a positive integer return Int(durationStr) ?? 0 } diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index ee39fd2940..63cfd55021 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -54,7 +54,7 @@ public struct WebAppParameters { case inline case simple case settings - + var isSimple: Bool { if [.simple, .inline, .settings].contains(self) { return true @@ -63,7 +63,7 @@ public struct WebAppParameters { } } } - + let source: Source let peerId: EnginePeer.Id let botId: EnginePeer.Id @@ -81,7 +81,7 @@ public struct WebAppParameters { let isFullscreen: Bool let sameOrigin: Bool let appSettings: BotAppSettings? - + public init( source: Source, peerId: EnginePeer.Id, @@ -114,7 +114,8 @@ public struct WebAppParameters { self.buttonText = buttonText self.keepAliveSignal = keepAliveSignal self.forceHasSettings = forceHasSettings - self.fullSize = fullSize || isFullscreen + // WinterGram: "Taller WebViews" opens every mini app expanded to full height. + self.fullSize = fullSize || isFullscreen || currentWinterGramSettings.increaseWebviewHeight self.isFullscreen = isFullscreen self.sameOrigin = sameOrigin self.appSettings = appSettings @@ -145,21 +146,21 @@ public func generateWebAppThemeParams(_ theme: PresentationTheme) -> [String: An private let registeredProtocols: Void = { class AppURLProtocol: URLProtocol { var urlTask: URLSessionDataTask? - + override class func canInit(with request: URLRequest) -> Bool { if request.url?.scheme == "https" { return false } return false } - + override class func canonicalRequest(for request: URLRequest) -> URLRequest { return request } - + override func startLoading() { super.startLoading() - + /*if self.urlTask != nil { return } @@ -179,7 +180,7 @@ private let registeredProtocols: Void = { }) self.urlTask?.resume()*/ } - + override func stopLoading() { self.urlTask?.cancel() } @@ -199,79 +200,79 @@ public final class WebAppController: ViewController, AttachmentContainable { public var cancelPanGesture: () -> Void = { } public var isContainerPanning: () -> Bool = { return false } public var isContainerExpanded: () -> Bool = { return false } - + static var activeDownloads: [FileDownload] = [] - + fileprivate class Node: ViewControllerTracingNode, WKNavigationDelegate, WKUIDelegate, WKDownloadDelegate, ASScrollViewDelegate { private weak var controller: WebAppController? - + private let backgroundNode: ASDisplayNode private let headerBackgroundNode: ASDisplayNode private let topOverscrollNode: ASDisplayNode - + fileprivate var webView: WebAppWebView? private var placeholderIcon: (UIImage, Bool)? private var placeholderNode: ShimmerEffectNode? private var fullscreenControls: ComponentView? - + fileprivate let loadingProgressPromise = Promise(nil) - + fileprivate var mainButtonState: AttachmentMainButtonState? { didSet { self.mainButtonStatePromise.set(.single(self.mainButtonState)) } } fileprivate let mainButtonStatePromise = Promise(nil) - + fileprivate var secondaryButtonState: AttachmentMainButtonState? { didSet { self.secondaryButtonStatePromise.set(.single(self.secondaryButtonState)) } } fileprivate let secondaryButtonStatePromise = Promise(nil) - + private let context: AccountContext var presentationData: PresentationData private var queryId: Int64? fileprivate let canMinimize = true - + fileprivate var hasBackButton = false - + private var placeholderDisposable = MetaDisposable() private var keepAliveDisposable: Disposable? private var paymentDisposable: Disposable? - + private var iconDisposable: Disposable? fileprivate var icon: UIImage? - + private var lastExpansionTimestamp: Double? - + private var didTransitionIn = false private var dismissed = false - + private var validLayout: (ContainerViewLayout, CGFloat)? - + init(context: AccountContext, controller: WebAppController) { #if DEBUG let _ = registeredProtocols #endif - + self.context = context self.controller = controller self.presentationData = controller.presentationData - + self.backgroundNode = ASDisplayNode() self.headerBackgroundNode = ASDisplayNode() self.topOverscrollNode = ASDisplayNode() - + super.init() - + if self.presentationData.theme.list.plainBackgroundColor.rgb == 0x000000 { self.backgroundColor = self.presentationData.theme.list.itemBlocksBackgroundColor } else { self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor } - + let webView = WebAppWebView(account: context.account) webView.alpha = 0.0 webView.navigationDelegate = self @@ -291,23 +292,23 @@ public final class WebAppController: ViewController, AttachmentContainable { } } } - + if self.presentationData.theme.overallDarkAppearance { webView.overrideUserInterfaceStyle = .dark } else { webView.overrideUserInterfaceStyle = .unspecified } - + self.webView = webView - + self.addSubnode(self.backgroundNode) self.addSubnode(self.headerBackgroundNode) - + let placeholderNode = ShimmerEffectNode() placeholderNode.allowsGroupOpacity = true self.addSubnode(placeholderNode) self.placeholderNode = placeholderNode - + let placeholder: Signal<(FileMediaReference, Bool)?, NoError> if let botAppSettings = controller.botAppSettings { Queue.mainQueue().justDispatch { @@ -367,7 +368,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } } } - + if let placeholderData = controller.botAppSettings?.placeholderData { Queue.mainQueue().justDispatch { let size = CGSize(width: 78.0, height: 78.0) @@ -393,7 +394,7 @@ public final class WebAppController: ViewController, AttachmentContainable { fileReference = nil isPlaceholder = true } - + if let fileReference = fileReference { let _ = freeMediaFileInteractiveFetched(account: strongSelf.context.account, userLocation: .other, fileReference: fileReference).start() let _ = (svgIconImageFile(account: strongSelf.context.account, fileReference: fileReference, stickToTop: isPlaceholder) @@ -421,7 +422,7 @@ public final class WebAppController: ViewController, AttachmentContainable { let image = generateImage(CGSize(width: 78.0, height: 78.0), rotatedContext: { size, context in context.clear(CGRect(origin: .zero, size: size)) context.setFillColor(UIColor.white.cgColor) - + let squareSize = CGSize(width: 36.0, height: 36.0) context.addPath(UIBezierPath(roundedRect: CGRect(origin: .zero, size: squareSize), cornerRadius: 5.0).cgPath) context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(x: size.width - squareSize.width, y: 0.0), size: squareSize), cornerRadius: 5.0).cgPath) @@ -436,7 +437,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } })) } - + self.iconDisposable = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: controller.botId)) |> mapToSignal { peer -> Signal in guard let peer else { @@ -451,15 +452,15 @@ public final class WebAppController: ViewController, AttachmentContainable { self.icon = icon }) } - + deinit { self.iconDisposable?.dispose() self.placeholderDisposable.dispose() self.keepAliveDisposable?.dispose() self.paymentDisposable?.dispose() - + self.webView?.removeObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress)) - + if self.motionManager.isAccelerometerActive { self.motionManager.stopAccelerometerUpdates() } @@ -467,29 +468,29 @@ public final class WebAppController: ViewController, AttachmentContainable { self.motionManager.stopGyroUpdates() } } - + override func didLoad() { super.didLoad() - + self.setupWebView() if let pendingExternalUrl = self.controller?.pendingExternalUrl { self.controller?.pendingExternalUrl = nil self.loadExternal(url: pendingExternalUrl) } - + guard let webView = self.webView else { return } self.view.addSubview(webView) webView.scrollView.insertSubview(self.topOverscrollNode.view, at: 0) } - + private func load(url: URL) { /*#if DEBUG if "".isEmpty { if #available(iOS 16.0, *) { let documentsPath = URL.documentsDirectory.path(percentEncoded: false) - + var hasher = SHA256() var urlString = url.absoluteString if let range = urlString.firstRange(of: "#") { @@ -498,9 +499,9 @@ public final class WebAppController: ViewController, AttachmentContainable { hasher.update(data: urlString.data(using: .utf8)!) let digest = Data(hasher.finalize()) let urlHash = hexString(digest) - + let cachedFilePath = documentsPath.appending("\(urlHash).bin") - + Task { do { let data: Data @@ -518,7 +519,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } } } - + return } #endif*/ @@ -530,7 +531,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } self.webView?.load(URLRequest(url: url)) } - + fileprivate func loadExternal(url: String) { guard let parsedUrl = URL(string: url) else { return @@ -542,7 +543,7 @@ public final class WebAppController: ViewController, AttachmentContainable { guard let controller = self.controller else { return } - + if let url = controller.url, controller.source != .menu { self.queryId = controller.queryId if let parsedUrl = URL(string: url) { @@ -603,7 +604,7 @@ public final class WebAppController: ViewController, AttachmentContainable { strongSelf.queryId = result.queryId strongSelf.controller?.sameOrigin = result.flags.contains(.sameOrigin) strongSelf.load(url: parsedUrl) - + if let keepAliveSignal = result.keepAliveSignal { strongSelf.keepAliveDisposable = (keepAliveSignal |> deliverOnMainQueue).start(error: { [weak self] _ in @@ -622,7 +623,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } } } - + @objc fileprivate func mainButtonPressed() { if let mainButtonState = self.mainButtonState, !mainButtonState.isVisible || !mainButtonState.isEnabled { return @@ -630,7 +631,7 @@ public final class WebAppController: ViewController, AttachmentContainable { self.webView?.lastTouchTimestamp = CACurrentMediaTime() self.webView?.sendEvent(name: "main_button_pressed", data: nil) } - + @objc fileprivate func secondaryButtonPressed() { if let secondaryButtonState = self.secondaryButtonState, !secondaryButtonState.isVisible || !secondaryButtonState.isEnabled { return @@ -638,16 +639,16 @@ public final class WebAppController: ViewController, AttachmentContainable { self.webView?.lastTouchTimestamp = CACurrentMediaTime() self.webView?.sendEvent(name: "secondary_button_pressed", data: nil) } - + private func updatePlaceholder(layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) -> CGSize { var shapes: [ShimmerEffect.ShimmerEffectNode.Shape] = [] var placeholderSize: CGSize = CGSize() - + if let (image, _) = self.placeholderIcon { shapes = [.image(image: image, rect: CGRect(origin: CGPoint(), size: image.size))] placeholderSize = image.size } - + let foregroundColor: UIColor let shimmeringColor: UIColor if let backgroundColor = self.placeholderBackgroundColor { @@ -663,13 +664,13 @@ public final class WebAppController: ViewController, AttachmentContainable { foregroundColor = theme.list.mediaPlaceholderColor shimmeringColor = theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.4) } - + self.placeholderNode?.update(backgroundColor: .clear, foregroundColor: foregroundColor, shimmeringColor: shimmeringColor, shapes: shapes, horizontal: true, size: placeholderSize, mask: true) - + return placeholderSize } - - + + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping @MainActor (WKNavigationResponsePolicy) -> Void) { if #available(iOS 14.5, *), navigationResponse.response.suggestedFilename?.lowercased().hasSuffix(".pkpass") == true { decisionHandler(.download) @@ -677,32 +678,32 @@ public final class WebAppController: ViewController, AttachmentContainable { decisionHandler(.allow) } } - + private var downloadArguments: (String, String)? - + @available(iOS 14.5, *) func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { download.delegate = self } - + @available(iOS 14.5, *) func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { download.delegate = self } - + @available(iOS 14.5, *) func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { let path = NSTemporaryDirectory() + NSUUID().uuidString self.downloadArguments = (path, suggestedFilename) completionHandler(URL(fileURLWithPath: path)) } - + @available(iOS 14.5, *) func downloadDidFinish(_ download: WKDownload) { if let (path, fileName) = self.downloadArguments { let tempFile = EngineTempBox.shared.file(path: path, fileName: fileName) let url = URL(fileURLWithPath: tempFile.path) - + if fileName.hasSuffix(".pkpass") { if let data = try? Data(contentsOf: url), let pass = try? PKPass(data: data) { let passLibrary = PKPassLibrary() @@ -724,12 +725,12 @@ public final class WebAppController: ViewController, AttachmentContainable { self.downloadArguments = nil } } - + @available(iOS 14.5, *) func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { self.downloadArguments = nil } - + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if let url = navigationAction.request.url?.absoluteString { if isTelegramMeLink(url) || isTelegraPhLink(url) { @@ -742,43 +743,43 @@ public final class WebAppController: ViewController, AttachmentContainable { decisionHandler(.allow) } } - + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { if navigationAction.targetFrame == nil, let url = navigationAction.request.url { self.controller?.openUrl(url.absoluteString, true, false, {}) } return nil } - + private func animateTransitionIn() { guard !self.didTransitionIn, let webView = self.webView else { return } self.didTransitionIn = true - + let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .linear) transition.updateAlpha(layer: webView.layer, alpha: 1.0) - + self.updateHeaderBackgroundColor(transition: transition) - + if let placeholderNode = self.placeholderNode { self.placeholderNode = nil transition.updateAlpha(node: placeholderNode, alpha: 0.0, completion: { [weak placeholderNode] _ in placeholderNode?.removeFromSupernode() }) } - + if let (layout, navigationBarHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) } } - + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { Queue.mainQueue().after(0.6, { self.animateTransitionIn() }) } - + @available(iOSApplicationExtension 15.0, iOS 15.0, *) func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void) { if self.controller?.isVerifyAgeBot == true && type == .camera { @@ -787,7 +788,7 @@ public final class WebAppController: ViewController, AttachmentContainable { decisionHandler(.prompt) } } - + func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) { var completed = false let alertController = AlertScreen( @@ -872,13 +873,13 @@ public final class WebAppController: ViewController, AttachmentContainable { ) self.controller?.present(promptController, in: .window(.root)) } - + private func updateNavigationBarAlpha(transition: ContainedViewLayoutTransition) { let contentOffset = self.webView?.scrollView.contentOffset.y ?? 0.0 let backgroundAlpha = min(30.0, contentOffset) / 30.0 self.controller?.navigationBar?.updateBackgroundAlpha(backgroundAlpha, transition: transition) } - + private var targetContentOffset: CGPoint? func scrollViewDidScroll(_ scrollView: UIScrollView) { self.updateNavigationBarAlpha(transition: .immediate) @@ -886,32 +887,32 @@ public final class WebAppController: ViewController, AttachmentContainable { scrollView.contentOffset = targetContentOffset } } - + fileprivate func isContainerPanningUpdated(_ isPanning: Bool) { if let (layout, navigationBarHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) } } - + private var updateWebViewWhenStable = false - + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { let previousLayout = self.validLayout?.0 self.validLayout = (layout, navigationBarHeight) - + guard let controller = self.controller else { return } - + self.updateStatusBarStyle() - + controller.navigationBar?.alpha = controller.isFullscreen ? 0.0 : 1.0 transition.updateAlpha(node: self.headerBackgroundNode, alpha: controller.isFullscreen ? 0.0 : 1.0) - + transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: .zero, size: layout.size)) transition.updateFrame(node: self.headerBackgroundNode, frame: CGRect(origin: .zero, size: CGSize(width: layout.size.width, height: navigationBarHeight))) transition.updateFrame(node: self.topOverscrollNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -1000.0), size: CGSize(width: layout.size.width, height: 1000.0))) - + var contentTopInset: CGFloat = 0.0 if controller.isFullscreen { var added = false @@ -975,38 +976,38 @@ public final class WebAppController: ViewController, AttachmentContainable { fullscreenControls.view?.removeFromSuperview() }) } - + if let webView = self.webView { let inputHeight = self.validLayout?.0.inputHeight ?? 0.0 - + let intrinsicBottomInset = layout.intrinsicInsets.bottom > 40.0 ? layout.intrinsicInsets.bottom : 0.0 - + var scrollInset = UIEdgeInsets(top: 0.0, left: 0.0, bottom: max(inputHeight, intrinsicBottomInset), right: 0.0) var frameBottomInset: CGFloat = 0.0 if scrollInset.bottom > 40.0 { frameBottomInset = scrollInset.bottom scrollInset.bottom = 0.0 } - + let topInset: CGFloat = controller.isFullscreen ? 0.0 : navigationBarHeight - + let webViewFrame = CGRect(origin: CGPoint(x: 0.0, y: topInset), size: CGSize(width: layout.size.width, height: max(1.0, layout.size.height - topInset - frameBottomInset))) if !webView.frame.width.isZero && webView.frame != webViewFrame { self.updateWebViewWhenStable = true } - + var viewportBottomInset = max(frameBottomInset, scrollInset.bottom) if (self.validLayout?.0.inputHeight ?? 0.0) < 44.0 { viewportBottomInset += layout.additionalInsets.bottom } let viewportFrame = CGRect(origin: CGPoint(x: layout.safeInsets.left, y: topInset), size: CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right, height: max(1.0, layout.size.height - topInset - viewportBottomInset))) - + if webView.scrollView.contentInset != scrollInset { webView.scrollView.contentInset = scrollInset webView.scrollView.horizontalScrollIndicatorInsets = scrollInset webView.scrollView.verticalScrollIndicatorInsets = scrollInset } - + if previousLayout != nil && (previousLayout?.inputHeight ?? 0.0).isZero, let inputHeight = layout.inputHeight, inputHeight > 44.0, transition.isAnimated { Queue.mainQueue().after(0.4, { if let inputHeight = self.validLayout?.0.inputHeight, inputHeight > 44.0 { @@ -1014,7 +1015,7 @@ public final class WebAppController: ViewController, AttachmentContainable { let _ = self // self?.targetContentOffset = contentOffset }, transition: transition) - + transition.updateFrame(view: webView, frame: webViewFrame) // Queue.mainQueue().after(0.1) { // self.targetContentOffset = nil @@ -1024,17 +1025,17 @@ public final class WebAppController: ViewController, AttachmentContainable { } else { transition.updateFrame(view: webView, frame: webViewFrame) } - + if let snapshotView = self.fullscreenSwitchSnapshotView { self.fullscreenSwitchSnapshotView = nil - + transition.updatePosition(layer: snapshotView.layer, position: webViewFrame.center) transition.updateTransform(layer: snapshotView.layer, transform: CATransform3DMakeScale(webViewFrame.width / snapshotView.frame.width, webViewFrame.height / snapshotView.frame.height, 1.0)) transition.updateAlpha(layer: snapshotView.layer, alpha: 0.0, completion: { _ in snapshotView.removeFromSuperview() }) } - + var customInsets: UIEdgeInsets = .zero if controller.isFullscreen { customInsets.top = layout.statusBarHeight ?? 0.0 @@ -1047,10 +1048,10 @@ public final class WebAppController: ViewController, AttachmentContainable { customInsets.left = layout.safeInsets.left customInsets.right = layout.safeInsets.left webView.customInsets = customInsets - + if let controller = self.controller { webView.updateMetrics(height: viewportFrame.height, isExpanded: controller.isContainerExpanded(), isStable: !controller.isContainerPanning(), transition: transition) - + let data: JSON = [ "top": Double(contentTopInset), "bottom": 0.0, @@ -1058,14 +1059,14 @@ public final class WebAppController: ViewController, AttachmentContainable { "right": 0.0 ] webView.sendEvent(name: "content_safe_area_changed", data: data.string) - + if self.updateWebViewWhenStable && !controller.isContainerPanning() { self.updateWebViewWhenStable = false webView.setNeedsLayout() } } } - + if let placeholderNode = self.placeholderNode { let height: CGFloat if case .compact = layout.metrics.widthClass { @@ -1073,7 +1074,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } else { height = layout.size.height - layout.intrinsicInsets.bottom } - + let placeholderSize = self.updatePlaceholder(layout: layout, navigationBarHeight: navigationBarHeight, transition: transition) let placeholderY: CGFloat if let (_, isPlaceholder) = self.placeholderIcon, isPlaceholder { @@ -1085,30 +1086,30 @@ public final class WebAppController: ViewController, AttachmentContainable { transition.updateFrame(node: placeholderNode, frame: placeholderFrame) placeholderNode.updateAbsoluteRect(placeholderFrame, within: layout.size) } - + if let previousLayout = previousLayout, (previousLayout.inputHeight ?? 0.0).isZero, let inputHeight = layout.inputHeight, inputHeight > 44.0 { Queue.mainQueue().justDispatch { self.controller?.requestAttachmentMenuExpansion() } } } - + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { if keyPath == "estimatedProgress", let webView = self.webView { self.loadingProgressPromise.set(.single(CGFloat(webView.estimatedProgress))) } } - + private let hapticFeedback = HapticFeedback() - + private weak var currentQrCodeScannerScreen: QrCodeScanScreen? - + func requestLayout(transition: ContainedViewLayoutTransition) { if let (layout, navigationBarHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) } } - + private var delayedScriptMessages: [WKScriptMessage] = [] private func handleScriptMessage(_ message: WKScriptMessage) { guard let controller = self.controller else { @@ -1126,7 +1127,7 @@ public final class WebAppController: ViewController, AttachmentContainable { let currentTimestamp = CACurrentMediaTime() let eventData = (body["eventData"] as? String)?.data(using: .utf8) let json = try? JSONSerialization.jsonObject(with: eventData ?? Data(), options: []) as? [String: Any] - + switch eventName { case "web_app_ready": self.animateTransitionIn() @@ -1169,21 +1170,21 @@ public final class WebAppController: ViewController, AttachmentContainable { if (text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { isVisible = false } - + let backgroundColorString = json["color"] as? String let backgroundColor = backgroundColorString.flatMap({ UIColor(hexString: $0) }) ?? self.presentationData.theme.list.itemCheckColors.fillColor let textColorString = json["text_color"] as? String let textColor = textColorString.flatMap({ UIColor(hexString: $0) }) ?? self.presentationData.theme.list.itemCheckColors.foregroundColor - + let isLoading = json["is_progress_visible"] as? Bool let isEnabled = json["is_active"] as? Bool let hasShimmer = json["has_shine_effect"] as? Bool - + var iconCustomEmojiId: Int64? if let stringValue = json["icon_custom_emoji_id"] as? String, let intValue = Int64(stringValue) { iconCustomEmojiId = intValue } - + let state = AttachmentMainButtonState( text: text, font: .bold, @@ -1207,22 +1208,22 @@ public final class WebAppController: ViewController, AttachmentContainable { if (text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { isVisible = false } - + let backgroundColorString = json["color"] as? String let backgroundColor = backgroundColorString.flatMap({ UIColor(hexString: $0) }) ?? self.presentationData.theme.list.itemCheckColors.fillColor let textColorString = json["text_color"] as? String let textColor = textColorString.flatMap({ UIColor(hexString: $0) }) ?? self.presentationData.theme.list.itemCheckColors.foregroundColor - + let isLoading = json["is_progress_visible"] as? Bool let isEnabled = json["is_active"] as? Bool let hasShimmer = json["has_shine_effect"] as? Bool let position = json["position"] as? String - + var iconCustomEmojiId: Int64? if let stringValue = json["icon_custom_emoji_id"] as? String, let intValue = Int64(stringValue) { iconCustomEmojiId = intValue } - + let state = AttachmentMainButtonState( text: text, font: .bold, @@ -1248,11 +1249,11 @@ public final class WebAppController: ViewController, AttachmentContainable { self.sendThemeChangedEvent() case "web_app_expand": if let lastExpansionTimestamp = self.lastExpansionTimestamp, currentTimestamp < lastExpansionTimestamp + 1.0 { - + } else { self.lastExpansionTimestamp = currentTimestamp controller.requestAttachmentMenuExpansion() - + Queue.mainQueue().after(0.4) { self.webView?.setNeedsLayout() } @@ -1330,10 +1331,10 @@ public final class WebAppController: ViewController, AttachmentContainable { if let escapedUrl = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: escapedUrl), let scheme = url.scheme?.lowercased(), !["http", "https"].contains(scheme) && !webAppConfiguration.allowedProtocols.contains(scheme) { return } - + let tryInstantView = json["try_instant_view"] as? Bool ?? false let tryBrowser = json["try_browser"] as? String - + if let lastTouchTimestamp = self.webView?.lastTouchTimestamp, currentTimestamp < lastTouchTimestamp + 10.0 { self.webView?.lastTouchTimestamp = nil if tryInstantView { @@ -1467,10 +1468,10 @@ public final class WebAppController: ViewController, AttachmentContainable { case "web_app_open_popup": if let json, let message = json["message"] as? String, let buttons = json["buttons"] as? [Any] { let presentationData = self.presentationData - + let title = json["title"] as? String var actions: [AlertScreen.Action] = [] - + for buttonJson in buttons.reversed() { if let button = buttonJson as? [String: Any], let id = button["id"] as? String, let type = button["type"] as? String { let buttonAction = { @@ -1507,7 +1508,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } } } - + var actionLayout: AlertScreen.ActionAligmnent = .default if actions.count > 2 { actionLayout = .vertical @@ -1557,7 +1558,7 @@ public final class WebAppController: ViewController, AttachmentContainable { if let json, let requestId = json["req_id"] as? String { let botId = controller.botId let isAttachMenu = controller.url == nil - + let _ = (self.context.engine.messages.attachMenuBots() |> take(1) |> deliverOnMainQueue).startStandalone(next: { [weak self] attachMenuBots in @@ -1566,7 +1567,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } let currentTimestamp = CACurrentMediaTime() var fillData = false - + let attachMenuBot = attachMenuBots.first(where: { $0.peer.id == botId && !$0.flags.contains(.notActivated) }) if isAttachMenu || attachMenuBot != nil || controller.isWhiteListedBot { if let lastTouchTimestamp = self.webView?.lastTouchTimestamp, currentTimestamp < lastTouchTimestamp + 10.0 { @@ -1574,7 +1575,7 @@ public final class WebAppController: ViewController, AttachmentContainable { fillData = true } } - + self.sendClipboardTextEvent(requestId: requestId, fillData: fillData) }) } @@ -1626,7 +1627,7 @@ public final class WebAppController: ViewController, AttachmentContainable { if let json, let mediaUrl = json["media_url"] as? String, isAllowedBotMediaUrl(mediaUrl) { let text = json["text"] as? String let link = json["widget_link"] as? [String: Any] - + var linkUrl: String? var linkName: String? if let link { @@ -1637,16 +1638,16 @@ public final class WebAppController: ViewController, AttachmentContainable { } } } - + enum FetchResult { case result(Data) case progress(Float) } - + let controller = OverlayStatusController(theme: self.presentationData.theme, type: .loading(cancelled: { })) self.controller?.present(controller, in: .window(.root)) - + let _ = (fetchHttpResource(url: mediaUrl) |> map(Optional.init) |> `catch` { error in @@ -1666,7 +1667,7 @@ public final class WebAppController: ViewController, AttachmentContainable { return } controller?.dismiss() - + switch next { case let .result(data): var source: Any? @@ -1682,7 +1683,7 @@ public final class WebAppController: ViewController, AttachmentContainable { let controller = self.context.sharedContext.makeStoryMediaEditorScreen(context: self.context, source: source, text: text, link: linkUrl.flatMap { ($0, linkName) }, remainingCount: 1, completion: { results, externalState, commit in let target: Stories.PendingTarget = results.first!.target externalState.storyTarget = target - + if let rootController = self.context.sharedContext.mainWindow?.viewController as? TelegramRootControllerInterface { rootController.proceedWithStoryUpload(target: target, results: results, existingMedia: nil, forwardInfo: nil, externalState: externalState, commit: commit) } @@ -1739,7 +1740,7 @@ public final class WebAppController: ViewController, AttachmentContainable { case "web_app_open_location_settings": if let lastTouchTimestamp = self.webView?.lastTouchTimestamp, currentTimestamp < lastTouchTimestamp + 10.0 { self.webView?.lastTouchTimestamp = nil - + self.openLocationSettings() } case "web_app_send_prepared_message": @@ -1760,7 +1761,7 @@ public final class WebAppController: ViewController, AttachmentContainable { if let json, let requestId = json["req_id"] as? String { if let key = json["key"] as? String { let value = json["value"] - + var effectiveValue: String? if let stringValue = value as? String { effectiveValue = stringValue @@ -1960,32 +1961,32 @@ public final class WebAppController: ViewController, AttachmentContainable { break } } - + fileprivate var needDismissConfirmation = false - + fileprivate var fullScreenStatusBarStyle: StatusBarStyle = .White fileprivate var appBackgroundColor: UIColor? fileprivate var placeholderBackgroundColor: UIColor? fileprivate var headerColor: UIColor? fileprivate var headerPrimaryTextColor: UIColor? private var headerColorKey: String? - + fileprivate var bottomPanelColor: UIColor? { didSet { self.bottomPanelColorPromise.set(.single(self.bottomPanelColor)) } } fileprivate let bottomPanelColorPromise = Promise(nil) - + private func updateBackgroundColor(transition: ContainedViewLayoutTransition) { transition.updateBackgroundColor(node: self.backgroundNode, color: self.appBackgroundColor ?? .clear) } - + private func updateHeaderBackgroundColor(transition: ContainedViewLayoutTransition) { guard let controller = self.controller else { return } - + let color: UIColor? var primaryTextColor: UIColor? var secondaryTextColor: UIColor? @@ -1999,7 +2000,7 @@ public final class WebAppController: ViewController, AttachmentContainable { let adaptiveAlpha = (luminance - targetLuminance + targetContrast) / targetContrast return max(0.5, min(0.64, adaptiveAlpha)) } - + primaryTextColor = textColor self.headerPrimaryTextColor = textColor secondaryTextColor = textColor.withAlphaComponent(calculateSecondaryAlpha(luminance: headerColor.lightness, targetContrast: 2.5)) @@ -2015,10 +2016,10 @@ public final class WebAppController: ViewController, AttachmentContainable { } else { color = nil } - + self.updateNavigationBarAlpha(transition: transition) controller.updateNavigationBarTheme(transition: transition) - + let statusBarStyle: StatusBarStyle if let primaryTextColor { if primaryTextColor.lightness < 0.5 { @@ -2029,20 +2030,20 @@ public final class WebAppController: ViewController, AttachmentContainable { } else { statusBarStyle = .White } - + if statusBarStyle != self.fullScreenStatusBarStyle { self.fullScreenStatusBarStyle = statusBarStyle self.updateStatusBarStyle() self.requestLayout(transition: .immediate) } - + controller.titleView?.updateTextColors(primary: primaryTextColor, secondary: secondaryTextColor, transition: transition) controller.cancelButtonNode.updateColor(primaryTextColor, transition: transition) controller.moreButtonNode.updateColor(primaryTextColor, transition: transition) transition.updateBackgroundColor(node: self.headerBackgroundNode, color: color ?? .clear) transition.updateBackgroundColor(node: self.topOverscrollNode, color: color ?? .clear) } - + private func updateStatusBarStyle() { guard let controller = self.controller, let parentController = controller.parentController() else { return @@ -2057,13 +2058,13 @@ public final class WebAppController: ViewController, AttachmentContainable { } } } - + private func handleSendData(data string: String) { guard let controller = self.controller, let buttonText = controller.buttonText, !self.dismissed else { return } controller.dismiss() - + if let data = string.data(using: .utf8), let jsonArray = try? JSONSerialization.jsonObject(with: data, options : .allowFragments) as? [String: Any], let data = jsonArray["data"] { var resultString: String? if let string = data as? String { @@ -2077,10 +2078,10 @@ public final class WebAppController: ViewController, AttachmentContainable { } } } - + func updatePresentationData(_ presentationData: PresentationData) { self.presentationData = presentationData - + if self.presentationData.theme.list.plainBackgroundColor.rgb == 0x000000 { self.backgroundColor = self.presentationData.theme.list.itemBlocksBackgroundColor } else { @@ -2088,21 +2089,21 @@ public final class WebAppController: ViewController, AttachmentContainable { } self.updateHeaderBackgroundColor(transition: .immediate) self.sendThemeChangedEvent() - + if self.presentationData.theme.overallDarkAppearance { self.webView?.overrideUserInterfaceStyle = .dark } else { self.webView?.overrideUserInterfaceStyle = .unspecified } } - + private func sendThemeChangedEvent() { let themeParams = generateWebAppThemeParams(self.presentationData.theme) var themeParamsString = "{theme_params: {" for (key, value) in themeParams { if let value = value as? Int32 { let color = UIColor(rgb: UInt32(bitPattern: value)) - + if themeParamsString.count > 16 { themeParamsString.append(", ") } @@ -2112,13 +2113,13 @@ public final class WebAppController: ViewController, AttachmentContainable { themeParamsString.append("}}") self.webView?.sendEvent(name: "theme_changed", data: themeParamsString) } - + enum InvoiceCloseResult { case paid case pending case cancelled case failed - + var string: String { switch self { case .paid: @@ -2132,7 +2133,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } } } - + private func sendInvoiceClosedEvent(slug: String, result: InvoiceCloseResult) { let data: JSON = [ "slug": slug, @@ -2140,15 +2141,15 @@ public final class WebAppController: ViewController, AttachmentContainable { ] self.webView?.sendEvent(name: "invoice_closed", data: data.string) } - + fileprivate func sendBackButtonEvent() { self.webView?.sendEvent(name: "back_button_pressed", data: nil) } - + fileprivate func sendSettingsButtonEvent() { self.webView?.sendEvent(name: "settings_button_pressed", data: nil) } - + fileprivate func sendAlertButtonEvent(id: String?) { var data: [String: Any] = [:] if let id { @@ -2158,7 +2159,7 @@ public final class WebAppController: ViewController, AttachmentContainable { self.webView?.sendEvent(name: "popup_closed", data: serializedData) } } - + fileprivate func sendQrCodeScannedEvent(dataString: String?) { var data: [String: Any] = [:] if let dataString { @@ -2168,11 +2169,11 @@ public final class WebAppController: ViewController, AttachmentContainable { self.webView?.sendEvent(name: "qr_text_received", data: serializedData) } } - + fileprivate func sendQrCodeScannerClosedEvent() { self.webView?.sendEvent(name: "scan_qr_popup_closed", data: nil) } - + fileprivate func sendClipboardTextEvent(requestId: String, fillData: Bool) { var data: [String: Any] = [:] data["req_id"] = requestId @@ -2184,19 +2185,19 @@ public final class WebAppController: ViewController, AttachmentContainable { self.webView?.sendEvent(name: "clipboard_text_received", data: serializedData) } } - + fileprivate func requestWriteAccess() { guard let controller = self.controller, !self.dismissed else { return } - + let sendEvent: (Bool) -> Void = { success in let data: JSON = [ "status": success ? "allowed" : "cancelled" ] self.webView?.sendEvent(name: "write_access_requested", data: data.string) } - + let _ = (self.context.engine.messages.canBotSendMessages(botId: controller.botId) |> deliverOnMainQueue).start(next: { [weak self] result in guard let self, let controller = self.controller else { @@ -2217,7 +2218,7 @@ public final class WebAppController: ViewController, AttachmentContainable { guard let self else { return } - + let _ = (self.context.engine.messages.allowBotSendMessages(botId: controller.botId) |> deliverOnMainQueue).start(completed: { sendEvent(true) @@ -2234,7 +2235,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } }) } - + fileprivate func shareAccountContact() { guard let context = self.controller?.context, let botId = self.controller?.botId, let botName = self.controller?.botName else { return @@ -2245,7 +2246,7 @@ public final class WebAppController: ViewController, AttachmentContainable { ] self.webView?.sendEvent(name: "phone_requested", data: data.string) } - + let _ = (self.context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId), TelegramEngine.EngineData.Item.Peer.IsBlocked(id: botId) @@ -2258,14 +2259,14 @@ public final class WebAppController: ViewController, AttachmentContainable { if case let .known(value) = isBlocked, value { requiresUnblock = true } - + let text: String if requiresUnblock { text = self.presentationData.strings.WebApp_SharePhoneConfirmationUnblock(botName).string } else { text = self.presentationData.strings.WebApp_SharePhoneConfirmation(botName).string } - + let alertController = AlertScreen( context: self.context, title: self.presentationData.strings.WebApp_SharePhoneTitle, @@ -2278,7 +2279,7 @@ public final class WebAppController: ViewController, AttachmentContainable { guard let self, case let .user(user) = accountPeer, let phone = user.phone, !phone.isEmpty else { return } - + let sendMessageSignal = enqueueMessages(account: self.context.account, peerId: botId, messages: [ .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaContact(firstName: user.firstName ?? "", lastName: user.lastName ?? "", phoneNumber: phone, peerId: user.id, vCardData: nil)), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) ]) @@ -2297,14 +2298,14 @@ public final class WebAppController: ViewController, AttachmentContainable { return .complete() } } - + let sendMessage = { let _ = (sendMessageSignal |> deliverOnMainQueue).start(completed: { sendEvent(true) }) } - + if requiresUnblock { let _ = (context.engine.privacy.requestUpdatePeerIsBlocked(peerId: botId, isBlocked: false) |> deliverOnMainQueue).start(completed: { @@ -2324,7 +2325,7 @@ public final class WebAppController: ViewController, AttachmentContainable { controller.present(alertController, in: .window(.root)) }) } - + fileprivate func requestChat(requestId: String) { guard let controller = self.controller, !self.dismissed else { return @@ -2337,7 +2338,7 @@ public final class WebAppController: ViewController, AttachmentContainable { switch button.action { case let .requestPeer(peerType, buttonId, maxQuantity): let _ = maxQuantity - + switch peerType { case let .createBot(createBot): Task { @MainActor [weak self] in @@ -2378,7 +2379,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } case let .channel(channel): if channel.isCreator { - + } default: break @@ -2388,7 +2389,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } }) } - + fileprivate func invokeCustomMethod(requestId: String, method: String, params: String) { guard let controller = self.controller, !self.dismissed else { return @@ -2413,12 +2414,12 @@ public final class WebAppController: ViewController, AttachmentContainable { self.webView?.sendEvent(name: "custom_method_invoked", data: jsonDataString) }) } - + fileprivate func sendBiometryInfoReceivedEvent() { guard let controller = self.controller else { return } - + self.context.engine.peers.updateBotBiometricsState(peerId: controller.botId, update: { state in let state = state ?? TelegramBotBiometricsState.create() return state @@ -2433,7 +2434,7 @@ public final class WebAppController: ViewController, AttachmentContainable { guard let state else { return } - + var data: [String: Any] = [:] if let biometricAuthentication = LocalAuth.biometricAuthentication { data["available"] = true @@ -2450,7 +2451,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } else { data["available"] = false } - + guard let jsonData = try? JSONSerialization.data(withJSONObject: data) else { return } @@ -2460,7 +2461,7 @@ public final class WebAppController: ViewController, AttachmentContainable { self.webView?.sendEvent(name: "biometry_info_received", data: jsonDataString) }) } - + fileprivate func requestBiometryAccess(reason: String?) { guard let controller = self.controller else { return @@ -2473,28 +2474,28 @@ public final class WebAppController: ViewController, AttachmentContainable { guard let self, let botPeer, let controller = self.controller else { return } - + if let currentState, currentState.accessRequested { self.sendBiometryInfoReceivedEvent() return } - + let updateAccessGranted: (Bool) -> Void = { [weak self] granted in guard let self else { return } - + self.context.engine.peers.updateBotBiometricsState(peerId: botPeer.id, update: { state in var state = state ?? TelegramBotBiometricsState.create() - + state.accessRequested = true state.accessGranted = granted return state }) - + self.sendBiometryInfoReceivedEvent() } - + var alertTitle: String? let alertText: String if let reason { @@ -2511,7 +2512,7 @@ public final class WebAppController: ViewController, AttachmentContainable { alertText = self.presentationData.strings.WebApp_AlertBiometryAccessText(botPeer.compactDisplayTitle).string } } - + let alertController = AlertScreen( context: self.context, title: alertTitle, @@ -2528,7 +2529,7 @@ public final class WebAppController: ViewController, AttachmentContainable { controller.present(alertController, in: .window(.root)) }) } - + fileprivate func requestBiometryAuth() { guard let controller = self.controller else { return @@ -2544,7 +2545,7 @@ public final class WebAppController: ViewController, AttachmentContainable { guard let state else { return } - + if state.accessRequested && state.accessGranted { guard let controller = self.controller else { return @@ -2553,10 +2554,10 @@ public final class WebAppController: ViewController, AttachmentContainable { return } let appBundleId = self.context.sharedContext.applicationBindings.appBundleId - + Thread { [weak self] in let key = LocalAuth.getOrCreatePrivateKey(baseAppBundleId: appBundleId, keyId: keyId) - + let decryptedData: LocalAuth.DecryptionResult if let key { if let encryptedData = state.opaqueToken { @@ -2580,12 +2581,12 @@ public final class WebAppController: ViewController, AttachmentContainable { } else { decryptedData = .error(.generic) } - + DispatchQueue.main.async { guard let self else { return } - + switch decryptedData { case let .result(token): self.sendBiometryAuthResult(isAuthorized: true, tokenData: state.opaqueToken != nil ? token : nil) @@ -2599,7 +2600,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } }) } - + fileprivate func sendBiometryAuthResult(isAuthorized: Bool, tokenData: Data?) { var data: [String: Any] = [:] data["status"] = isAuthorized ? "authorized" : "failed" @@ -2610,7 +2611,7 @@ public final class WebAppController: ViewController, AttachmentContainable { data["token"] = "" } } - + guard let jsonData = try? JSONSerialization.data(withJSONObject: data) else { return } @@ -2619,7 +2620,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } self.webView?.sendEvent(name: "biometry_auth_requested", data: jsonDataString) } - + fileprivate func requestBiometryUpdateToken(tokenData: Data?) { guard let controller = self.controller else { return @@ -2627,12 +2628,12 @@ public final class WebAppController: ViewController, AttachmentContainable { guard let keyId = "A\(UInt64(bitPattern: self.context.account.id.int64))WebBot\(UInt64(bitPattern: controller.botId.toInt64()))".data(using: .utf8) else { return } - + if let tokenData { let appBundleId = self.context.sharedContext.applicationBindings.appBundleId Thread { [weak self] in let key = LocalAuth.getOrCreatePrivateKey(baseAppBundleId: appBundleId, keyId: keyId) - + var encryptedData: TelegramBotBiometricsState.OpaqueToken? if let key { if let result = key.encrypt(data: tokenData) { @@ -2642,12 +2643,12 @@ public final class WebAppController: ViewController, AttachmentContainable { ) } } - + DispatchQueue.main.async { guard let self else { return } - + if let encryptedData { self.context.engine.peers.updateBotBiometricsState(peerId: controller.botId, update: { state in var state = state ?? TelegramBotBiometricsState.create() @@ -2678,7 +2679,7 @@ public final class WebAppController: ViewController, AttachmentContainable { self.webView?.sendEvent(name: "biometry_token_updated", data: data.string) } } - + fileprivate func openBotSettings() { guard let controller = self.controller else { return @@ -2689,7 +2690,7 @@ public final class WebAppController: ViewController, AttachmentContainable { navigationController.pushViewController(settingsController) } } - + private var fullscreenSwitchSnapshotView: UIView? fileprivate func setIsFullscreen(_ isFullscreen: Bool) { guard let controller = self.controller else { @@ -2702,27 +2703,27 @@ public final class WebAppController: ViewController, AttachmentContainable { self.webView?.sendEvent(name: "fullscreen_failed", data: data.string) return } - + let data: JSON = [ "is_fullscreen": isFullscreen ] self.webView?.sendEvent(name: "fullscreen_changed", data: data.string) - + controller.isFullscreen = isFullscreen if isFullscreen { controller.requestAttachmentMenuExpansion() } - + if let (layout, _) = self.validLayout, case .regular = layout.metrics.widthClass { if let snapshotView = self.webView?.snapshotView(afterScreenUpdates: false) { self.webView?.superview?.addSubview(snapshotView) self.fullscreenSwitchSnapshotView = snapshotView } } - + (controller.parentController() as? AttachmentController)?.requestLayout(transition: .animated(duration: 0.4, curve: .spring)) } - + private let motionManager = CMMotionManager() private var isAccelerometerActive = false fileprivate func setIsAccelerometerActive(_ isActive: Bool, refreshRate: Double? = nil) { @@ -2739,7 +2740,7 @@ public final class WebAppController: ViewController, AttachmentContainable { self.isAccelerometerActive = isActive if isActive { self.webView?.sendEvent(name: "accelerometer_started", data: nil) - + if let refreshRate { self.motionManager.accelerometerUpdateInterval = refreshRate * 0.001 } else { @@ -2764,7 +2765,7 @@ public final class WebAppController: ViewController, AttachmentContainable { self.webView?.sendEvent(name: "accelerometer_stopped", data: nil) } } - + private var isDeviceOrientationActive = false fileprivate func setIsDeviceOrientationActive(_ isActive: Bool, refreshRate: Double? = nil, absolute: Bool = false) { guard self.motionManager.isDeviceMotionAvailable else { @@ -2780,16 +2781,16 @@ public final class WebAppController: ViewController, AttachmentContainable { self.isDeviceOrientationActive = isActive if isActive { self.webView?.sendEvent(name: "device_orientation_started", data: nil) - + if let refreshRate { self.motionManager.deviceMotionUpdateInterval = refreshRate * 0.001 } else { self.motionManager.deviceMotionUpdateInterval = 1.0 } - + var effectiveIsAbsolute = false let referenceFrame: CMAttitudeReferenceFrame - + if absolute && [.authorizedWhenInUse, .authorizedAlways].contains(CLLocationManager.authorizationStatus()) && CMMotionManager.availableAttitudeReferenceFrames().contains(.xTrueNorthZVertical) { referenceFrame = .xTrueNorthZVertical effectiveIsAbsolute = true @@ -2819,7 +2820,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } else { alpha = motionData.attitude.yaw } - + let data: JSON = [ "absolute": effectiveIsAbsolute, "alpha": Double(alpha), @@ -2835,7 +2836,7 @@ public final class WebAppController: ViewController, AttachmentContainable { self.webView?.sendEvent(name: "device_orientation_stopped", data: nil) } } - + private var isGyroscopeActive = false fileprivate func setIsGyroscopeActive(_ isActive: Bool, refreshRate: Double? = nil) { guard self.motionManager.isGyroAvailable else { @@ -2851,7 +2852,7 @@ public final class WebAppController: ViewController, AttachmentContainable { self.isGyroscopeActive = isActive if isActive { self.webView?.sendEvent(name: "gyroscope_started", data: nil) - + if let refreshRate { self.motionManager.gyroUpdateInterval = refreshRate * 0.001 } else { @@ -2875,7 +2876,7 @@ public final class WebAppController: ViewController, AttachmentContainable { self.webView?.sendEvent(name: "gyroscope_stopped", data: nil) } } - + fileprivate func sendPreparedMessage(id: String) { guard let controller = self.controller else { return @@ -2906,12 +2907,12 @@ public final class WebAppController: ViewController, AttachmentContainable { controller.parentController()?.push(previewController) }) } - + fileprivate func downloadFile(url: String, fileName: String) { guard let controller = self.controller else { return } - + guard !fileName.contains("/") && fileName.lengthOfBytes(using: .utf8) < 256 && url.lengthOfBytes(using: .utf8) < 32768 else { let data: JSON = [ "status": "cancelled" @@ -2919,7 +2920,7 @@ public final class WebAppController: ViewController, AttachmentContainable { self.webView?.sendEvent(name: "file_download_requested", data: data.string) return } - + var isMedia = false var title: String? let photoExtensions = [".jpg", ".png", ".gif", ".tiff"] @@ -2943,7 +2944,7 @@ public final class WebAppController: ViewController, AttachmentContainable { if title == nil { title = self.presentationData.strings.WebApp_Download_Document } - + let _ = combineLatest(queue: Queue.mainQueue(), FileDownload.getFileSize(url: url), self.context.engine.messages.checkBotDownload(botId: controller.botId, fileName: fileName, url: url) @@ -2962,9 +2963,9 @@ public final class WebAppController: ViewController, AttachmentContainable { if let fileSize { fileSizeString = " (\(dataSizeString(fileSize, formatting: DataSizeStringFormatting(presentationData: self.presentationData))))" } - + let text: String = self.presentationData.strings.WebApp_Download_Text(controller.botName, fileName, fileSizeString).string - + let alertController = AlertScreen( context: self.context, title: title, @@ -2990,7 +2991,7 @@ public final class WebAppController: ViewController, AttachmentContainable { controller.present(alertController, in: .window(.root)) }) } - + fileprivate weak var fileDownloadTooltip: UndoOverlayController? fileprivate func startDownload(url: String, fileName: String, fileSize: Int64?, isMedia: Bool) { guard let controller = self.controller else { @@ -3000,7 +3001,7 @@ public final class WebAppController: ViewController, AttachmentContainable { "status": "downloading" ] self.webView?.sendEvent(name: "file_download_requested", data: data.string) - + var removeImpl: (() -> Void)? let fileDownload = FileDownload( from: URL(string: url)!, @@ -3018,7 +3019,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } else { text = "\(Int32(progress))%" } - + self.fileDownloadTooltip?.content = .progress( progress: progress, title: fileName, @@ -3029,7 +3030,7 @@ public final class WebAppController: ViewController, AttachmentContainable { completion: { [weak self] resultUrl, _ in if let resultUrl, let self { removeImpl?() - + let tooltipContent: UndoOverlayContent = .actionSucceeded(title: fileName, text: isMedia ? self.presentationData.strings.WebApp_Download_SavedToPhotos : self.presentationData.strings.WebApp_Download_SavedToFiles, cancel: nil, destructive: false) if isMedia { let saveToPhotos: (URL, Bool) -> Void = { url, isVideo in @@ -3050,7 +3051,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } let isVideo = fileName.lowercased().hasSuffix(".mp4") || fileName.lowercased().hasSuffix(".mov") saveToPhotos(resultUrl, isVideo) - + if let tooltip = self.fileDownloadTooltip { tooltip.content = tooltipContent } else { @@ -3069,11 +3070,11 @@ public final class WebAppController: ViewController, AttachmentContainable { if let tooltip = self.fileDownloadTooltip { tooltip.dismissWithCommitAction() } - + let tempFile = EngineTempBox.shared.file(path: resultUrl.absoluteString, fileName: fileName) let url = URL(fileURLWithPath: tempFile.path) try? FileManager.default.copyItem(at: resultUrl, to: url) - + let pickerController = legacyICloudFilePicker(theme: self.presentationData.theme, mode: .export, url: url, documentTypes: [], forceDarkTheme: false, dismissed: {}, completion: { [weak self, weak controller] urls in guard let self, let controller, !urls.isEmpty else { return @@ -3095,20 +3096,20 @@ public final class WebAppController: ViewController, AttachmentContainable { } ) WebAppController.activeDownloads.append(fileDownload) - + removeImpl = { [weak fileDownload] in if let fileDownload { WebAppController.activeDownloads.removeAll(where: { $0 === fileDownload }) } } - + let text: String if let fileSize { text = "0 KB / \(dataSizeString(fileSize, formatting: DataSizeStringFormatting(presentationData: self.presentationData)))" } else { text = "0%" } - + let tooltipController = UndoOverlayController( presentationData: self.presentationData, content: .progress( @@ -3130,7 +3131,7 @@ public final class WebAppController: ViewController, AttachmentContainable { controller.present(tooltipController, in: .current) self.fileDownloadTooltip = tooltipController } - + fileprivate func requestEmojiStatusAccess() { guard let controller = self.controller else { return @@ -3175,14 +3176,14 @@ public final class WebAppController: ViewController, AttachmentContainable { demoController?.replace(with: c) } controller.parentController()?.push(demoController) - + let data: JSON = [ "status": "cancelled" ] self.webView?.sendEvent(name: "emoji_status_access_requested", data: data.string) return } - + let _ = (context.engine.peers.toggleBotEmojiStatusAccess(peerId: botId, enabled: true) |> deliverOnMainQueue).startStandalone(completed: { [weak self] in let data: JSON = [ @@ -3212,7 +3213,7 @@ public final class WebAppController: ViewController, AttachmentContainable { ] self.webView?.sendEvent(name: "emoji_status_access_requested", data: data.string) } - + if !byOutsideTap { let _ = updateWebAppPermissionsStateInteractively(context: context, peerId: botId) { current in return WebAppPermissionsState(location: current?.location, emojiStatus: WebAppPermissionsState.EmojiStatus(isRequested: true)) @@ -3223,7 +3224,7 @@ public final class WebAppController: ViewController, AttachmentContainable { controller.present(alertController, in: .window(.root)) }) } - + fileprivate func setEmojiStatus(_ fileId: Int64, duration: Int32? = nil) { guard let controller = self.controller else { return @@ -3266,14 +3267,14 @@ public final class WebAppController: ViewController, AttachmentContainable { demoController?.replace(with: c) } controller.parentController()?.push(demoController) - + let data: JSON = [ "error": "USER_DECLINED" ] self.webView?.sendEvent(name: "emoji_status_failed", data: data.string) return } - + var expirationDate: Int32? if let duration { expirationDate = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) + duration @@ -3295,7 +3296,7 @@ public final class WebAppController: ViewController, AttachmentContainable { elevatedLayout: true, action: { action in if case .undo = action { - + } return true } @@ -3312,12 +3313,12 @@ public final class WebAppController: ViewController, AttachmentContainable { controller.parentController()?.push(confirmController) }) } - + fileprivate func addToHomeScreen() { guard let controller = self.controller else { return } - + let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: controller.botId)) |> deliverOnMainQueue ).start(next: { [weak controller] peer in @@ -3338,12 +3339,12 @@ public final class WebAppController: ViewController, AttachmentContainable { UIApplication.shared.open(url) }) } - + fileprivate func openSecureBotStorageTransfer(requestId: String, key: String, storedKeys: [WebAppSecureStorage.ExistingKey]) { guard let controller = self.controller else { return } - + let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: controller.botId)) |> deliverOnMainQueue).start(next: { [weak self] botPeer in guard let self, let botPeer, let controller = self.controller else { @@ -3365,7 +3366,7 @@ public final class WebAppController: ViewController, AttachmentContainable { self.webView?.sendEvent(name: "secure_storage_failed", data: data.string) return } - + let _ = (WebAppSecureStorage.transferAllValues(context: self.context, fromUuid: uuid, botId: controller.botId) |> deliverOnMainQueue).start(completed: { [weak self] in guard let self else { @@ -3385,7 +3386,7 @@ public final class WebAppController: ViewController, AttachmentContainable { controller.parentController()?.push(transferController) }) } - + fileprivate func openLocationSettings() { guard let controller = self.controller else { return @@ -3400,7 +3401,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } }) } - + fileprivate func checkLocation() { guard let controller = self.controller else { return @@ -3426,7 +3427,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } }) } - + private let locationManager = LocationManager() fileprivate func requestLocation() { let context = self.context @@ -3446,7 +3447,7 @@ public final class WebAppController: ViewController, AttachmentContainable { guard let self else { return } - + var shouldRequest = false if let location = state?.location { if location.isRequested { @@ -3488,7 +3489,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } else { shouldRequest = true } - + if shouldRequest { let _ = (context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId), @@ -3513,13 +3514,13 @@ public final class WebAppController: ViewController, AttachmentContainable { elevatedLayout: true, action: { action in if case .undo = action { - + } return true } ) controller.present(resultController, in: .window(.root)) - + Queue.mainQueue().after(0.1, { self.requestLocation() }) @@ -3540,18 +3541,18 @@ public final class WebAppController: ViewController, AttachmentContainable { }) } } - + fileprivate var controllerNode: Node { return self.displayNode as! Node } - + private var titleView: WebAppTitleView? fileprivate let cancelButtonNode: WebAppCancelButtonNode fileprivate let moreButtonNode: MoreButtonNode private var cancelBarButtonNode: BarComponentHostNode? private var moreBarButtonNode: BarComponentHostNode? private let moreButtonPlayOnce = ActionSlot() - + private let context: AccountContext public let source: WebAppParameters.Source private let peerId: EnginePeer.Id @@ -3572,20 +3573,20 @@ public final class WebAppController: ViewController, AttachmentContainable { public var isFullscreen: Bool private var sameOrigin: Bool private var pendingExternalUrl: String? - + private var presentationData: PresentationData fileprivate let updatedPresentationData: (initial: PresentationData, signal: Signal)? private var presentationDataDisposable: Disposable? - + private var hasSettings = false - + public var openUrl: (String, Bool, Bool, @escaping () -> Void) -> Void = { _, _, _, _ in } public var getNavigationController: () -> NavigationController? = { return nil } public var completion: () -> Void = {} public var requestSwitchInline: (String, [ReplyMarkupButtonRequestPeerType]?, @escaping () -> Void) -> Void = { _, _, _ in } - + public var verifyAgeCompletion: ((Int) -> Void)? - + public init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, params: WebAppParameters, replyToMessageId: EngineMessage.Id?, threadId: Int64?) { self.context = context self.source = params.source @@ -3606,80 +3607,80 @@ public final class WebAppController: ViewController, AttachmentContainable { self.threadId = threadId self.isFullscreen = params.isFullscreen self.sameOrigin = params.sameOrigin - + self.updatedPresentationData = updatedPresentationData - + var presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 } let updatedTheme = presentationData.theme.withModalBlocksBackground() presentationData = presentationData.withUpdated(theme: updatedTheme) self.presentationData = presentationData - + self.cancelButtonNode = WebAppCancelButtonNode(theme: self.presentationData.theme, strings: self.presentationData.strings) - + self.moreButtonNode = MoreButtonNode(theme: self.presentationData.theme) self.moreButtonNode.iconNode.enqueueState(.more, animated: false) - + let navigationBarPresentationData = NavigationBarPresentationData(theme: NavigationBarTheme(rootControllerTheme: self.presentationData.theme), strings: NavigationBarStrings(back: "", close: "")) super.init(navigationBarPresentationData: navigationBarPresentationData) - + self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style self.automaticallyControlPresentationContextLayout = false - + if case .attachMenu = self.source { - + } else if self.isVerifyAgeBot { - + } else { self.navigationItem.leftBarButtonItem = UIBarButtonItem(customDisplayNode: self.cancelButtonNode) self.navigationItem.leftBarButtonItem?.action = #selector(self.cancelPressed) self.navigationItem.leftBarButtonItem?.target = self - + if !self.isVerifyAgeBot { self.navigationItem.rightBarButtonItem = UIBarButtonItem(customDisplayNode: self.moreButtonNode) self.navigationItem.rightBarButtonItem?.action = #selector(self.moreButtonPressed) self.navigationItem.rightBarButtonItem?.target = self } } - + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) - + if !self.isVerifyAgeBot { let titleView = WebAppTitleView(context: self.context, theme: self.presentationData.theme, isAttachMenu: self.source == .attachMenu) titleView.title = WebAppTitle(title: params.botName, counter: self.presentationData.strings.WebApp_Miniapp, isVerified: params.botVerified) self.navigationItem.titleView = titleView self.titleView = titleView } - + self.moreButtonNode.action = { [weak self] _, gesture in if let strongSelf = self { strongSelf.morePressed(view: strongSelf.moreButtonNode.contextSourceNode.view, gesture: gesture) } } - + self.presentationDataDisposable = ((updatedPresentationData?.signal ?? context.sharedContext.presentationData) |> deliverOnMainQueue).start(next: { [weak self] presentationData in if let strongSelf = self { let updatedTheme = presentationData.theme.withModalBlocksBackground() let presentationData = presentationData.withUpdated(theme: updatedTheme) strongSelf.presentationData = presentationData - + strongSelf.updateNavigationBarTheme(transition: .immediate) strongSelf.titleView?.theme = presentationData.theme strongSelf.cancelButtonNode.theme = presentationData.theme strongSelf.moreButtonNode.theme = presentationData.theme - + if strongSelf.isNodeLoaded { strongSelf.controllerNode.updatePresentationData(presentationData) } } }) - + self.longTapWithTabBar = { [weak self] in guard let self else { return } - + let _ = (context.engine.messages.attachMenuBots() |> take(1) |> deliverOnMainQueue).start(next: { [weak self] attachMenuBots in @@ -3692,19 +3693,19 @@ public final class WebAppController: ViewController, AttachmentContainable { } }) } - + self.updateNavigationButtons() } - + required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + deinit { assert(true) self.presentationDataDisposable?.dispose() } - + private func updateNavigationButtons() { var showGlassButtons = false if case .attachMenu = self.source { @@ -3732,7 +3733,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } )) ) - + let moreComponent: AnyComponentWithIdentity = AnyComponentWithIdentity( id: "more", component: AnyComponent(GlassBarButtonComponent( @@ -3756,7 +3757,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } )) ) - + let cancelButtonNode: BarComponentHostNode if let current = self.cancelBarButtonNode { cancelButtonNode = current @@ -3766,7 +3767,7 @@ public final class WebAppController: ViewController, AttachmentContainable { self.cancelBarButtonNode = cancelButtonNode self.navigationItem.leftBarButtonItem = UIBarButtonItem(customDisplayNode: cancelButtonNode) } - + if !self.isVerifyAgeBot { let moreButtonNode: BarComponentHostNode if let current = self.moreBarButtonNode { @@ -3779,10 +3780,10 @@ public final class WebAppController: ViewController, AttachmentContainable { } } } - + self.cancelButtonNode.setState(self.controllerNode.hasBackButton ? .back : .cancel, animated: true) } - + private var isVerifyAgeBot: Bool { if let ageBotUsername = self.context.currentAppConfiguration.with({ $0 }).data?["verify_age_bot_username"] as? String { if self.botAddress == ageBotUsername { @@ -3791,7 +3792,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } return false } - + private var isWhiteListedBot: Bool { if let whiteListedBots = self.context.currentAppConfiguration.with({ $0 }).data?["whitelisted_bots"] as? [Double] { let botId = self.botId.id._internalGetInt64Value() @@ -3803,7 +3804,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } return false } - + public func beforeMaximize(navigationController: NavigationController, completion: @escaping () -> Void) { switch self.source { case .generic, .settings: @@ -3822,7 +3823,7 @@ public final class WebAppController: ViewController, AttachmentContainable { }) } } - + fileprivate func updateNavigationBarTheme(transition: ContainedViewLayoutTransition) { let navigationBarPresentationData: NavigationBarPresentationData if let backgroundColor = self.controllerNode.headerColor, let textColor = self.controllerNode.headerPrimaryTextColor { @@ -3852,7 +3853,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } self.navigationBar?.updatePresentationData(navigationBarPresentationData, transition: .immediate) } - + @objc fileprivate func cancelPressed() { if case .back = self.cancelButtonNode.state { self.controllerNode.sendBackButtonEvent() @@ -3862,11 +3863,11 @@ public final class WebAppController: ViewController, AttachmentContainable { } } } - + @objc fileprivate func moreButtonPressed() { self.moreButtonNode.buttonPressed() } - + @objc fileprivate func morePressed(view: UIView, gesture: ContextGesture?) { let context = self.context var presentationData = self.presentationData @@ -3875,14 +3876,14 @@ public final class WebAppController: ViewController, AttachmentContainable { presentationData = presentationData.withUpdated(theme: defaultDarkPresentationTheme) } } - + let peerId = self.peerId let botId = self.botId - + let source = self.source - + let hasSettings = self.hasSettings - + let activeDownload = WebAppController.activeDownloads.first let activeDownloadProgress: Signal if let activeDownload { @@ -3894,7 +3895,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } else { activeDownloadProgress = .single(nil) } - + let items = combineLatest(queue: Queue.mainQueue(), context.engine.messages.attachMenuBots() |> take(1), context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.botId)), @@ -3904,7 +3905,7 @@ public final class WebAppController: ViewController, AttachmentContainable { ) |> map { [weak self] attachMenuBots, botPeer, botCommands, privacyPolicyUrl, activeDownloadProgress -> ContextController.Items in var items: [ContextMenuItem] = [] - + if let activeDownload, let progress = activeDownloadProgress { let isActive = progress < 1.0 - .ulpOfOne let progressString: String @@ -3920,40 +3921,40 @@ public final class WebAppController: ViewController, AttachmentContainable { } items.append(.action(ContextMenuActionItem(text: activeDownload.fileName, textLayout: .secondLineWithValue(progressString), icon: { theme in return isActive ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.contextMenu.primaryColor) : nil }, iconPosition: .right, action: isActive ? { [weak self, weak activeDownload] _, f in f(.default) - + WebAppController.activeDownloads.removeAll(where: { $0 === activeDownload }) activeDownload?.cancel() - + if let fileDownloadTooltip = self?.controllerNode.fileDownloadTooltip { fileDownloadTooltip.dismissWithCommitAction() } } : nil))) items.append(.separator) } - + let attachMenuBot = attachMenuBots.first(where: { $0.peer.id == botId && !$0.flags.contains(.notActivated) }) if hasSettings { items.append(.action(ContextMenuActionItem(text: presentationData.strings.WebApp_Settings, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Settings"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c?.dismiss(completion: nil) - + if let strongSelf = self { strongSelf.controllerNode.sendSettingsButtonEvent() } }))) } - + if peerId != botId { items.append(.action(ContextMenuActionItem(text: presentationData.strings.WebApp_OpenBot, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Bots"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c?.dismiss(completion: nil) - + guard let strongSelf = self else { return } - + let _ = (context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.botId) ) @@ -3968,13 +3969,13 @@ public final class WebAppController: ViewController, AttachmentContainable { }) }))) } - + if let addressName = botPeer?.addressName { items.append(.action(ContextMenuActionItem(text: presentationData.strings.WebApp_Share, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c?.dismiss(completion: nil) - + guard let self else { return } @@ -3985,34 +3986,34 @@ public final class WebAppController: ViewController, AttachmentContainable { self.present(shareController, in: .window(.root)) }))) } - + items.append(.action(ContextMenuActionItem(text: presentationData.strings.WebApp_ReloadPage, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Reload"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c?.dismiss(completion: nil) - + self?.controllerNode.webView?.reload() }))) - + if let _ = self?.appName { items.append(.action(ContextMenuActionItem(text: presentationData.strings.WebApp_AddToHomeScreen, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddSquare"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c?.dismiss(completion: nil) - + self?.controllerNode.addToHomeScreen() }))) } - + items.append(.action(ContextMenuActionItem(text: presentationData.strings.WebApp_TermsOfUse, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c?.dismiss(completion: nil) - + guard let self, let navigationController = self.getNavigationController() else { return } - + let context = self.context let _ = (cachedWebAppTermsPage(context: context) |> deliverOnMainQueue).startStandalone(next: { resolvedUrl in @@ -4022,22 +4023,22 @@ public final class WebAppController: ViewController, AttachmentContainable { }, dismissInput: {}, contentContext: nil, progress: nil, completion: nil) }) }))) - + items.append(.action(ContextMenuActionItem(text: presentationData.strings.WebApp_PrivacyPolicy, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Privacy"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c?.dismiss(completion: nil) - + guard let self else { return } - + (self.parentController() as? AttachmentController)?.minimizeIfNeeded() if let privacyPolicyUrl { self.context.sharedContext.openExternalUrl(context: self.context, urlContext: .generic, url: privacyPolicyUrl, forceExternal: false, presentationData: self.presentationData, navigationController: self.getNavigationController(), dismissInput: {}) } else if let botCommands, botCommands.contains(where: { $0.text == "privacy" }) { let _ = enqueueMessages(account: self.context.account, peerId: self.botId, messages: [.message(text: "/privacy", attributes: [], inlineStickers: [:], mediaReference: nil, threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]).startStandalone() - + if let botPeer, let navigationController = self.getNavigationController() { self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(botPeer))) } @@ -4045,26 +4046,26 @@ public final class WebAppController: ViewController, AttachmentContainable { self.context.sharedContext.openExternalUrl(context: self.context, urlContext: .generic, url: self.presentationData.strings.WebApp_PrivacyPolicy_URL, forceExternal: false, presentationData: self.presentationData, navigationController: self.getNavigationController(), dismissInput: {}) } }))) - + if let _ = attachMenuBot, [.attachMenu, .settings, .generic].contains(source) { items.append(.action(ContextMenuActionItem(text: presentationData.strings.WebApp_RemoveBot, textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { [weak self] c, _ in c?.dismiss(completion: nil) - + if let self { self.removeAttachBot() } }))) } - + return ContextController.Items(content: .list(items)) } - + let contextController = makeContextController(presentationData: presentationData, source: .reference(WebAppContextReferenceContentSource(controller: self, sourceView: view)), items: items, gesture: gesture) self.presentInGlobalOverlay(contextController) } - + private func removeAttachBot() { let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let alertController = AlertScreen( @@ -4084,14 +4085,14 @@ public final class WebAppController: ViewController, AttachmentContainable { ) self.present(alertController, in: .window(.root)) } - + override public func loadDisplayNode() { self.displayNode = Node(context: self.context, controller: self) - + self.navigationBar?.updateBackgroundAlpha(0.0, transition: .immediate) self.updateTabBarAlpha(1.0, .immediate) } - + public func loadExternal(url: String) { if self.isNodeLoaded { self.controllerNode.loadExternal(url: url) @@ -4103,14 +4104,14 @@ public final class WebAppController: ViewController, AttachmentContainable { public func isContainerPanningUpdated(_ isPanning: Bool) { self.controllerNode.isContainerPanningUpdated(isPanning) } - + private var validLayout: ContainerViewLayout? override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { self.validLayout = layout super.containerLayoutUpdated(layout, transition: transition) - + let navigationBarHeight = self.navigationLayout(layout: layout).navigationFrame.maxY - + var presentationLayout = layout if self.isFullscreen { presentationLayout.intrinsicInsets.top = (presentationLayout.statusBarHeight ?? 0.0) + 36.0 @@ -4118,29 +4119,29 @@ public final class WebAppController: ViewController, AttachmentContainable { presentationLayout.intrinsicInsets.top = navigationBarHeight } self.presentationContext.containerLayoutUpdated(presentationLayout, transition: transition) - + self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) } - + override public var presentationController: UIPresentationController? { get { return nil } set(value) { } } - + public var mediaPickerContext: AttachmentMediaPickerContext? { return WebAppPickerContext(controller: self) } - + public func prepareForReuse() { self.updateTabBarAlpha(1.0, .immediate) } - + public func refresh() { self.controllerNode.setupWebView() } - + public func requestDismiss(completion: @escaping () -> Void) { if self.controllerNode.needDismissConfirmation { let alertController = textAlertController( @@ -4159,7 +4160,7 @@ public final class WebAppController: ViewController, AttachmentContainable { completion() } } - + public var isMinimized: Bool = false { didSet { if self.isMinimized != oldValue { @@ -4169,7 +4170,7 @@ public final class WebAppController: ViewController, AttachmentContainable { self.requestLayout(transition: .immediate) self.controllerNode.webView?.setNeedsLayout() } - + let data: JSON = [ "is_visible": !self.isMinimized, ] @@ -4177,15 +4178,15 @@ public final class WebAppController: ViewController, AttachmentContainable { } } } - + public var isMinimizable: Bool { return true } - + public func requestMinimize(topEdgeOffset: CGFloat?, initialVelocity: CGFloat?) { (self.parentController() as? AttachmentController)?.requestMinimize(topEdgeOffset: topEdgeOffset, initialVelocity: initialVelocity) } - + public func shouldDismissImmediately() -> Bool { if self.controllerNode.needDismissConfirmation { return false @@ -4193,7 +4194,7 @@ public final class WebAppController: ViewController, AttachmentContainable { return true } } - + fileprivate var _isPanGestureEnabled = true public var isInnerPanGestureEnabled: (() -> Bool)? { return { [weak self] in @@ -4203,20 +4204,20 @@ public final class WebAppController: ViewController, AttachmentContainable { return self._isPanGestureEnabled } } - + fileprivate var canMinimize: Bool { return self.controllerNode.canMinimize } - + public var minimizedIcon: UIImage? { return self.controllerNode.icon } - + public func makeContentSnapshotView() -> UIView? { guard let webView = self.controllerNode.webView, let _ = self.validLayout else { return nil } - + let configuration = WKSnapshotConfiguration() configuration.rect = CGRect(origin: .zero, size: webView.frame.size) @@ -4231,31 +4232,31 @@ public final class WebAppController: ViewController, AttachmentContainable { final class WebAppPickerContext: AttachmentMediaPickerContext { private weak var controller: WebAppController? - + public var loadingProgress: Signal { return self.controller?.controllerNode.loadingProgressPromise.get() ?? .single(nil) } - + public var mainButtonState: Signal { return self.controller?.controllerNode.mainButtonStatePromise.get() ?? .single(nil) } - + public var secondaryButtonState: Signal { return self.controller?.controllerNode.secondaryButtonStatePromise.get() ?? .single(nil) } - + public var bottomPanelBackgroundColor: Signal { return self.controller?.controllerNode.bottomPanelColorPromise.get() ?? .single(nil) } - + init(controller: WebAppController) { self.controller = controller } - + func mainButtonAction() { self.controller?.controllerNode.mainButtonPressed() } - + func secondaryButtonAction() { self.controller?.controllerNode.secondaryButtonPressed() } @@ -4265,12 +4266,12 @@ final class WebAppPickerContext: AttachmentMediaPickerContext { private final class WebAppContextReferenceContentSource: ContextReferenceContentSource { private let controller: ViewController private let sourceView: UIView - + init(controller: ViewController, sourceView: UIView) { self.controller = controller self.sourceView = sourceView } - + func transitionInfo() -> ContextControllerReferenceViewInfo? { return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds) } @@ -4332,13 +4333,13 @@ private struct WebAppConfiguration { static var defaultValue: WebAppConfiguration { return WebAppConfiguration(allowedProtocols: []) } - + let allowedProtocols: [String] - + fileprivate init(allowedProtocols: [String]) { self.allowedProtocols = allowedProtocols } - + static func with(appConfiguration: AppConfiguration) -> WebAppConfiguration { if let data = appConfiguration.data { var allowedProtocols: [String] = [] diff --git a/versions.json b/versions.json index 4663def5b0..555ec09321 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { - "app": "12.8", - "xcode": "26.2", - "deploy_xcode": "26.2", + "app": "1.1", + "xcode": "26.5", + "deploy_xcode": "26.5", "bazel": "8.4.2:45e9388abf21d1107e146ea366ad080eb93cb6a5f3a4a3b048f78de0bc3faffa", "macos": "26" }