GLEGram 12.5 — Initial public release

Based on Swiftgram 12.5 (Telegram iOS 12.5).
All GLEGram features ported and organized in GLEGram/ folder.

Features: Ghost Mode, Saved Deleted Messages, Content Protection Bypass,
Font Replacement, Fake Profile, Chat Export, Plugin System, and more.

See CHANGELOG_12.5.md for full details.
This commit is contained in:
Leeksov
2026-04-06 09:48:12 +03:00
commit 4647310322
39685 changed files with 11052678 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
filegroup(
name = "SGUIAssets",
srcs = glob(["Images.xcassets/**"]),
visibility = ["//visibility:public"],
)
swift_library(
name = "SGSettingsUI",
module_name = "SGSettingsUI",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//Swiftgram/SGItemListUI:SGItemListUI",
"//Swiftgram/SGLogging:SGLogging",
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//Swiftgram/SGStrings:SGStrings",
# "//Swiftgram/SGAPI:SGAPI",
"//Swiftgram/SGAPIToken:SGAPIToken",
"//Swiftgram/SGSwiftUI:SGSwiftUI",
# MARK: GLEGram
"//GLEGram/SGDeletedMessages:SGDeletedMessages",
"//GLEGram/SGFakeLocation:SGFakeLocation",
"//GLEGram/SGSupporters:SGSupporters",
"//GLEGram/DoubleBottom:DoubleBottom",
"//GLEGram/ChatPassword:ChatPassword",
"//GLEGram/VoiceMorpher:VoiceMorpher",
# MARK: End GLEGram
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/AsyncDisplayKit:AsyncDisplayKit",
"//submodules/Display:Display",
"//submodules/Postbox:Postbox",
"//submodules/TelegramCore:TelegramCore",
"//submodules/MtProtoKit:MtProtoKit",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/TelegramUIPreferences:TelegramUIPreferences",
"//submodules/ItemListUI:ItemListUI",
"//submodules/PresentationDataUtils:PresentationDataUtils",
"//submodules/OverlayStatusController:OverlayStatusController",
"//submodules/AccountContext:AccountContext",
"//submodules/AppBundle:AppBundle",
"//submodules/TelegramUI/Components/Settings/PeerNameColorScreen",
"//submodules/UndoUI:UndoUI",
"//submodules/LegacyUI:LegacyUI",
"//submodules/LocalizedPeerData:LocalizedPeerData",
"//submodules/SettingsUI:SettingsUI",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1 @@
{"images":[{"filename":"EyesInverse.png","idiom":"universal"}],"info":{"author":"xcode","version":1}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 725 B

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "GLEGramSettings.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1 @@
{"images":[{"filename":"GLEGramTabAppearance.png","idiom":"universal"}],"info":{"author":"xcode","version":1}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1 @@
{"images":[{"filename":"GLEGramTabOther.png","idiom":"universal"}],"info":{"author":"xcode","version":1}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

@@ -0,0 +1 @@
{"images":[{"filename":"GLEGramTabPlugins.png","idiom":"universal"}],"info":{"author":"xcode","version":1}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

@@ -0,0 +1 @@
{"images":[{"filename":"GLEGramTabSecurity.png","idiom":"universal"}],"info":{"author":"xcode","version":1}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

@@ -0,0 +1 @@
{"images":[{"filename":"Galochka.png","idiom":"universal"}],"info":{"author":"xcode","version":1}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

@@ -0,0 +1 @@
{"images":[{"filename":"MusicBackward.png","idiom":"universal"}],"info":{"author":"xcode","version":1}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

@@ -0,0 +1 @@
{"images":[{"filename":"MusicForward.png","idiom":"universal"}],"info":{"author":"xcode","version":1}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

@@ -0,0 +1 @@
{"images":[{"filename":"MusicPause.png","idiom":"universal"}],"info":{"author":"xcode","version":1}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 B

@@ -0,0 +1 @@
{"images":[{"filename":"MusicPlay.png","idiom":"universal"}],"info":{"author":"xcode","version":1}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

@@ -0,0 +1,34 @@
# Куда класть свои иконки (GLEGram)
Замените файлы в этих папках своими картинками — приложение подхватит их автоматически.
## Шапка экрана GLEGram
| Папка | Файл | Назначение |
|-------|------|------------|
| `GLEGramSettings.imageset/` | **GLEGramSettings.png** | Большая иконка в шапке экрана GLEGram |
Рекомендуемый размер: около 80×80 pt (или 240×240 px для @3x).
---
## Вкладки раздела «Функции»
| Папка | Файл | Назначение |
|-------|------|------------|
| `GLEGramTabAppearance.imageset/` | **GLEGramTabAppearance.png** | Иконка «Оформление» |
| `GLEGramTabSecurity.imageset/` | **GLEGramTabSecurity.png** | Иконка «Приватность» |
| `GLEGramTabPlugins.imageset/` | **GLEGramTabPlugins.png** | Иконка «Твики» |
| `GLEGramTabOther.imageset/` | **GLEGramTabOther.png** | Иконка «Другие функции» |
Рекомендуемый размер для иконок в списке: 24×24 pt (72×72 px для @3x). Формат: PNG (можно и PDF в одной шкале).
---
## Другие ресурсы
- `GLEGramVerifiedBadge.imageset/` — значок верификации (Galochka.png).
- `glePlugins/1.imageset/` — иконка по умолчанию для плагинов без своей иконки.
- `SwiftgramSettings.imageset/`, `SwiftgramPro.imageset/` — иконки пунктов меню настроек.
После замены файлов пересоберите приложение (Bazel).
@@ -0,0 +1,12 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "ic_lt_savetocloud.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ScrollToTopIcon.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

@@ -0,0 +1,12 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "swiftgram_context_menu.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,81 @@
%PDF-1.7
1 0 obj
<< >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 4.000000 2.964844 cm
0.000000 0.000000 0.000000 scn
15.076375 10.766671 m
15.473662 11.399487 14.937258 12.204764 14.200223 12.081993 c
9.059459 11.225675 l
8.855769 11.191745 8.670359 11.348825 8.670359 11.555322 c
8.670359 18.524288 l
8.670359 19.289572 7.652856 19.554642 7.279467 18.886631 c
1.036950 7.718488 l
0.658048 7.040615 1.293577 6.244993 2.038416 6.464749 c
9.378864 8.630468 l
9.637225 8.706696 9.814250 8.373775 9.606588 8.202201 c
6.918006 5.980853 l
6.462659 5.604639 6.199009 5.044809 6.199009 4.454151 c
6.199009 -0.793964 l
6.199009 -1.539309 7.174314 -1.820084 7.570620 -1.188831 c
15.076375 10.766671 l
h
f*
n
Q
endstream
endobj
3 0 obj
702
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 24.000000 24.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Pages 5 0 R
/Type /Catalog
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000034 00000 n
0000000792 00000 n
0000000814 00000 n
0000000987 00000 n
0000001061 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
1120
%%EOF
@@ -0,0 +1 @@
{"images":[{"filename":"SwiftgramPro.png","idiom":"universal"}],"info":{"author":"xcode","version":1}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1 @@
{"images":[{"filename":"SwiftgramSettings.png","idiom":"universal"}],"info":{"author":"xcode","version":1}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1 @@
{"images":[{"filename":"1.png","idiom":"universal"}],"info":{"author":"xcode","version":1}}
+34
View File
@@ -0,0 +1,34 @@
# Fake Profile — плагин для iOS (GLEGram)
# При __user_display__ = True приложение само применяет настройки плагина к отображению профиля (без изменения кода Telegram).
__id__ = "FakeDataMod"
__name__ = "Fake Profile"
__description__ = "Визуально меняет данные в профиле: имя, фамилию, username, телефон, ID, статусы Premium/Verified/Scam/Fake/Support/Bot. Поддержка любого пользователя по Telegram ID."
__author__ = "@catupl , @Fantom_Plugins (original)"
__version__ = "2.0"
__icon__ = "glePlugins/1"
__min_version__ = "1.0.0"
__user_display__ = True
def create_settings():
return [
{"type": "header", "text": "Настройки Fake Profile"},
{"type": "switch", "key": "enabled", "text": "Включить плагин", "subtext": "Активирует визуальную замену данных.", "default": False},
{"type": "divider", "text": "Для полного применения изменений может потребоваться перезапуск приложения."},
{"type": "header", "text": "Целевой пользователь"},
{"type": "input", "key": "target_user_id", "text": "Telegram ID пользователя", "default": "", "subtext": "Оставьте пустым для своего профиля. Укажите ID для другого пользователя."},
{"type": "divider", "text": "Чтобы узнать ID, используйте бота @userinfobot"},
{"type": "header", "text": "Личные данные"},
{"type": "input", "key": "fake_first_name", "text": "Имя", "default": ""},
{"type": "input", "key": "fake_last_name", "text": "Фамилия", "default": ""},
{"type": "input", "key": "fake_username", "text": "Юзернейм (без @)", "default": ""},
{"type": "input", "key": "fake_phone", "text": "Номер телефона (без +)", "default": ""},
{"type": "input", "key": "fake_id", "text": "Telegram ID", "default": "", "subtext": "Визуально изменяет ID"},
{"type": "header", "text": "Статусы и значки"},
{"type": "switch", "key": "fake_premium", "text": "Premium статус", "default": False},
{"type": "switch", "key": "fake_verified", "text": "Статус верификации", "default": False},
{"type": "switch", "key": "fake_scam", "text": "Scam статус", "default": False},
{"type": "switch", "key": "fake_fake", "text": "Fake статус", "default": False},
{"type": "switch", "key": "fake_support", "text": "Support статус", "default": False},
{"type": "switch", "key": "fake_bot", "text": "Bot статус", "default": False},
{"type": "divider", "text": "Оставьте поля пустыми, чтобы использовать реальные данные."},
]
+175
View File
@@ -0,0 +1,175 @@
// MARK: Swiftgram Extract .dylib from .deb packages (Cydia-style tweaks)
import Foundation
import Compression
/// Result of installing a .deb: package name/version (if parsed) and list of installed .dylib filenames.
public struct DebInstallResult {
public let packageName: String?
public let packageVersion: String?
public let installedDylibs: [String]
}
/// Extracts .dylib files from a .deb and installs them into TweakLoader's directory.
public enum DebExtractor {
private static let arMagic = "!<arch>\n"
private static let arMagicData = Data(arMagic.utf8)
/// Install .deb: extract data archive, find all .dylib, copy to Tweaks directory.
/// Supports data.tar.gz and data.tar.lzma. Returns installed dylib filenames or throws.
public static func installDeb(from url: URL, tweaksDirectory: URL) throws -> DebInstallResult {
let data = try Data(contentsOf: url)
let (controlName, controlVersion) = parseControl(from: data)
let dataTar = try extractDataTar(from: data)
let (tempDir, dylibEntries) = try listDylibsInTar(data: dataTar)
defer { try? FileManager.default.removeItem(at: tempDir) }
let fileManager = FileManager.default
try fileManager.createDirectory(at: tweaksDirectory, withIntermediateDirectories: true)
var installed: [String] = []
for entry in dylibEntries {
let name = (entry.path as NSString).lastPathComponent
guard name.lowercased().hasSuffix(".dylib") else { continue }
let dest = tweaksDirectory.appendingPathComponent(name)
if fileManager.fileExists(atPath: dest.path) { try? fileManager.removeItem(at: dest) }
try fileManager.copyItem(at: entry.url, to: dest)
installed.append(name)
}
if installed.isEmpty {
throw NSError(domain: "DebExtractor", code: 2, userInfo: [NSLocalizedDescriptionKey: "No .dylib files found in the .deb package"])
}
return DebInstallResult(packageName: controlName, packageVersion: controlVersion, installedDylibs: installed)
}
/// Parse ar archive and return raw content of member whose name starts with `prefix`.
private static func readArMember(data: Data, namePrefix: String) -> Data? {
guard data.count >= 8, data.prefix(8).elementsEqual(arMagicData) else { return nil }
var offset = 8
while offset + 60 <= data.count {
let header = data.subdata(in: offset ..< offset + 60)
guard let name = String(data: header.prefix(16), encoding: .ascii)?.trimmingCharacters(in: CharacterSet.whitespaces.union(CharacterSet(charactersIn: "\0"))),
let sizeStr = String(data: header.subdata(in: 48 ..< 58), encoding: .ascii)?.trimmingCharacters(in: .whitespaces),
let size = Int(sizeStr, radix: 10), size >= 0 else {
break
}
offset += 60
if name == "/" || name.isEmpty { offset += size; if size % 2 != 0 { offset += 1 }; continue }
if name.hasPrefix(namePrefix) {
guard offset + size <= data.count else { return nil }
return data.subdata(in: offset ..< offset + size)
}
offset += size
if offset % 2 != 0 { offset += 1 }
}
return nil
}
/// Parse control.tar.gz to get Package and Version (optional).
private static func parseControl(from debData: Data) -> (name: String?, version: String?) {
guard let controlTar = readArMember(data: debData, namePrefix: "control.tar") else { return (nil, nil) }
let decompressed: Data
if controlTar.prefix(2) == Data([0x1f, 0x8b]) {
guard let d = decompressGzip(controlTar) else { return (nil, nil) }
decompressed = d
} else {
decompressed = controlTar
}
guard let controlFile = readFileFromTar(data: decompressed, nameSuffix: "control") else { return (nil, nil) }
guard let str = String(data: controlFile, encoding: .utf8) else { return (nil, nil) }
var name: String?
var version: String?
for line in str.components(separatedBy: .newlines) {
if line.hasPrefix("Package:") { name = line.dropFirst(8).trimmingCharacters(in: .whitespaces) }
if line.hasPrefix("Version:") { version = line.dropFirst(8).trimmingCharacters(in: .whitespaces) }
}
return (name, version)
}
/// Extract data.tar.* from .deb and decompress to raw tar.
private static func extractDataTar(from debData: Data) throws -> Data {
guard let dataMember = readArMember(data: debData, namePrefix: "data.tar") else {
throw NSError(domain: "DebExtractor", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid .deb: no data.tar found"])
}
if dataMember.prefix(2) == Data([0x1f, 0x8b]) {
guard let d = decompressGzip(dataMember) else {
throw NSError(domain: "DebExtractor", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress data.tar.gz"])
}
return d
}
if dataMember.prefix(3).elementsEqual(Data([0x5d, 0x00, 0x00])) || dataMember.prefix(1) == Data([0x5d]) {
guard let d = decompressLzma(dataMember) else {
throw NSError(domain: "DebExtractor", code: 4, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress data.tar.lzma"])
}
return d
}
return dataMember
}
/// List .dylib entries in tar; extract each to a temp file and return (tempDir, entries). Caller must remove tempDir after copying.
private static func listDylibsInTar(data: Data) throws -> (tempDir: URL, entries: [(path: String, url: URL)]) {
var results: [(path: String, url: URL)] = []
let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
var offset = 0
while offset + 512 <= data.count {
let header = data.subdata(in: offset ..< offset + 512)
if header.prefix(257).allSatisfy({ $0 == 0 }) { break }
guard let name = String(data: header.prefix(100), encoding: .ascii)?.trimmingCharacters(in: CharacterSet(charactersIn: "\0")) else {
offset += 512
continue
}
let sizeStr = String(data: header.subdata(in: 124 ..< 136), encoding: .ascii)?.trimmingCharacters(in: .whitespaces) ?? "0"
let size = Int(sizeStr, radix: 8) ?? 0
offset += 512
let contentStart = offset
offset += (size + 511) / 512 * 512
guard name.hasSuffix(".dylib"), size > 0, contentStart + size <= data.count else { continue }
let content = data.subdata(in: contentStart ..< contentStart + size)
let base = (name as NSString).lastPathComponent
let tmpFile = tmpDir.appendingPathComponent(base)
try content.write(to: tmpFile)
results.append((name, tmpFile))
}
return (tmpDir, results)
}
/// Read first file from tar that has given name suffix (e.g. "control").
private static func readFileFromTar(data: Data, nameSuffix: String) -> Data? {
var offset = 0
while offset + 512 <= data.count {
let header = data.subdata(in: offset ..< offset + 512)
if header.prefix(257).allSatisfy({ $0 == 0 }) { break }
guard let name = String(data: header.prefix(100), encoding: .ascii)?.trimmingCharacters(in: CharacterSet(charactersIn: "\0")),
name.hasSuffix(nameSuffix) else {
let sizeStr = String(data: header.subdata(in: 124 ..< 136), encoding: .ascii)?.trimmingCharacters(in: .whitespaces) ?? "0"
let size = Int(sizeStr, radix: 8) ?? 0
offset += 512 + (size + 511) / 512 * 512
continue
}
let sizeStr = String(data: header.subdata(in: 124 ..< 136), encoding: .ascii)?.trimmingCharacters(in: .whitespaces) ?? "0"
let size = Int(sizeStr, radix: 8) ?? 0
offset += 512
guard offset + size <= data.count else { return nil }
return data.subdata(in: offset ..< offset + size)
}
return nil
}
private static func decompressGzip(_ data: Data) -> Data? {
return decompress(data, algorithm: COMPRESSION_ZLIB)
}
/// LZMA (e.g. data.tar.lzma).
private static func decompressLzma(_ data: Data) -> Data? {
return decompress(data, algorithm: COMPRESSION_LZMA)
}
private static func decompress(_ data: Data, algorithm: compression_algorithm) -> Data? {
let destSize = 16 * 1024 * 1024
let dest = UnsafeMutablePointer<UInt8>.allocate(capacity: destSize)
defer { dest.deallocate() }
let decoded = data.withUnsafeBytes { (src: UnsafeRawBufferPointer) -> Int in
compression_decode_buffer(dest, destSize, src.bindMemory(to: UInt8.self).baseAddress!, data.count, nil, algorithm)
}
guard decoded > 0 else { return nil }
return Data(bytes: dest, count: decoded)
}
}
@@ -0,0 +1,19 @@
import Foundation
import UIKit
import Display
import AccountContext
import TelegramPresentationData
import PresentationDataUtils
// MARK: - GLEGram Double Bottom Settings (stub)
public func doubleBottomSettingsController(context: AccountContext) -> ViewController {
let pd = context.sharedContext.currentPresentationData.with { $0 }
let lang = pd.strings.baseLanguageCode
let controller = textAlertController(
context: context,
title: lang == "ru" ? "Двойное дно" : "Double Bottom",
text: lang == "ru" ? "Функция в разработке." : "Feature in development.",
actions: [TextAlertAction(type: .defaultAction, title: pd.strings.Common_OK, action: {})]
)
return controller
}
@@ -0,0 +1,259 @@
// MARK: Swiftgram Fake Profile settings: target user, name/username/phone/ID, badges
import Foundation
import UIKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import SGItemListUI
import SGSimpleSettings
private enum FakeProfileSection: Int32, SGItemListSection {
case targetUser = 0
case personalData
case badges
}
private enum FakeProfileEntry: ItemListNodeEntry {
case targetHeader(id: Int, text: String)
case targetUserId(id: Int, text: String, placeholder: String)
case targetNotice(id: Int, text: String)
case personalHeader(id: Int, text: String)
case firstName(id: Int, text: String, placeholder: String)
case lastName(id: Int, text: String, placeholder: String)
case username(id: Int, text: String, placeholder: String)
case phone(id: Int, text: String, placeholder: String)
case fakeId(id: Int, text: String, placeholder: String)
case personalNotice(id: Int, text: String)
case badgesHeader(id: Int, text: String)
case premium(id: Int, title: String, subtext: String?, value: Bool)
case verified(id: Int, title: String, subtext: String?, value: Bool)
case scam(id: Int, title: String, subtext: String?, value: Bool)
case fake(id: Int, title: String, subtext: String?, value: Bool)
case support(id: Int, title: String, subtext: String?, value: Bool)
case bot(id: Int, title: String, subtext: String?, value: Bool)
case badgesNotice(id: Int, text: String)
var id: Int { stableId }
var section: ItemListSectionId {
switch self {
case .targetHeader, .targetUserId, .targetNotice: return FakeProfileSection.targetUser.rawValue
case .personalHeader, .firstName, .lastName, .username, .phone, .fakeId, .personalNotice: return FakeProfileSection.personalData.rawValue
default: return FakeProfileSection.badges.rawValue
}
}
var stableId: Int {
switch self {
case .targetHeader(let i, _), .targetUserId(let i, _, _), .targetNotice(let i, _),
.personalHeader(let i, _), .firstName(let i, _, _), .lastName(let i, _, _), .username(let i, _, _),
.phone(let i, _, _), .fakeId(let i, _, _), .personalNotice(let i, _),
.badgesHeader(let i, _), .premium(let i, _, _, _), .verified(let i, _, _, _), .scam(let i, _, _, _),
.fake(let i, _, _, _), .support(let i, _, _, _), .bot(let i, _, _, _), .badgesNotice(let i, _): return i
}
}
static func < (lhs: FakeProfileEntry, rhs: FakeProfileEntry) -> Bool { lhs.stableId < rhs.stableId }
static func == (lhs: FakeProfileEntry, rhs: FakeProfileEntry) -> Bool {
switch (lhs, rhs) {
case let (.targetHeader(a, t1), .targetHeader(b, t2)): return a == b && t1 == t2
case let (.targetUserId(a, t1, p1), .targetUserId(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.targetNotice(a, t1), .targetNotice(b, t2)): return a == b && t1 == t2
case let (.personalHeader(a, t1), .personalHeader(b, t2)): return a == b && t1 == t2
case let (.firstName(a, t1, p1), .firstName(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.lastName(a, t1, p1), .lastName(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.username(a, t1, p1), .username(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.phone(a, t1, p1), .phone(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.fakeId(a, t1, p1), .fakeId(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.personalNotice(a, t1), .personalNotice(b, t2)): return a == b && t1 == t2
case let (.badgesHeader(a, t1), .badgesHeader(b, t2)): return a == b && t1 == t2
case let (.premium(a, t1, s1, v1), .premium(b, t2, s2, v2)): return a == b && t1 == t2 && s1 == s2 && v1 == v2
case let (.verified(a, t1, s1, v1), .verified(b, t2, s2, v2)): return a == b && t1 == t2 && s1 == s2 && v1 == v2
case let (.scam(a, t1, s1, v1), .scam(b, t2, s2, v2)): return a == b && t1 == t2 && s1 == s2 && v1 == v2
case let (.fake(a, t1, s1, v1), .fake(b, t2, s2, v2)): return a == b && t1 == t2 && s1 == s2 && v1 == v2
case let (.support(a, t1, s1, v1), .support(b, t2, s2, v2)): return a == b && t1 == t2 && s1 == s2 && v1 == v2
case let (.bot(a, t1, s1, v1), .bot(b, t2, s2, v2)): return a == b && t1 == t2 && s1 == s2 && v1 == v2
case let (.badgesNotice(a, t1), .badgesNotice(b, t2)): return a == b && t1 == t2
default: return false
}
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let theme = presentationData.theme
let args = arguments as! FakeProfileArguments
switch self {
case .targetHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: section)
case .targetUserId(_, let text, let placeholder):
return ItemListSingleLineInputItem(presentationData: presentationData, systemStyle: .glass, title: NSAttributedString(string: "ID", textColor: theme.list.itemPrimaryTextColor), text: text, placeholder: placeholder, type: .regular(capitalization: false, autocorrection: false), clearType: .always, sectionId: section, textUpdated: { args.updateTargetUserId($0) }, action: {})
case .targetNotice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: section)
case .personalHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: section)
case .firstName(_, let text, let placeholder):
return ItemListSingleLineInputItem(presentationData: presentationData, systemStyle: .glass, title: NSAttributedString(string: (presentationData.strings.baseLanguageCode == "ru" ? "Имя" : "First name"), textColor: theme.list.itemPrimaryTextColor), text: text, placeholder: placeholder, type: .regular(capitalization: true, autocorrection: false), clearType: .always, sectionId: section, textUpdated: { args.updateFirstName($0) }, action: {})
case .lastName(_, let text, let placeholder):
return ItemListSingleLineInputItem(presentationData: presentationData, systemStyle: .glass, title: NSAttributedString(string: (presentationData.strings.baseLanguageCode == "ru" ? "Фамилия" : "Last name"), textColor: theme.list.itemPrimaryTextColor), text: text, placeholder: placeholder, type: .regular(capitalization: true, autocorrection: false), clearType: .always, sectionId: section, textUpdated: { args.updateLastName($0) }, action: {})
case .username(_, let text, let placeholder):
return ItemListSingleLineInputItem(presentationData: presentationData, systemStyle: .glass, title: NSAttributedString(string: (presentationData.strings.baseLanguageCode == "ru" ? "Юзернейм (без @)" : "Username (no @)"), textColor: theme.list.itemPrimaryTextColor), text: text, placeholder: placeholder, type: .regular(capitalization: false, autocorrection: false), clearType: .always, sectionId: section, textUpdated: { args.updateUsername($0) }, action: {})
case .phone(_, let text, let placeholder):
return ItemListSingleLineInputItem(presentationData: presentationData, systemStyle: .glass, title: NSAttributedString(string: (presentationData.strings.baseLanguageCode == "ru" ? "Телефон (без +)" : "Phone (no +)"), textColor: theme.list.itemPrimaryTextColor), text: text, placeholder: placeholder, type: .number, clearType: .always, sectionId: section, textUpdated: { args.updatePhone($0) }, action: {})
case .fakeId(_, let text, let placeholder):
return ItemListSingleLineInputItem(presentationData: presentationData, systemStyle: .glass, title: NSAttributedString(string: "Telegram ID", textColor: theme.list.itemPrimaryTextColor), text: text, placeholder: placeholder, type: .number, clearType: .always, sectionId: section, textUpdated: { args.updateFakeId($0) }, action: {})
case .personalNotice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: section)
case .badgesHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: section)
case .premium(_, let title, let subtext, let value):
return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: title, text: subtext, value: value, sectionId: section, style: .blocks, updated: { args.updatePremium($0) })
case .verified(_, let title, let subtext, let value):
return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: title, text: subtext, value: value, sectionId: section, style: .blocks, updated: { args.updateVerified($0) })
case .scam(_, let title, let subtext, let value):
return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: title, text: subtext, value: value, sectionId: section, style: .blocks, updated: { args.updateScam($0) })
case .fake(_, let title, let subtext, let value):
return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: title, text: subtext, value: value, sectionId: section, style: .blocks, updated: { args.updateFake($0) })
case .support(_, let title, let subtext, let value):
return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: title, text: subtext, value: value, sectionId: section, style: .blocks, updated: { args.updateSupport($0) })
case .bot(_, let title, let subtext, let value):
return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: title, text: subtext, value: value, sectionId: section, style: .blocks, updated: { args.updateBot($0) })
case .badgesNotice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: section)
}
}
}
private final class FakeProfileArguments {
let reload: () -> Void
init(reload: @escaping () -> Void) { self.reload = reload }
func updateTargetUserId(_ value: String) {
SGSimpleSettings.shared.fakeProfileTargetUserId = value
reload()
}
func updateFirstName(_ value: String) {
SGSimpleSettings.shared.fakeProfileFirstName = value
reload()
}
func updateLastName(_ value: String) {
SGSimpleSettings.shared.fakeProfileLastName = value
reload()
}
func updateUsername(_ value: String) {
SGSimpleSettings.shared.fakeProfileUsername = value
reload()
}
func updatePhone(_ value: String) {
SGSimpleSettings.shared.fakeProfilePhone = value
reload()
}
func updateFakeId(_ value: String) {
SGSimpleSettings.shared.fakeProfileId = value
reload()
}
func updatePremium(_ value: Bool) {
SGSimpleSettings.shared.fakeProfilePremium = value
reload()
}
func updateVerified(_ value: Bool) {
SGSimpleSettings.shared.fakeProfileVerified = value
reload()
}
func updateScam(_ value: Bool) {
SGSimpleSettings.shared.fakeProfileScam = value
reload()
}
func updateFake(_ value: Bool) {
SGSimpleSettings.shared.fakeProfileFake = value
reload()
}
func updateSupport(_ value: Bool) {
SGSimpleSettings.shared.fakeProfileSupport = value
reload()
}
func updateBot(_ value: Bool) {
SGSimpleSettings.shared.fakeProfileBot = value
reload()
}
}
private func fakeProfileEntries(presentationData: PresentationData) -> [FakeProfileEntry] {
let lang = presentationData.strings.baseLanguageCode
let s = SGSimpleSettings.shared
var entries: [FakeProfileEntry] = []
var id = 0
entries.append(.targetHeader(id: id, text: lang == "ru" ? "ЦЕЛЕВОЙ ПОЛЬЗОВАТЕЛЬ" : "TARGET USER"))
id += 1
entries.append(.targetUserId(id: id, text: s.fakeProfileTargetUserId, placeholder: lang == "ru" ? "Оставьте пустым для своего профиля" : "Leave empty for your profile"))
id += 1
entries.append(.targetNotice(id: id, text: lang == "ru" ? "Чтобы узнать ID, используйте @userinfobot" : "Use @userinfobot to get user ID"))
id += 1
entries.append(.personalHeader(id: id, text: lang == "ru" ? "ЛИЧНЫЕ ДАННЫЕ" : "PERSONAL DATA"))
id += 1
entries.append(.firstName(id: id, text: s.fakeProfileFirstName, placeholder: lang == "ru" ? "Имя" : "First name"))
id += 1
entries.append(.lastName(id: id, text: s.fakeProfileLastName, placeholder: lang == "ru" ? "Фамилия" : "Last name"))
id += 1
entries.append(.username(id: id, text: s.fakeProfileUsername, placeholder: lang == "ru" ? "без @" : "no @"))
id += 1
entries.append(.phone(id: id, text: s.fakeProfilePhone, placeholder: lang == "ru" ? "без +" : "no +"))
id += 1
entries.append(.fakeId(id: id, text: s.fakeProfileId, placeholder: lang == "ru" ? "Визуально изменить ID" : "Override displayed ID"))
id += 1
entries.append(.personalNotice(id: id, text: lang == "ru" ? "Пустые поля — реальные данные." : "Empty = real data."))
id += 1
entries.append(.badgesHeader(id: id, text: lang == "ru" ? "СТАТУСЫ И ЗНАЧКИ" : "BADGES"))
id += 1
let premiumTitle = lang == "ru" ? "Premium" : "Premium"
let premiumSub = lang == "ru" ? "Визуально добавляет иконку Premium." : "Shows Premium badge."
entries.append(.premium(id: id, title: premiumTitle, subtext: premiumSub, value: s.fakeProfilePremium))
id += 1
let verifiedTitle = lang == "ru" ? "Верификация" : "Verified"
let verifiedSub = lang == "ru" ? "Визуально добавляет галочку." : "Shows verification badge."
entries.append(.verified(id: id, title: verifiedTitle, subtext: verifiedSub, value: s.fakeProfileVerified))
id += 1
let scamTitle = lang == "ru" ? "Scam" : "Scam"
let scamSub = lang == "ru" ? "Помечает как скам." : "Marks as scam."
entries.append(.scam(id: id, title: scamTitle, subtext: scamSub, value: s.fakeProfileScam))
id += 1
let fakeTitle = lang == "ru" ? "Fake" : "Fake"
let fakeSub = lang == "ru" ? "Помечает как фейк." : "Marks as fake."
entries.append(.fake(id: id, title: fakeTitle, subtext: fakeSub, value: s.fakeProfileFake))
id += 1
let supportTitle = lang == "ru" ? "Support" : "Support"
let supportSub = lang == "ru" ? "Официальная поддержка." : "Official support badge."
entries.append(.support(id: id, title: supportTitle, subtext: supportSub, value: s.fakeProfileSupport))
id += 1
let botTitle = lang == "ru" ? "Бот" : "Bot"
let botSub = lang == "ru" ? "Помечает как бота." : "Marks as bot."
entries.append(.bot(id: id, title: botTitle, subtext: botSub, value: s.fakeProfileBot))
id += 1
entries.append(.badgesNotice(id: id, text: lang == "ru" ? "Для полного применения может потребоваться перезапуск." : "Restart may be required for full effect."))
return entries
}
/// Fake Profile settings: target user ID, first/last name, username, phone, fake ID, badge toggles.
public func FakeProfileSettingsController(context: AccountContext, onSave: @escaping () -> Void) -> ViewController {
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
let arguments = FakeProfileArguments(reload: { reloadPromise.set(true) })
let signal = combineLatest(reloadPromise.get(), context.sharedContext.presentationData)
|> map { _, presentationData -> (ItemListControllerState, (ItemListNodeState, FakeProfileArguments)) in
let lang = presentationData.strings.baseLanguageCode
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(lang == "ru" ? "Настройки Fake Profile" : "Fake Profile settings"),
leftNavigationButton: nil,
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = fakeProfileEntries(presentationData: presentationData)
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: nil, initialScrollToItem: nil)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
return controller
}
@@ -0,0 +1,83 @@
// MARK: Swiftgram Local stars balance: edit amount
import Foundation
import UIKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import SGSimpleSettings
private enum FeelRichAmountEntry: ItemListNodeEntry {
case header(id: Int, text: String)
case amount(id: Int, text: String, placeholder: String)
var id: Int { stableId }
var section: ItemListSectionId { 0 }
var stableId: Int {
switch self {
case .header(let id, _), .amount(let id, _, _): return id
}
}
static func < (lhs: FeelRichAmountEntry, rhs: FeelRichAmountEntry) -> Bool { lhs.stableId < rhs.stableId }
static func == (lhs: FeelRichAmountEntry, rhs: FeelRichAmountEntry) -> Bool {
switch (lhs, rhs) {
case let (.header(a, t1), .header(b, t2)): return a == b && t1 == t2
case let (.amount(a, t1, p1), .amount(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
default: return false
}
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let theme = presentationData.theme
let args = arguments as! FeelRichAmountArguments
switch self {
case .header(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: section)
case .amount(_, let text, let placeholder):
return ItemListSingleLineInputItem(presentationData: presentationData, systemStyle: .glass, title: NSAttributedString(string: (presentationData.strings.baseLanguageCode == "ru" ? "Сумма (звёзды)" : "Amount (stars)"), textColor: theme.list.itemPrimaryTextColor), text: text, placeholder: placeholder, type: .number, clearType: .always, sectionId: section, textUpdated: { args.updateAmount($0) }, action: {})
}
}
}
private final class FeelRichAmountArguments {
let reload: () -> Void
init(reload: @escaping () -> Void) { self.reload = reload }
func updateAmount(_ value: String) {
SGSimpleSettings.shared.feelRichStarsAmount = value
reload()
}
}
private func feelRichAmountEntries(presentationData: PresentationData) -> [FeelRichAmountEntry] {
let lang = presentationData.strings.baseLanguageCode
var entries: [FeelRichAmountEntry] = []
var id = 0
entries.append(.header(id: id, text: lang == "ru" ? "БАЛАНС ЗВЁЗД" : "STARS BALANCE"))
id += 1
entries.append(.amount(id: id, text: SGSimpleSettings.shared.feelRichStarsAmount, placeholder: "1000"))
return entries
}
/// Edit local stars balance amount.
public func FeelRichAmountController(context: AccountContext, onSave: @escaping () -> Void) -> ViewController {
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
let arguments = FeelRichAmountArguments(reload: { reloadPromise.set(true) })
let signal = combineLatest(reloadPromise.get(), context.sharedContext.presentationData)
|> map { _, presentationData -> (ItemListControllerState, (ItemListNodeState, FeelRichAmountArguments)) in
let lang = presentationData.strings.baseLanguageCode
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(lang == "ru" ? "Сумма звёзд" : "Stars amount"),
leftNavigationButton: nil,
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = feelRichAmountEntries(presentationData: presentationData)
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: nil, initialScrollToItem: nil)
return (controllerState, (listState, arguments))
}
return ItemListController(context: context, state: signal)
}
+205
View File
@@ -0,0 +1,205 @@
// MARK: Swiftgram - Download fonts from the internet (search + list)
import SGSimpleSettings
import Foundation
import UIKit
import CoreText
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import AccountContext
/// Downloaded fonts directory name under Documents/SwiftgramFonts/
private let kDownloadedFontsSubdir = "Downloaded"
/// Register all .ttf files in the downloaded fonts directory so they appear in the font picker.
public func registerAllDownloadedFonts() {
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
let base = documents.appendingPathComponent("SwiftgramFonts", isDirectory: true).appendingPathComponent(kDownloadedFontsSubdir, isDirectory: true)
guard let contents = try? FileManager.default.contentsOfDirectory(at: base, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) else { return }
let ttfUrls = contents.filter { $0.pathExtension.lowercased() == "ttf" }
if ttfUrls.isEmpty { return }
CTFontManagerRegisterFontURLs(ttfUrls as CFArray, .process, true, nil)
}
/// Static list: (display name, .ttf URL). Google Fonts from GitHub raw.
private let kDownloadableFonts: [(name: String, url: String)] = [
("Roboto", "https://github.com/google/fonts/raw/main/apache/roboto/Roboto-Regular.ttf"),
("Roboto Condensed", "https://github.com/google/fonts/raw/main/apache/robotocondensed/RobotoCondensed-Regular.ttf"),
("Open Sans", "https://github.com/google/fonts/raw/main/apache/opensans/OpenSans-Regular.ttf"),
("Lato", "https://github.com/google/fonts/raw/main/ofl/lato/Lato-Regular.ttf"),
("Oswald", "https://github.com/google/fonts/raw/main/ofl/oswald/Oswald-Regular.ttf"),
("Source Sans 3", "https://github.com/google/fonts/raw/main/ofl/sourcesans3/SourceSans3-Regular.ttf"),
("Montserrat", "https://github.com/google/fonts/raw/main/ofl/montserrat/Montserrat-Regular.ttf"),
("Raleway", "https://github.com/google/fonts/raw/main/ofl/raleway/Raleway-Regular.ttf"),
("PT Sans", "https://github.com/google/fonts/raw/main/ofl/ptsans/PT_Sans-Regular.ttf"),
("Merriweather", "https://github.com/google/fonts/raw/main/ofl/merriweather/Merriweather-Regular.ttf"),
("Nunito", "https://github.com/google/fonts/raw/main/ofl/nunito/Nunito-Regular.ttf"),
("Fira Sans", "https://github.com/google/fonts/raw/main/ofl/firasans/FiraSans-Regular.ttf"),
("Ubuntu", "https://github.com/google/fonts/raw/main/ufl/ubuntu/Ubuntu-Regular.ttf"),
("Playfair Display", "https://github.com/google/fonts/raw/main/ofl/playfairdisplay/PlayfairDisplay-Regular.ttf"),
("Oxygen", "https://github.com/google/fonts/raw/main/ofl/oxygen/Oxygen-Regular.ttf"),
("Manrope", "https://github.com/google/fonts/raw/main/ofl/manrope/Manrope-Regular.ttf"),
("Inter", "https://github.com/google/fonts/raw/main/ofl/inter/Inter-Regular.ttf"),
("Poppins", "https://github.com/google/fonts/raw/main/ofl/poppins/Poppins-Regular.ttf"),
("Work Sans", "https://github.com/google/fonts/raw/main/ofl/worksans/WorkSans-Regular.ttf"),
("Rubik", "https://github.com/google/fonts/raw/main/ofl/rubik/Rubik-Regular.ttf"),
]
private enum FontDownloadEntry: ItemListNodeEntry {
case search(entryId: Int, query: String)
case font(entryId: Int, name: String, url: String, isDownloading: Bool, isDownloaded: Bool)
var section: ItemListSectionId { 0 }
var stableId: Int {
switch self {
case .search(let id, _): return id
case .font(let id, _, _, _, _): return id
}
}
var id: Int { stableId }
static func == (lhs: FontDownloadEntry, rhs: FontDownloadEntry) -> Bool {
switch (lhs, rhs) {
case (.search(let id1, let q1), .search(let id2, let q2)): return id1 == id2 && q1 == q2
case (.font(let id1, let n1, _, let d1, let i1), .font(let id2, let n2, _, let d2, let i2)): return id1 == id2 && n1 == n2 && d1 == d2 && i1 == i2
default: return false
}
}
static func < (lhs: FontDownloadEntry, rhs: FontDownloadEntry) -> Bool { lhs.stableId < rhs.stableId }
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! FontDownloadArguments
switch self {
case .search(_, let query):
let placeholder = presentationData.strings.baseLanguageCode == "ru" ? "Поиск шрифта" : "Search font"
return ItemListSingleLineInputItem(
presentationData: presentationData,
title: NSAttributedString(),
text: query,
placeholder: placeholder,
returnKeyType: .search,
spacing: 0,
clearType: .always,
sectionId: section,
textUpdated: { args.updateSearch($0) },
shouldUpdateText: { _ in true },
action: {}
)
case .font(_, let name, let url, let isDownloading, let isDownloaded):
let label: String
if isDownloading {
label = presentationData.strings.baseLanguageCode == "ru" ? "Загрузка…" : "Downloading…"
} else if isDownloaded {
label = presentationData.strings.baseLanguageCode == "ru" ? "Установлен" : "Installed"
} else {
label = ""
}
return ItemListDisclosureItem(
presentationData: presentationData,
title: name,
enabled: !isDownloading,
label: label,
sectionId: section,
style: .blocks,
action: isDownloading ? nil : { args.download(name, url) }
)
}
}
}
private struct FontDownloadArguments {
let updateSearch: (String) -> Void
let download: (String, String) -> Void
}
private struct FontDownloadState: Equatable {
var searchQuery: String
var downloadingNames: Set<String>
var downloadedNames: Set<String>
}
public func FontDownloadController(context: AccountContext, onFontAdded: @escaping () -> Void) -> ViewController {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
var state = FontDownloadState(searchQuery: "", downloadingNames: [], downloadedNames: [])
func downloadedFontsDirectory() -> URL? {
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil }
let base = documents.appendingPathComponent("SwiftgramFonts", isDirectory: true).appendingPathComponent(kDownloadedFontsSubdir, isDirectory: true)
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
return base
}
func isFontDownloaded(displayName: String) -> Bool {
guard let dir = downloadedFontsDirectory() else { return false }
let sanitized = displayName.replacingOccurrences(of: " ", with: "_")
let path = dir.appendingPathComponent(sanitized + ".ttf").path
return FileManager.default.fileExists(atPath: path)
}
let statePromise = ValuePromise<FontDownloadState>(state, ignoreRepeated: true)
let updateState: ((String?, Set<String>?, Set<String>?) -> Void) = { query, downloading, downloaded in
if let q = query { state.searchQuery = q }
if let d = downloading { state.downloadingNames = d }
if let d = downloaded { state.downloadedNames = d }
statePromise.set(state)
}
let arguments = FontDownloadArguments(
updateSearch: { updateState($0, nil, nil) },
download: { name, urlString in
updateState(nil, { var s = state.downloadingNames; s.insert(name); return s }(), nil)
guard let url = URL(string: urlString), let dir = downloadedFontsDirectory() else {
updateState(nil, { var s = state.downloadingNames; s.remove(name); return s }(), nil)
return
}
let task = URLSession.shared.dataTask(with: url) { data, _, _ in
DispatchQueue.main.async {
updateState(nil, { var s = state.downloadingNames; s.remove(name); return s }(), nil)
guard let data = data, !data.isEmpty else { return }
let sanitized = name.replacingOccurrences(of: " ", with: "_")
let file = dir.appendingPathComponent(sanitized + ".ttf")
do {
try data.write(to: file)
CTFontManagerRegisterFontURLs([file] as CFArray, .process, true, nil)
updateState(nil, nil, { var s = state.downloadedNames; s.insert(name); return s }())
onFontAdded()
} catch {}
}
}
task.resume()
}
)
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(presentationData.strings.baseLanguageCode == "ru" ? "Загрузить шрифт" : "Download font"),
leftNavigationButton: nil,
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let signal: Signal<(ItemListControllerState, (ItemListNodeState, FontDownloadArguments)), NoError> = statePromise.get()
|> map { (s: FontDownloadState) -> (ItemListControllerState, (ItemListNodeState, FontDownloadArguments)) in
var entries: [FontDownloadEntry] = []
entries.append(.search(entryId: 0, query: s.searchQuery))
let filtered = kDownloadableFonts.filter { s.searchQuery.isEmpty || $0.name.localizedCaseInsensitiveContains(s.searchQuery) }
for (idx, item) in filtered.enumerated() {
let id = idx + 1
let isDl = s.downloadingNames.contains(item.name)
let isDone = s.downloadedNames.contains(item.name) || isFontDownloaded(displayName: item.name)
entries.append(.font(entryId: id, name: item.name, url: item.url, isDownloading: isDl, isDownloaded: isDone))
}
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
initialScrollToItem: nil
)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
return controller
}
@@ -0,0 +1,163 @@
// MARK: Swiftgram - Font replacement picker (A-Font style)
import SGSimpleSettings
import Foundation
import UIKit
import CoreText
import CoreGraphics
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import AccountContext
/// Bundled default fonts shown at the top of the picker (display name, bundle filename without extension).
private let bundledDefaultFonts: [(displayName: String, fileName: String)] = [
(displayName: "Minecraft Default Bold", fileName: "MinecraftDefault-Bold-2"),
]
/// Registers a .ttf from the given bundle if present and returns its PostScript name, or nil.
private func registerBundledFont(bundle: Bundle, fileName: String) -> String? {
guard let path = bundle.path(forResource: fileName, ofType: "ttf") else {
return nil
}
let url = URL(fileURLWithPath: path)
guard let provider = CGDataProvider(url: url as CFURL),
let cgFont = CGFont(provider),
let name = cgFont.postScriptName as String?, !name.isEmpty else {
return nil
}
CTFontManagerRegisterFontURLs([url] as CFArray, .process, true, nil)
return name
}
public enum FontReplacementPickerMode {
case main
case bold
}
private struct FontReplacementPickerArguments {
let selectFont: (String) -> Void
let dismiss: () -> Void
}
private struct FontReplacementPickerEntry: ItemListNodeEntry {
let entryId: Int
let fontName: String
let displayTitle: String
var section: ItemListSectionId { 0 }
var stableId: Int { entryId }
var id: Int { entryId }
static func == (lhs: FontReplacementPickerEntry, rhs: FontReplacementPickerEntry) -> Bool {
lhs.entryId == rhs.entryId && lhs.fontName == rhs.fontName
}
static func < (lhs: FontReplacementPickerEntry, rhs: FontReplacementPickerEntry) -> Bool {
lhs.entryId < rhs.entryId
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! FontReplacementPickerArguments
let fontSize = presentationData.fontSize.itemListBaseFontSize
let textColor = presentationData.theme.list.itemAccentColor
let attributedTitle: NSAttributedString?
if fontName.isEmpty {
attributedTitle = nil
} else if let font = UIFont(name: fontName, size: fontSize) {
attributedTitle = NSAttributedString(string: displayTitle, font: font, textColor: textColor)
} else {
attributedTitle = nil
}
return ItemListDisclosureItem(
presentationData: presentationData,
title: displayTitle,
attributedTitle: attributedTitle,
label: "",
sectionId: section,
style: .blocks,
disclosureStyle: .none,
action: {
args.selectFont(self.fontName)
args.dismiss()
}
)
}
}
public func FontReplacementPickerController(context: AccountContext, mode: FontReplacementPickerMode, onSave: @escaping () -> Void) -> ViewController {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
var dismissImpl: (() -> Void)?
// Re-register downloaded fonts so they appear in the list
registerAllDownloadedFonts()
let (fontNames, bundledDisplayNames): ([String], [String: String]) = {
var list: [String] = []
var bundledMap: [String: String] = [:]
for (displayName, fileName) in bundledDefaultFonts {
if let postScriptName = registerBundledFont(bundle: .main, fileName: fileName), !list.contains(postScriptName) {
list.append(postScriptName)
bundledMap[postScriptName] = displayName
}
}
for family in UIFont.familyNames.sorted() {
for name in UIFont.fontNames(forFamilyName: family).sorted() {
if !list.contains(name) {
list.append(name)
}
}
}
return (list.sorted(), bundledMap)
}()
let selectFont: (String) -> Void = { name in
switch mode {
case .main:
SGSimpleSettings.shared.fontReplacementName = name
SGSimpleSettings.shared.fontReplacementFilePath = "" // only "Import from file" sets path
case .bold:
SGSimpleSettings.shared.fontReplacementBoldName = name
SGSimpleSettings.shared.fontReplacementBoldFilePath = ""
}
onSave()
dismissImpl?()
}
let arguments = FontReplacementPickerArguments(
selectFont: selectFont,
dismiss: { dismissImpl?() }
)
var entries: [FontReplacementPickerEntry] = []
let systemTitle = presentationData.strings.baseLanguageCode == "ru" ? "Системный" : "System"
let autoTitle = presentationData.strings.baseLanguageCode == "ru" ? "Авто" : "Auto"
entries.append(FontReplacementPickerEntry(entryId: 0, fontName: "", displayTitle: mode == .main ? systemTitle : autoTitle))
for (idx, name) in fontNames.enumerated() {
let displayTitle = bundledDisplayNames[name] ?? name
entries.append(FontReplacementPickerEntry(entryId: idx + 1, fontName: name, displayTitle: displayTitle))
}
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(mode == .main ? (presentationData.strings.baseLanguageCode == "ru" ? "Шрифт" : "Font") : (presentationData.strings.baseLanguageCode == "ru" ? "Жирный шрифт" : "Bold font")),
leftNavigationButton: nil,
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
initialScrollToItem: nil
)
let signal: Signal<(ItemListControllerState, (ItemListNodeState, FontReplacementPickerArguments)), NoError> = .single((controllerState, (listState, arguments)))
let controller = ItemListController(context: context, state: signal)
dismissImpl = { [weak controller] in
controller?.dismiss()
}
return controller
}
+135
View File
@@ -0,0 +1,135 @@
// MARK: Swiftgram GLEGram settings footer
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import ItemListUI
public final class GLEGramFooterItem: ItemListControllerFooterItem {
let theme: PresentationTheme
let title: String
let linkTitle: String
let action: () -> Void
public init(theme: PresentationTheme, title: String, linkTitle: String, action: @escaping () -> Void) {
self.theme = theme
self.title = title
self.linkTitle = linkTitle
self.action = action
}
public func isEqual(to: ItemListControllerFooterItem) -> Bool {
if let item = to as? GLEGramFooterItem {
return self.theme === item.theme && self.title == item.title && self.linkTitle == item.linkTitle
}
return false
}
public func node(current: ItemListControllerFooterItemNode?) -> ItemListControllerFooterItemNode {
if let current = current as? GLEGramFooterItemNode {
current.item = self
return current
}
return GLEGramFooterItemNode(item: self)
}
}
final class GLEGramFooterItemNode: ItemListControllerFooterItemNode {
private let backgroundNode: ASDisplayNode
private let titleNode: ImmediateTextNode
private let linkNode: ImmediateTextNode
private var validLayout: ContainerViewLayout?
var item: GLEGramFooterItem {
didSet {
updateItem()
if let layout = validLayout {
_ = updateLayout(layout: layout, transition: .immediate)
}
}
}
init(item: GLEGramFooterItem) {
self.item = item
self.backgroundNode = ASDisplayNode()
self.backgroundNode.backgroundColor = item.theme.list.blocksBackgroundColor
self.titleNode = ImmediateTextNode()
self.titleNode.maximumNumberOfLines = 1
self.linkNode = ImmediateTextNode()
self.linkNode.maximumNumberOfLines = 1
super.init()
addSubnode(backgroundNode)
addSubnode(titleNode)
addSubnode(linkNode)
updateItem()
}
private func updateItem() {
backgroundNode.backgroundColor = item.theme.list.blocksBackgroundColor
titleNode.attributedText = NSAttributedString(
string: item.title,
font: Font.regular(15.0),
textColor: item.theme.list.freeTextColor
)
linkNode.attributedText = NSAttributedString(
string: item.linkTitle,
font: Font.medium(15.0),
textColor: item.theme.list.itemAccentColor
)
}
override func updateBackgroundAlpha(_ alpha: CGFloat, transition: ContainedViewLayoutTransition) {
transition.updateAlpha(node: backgroundNode, alpha: alpha)
}
override func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) -> CGFloat {
validLayout = layout
let inset: CGFloat = 16.0
let verticalInset: CGFloat = 20.0
let spacing: CGFloat = 4.0
let width = layout.size.width - layout.safeInsets.left - layout.safeInsets.right - inset * 2.0
let titleSize = titleNode.updateLayout(CGSize(width: width, height: .greatestFiniteMagnitude))
let linkSize = linkNode.updateLayout(CGSize(width: width, height: .greatestFiniteMagnitude))
let contentHeight = titleSize.height + spacing + linkSize.height
let panelHeight = contentHeight + verticalInset * 2.0
let panelFrame = CGRect(
x: 0,
y: 0,
width: layout.size.width,
height: panelHeight
)
transition.updateFrame(node: backgroundNode, frame: panelFrame)
transition.updateFrame(
node: titleNode,
frame: CGRect(
x: layout.safeInsets.left + inset,
y: verticalInset,
width: titleSize.width,
height: titleSize.height
)
)
transition.updateFrame(
node: linkNode,
frame: CGRect(
x: layout.safeInsets.left + inset,
y: verticalInset + titleSize.height + spacing,
width: linkSize.width,
height: linkSize.height
)
)
return panelHeight
}
override func didLoad() {
super.didLoad()
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap)))
}
@objc private func handleTap() {
item.action()
}
}
+110
View File
@@ -0,0 +1,110 @@
// MARK: Swiftgram GLEGram settings header (icon + title + tagline)
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import ItemListUI
import AppBundle
public final class GLEGramHeaderItem: ItemListControllerHeaderItem {
let theme: PresentationTheme
let title: String
let subtitle: String
public init(theme: PresentationTheme, title: String, subtitle: String) {
self.theme = theme
self.title = title
self.subtitle = subtitle
}
public func isEqual(to: ItemListControllerHeaderItem) -> Bool {
if let item = to as? GLEGramHeaderItem {
return theme === item.theme && title == item.title && subtitle == item.subtitle
}
return false
}
public func node(current: ItemListControllerHeaderItemNode?) -> ItemListControllerHeaderItemNode {
if let current = current as? GLEGramHeaderItemNode {
current.item = self
return current
}
return GLEGramHeaderItemNode(item: self)
}
}
private let titleFont = Font.bold(22.0)
private let subtitleFont = Font.regular(14.0)
private let iconSize: CGFloat = 64.0
private let iconCornerRadius: CGFloat = 14.0
final class GLEGramHeaderItemNode: ItemListControllerHeaderItemNode {
private let backgroundNode: ASDisplayNode
private let iconNode: ASImageNode
private let titleNode: ImmediateTextNode
private let subtitleNode: ImmediateTextNode
private var validLayout: ContainerViewLayout?
var item: GLEGramHeaderItem {
didSet {
updateItem()
if let layout = validLayout {
_ = updateLayout(layout: layout, transition: .immediate)
}
}
}
init(item: GLEGramHeaderItem) {
self.item = item
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.iconNode = ASImageNode()
self.iconNode.contentMode = .scaleAspectFit
self.iconNode.cornerRadius = iconCornerRadius
self.iconNode.clipsToBounds = true
if let rawIcon = UIImage(bundleImageName: "GLEGramSettings") {
self.iconNode.image = rawIcon
}
self.titleNode = ImmediateTextNode()
self.titleNode.maximumNumberOfLines = 1
self.subtitleNode = ImmediateTextNode()
self.subtitleNode.maximumNumberOfLines = 2
super.init()
addSubnode(backgroundNode)
addSubnode(iconNode)
addSubnode(titleNode)
addSubnode(subtitleNode)
updateItem()
}
private func updateItem() {
backgroundNode.backgroundColor = item.theme.list.blocksBackgroundColor
titleNode.attributedText = NSAttributedString(string: item.title, font: titleFont, textColor: item.theme.list.itemPrimaryTextColor)
subtitleNode.attributedText = NSAttributedString(string: item.subtitle, font: subtitleFont, textColor: item.theme.list.itemSecondaryTextColor)
}
override func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) -> CGFloat {
validLayout = layout
let width = layout.size.width - 32.0
let spacing: CGFloat = 6.0
let iconTitleSpacing: CGFloat = 10.0
let bottomInset: CGFloat = 4.0
let desiredHeaderHeight: CGFloat = 200.0
let extraTopOffset: CGFloat = 36.0
let titleSize = titleNode.updateLayout(CGSize(width: width, height: .greatestFiniteMagnitude))
let subtitleSize = subtitleNode.updateLayout(CGSize(width: width, height: .greatestFiniteMagnitude))
let subtitleHeight = min(subtitleSize.height, 36.0)
let contentBlockHeight = iconSize + iconTitleSpacing + titleSize.height + spacing + subtitleHeight
let topInset = extraTopOffset + max(12.0, (desiredHeaderHeight - extraTopOffset - contentBlockHeight - bottomInset) / 2.0)
backgroundNode.frame = CGRect(origin: .zero, size: CGSize(width: layout.size.width, height: desiredHeaderHeight))
iconNode.frame = CGRect(x: floor((layout.size.width - iconSize) / 2.0), y: topInset, width: iconSize, height: iconSize)
let titleY = topInset + iconSize + iconTitleSpacing
titleNode.frame = CGRect(x: floor((layout.size.width - titleSize.width) / 2.0), y: titleY, width: titleSize.width, height: titleSize.height)
subtitleNode.frame = CGRect(x: floor((layout.size.width - subtitleSize.width) / 2.0), y: titleY + titleSize.height + spacing, width: subtitleSize.width, height: subtitleHeight)
return desiredHeaderHeight
}
}
@@ -0,0 +1,464 @@
import Foundation
import SwiftUI
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import AccountContext
import SGSupporters
import SGSwiftUI
import LegacyUI
private let innerShadowWidth: CGFloat = 15.0
private let accentColorHex: String = "C0B0D8"
private struct GLEGramBackgroundView: View {
var body: some View {
ZStack {
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color(hex: "1A0A33"), location: 0.0),
.init(color: Color(hex: "3D1B6E"), location: 0.35),
.init(color: Color(hex: "2F1A57"), location: 0.7),
.init(color: Color(hex: "1A0A33"), location: 1.0),
]),
startPoint: .top,
endPoint: .bottom
)
.edgesIgnoringSafeArea(.all)
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color(hex: "4F298F").opacity(0.5), location: 0.0),
.init(color: Color.clear, location: 0.25),
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.edgesIgnoringSafeArea(.all)
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color(hex: "604080").opacity(0.3), location: 0.0),
.init(color: Color.clear, location: 0.2),
]),
startPoint: .topTrailing,
endPoint: .bottomLeading
)
.edgesIgnoringSafeArea(.all)
.overlay(
RoundedRectangle(cornerRadius: 0)
.stroke(Color.clear, lineWidth: 0)
.background(
ZStack {
innerShadow(x: -2, y: -2, blur: 6, color: Color(hex: "785B9E").opacity(0.6))
innerShadow(x: 2, y: 2, blur: 6, color: Color(hex: "4F298F").opacity(0.4))
}
)
)
.edgesIgnoringSafeArea(.all)
}
}
func innerShadow(x: CGFloat, y: CGFloat, blur: CGFloat, color: Color) -> some View {
RoundedRectangle(cornerRadius: 0)
.stroke(color, lineWidth: innerShadowWidth)
.blur(radius: blur)
.offset(x: x, y: y)
.mask(RoundedRectangle(cornerRadius: 0).fill(LinearGradient(gradient: Gradient(colors: [Color.black, Color.clear]), startPoint: .top, endPoint: .bottom)))
}
}
@available(iOS 13.0, *)
private struct GLEGramPaywallView: View {
let promo: GLEGramPromo
let trialAvailable: Bool
let onTrial: () -> Void
let onSubscribe: () -> Void
let onBack: () -> Void
@Environment(\.containerViewLayout) var containerViewLayout: ContainerViewLayout?
@State private var buttonsSectionSize: CGSize = .zero
var body: some View {
ZStack {
GLEGramBackgroundView()
ZStack(alignment: .bottom) {
ScrollView(showsIndicators: false) {
VStack(spacing: 28) {
ZStack {
Circle()
.fill(
RadialGradient(
gradient: Gradient(colors: [
Color(hex: "4F298F").opacity(0.4),
Color.clear
]),
center: .center,
startRadius: 20,
endRadius: 60
)
)
.frame(width: 120, height: 120)
Image("GLEGramSettings")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 88, height: 88)
.shadow(color: Color(hex: "785B9E").opacity(0.5), radius: 12, x: 0, y: 4)
}
VStack(spacing: 10) {
Text(promo.title)
.font(.system(size: 28, weight: .bold))
.foregroundColor(.white)
.shadow(color: Color(hex: "1A0A33").opacity(0.5), radius: 2, x: 0, y: 1)
Text(promo.subtitle)
.font(.callout)
.foregroundColor(Color(hex: "D0C0E8"))
.multilineTextAlignment(.center)
.padding(.horizontal)
.lineSpacing(4)
}
VStack(alignment: .leading, spacing: 10) {
ForEach(promo.features, id: \.self) { feature in
HStack(alignment: .top, spacing: 14) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color(hex: accentColorHex))
.font(.system(size: 22))
Text(feature)
.font(.subheadline)
.foregroundColor(Color(hex: "E8E0F0"))
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color.white.opacity(0.08))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color(hex: "4F298F").opacity(0.3), lineWidth: 1)
)
)
}
}
.padding(.horizontal)
Color.clear.frame(height: buttonsSectionSize.height + 24)
}
.padding(.vertical, 36)
}
.padding(.leading, max(innerShadowWidth + 8.0, sgLeftSafeAreaInset(containerViewLayout)))
.padding(.trailing, max(innerShadowWidth + 8.0, sgRightSafeAreaInset(containerViewLayout)))
VStack(spacing: 0) {
Rectangle()
.fill(
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "1A0A33").opacity(0),
Color(hex: "1A0A33").opacity(0.8)
]),
startPoint: .top,
endPoint: .bottom
)
)
.frame(height: 20)
Divider()
.background(Color(hex: "4F298F").opacity(0.4))
VStack(spacing: 12) {
if trialAvailable {
Button(action: onTrial) {
Text(promo.trialButtonText)
.fontWeight(.semibold)
.font(.system(size: 17))
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "A78BDA"),
Color(hex: "785B9E")
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.foregroundColor(.white)
.cornerRadius(14)
.shadow(color: Color(hex: "4F298F").opacity(0.5), radius: 8, x: 0, y: 4)
}
.buttonStyle(PlainButtonStyle())
}
Button(action: onSubscribe) {
Text(promo.subscribeButtonText)
.fontWeight(.semibold)
.font(.system(size: 17))
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Color.white.opacity(0.12))
.cornerRadius(14)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color(hex: "C0B0D8").opacity(0.4), lineWidth: 1)
)
.foregroundColor(Color(hex: "E8E0F0"))
}
.buttonStyle(PlainButtonStyle())
}
.padding([.horizontal, .top], 16)
.padding(.bottom, sgBottomSafeAreaInset(containerViewLayout) + 16)
}
.background(Color(hex: "1A0A33"))
.shadow(color: Color(hex: "1A0A33").opacity(0.5), radius: 12, y: -4)
.trackSize($buttonsSectionSize)
}
}
.overlay(backButtonView)
.colorScheme(.dark)
}
private var backButtonView: some View {
VStack {
HStack {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(Color(hex: "E8E0F0"))
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
Spacer()
}
.padding([.top, .leading], 16)
Spacer()
}
}
}
public func gleGramPaywallController(context: AccountContext, promo: GLEGramPromo, trialAvailable: Bool) -> ViewController {
if #available(iOS 13.0, *) {
let theme = defaultDarkColorPresentationTheme
let strings = context.sharedContext.currentPresentationData.with { $0 }.strings
let legacyController = LegacySwiftUIController(
presentation: .navigation,
theme: theme,
strings: strings
)
legacyController.statusBar.statusBarStyle = .White
legacyController.displayNavigationBar = false
legacyController.title = ""
var weakLegacy: LegacySwiftUIController?
weakLegacy = legacyController
let swiftUIView = SGSwiftUIView<GLEGramPaywallView>(
legacyController: legacyController,
content: {
GLEGramPaywallView(
promo: promo,
trialAvailable: trialAvailable,
onTrial: { [weak context] in
guard let context else { return }
let userId = context.account.peerId.id._internalGetInt64Value()
guard let signal = startTrialIfConfigured(userId: userId) else { return }
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let lang = presentationData.strings.baseLanguageCode
_ = (signal |> deliverOnMainQueue).start(next: { trial in
if let trial = trial, trial.alreadyUsed {
let text = lang == "ru" ? "Пробный период уже был использован" : "Trial has already been used"
weakLegacy?.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: text, actions: [
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})
]), in: .window(.root))
} else if let trial = trial, trial.active {
refreshGLEGramStatusIfConfigured(userId: userId)
let text = lang == "ru" ? "Пробный период активирован!" : "Trial activated!"
weakLegacy?.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: text, actions: [
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {
weakLegacy?.navigationController?.popViewController(animated: true)
})
]), in: .window(.root))
}
}, error: { err in
let text: String
if case .tooManyRequests = err {
text = lang == "ru" ? "Слишком много запросов. Подождите минуту." : "Too many requests. Wait a minute."
} else {
text = lang == "ru" ? "Ошибка сети. Попробуйте позже." : "Network error. Try again later."
}
weakLegacy?.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: text, actions: [
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})
]), in: .window(.root))
})
},
onSubscribe: { [weak context] in
guard let context, let urlString = promo.miniAppUrl, isUrlSafeForExternalOpen(urlString) else { return }
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: urlString, forceExternal: false, presentationData: presentationData, navigationController: weakLegacy?.navigationController as? NavigationController, dismissInput: {})
},
onBack: { weakLegacy?.navigationController?.popViewController(animated: true) }
)
}
)
let hostingController = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true)
legacyController.bind(controller: hostingController)
return legacyController
} else {
return GLEGramPaywallFallbackController(context: context, promo: promo, trialAvailable: trialAvailable)
}
}
private final class GLEGramPaywallFallbackController: ViewController {
private let context: AccountContext
private let promo: GLEGramPromo
private let trialAvailable: Bool
init(context: AccountContext, promo: GLEGramPromo, trialAvailable: Bool) {
self.context = context
self.promo = promo
self.trialAvailable = trialAvailable
super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: context.sharedContext.currentPresentationData.with { $0 }))
self.title = "GLEGram"
}
required init(coder: NSCoder) { fatalError() }
override public func loadDisplayNode() {
self.displayNode = ASDisplayNode()
self.displayNode.backgroundColor = UIColor(red: 26/255, green: 10/255, blue: 51/255, alpha: 1)
}
private var scrollView: UIScrollView?
private var contentLoaded = false
override public func viewDidLoad() {
super.viewDidLoad()
let sv = UIScrollView()
sv.alwaysBounceVertical = true
view.addSubview(sv)
scrollView = sv
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
guard let sv = scrollView else { return }
if !contentLoaded {
contentLoaded = true
let sideInset: CGFloat = 24
let maxW = layout.size.width - sideInset * 2
var y: CGFloat = 40
let titleLabel = UILabel()
titleLabel.text = promo.title
titleLabel.font = Font.bold(28)
titleLabel.textColor = .white
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 0
titleLabel.frame = CGRect(x: sideInset, y: y, width: maxW, height: 60)
sv.addSubview(titleLabel)
y += 70
let subLabel = UILabel()
subLabel.text = promo.subtitle
subLabel.font = Font.regular(16)
subLabel.textColor = UIColor(red: 232/255, green: 224/255, blue: 240/255, alpha: 1)
subLabel.textAlignment = .center
subLabel.numberOfLines = 0
let subSize = subLabel.sizeThatFits(CGSize(width: maxW, height: 200))
subLabel.frame = CGRect(x: sideInset, y: y, width: maxW, height: subSize.height)
sv.addSubview(subLabel)
y += subSize.height + 24
for f in promo.features {
let l = UILabel()
l.text = "\(f)"
l.font = Font.regular(17)
l.textColor = .white
l.numberOfLines = 0
let sz = l.sizeThatFits(CGSize(width: maxW - 16, height: 100))
l.frame = CGRect(x: sideInset + 8, y: y, width: maxW - 16, height: sz.height)
sv.addSubview(l)
y += sz.height + 12
}
y += 24
if trialAvailable {
let btn = UIButton(type: .system)
btn.setTitle(promo.trialButtonText, for: .normal)
btn.titleLabel?.font = Font.semibold(17)
btn.setTitleColor(.white, for: .normal)
btn.backgroundColor = UIColor(red: 120/255, green: 91/255, blue: 158/255, alpha: 1)
btn.layer.cornerRadius = 12
btn.frame = CGRect(x: sideInset, y: y, width: maxW, height: 50)
btn.addTarget(self, action: #selector(trialTap), for: .touchUpInside)
sv.addSubview(btn)
y += 62
}
let subBtn = UIButton(type: .system)
subBtn.setTitle(promo.subscribeButtonText, for: .normal)
subBtn.titleLabel?.font = Font.semibold(17)
subBtn.setTitleColor(.white, for: .normal)
subBtn.backgroundColor = UIColor(white: 1, alpha: 0.12)
subBtn.layer.cornerRadius = 12
subBtn.frame = CGRect(x: sideInset, y: y, width: maxW, height: 50)
subBtn.addTarget(self, action: #selector(subscribeTap), for: .touchUpInside)
sv.addSubview(subBtn)
y += 70
sv.contentSize = CGSize(width: layout.size.width, height: y)
}
let topInset = layout.safeInsets.top
sv.frame = CGRect(x: 0, y: topInset, width: layout.size.width, height: layout.size.height - topInset)
}
@objc private func trialTap() {
let userId = context.account.peerId.id._internalGetInt64Value()
guard let signal = startTrialIfConfigured(userId: userId) else { return }
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let lang = presentationData.strings.baseLanguageCode
_ = (signal |> deliverOnMainQueue).start(next: { [weak self] trial in
guard let self else { return }
if let trial = trial, trial.alreadyUsed {
let text = lang == "ru" ? "Пробный период уже был использован" : "Trial has already been used"
self.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: text, actions: [
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})
]), in: .window(.root))
} else if let trial = trial, trial.active {
refreshGLEGramStatusIfConfigured(userId: userId)
let text = lang == "ru" ? "Пробный период активирован!" : "Trial activated!"
self.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: text, actions: [
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: { [weak self] in
self?.navigationController?.popViewController(animated: true)
})
]), in: .window(.root))
}
}, error: { [weak self] err in
guard let self else { return }
let text: String
if case .tooManyRequests = err {
text = lang == "ru" ? "Слишком много запросов. Подождите минуту." : "Too many requests. Wait a minute."
} else {
text = lang == "ru" ? "Ошибка сети. Попробуйте позже." : "Network error. Try again later."
}
self.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: text, actions: [
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})
]), in: .window(.root))
})
}
@objc private func subscribeTap() {
// MARK: GLEGram redirect to support chat
let urlString = "https://t.me/glesign_support"
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: urlString, forceExternal: false, presentationData: presentationData, navigationController: navigationController as? NavigationController, dismissInput: {})
}
}
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
// MARK: Swiftgram Plugin row item (like Active sites: icon, name, author, description, switch)
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import AppBundle
/// One row per plugin: icon, name, author, description; switch on the right (like Active sites).
final class ItemListPluginRowItem: ListViewItem, ItemListItem {
let presentationData: ItemListPresentationData
let plugin: PluginInfo
let icon: UIImage?
let sectionId: ItemListSectionId
let toggle: (Bool) -> Void
let action: (() -> Void)?
init(presentationData: ItemListPresentationData, plugin: PluginInfo, icon: UIImage?, sectionId: ItemListSectionId, toggle: @escaping (Bool) -> Void, action: (() -> Void)? = nil) {
self.presentationData = presentationData
self.plugin = plugin
self.icon = icon
self.sectionId = sectionId
self.toggle = toggle
self.action = action
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = ItemListPluginRowItemNode()
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) }) })
}
}
}
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? ItemListPluginRowItemNode {
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.isAnimated) })
}
}
}
}
}
var selectable: Bool { action != nil }
func selected(listView: ListView) {
listView.clearHighlightAnimated(true)
action?()
}
}
private let leftInsetNoIcon: CGFloat = 16.0
private let iconSize: CGFloat = 30.0
private let leftInsetWithIcon: CGFloat = 16.0 + iconSize + 13.0
private let switchWidth: CGFloat = 51.0
private let switchRightInset: CGFloat = 15.0
final class ItemListPluginRowItemNode: ListViewItemNode {
private let backgroundNode: ASDisplayNode
private let topStripeNode: ASDisplayNode
private let bottomStripeNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode
private let maskNode: ASImageNode
private let iconNode: ASImageNode
private let titleNode: TextNode
private let authorNode: TextNode
private let descriptionNode: TextNode
private var switchNode: ASDisplayNode?
private var switchView: UISwitch?
private var layoutParams: (ItemListPluginRowItem, ListViewItemLayoutParams, ItemListNeighbors)?
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.iconNode = ASImageNode()
self.iconNode.contentMode = .scaleAspectFit
self.iconNode.cornerRadius = 7.0
self.iconNode.clipsToBounds = true
self.iconNode.isLayerBacked = true
self.titleNode = TextNode()
self.titleNode.isUserInteractionEnabled = false
self.titleNode.contentsScale = UIScreen.main.scale
self.authorNode = TextNode()
self.authorNode.isUserInteractionEnabled = false
self.authorNode.contentsScale = UIScreen.main.scale
self.descriptionNode = TextNode()
self.descriptionNode.isUserInteractionEnabled = false
self.descriptionNode.contentsScale = UIScreen.main.scale
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isLayerBacked = true
super.init(layerBacked: false, rotated: false, seeThrough: false)
addSubnode(self.backgroundNode)
addSubnode(self.topStripeNode)
addSubnode(self.bottomStripeNode)
addSubnode(self.maskNode)
addSubnode(self.iconNode)
addSubnode(self.titleNode)
addSubnode(self.authorNode)
addSubnode(self.descriptionNode)
}
func asyncLayout() -> (ItemListPluginRowItem, ListViewItemLayoutParams, ItemListNeighbors) -> (ListViewItemNodeLayout, (Bool) -> Void) {
let makeTitle = TextNode.asyncLayout(self.titleNode)
let makeAuthor = TextNode.asyncLayout(self.authorNode)
let makeDescription = TextNode.asyncLayout(self.descriptionNode)
return { item, params, neighbors in
let titleFont = Font.medium(floor(item.presentationData.fontSize.itemListBaseFontSize * 16.0 / 17.0))
let textFont = Font.regular(floor(item.presentationData.fontSize.itemListBaseFontSize * 14.0 / 17.0))
let leftInset = leftInsetWithIcon + params.leftInset
let rightInset = params.rightInset + switchWidth + switchRightInset
let textWidth = params.width - leftInset - rightInset - 8.0
let meta = item.plugin.metadata
let titleAttr = NSAttributedString(string: meta.name, font: titleFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor)
let lang = item.presentationData.strings.baseLanguageCode
let versionAuthor = (lang == "ru" ? "Версия " : "Version ") + "\(meta.version) · \(meta.author)"
let authorAttr = NSAttributedString(string: versionAuthor, font: textFont, textColor: item.presentationData.theme.list.itemSecondaryTextColor)
let descAttr = NSAttributedString(string: meta.description, font: textFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor)
let (titleLayout, titleApply) = makeTitle(TextNodeLayoutArguments(attributedString: titleAttr, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: textWidth, height: .greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: .zero))
let (authorLayout, authorApply) = makeAuthor(TextNodeLayoutArguments(attributedString: authorAttr, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: textWidth, height: .greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: .zero))
let (descLayout, descApply) = makeDescription(TextNodeLayoutArguments(attributedString: descAttr, backgroundColor: nil, maximumNumberOfLines: 2, truncationType: .end, constrainedSize: CGSize(width: textWidth, height: .greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: .zero))
let verticalInset: CGFloat = 4.0
let rowHeight: CGFloat = verticalInset * 2 + 10 + titleLayout.size.height + 4 + authorLayout.size.height + 4 + descLayout.size.height
let contentHeight = max(75.0, rowHeight)
let insets = itemListNeighborsGroupedInsets(neighbors, params)
let layout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: contentHeight), insets: insets)
let layoutSize = layout.size
let separatorHeight = UIScreenPixel
return (layout, { [weak self] animated in
guard let self = self else { return }
self.layoutParams = (item, params, neighbors)
let theme = item.presentationData.theme
self.topStripeNode.backgroundColor = theme.list.itemBlocksSeparatorColor
self.bottomStripeNode.backgroundColor = theme.list.itemBlocksSeparatorColor
self.backgroundNode.backgroundColor = theme.list.itemBlocksBackgroundColor
self.highlightedBackgroundNode.backgroundColor = theme.list.itemHighlightedBackgroundColor
self.iconNode.image = item.icon
let _ = titleApply()
let _ = authorApply()
let _ = descApply()
if self.switchView == nil {
let sw = UISwitch()
sw.addTarget(self, action: #selector(self.switchChanged(_:)), for: .valueChanged)
self.switchView = sw
self.switchNode = ASDisplayNode(viewBlock: { sw })
self.addSubnode(self.switchNode!)
}
self.switchView?.isOn = item.plugin.enabled
self.switchView?.isUserInteractionEnabled = true
let hasCorners = itemListHasRoundedBlockLayout(params)
var hasTopCorners = false
var hasBottomCorners = false
switch neighbors.top {
case .sameSection(false): self.topStripeNode.isHidden = true
default: hasTopCorners = true; self.topStripeNode.isHidden = hasCorners
}
let bottomStripeInset: CGFloat
switch neighbors.bottom {
case .sameSection(false): bottomStripeInset = leftInsetWithIcon + params.leftInset
default: bottomStripeInset = 0; hasBottomCorners = true; self.bottomStripeNode.isHidden = hasCorners
}
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(theme, top: hasTopCorners, bottom: hasBottomCorners, glass: false) : nil
self.backgroundNode.frame = CGRect(origin: CGPoint(x: 0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentHeight + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))
self.maskNode.frame = self.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0)
self.topStripeNode.frame = CGRect(x: 0, y: -min(insets.top, separatorHeight), width: layoutSize.width, height: separatorHeight)
self.bottomStripeNode.frame = CGRect(x: bottomStripeInset, y: contentHeight, width: layoutSize.width - bottomStripeInset - params.rightInset, height: separatorHeight)
self.iconNode.frame = CGRect(x: params.leftInset + 16, y: verticalInset + 10, width: iconSize, height: iconSize)
let textX = params.leftInset + 16 + iconSize + 13
self.titleNode.frame = CGRect(origin: CGPoint(x: textX, y: verticalInset + 10), size: titleLayout.size)
self.authorNode.frame = CGRect(origin: CGPoint(x: textX, y: verticalInset + 10 + titleLayout.size.height + 4), size: authorLayout.size)
self.descriptionNode.frame = CGRect(origin: CGPoint(x: textX, y: verticalInset + 10 + titleLayout.size.height + 4 + authorLayout.size.height + 4), size: descLayout.size)
let switchSize = self.switchView?.bounds.size ?? CGSize(width: switchWidth, height: 31)
self.switchNode?.frame = CGRect(x: params.width - params.rightInset - switchWidth - switchRightInset, y: floor((contentHeight - switchSize.height) / 2.0), width: switchWidth, height: switchSize.height)
self.highlightedBackgroundNode.frame = self.backgroundNode.frame
})
}
}
@objc private func switchChanged(_ sender: UISwitch) {
if let item = self.layoutParams?.0 {
item.toggle(sender.isOn)
}
}
override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) {
super.setHighlighted(highlighted, at: point, animated: animated)
if highlighted {
self.highlightedBackgroundNode.alpha = 1
if self.highlightedBackgroundNode.supernode == nil {
self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.backgroundNode)
}
} else {
if animated {
self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0, duration: 0.25)
}
self.highlightedBackgroundNode.alpha = 0
}
}
}
+37
View File
@@ -0,0 +1,37 @@
// MARK: Swiftgram Plugin bridge (Swift Python runtime for exteraGram .plugin files)
//
// This module provides a bridge to run or query exteraGram-style .plugin files (Python).
// - Default: metadata and settings detection via regex (PluginMetadataParser), works on iOS/macOS.
// - Optional: when PythonKit (https://github.com/pvieito/PythonKit) is available, use
// PythonPluginRuntime to execute plugin code in a sandbox and read metadata from Python.
//
// swift-bridge (https://github.com/chinedufn/swift-bridge) is for RustSwift; for SwiftPython
// we use PythonKit. This protocol allows swapping implementations (regex-only vs PythonKit).
import Foundation
/// Runtime used to parse or execute .plugin file content (exteraGram Python format).
public protocol PluginRuntime: Sendable {
/// Parses plugin metadata (__name__, __id__, __description__, etc.) from file content.
func parseMetadata(content: String) -> PluginMetadata?
/// Returns true if the plugin defines create_settings or __settings__ = True.
func hasCreateSettings(content: String) -> Bool
}
/// Default implementation using regex-based parsing (no Python required). Works on iOS and macOS.
public final class DefaultPluginRuntime: PluginRuntime, @unchecked Sendable {
public static let shared = DefaultPluginRuntime()
public init() {}
public func parseMetadata(content: String) -> PluginMetadata? {
PluginMetadataParser.parse(content: content)
}
public func hasCreateSettings(content: String) -> Bool {
PluginMetadataParser.hasCreateSettings(content: content)
}
}
/// Current runtime used by the app. Set to a PythonKit-based runtime when Python is available.
public var currentPluginRuntime: PluginRuntime = DefaultPluginRuntime.shared
+37
View File
@@ -0,0 +1,37 @@
// MARK: Swiftgram Plugin bridge via PythonKit (Swift Python)
//
// Uses PythonKit (https://github.com/pvieito/PythonKit) when available.
// exteraGram plugins import Android/Java (base_plugin, org.telegram.messenger, etc.);
// on iOS/macOS those are unavailable, so we use regex parsing by default. When PythonKit
// is linked, you can implement full execution with builtins.exec(code, globals, locals)
// and stub modules (base_plugin, java, ui, ...) so the script runs and exposes __name__, etc.
//
// To enable PythonKit: add as SPM dependency or vendored; on iOS embed a Python framework.
import Foundation
#if canImport(PythonKit)
import PythonKit
/// Runtime that can use Python to parse/run plugin content when PythonKit is available.
/// Currently delegates to regex parser; replace with exec()-based implementation when
/// stubs for base_plugin/java/android are ready.
public final class PythonPluginRuntime: PluginRuntime, @unchecked Sendable {
public static let shared = PythonPluginRuntime()
private init() {}
public func parseMetadata(content: String) -> PluginMetadata? {
// Optional: use Python builtins.exec(content, globals, locals) with stubbed
// base_plugin, java, ui, etc., then read __name__, __id__, ... from globals.
// For now use regex so it works without a full Python stub environment.
return PluginMetadataParser.parse(content: content)
}
public func hasCreateSettings(content: String) -> Bool {
PluginMetadataParser.hasCreateSettings(content: content)
}
}
#else
// When PythonKit is not linked, PythonPluginRuntime is not compiled; app uses DefaultPluginRuntime.
#endif
@@ -0,0 +1,235 @@
// MARK: GLEGram Plugin code editor (create/edit JS plugins inline)
import Foundation
import UIKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import SGSimpleSettings
// MARK: - State
private final class PluginCodeEditorStateHolder {
var name: String
var code: String
init(name: String, code: String) {
self.name = name
self.code = code
}
}
private struct PluginCodeEditorState: Equatable {
var name: String
var code: String
}
// MARK: - Entries
private enum PluginCodeEditorEntry: ItemListNodeEntry {
case nameInput(id: Int, text: String, placeholder: String)
case codeInput(id: Int, text: String, placeholder: String)
case notice(id: Int, text: String)
var section: ItemListSectionId {
switch self {
case .nameInput: return 0
case .codeInput: return 1
case .notice: return 2
}
}
var stableId: Int {
switch self {
case .nameInput(let id, _, _): return id
case .codeInput(let id, _, _): return id
case .notice(let id, _): return id
}
}
static func == (lhs: PluginCodeEditorEntry, rhs: PluginCodeEditorEntry) -> Bool {
switch (lhs, rhs) {
case let (.nameInput(a, t1, p1), .nameInput(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.codeInput(a, t1, p1), .codeInput(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.notice(a, t1), .notice(b, t2)): return a == b && t1 == t2
default: return false
}
}
static func < (lhs: PluginCodeEditorEntry, rhs: PluginCodeEditorEntry) -> Bool {
lhs.stableId < rhs.stableId
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! PluginCodeEditorArguments
switch self {
case .nameInput(_, let text, let placeholder):
return ItemListSingleLineInputItem(
presentationData: presentationData,
title: NSAttributedString(),
text: text,
placeholder: placeholder,
sectionId: section,
textUpdated: { newText in args.updatedName(newText) },
action: {}
)
case .codeInput(_, let text, let placeholder):
return ItemListMultilineInputItem(
presentationData: presentationData,
text: text,
placeholder: placeholder,
maxLength: nil,
sectionId: section,
style: .blocks,
textUpdated: { newText in args.updatedCode(newText) },
updatedFocus: nil,
tag: nil,
action: nil,
inlineAction: nil
)
case .notice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: section)
}
}
}
// MARK: - Arguments
private final class PluginCodeEditorArguments {
var updatedName: (String) -> Void = { _ in }
var updatedCode: (String) -> Void = { _ in }
}
private final class PluginCodeEditorNavActions {
var cancel: (() -> Void)?
var done: (() -> Void)?
}
// MARK: - Entries builder
private func pluginCodeEditorEntries(state: PluginCodeEditorState, presentationData: PresentationData) -> [PluginCodeEditorEntry] {
let lang = presentationData.strings.baseLanguageCode
var entries: [PluginCodeEditorEntry] = []
entries.append(.nameInput(id: 0, text: state.name, placeholder: lang == "ru" ? "Имя плагина" : "Plugin name"))
entries.append(.codeInput(id: 1, text: state.code, placeholder: lang == "ru" ? "JavaScript код..." : "JavaScript code..."))
let noticeText = lang == "ru"
? "Используйте GLEGram.ui, GLEGram.chat, GLEGram.compose, GLEGram.messageActions, GLEGram.intercept, GLEGram.network, GLEGram.settings, GLEGram.events API."
: "Use GLEGram.ui, GLEGram.chat, GLEGram.compose, GLEGram.messageActions, GLEGram.intercept, GLEGram.network, GLEGram.settings, GLEGram.events API."
entries.append(.notice(id: 2, text: noticeText))
return entries
}
// MARK: - Controller
public func pluginCodeEditorController(context: AccountContext, existingPlugin: PluginInfo?, initialCode: String, onSave: @escaping (PluginInfo) -> Void) -> ViewController {
let initialName = existingPlugin?.metadata.name ?? ""
let stateHolder = PluginCodeEditorStateHolder(name: initialName, code: initialCode)
let navActions = PluginCodeEditorNavActions()
let statePromise = ValuePromise(PluginCodeEditorState(name: initialName, code: initialCode), ignoreRepeated: true)
let arguments = PluginCodeEditorArguments()
arguments.updatedName = { newName in
stateHolder.name = newName
statePromise.set(PluginCodeEditorState(name: newName, code: stateHolder.code))
}
arguments.updatedCode = { newCode in
stateHolder.code = newCode
statePromise.set(PluginCodeEditorState(name: stateHolder.name, code: newCode))
}
let signal = combineLatest(context.sharedContext.presentationData, statePromise.get())
|> map { presentationData, state -> (ItemListControllerState, (ItemListNodeState, PluginCodeEditorArguments)) in
let lang = presentationData.strings.baseLanguageCode
let title = existingPlugin != nil
? (lang == "ru" ? "Редактор" : "Editor")
: (lang == "ru" ? "Новый плагин" : "New Plugin")
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(title),
leftNavigationButton: ItemListNavigationButton(content: .text(presentationData.strings.Common_Cancel), style: .regular, enabled: true, action: { navActions.cancel?() }),
rightNavigationButton: ItemListNavigationButton(content: .text(lang == "ru" ? "Сохранить" : "Save"), style: .bold, enabled: !state.code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, action: { navActions.done?() }),
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = pluginCodeEditorEntries(state: state, presentationData: presentationData)
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
initialScrollToItem: nil
)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
navActions.cancel = { [weak controller] in
controller?.dismiss()
}
navActions.done = { [weak controller] in
let code = stateHolder.code.trimmingCharacters(in: .whitespacesAndNewlines)
guard !code.isEmpty else { return }
// Parse metadata from code
var metadata: PluginMetadata
if let parsed = PluginMetadataParser.parse(content: code) {
metadata = parsed
} else {
let name = stateHolder.name.trimmingCharacters(in: .whitespacesAndNewlines)
let safeName = name.isEmpty ? "Untitled Plugin" : name
let safeId = existingPlugin?.metadata.id ?? safeName.lowercased()
.replacingOccurrences(of: " ", with: "-")
.filter { $0.isLetter || $0.isNumber || $0 == "-" }
let id = safeId.isEmpty ? "plugin-\(UUID().uuidString.prefix(8))" : safeId
metadata = PluginMetadata(id: id, name: safeName, description: "", version: "1.0", author: "")
}
// If editing, keep the same ID
if let existing = existingPlugin {
metadata = PluginMetadata(
id: existing.metadata.id,
name: metadata.name,
description: metadata.description,
version: metadata.version,
author: metadata.author,
iconRef: metadata.iconRef,
minVersion: metadata.minVersion,
hasUserDisplay: metadata.hasUserDisplay
)
}
// Write file
let fileManager = FileManager.default
guard let supportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
let pluginsDir = supportURL.appendingPathComponent("Plugins", isDirectory: true)
try? fileManager.createDirectory(at: pluginsDir, withIntermediateDirectories: true)
let destURL = pluginsDir.appendingPathComponent("\(metadata.id).js")
try? code.write(to: destURL, atomically: true, encoding: .utf8)
// Update installed list
let pluginInfo = PluginInfo(metadata: metadata, path: destURL.path, enabled: true, hasSettings: false)
var plugins: [PluginInfo]
if let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let existing = try? JSONDecoder().decode([PluginInfo].self, from: data) {
plugins = existing
} else {
plugins = []
}
plugins.removeAll { $0.metadata.id == metadata.id }
plugins.append(pluginInfo)
if let data = try? JSONEncoder().encode(plugins),
let json = String(data: data, encoding: .utf8) {
SGSimpleSettings.shared.installedPluginsJson = json
SGSimpleSettings.shared.synchronizeShared()
}
// Reload plugins
PluginRunner.install()
onSave(pluginInfo)
controller?.dismiss()
}
return controller
}
@@ -0,0 +1,358 @@
// MARK: Swiftgram Plugin install popup (tap .plugin file in chat)
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import AccountContext
import SGSimpleSettings
import AppBundle
private func loadInstalledPlugins() -> [PluginInfo] {
guard let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let list = try? JSONDecoder().decode([PluginInfo].self, from: data) else {
return []
}
return list
}
private func saveInstalledPlugins(_ plugins: [PluginInfo]) {
if let data = try? JSONEncoder().encode(plugins),
let json = String(data: data, encoding: .utf8) {
SGSimpleSettings.shared.installedPluginsJson = json
SGSimpleSettings.shared.synchronizeShared()
}
}
/// Modal popup when user taps a .plugin file in chat: shows plugin info and "Install" button.
public final class PluginInstallPopupController: ViewController {
private let context: AccountContext
private let message: Message
private let file: TelegramMediaFile
private var onInstalled: (() -> Void)?
private var loadDisposable: Disposable?
private var state: State = .loading {
didSet { applyState() }
}
private enum State {
case loading
case loaded(metadata: PluginMetadata, hasSettings: Bool, filePath: String)
case error(String)
}
private let contentNode: PluginInstallPopupContentNode
public init(context: AccountContext, message: Message, file: TelegramMediaFile, onInstalled: (() -> Void)? = nil) {
self.context = context
self.message = message
self.file = file
self.onInstalled = onInstalled
self.contentNode = PluginInstallPopupContentNode()
super.init(navigationBarPresentationData: nil)
self.blocksBackgroundWhenInOverlay = true
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
loadDisposable?.dispose()
}
override public func loadDisplayNode() {
self.displayNode = contentNode
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
contentNode.backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor
contentNode.controller = self
contentNode.installAction = { [weak self] enableAfterInstall in
self?.performInstall(enableAfterInstall: enableAfterInstall)
}
contentNode.closeAction = { [weak self] in
self?.dismiss()
}
contentNode.shareAction = { [weak self] in
self?.sharePlugin()
}
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Close, style: .plain, target: self, action: #selector(closeTapped))
self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(shareTapped))
applyState()
startLoading()
}
@objc private func closeTapped() {
dismiss()
}
@objc private func shareTapped() {
sharePlugin()
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
}
private func startLoading() {
let postbox = context.account.postbox
let resource = file.resource
loadDisposable?.dispose()
loadDisposable = (postbox.mediaBox.resourceData(resource, option: .complete(waitUntilFetchStatus: true))
|> filter { $0.complete }
|> take(1)
|> deliverOnMainQueue
).start(next: { [weak self] data in
guard let self = self else { return }
guard let content = try? String(contentsOfFile: data.path, encoding: .utf8) else {
self.state = .error("Не удалось прочитать файл")
return
}
guard let metadata = currentPluginRuntime.parseMetadata(content: content) else {
self.state = .error("Неверный формат плагина")
return
}
let hasSettings = currentPluginRuntime.hasCreateSettings(content: content)
self.state = .loaded(metadata: metadata, hasSettings: hasSettings, filePath: data.path)
})
}
private func applyState() {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
switch state {
case .loading:
contentNode.setLoading(presentationData: presentationData)
case .loaded(let metadata, let hasSettings, _):
contentNode.setLoaded(presentationData: presentationData, metadata: metadata, hasSettings: hasSettings)
case .error(let message):
contentNode.setError(presentationData: presentationData, message: message, retry: { [weak self] in
self?.state = .loading
self?.startLoading()
})
}
}
private func performInstall(enableAfterInstall: Bool) {
guard case .loaded(let metadata, let hasSettings, let filePath) = state else { return }
let fileManager = FileManager.default
guard let supportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
let pluginsDir = supportURL.appendingPathComponent("Plugins", isDirectory: true)
let destPath = pluginsDir.appendingPathComponent("\(metadata.id).plugin").path
do {
try fileManager.createDirectory(at: pluginsDir, withIntermediateDirectories: true)
let destURL = URL(fileURLWithPath: destPath)
try? fileManager.removeItem(at: destURL)
try fileManager.copyItem(at: URL(fileURLWithPath: filePath), to: destURL)
} catch {
contentNode.showError("Не удалось установить: \(error.localizedDescription)")
return
}
var plugins = loadInstalledPlugins()
plugins.removeAll { $0.metadata.id == metadata.id }
plugins.append(PluginInfo(metadata: metadata, path: destPath, enabled: enableAfterInstall, hasSettings: hasSettings))
saveInstalledPlugins(plugins)
onInstalled?()
dismiss()
}
private func sharePlugin() {
guard case .loaded(_, _, let filePath) = state else { return }
let url = URL(fileURLWithPath: filePath)
let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil)
if let window = self.view.window, let root = window.rootViewController {
var top = root
while let presented = top.presentedViewController { top = presented }
if let popover = activityVC.popoverPresentationController {
popover.sourceView = view
popover.sourceRect = CGRect(x: view.bounds.midX, y: 60, width: 0, height: 0)
popover.permittedArrowDirections = .up
}
top.present(activityVC, animated: true)
}
}
}
// MARK: - Content node (icon, name, version, description, Install, checkbox)
private final class PluginInstallPopupContentNode: ViewControllerTracingNode {
weak var controller: PluginInstallPopupController?
var installAction: ((Bool) -> Void)?
var closeAction: (() -> Void)?
var shareAction: (() -> Void)?
var retryBlock: (() -> Void)?
private let scrollNode = ASScrollNode()
private let iconNode = ASImageNode()
private let nameNode = ImmediateTextNode()
private let versionNode = ImmediateTextNode()
private let descriptionNode = ImmediateTextNode()
private let installButton = ASButtonNode()
private let enableAfterContainer = ASDisplayNode()
private let enableAfterLabel = ImmediateTextNode()
private let loadingNode = ASDisplayNode()
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let errorLabel = ImmediateTextNode()
private let retryButton = ASButtonNode()
private var enableAfterInstall: Bool = true
private var currentMetadata: PluginMetadata?
private var switchView: UISwitch?
override init() {
super.init()
addSubnode(scrollNode)
scrollNode.addSubnode(iconNode)
scrollNode.addSubnode(nameNode)
scrollNode.addSubnode(versionNode)
scrollNode.addSubnode(descriptionNode)
scrollNode.addSubnode(installButton)
scrollNode.addSubnode(enableAfterContainer)
scrollNode.addSubnode(enableAfterLabel)
addSubnode(loadingNode)
addSubnode(errorLabel)
addSubnode(retryButton)
iconNode.contentMode = .scaleAspectFit
installButton.addTarget(self, action: #selector(installTapped), forControlEvents: .touchUpInside)
retryButton.addTarget(self, action: #selector(retryTapped), forControlEvents: .touchUpInside)
}
func setLoading(presentationData: PresentationData) {
backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor
loadingNode.isHidden = false
loadingNode.view.addSubview(loadingIndicator)
loadingIndicator.startAnimating()
scrollNode.isHidden = true
errorLabel.isHidden = true
retryButton.isHidden = true
}
func setLoaded(presentationData: PresentationData, metadata: PluginMetadata, hasSettings: Bool) {
backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor
currentMetadata = metadata
loadingNode.isHidden = true
loadingIndicator.stopAnimating()
errorLabel.isHidden = true
retryButton.isHidden = true
scrollNode.isHidden = false
let theme = presentationData.theme
let lang = presentationData.strings.baseLanguageCode
let isRu = lang == "ru"
iconNode.image = (metadata.iconRef.flatMap { UIImage(bundleImageName: $0) }) ?? UIImage(bundleImageName: "glePlugins/1")
nameNode.attributedText = NSAttributedString(string: metadata.name, font: Font.bold(22), textColor: theme.list.itemPrimaryTextColor)
nameNode.maximumNumberOfLines = 1
nameNode.truncationMode = .byTruncatingTail
let versionAuthor = (isRu ? "Версия " : "Version ") + "\(metadata.version)" + (metadata.author.isEmpty ? "" : "\(metadata.author)")
versionNode.attributedText = NSAttributedString(string: versionAuthor, font: Font.regular(15), textColor: theme.list.itemSecondaryTextColor)
versionNode.maximumNumberOfLines = 1
descriptionNode.attributedText = NSAttributedString(string: metadata.description.isEmpty ? (isRu ? "Нет описания." : "No description.") : metadata.description, font: Font.regular(15), textColor: theme.list.itemPrimaryTextColor)
descriptionNode.maximumNumberOfLines = 6
descriptionNode.truncationMode = .byTruncatingTail
installButton.setTitle(isRu ? "Установить" : "Install", with: Font.semibold(17), with: .white, for: .normal)
installButton.backgroundColor = theme.list.itemAccentColor
installButton.cornerRadius = 12
installButton.contentEdgeInsets = UIEdgeInsets(top: 14, left: 24, bottom: 14, right: 24)
enableAfterLabel.attributedText = NSAttributedString(string: isRu ? "Включить после установки" : "Enable after installation", font: Font.regular(16), textColor: theme.list.itemPrimaryTextColor)
enableAfterLabel.maximumNumberOfLines = 1
if switchView == nil {
let sw = UISwitch()
sw.isOn = enableAfterInstall
sw.addTarget(self, action: #selector(enableAfterChanged(_:)), for: .valueChanged)
enableAfterContainer.view.addSubview(sw)
switchView = sw
}
switchView?.isOn = enableAfterInstall
layoutContent()
}
@objc private func enableAfterChanged(_ sender: UISwitch) {
enableAfterInstall = sender.isOn
}
func setError(presentationData: PresentationData, message: String, retry: @escaping () -> Void) {
backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor
retryBlock = retry
currentMetadata = nil
loadingNode.isHidden = true
scrollNode.isHidden = true
errorLabel.isHidden = false
retryButton.isHidden = false
errorLabel.attributedText = NSAttributedString(string: message, font: Font.regular(16), textColor: presentationData.theme.list.itemDestructiveColor)
let retryTitle = (presentationData.strings.baseLanguageCode == "ru" ? "Повторить" : "Retry")
retryButton.setTitle(retryTitle, with: Font.regular(17), with: presentationData.theme.list.itemAccentColor, for: .normal)
layoutContent()
}
func showError(_ message: String) {
errorLabel.attributedText = NSAttributedString(string: message, font: Font.regular(16), textColor: .red)
errorLabel.isHidden = false
errorLabel.frame = CGRect(x: 24, y: 120, width: bounds.width - 48, height: 60)
}
@objc private func installTapped() {
installAction?(enableAfterInstall)
}
@objc private func retryTapped() {
guard let retry = retryBlock else { return }
retry()
}
private func layoutContent() {
let b = bounds
let w = b.width > 0 ? b.width : 320
let pad: CGFloat = 24
loadingIndicator.center = CGPoint(x: b.midX, y: b.midY)
loadingNode.frame = b
errorLabel.frame = CGRect(x: pad, y: b.midY - 40, width: w - pad * 2, height: 60)
retryButton.frame = CGRect(x: pad, y: b.midY + 20, width: w - pad * 2, height: 44)
scrollNode.frame = b
let contentW = w - pad * 2
iconNode.frame = CGRect(x: pad, y: 20, width: 56, height: 56)
nameNode.frame = CGRect(x: pad, y: 86, width: contentW, height: 28)
versionNode.frame = CGRect(x: pad, y: 118, width: contentW, height: 22)
let descY: CGFloat = 150
let descMaxH: CGFloat = 80
if let att = descriptionNode.attributedText {
let descSize = att.boundingRect(with: CGSize(width: contentW, height: descMaxH), options: .usesLineFragmentOrigin, context: nil).size
descriptionNode.frame = CGRect(x: pad, y: descY, width: contentW, height: min(descMaxH, ceil(descSize.height)))
} else {
descriptionNode.frame = CGRect(x: pad, y: descY, width: contentW, height: 22)
}
let buttonY: CGFloat = 240
installButton.frame = CGRect(x: pad, y: buttonY, width: contentW, height: 50)
let rowY: CGFloat = 306
let switchW: CGFloat = 51
let switchH: CGFloat = 31
enableAfterLabel.frame = CGRect(x: pad, y: rowY, width: contentW - switchW - 12, height: 24)
enableAfterContainer.frame = CGRect(x: w - pad - switchW, y: rowY, width: switchW, height: switchH)
switchView?.frame = CGRect(origin: .zero, size: CGSize(width: switchW, height: switchH))
let contentHeight: CGFloat = 360
scrollNode.view.contentSize = CGSize(width: w, height: contentHeight)
}
override func layout() {
super.layout()
layoutContent()
}
}
+381
View File
@@ -0,0 +1,381 @@
// MARK: Swiftgram Plugin list (like Active sites: icon, name, author, description, switch; Settings below)
import Foundation
import UIKit
import ObjectiveC
import UniformTypeIdentifiers
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import SGSimpleSettings
import AppBundle
private var documentPickerDelegateKey: UInt8 = 0
private func loadInstalledPlugins() -> [PluginInfo] {
guard let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let list = try? JSONDecoder().decode([PluginInfo].self, from: data) else {
return []
}
return list
}
private func saveInstalledPlugins(_ plugins: [PluginInfo]) {
if let data = try? JSONEncoder().encode(plugins),
let json = String(data: data, encoding: .utf8) {
SGSimpleSettings.shared.installedPluginsJson = json
SGSimpleSettings.shared.synchronizeShared()
}
}
// Custom entries: .plugin plugins + .deb/.dylib tweaks.
private enum PluginListEntry: ItemListNodeEntry {
case addHeader(id: Int, text: String)
case addAction(id: Int, text: String)
case addNotice(id: Int, text: String)
case addDebAction(id: Int, text: String)
case addDebNotice(id: Int, text: String)
case listHeader(id: Int, text: String)
case pluginRow(id: Int, plugin: PluginInfo)
case pluginSettings(id: Int, pluginId: String, text: String)
case pluginDelete(id: Int, pluginId: String, text: String)
case emptyNotice(id: Int, text: String)
case tweaksChannelLink(id: Int, text: String, url: String)
case tweaksHeader(id: Int, text: String)
case installLiveContainer(id: Int, text: String)
case tweaksDylibHeader(id: Int, text: String)
case tweakRow(id: Int, filename: String)
case tweakDelete(id: Int, filename: String, text: String)
case tweaksEmptyNotice(id: Int, text: String)
var id: Int { stableId }
var section: ItemListSectionId {
switch self {
case .addHeader, .addAction, .addNotice, .addDebAction, .addDebNotice: return 0
case .listHeader, .pluginRow, .pluginSettings, .pluginDelete, .emptyNotice: return 1
case .tweaksChannelLink, .tweaksHeader, .installLiveContainer, .tweaksDylibHeader, .tweakRow, .tweakDelete, .tweaksEmptyNotice: return 2
}
}
var stableId: Int {
switch self {
case .addHeader(let id, _), .addAction(let id, _), .addNotice(let id, _), .addDebAction(let id, _), .addDebNotice(let id, _),
.listHeader(let id, _), .pluginRow(let id, _), .pluginSettings(let id, _, _), .pluginDelete(let id, _, _), .emptyNotice(let id, _),
.tweaksChannelLink(let id, _, _), .tweaksHeader(let id, _), .installLiveContainer(let id, _), .tweaksDylibHeader(let id, _), .tweakRow(let id, _), .tweakDelete(let id, _, _), .tweaksEmptyNotice(let id, _): return id
}
}
static func < (lhs: PluginListEntry, rhs: PluginListEntry) -> Bool { lhs.stableId < rhs.stableId }
static func == (lhs: PluginListEntry, rhs: PluginListEntry) -> Bool {
switch (lhs, rhs) {
case let (.addHeader(a, t1), .addHeader(b, t2)), let (.addNotice(a, t1), .addNotice(b, t2)), let (.emptyNotice(a, t1), .emptyNotice(b, t2)): return a == b && t1 == t2
case let (.addAction(a, t1), .addAction(b, t2)), let (.addDebAction(a, t1), .addDebAction(b, t2)), let (.addDebNotice(a, t1), .addDebNotice(b, t2)): return a == b && t1 == t2
case let (.listHeader(a, t1), .listHeader(b, t2)), let (.tweaksHeader(a, t1), .tweaksHeader(b, t2)), let (.tweaksDylibHeader(a, t1), .tweaksDylibHeader(b, t2)): return a == b && t1 == t2
case let (.tweaksChannelLink(a, t1, u1), .tweaksChannelLink(b, t2, u2)): return a == b && t1 == t2 && u1 == u2
case let (.installLiveContainer(a, t1), .installLiveContainer(b, t2)): return a == b && t1 == t2
case let (.pluginRow(a, p1), .pluginRow(b, p2)): return a == b && p1.metadata.id == p2.metadata.id && p1.enabled == p2.enabled
case let (.pluginSettings(a, id1, t1), .pluginSettings(b, id2, t2)), let (.pluginDelete(a, id1, t1), .pluginDelete(b, id2, t2)): return a == b && id1 == id2 && t1 == t2
case let (.tweakRow(a, f1), .tweakRow(b, f2)): return a == b && f1 == f2
case let (.tweakDelete(a, f1, t1), .tweakDelete(b, f2, t2)): return a == b && f1 == f2 && t1 == t2
case let (.tweaksEmptyNotice(a, t1), .tweaksEmptyNotice(b, t2)): return a == b && t1 == t2
default: return false
}
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! PluginListArguments
switch self {
case .addHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case .addAction(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.addPlugin() })
case .addNotice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section)
case .addDebAction(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.addDeb() })
case .addDebNotice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section)
case .listHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case .pluginRow(_, let plugin):
let icon = args.iconResolver(plugin.metadata.iconRef)
return ItemListPluginRowItem(presentationData: presentationData, plugin: plugin, icon: icon, sectionId: self.section, toggle: { value in args.toggle(plugin.metadata.id, value) }, action: nil)
case .pluginSettings(_, let pluginId, let text):
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: "", sectionId: self.section, style: .blocks, action: { args.openSettings(pluginId) })
case .pluginDelete(_, let pluginId, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .destructive, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.deletePlugin(pluginId) })
case .emptyNotice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section)
case .tweaksChannelLink(_, let text, let url):
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: "", sectionId: self.section, style: .blocks, action: { args.openTweaksChannel(url) })
case .tweaksHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case .installLiveContainer(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.openLiveContainer() })
case .tweaksDylibHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case .tweakRow(_, let filename):
return ItemListDisclosureItem(presentationData: presentationData, title: filename, label: "", sectionId: self.section, style: .blocks, action: nil)
case .tweakDelete(_, let filename, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .destructive, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.removeTweak(filename) })
case .tweaksEmptyNotice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section)
}
}
}
private final class PluginListArguments {
let toggle: (String, Bool) -> Void
let openSettings: (String) -> Void
let deletePlugin: (String) -> Void
let addPlugin: () -> Void
let addDeb: () -> Void
let openTweaksChannel: (String) -> Void
let openLiveContainer: () -> Void
let removeTweak: (String) -> Void
let iconResolver: (String?) -> UIImage?
init(toggle: @escaping (String, Bool) -> Void, openSettings: @escaping (String) -> Void, deletePlugin: @escaping (String) -> Void, addPlugin: @escaping () -> Void, addDeb: @escaping () -> Void, openTweaksChannel: @escaping (String) -> Void, openLiveContainer: @escaping () -> Void, removeTweak: @escaping (String) -> Void, iconResolver: @escaping (String?) -> UIImage?) {
self.toggle = toggle
self.openSettings = openSettings
self.deletePlugin = deletePlugin
self.addPlugin = addPlugin
self.addDeb = addDeb
self.openTweaksChannel = openTweaksChannel
self.openLiveContainer = openLiveContainer
self.removeTweak = removeTweak
self.iconResolver = iconResolver
}
}
private func pluginListEntries(presentationData: PresentationData, plugins: [PluginInfo], tweakFilenames: [String]) -> [PluginListEntry] {
let lang = presentationData.strings.baseLanguageCode
var entries: [PluginListEntry] = []
var id = 0
entries.append(.addHeader(id: id, text: lang == "ru" ? "ДОБАВИТЬ ПЛАГИН" : "ADD PLUGIN"))
id += 1
entries.append(.addAction(id: id, text: lang == "ru" ? "Выбрать файл .plugin" : "Select .plugin file"))
id += 1
entries.append(.addNotice(id: id, text: lang == "ru" ? "Файлы плагинов .plugin можно устанавливать здесь." : "Plugin .plugin files can be installed here."))
id += 1
entries.append(.addDebAction(id: id, text: lang == "ru" ? "Установить пакет .deb (твики)" : "Install .deb package (tweaks)"))
id += 1
entries.append(.addDebNotice(id: id, text: lang == "ru" ? "Пакеты .deb (Cydia/Sileo) — из них извлекаются .dylib и устанавливаются. Перезапустите приложение после установки." : ".deb packages (Cydia/Sileo): .dylib files are extracted and installed. Restart the app after installing."))
id += 1
entries.append(.listHeader(id: id, text: lang == "ru" ? "УСТАНОВЛЕННЫЕ ПЛАГИНЫ" : "INSTALLED PLUGINS"))
id += 1
for plugin in plugins {
let meta = plugin.metadata
entries.append(.pluginRow(id: id, plugin: plugin))
id += 1
if plugin.hasSettings {
entries.append(.pluginSettings(id: id, pluginId: meta.id, text: lang == "ru" ? "Настройки" : "Settings"))
id += 1
}
entries.append(.pluginDelete(id: id, pluginId: meta.id, text: lang == "ru" ? "Удалить" : "Remove"))
id += 1
}
if plugins.isEmpty {
entries.append(.emptyNotice(id: id, text: lang == "ru" ? "Нет установленных плагинов." : "No installed plugins."))
}
id += 1
entries.append(.tweaksChannelLink(id: id, text: lang == "ru" ? "Скачать твики (канал)" : "Download tweaks (channel)", url: "https://t.me/glegramiostweaks"))
id += 1
entries.append(.tweaksHeader(id: id, text: lang == "ru" ? "УСТАНОВИТЬ В" : "INSTALL IN"))
id += 1
entries.append(.installLiveContainer(id: id, text: lang == "ru" ? "Установить в LiveContainer" : "Install in LiveContainer"))
id += 1
entries.append(.tweaksDylibHeader(id: id, text: lang == "ru" ? "УСТАНОВЛЕННЫЕ ТВИКИ (.dylib)" : "INSTALLED TWEAKS (.dylib)"))
id += 1
for filename in tweakFilenames {
entries.append(.tweakRow(id: id, filename: filename))
id += 1
entries.append(.tweakDelete(id: id, filename: filename, text: lang == "ru" ? "Удалить" : "Remove"))
id += 1
}
if tweakFilenames.isEmpty {
entries.append(.tweaksEmptyNotice(id: id, text: lang == "ru" ? "Нет установленных твиков. Установите .deb." : "No installed tweaks. Install a .deb package."))
}
return entries
}
public func PluginListController(context: AccountContext, onPluginsChanged: @escaping () -> Void) -> ViewController {
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
var presentDocumentPicker: (() -> Void)?
var pushControllerImpl: ((ViewController) -> Void)?
var backAction: (() -> Void)?
var presentDebPicker: (() -> Void)?
var openLiveContainerImpl: (() -> Void)?
var showDebResultAlertImpl: ((String, String) -> Void)?
let arguments = PluginListArguments(
toggle: { pluginId, value in
var plugins = loadInstalledPlugins()
if let idx = plugins.firstIndex(where: { $0.metadata.id == pluginId }) {
plugins[idx].enabled = value
saveInstalledPlugins(plugins)
reloadPromise.set(true)
onPluginsChanged()
}
},
openSettings: { pluginId in
let plugins = loadInstalledPlugins()
guard let plugin = plugins.first(where: { $0.metadata.id == pluginId }) else { return }
let settingsController = PluginSettingsController(context: context, plugin: plugin, onSave: {
reloadPromise.set(true)
onPluginsChanged()
})
pushControllerImpl?(settingsController)
},
deletePlugin: { pluginId in
var plugins = loadInstalledPlugins()
plugins.removeAll { $0.metadata.id == pluginId }
saveInstalledPlugins(plugins)
reloadPromise.set(true)
onPluginsChanged()
},
addPlugin: { presentDocumentPicker?() },
addDeb: { presentDebPicker?() },
openTweaksChannel: { url in
if let u = URL(string: url) { UIApplication.shared.open(u) }
},
openLiveContainer: { openLiveContainerImpl?() },
removeTweak: { filename in
try? TweakLoader.removeTweak(filename: filename)
reloadPromise.set(true)
onPluginsChanged()
},
iconResolver: { iconRef in
guard let ref = iconRef, !ref.isEmpty else { return nil }
if let img = UIImage(bundleImageName: ref) { return img }
return UIImage(bundleImageName: "glePlugins/1")
}
)
let signal = combineLatest(reloadPromise.get(), context.sharedContext.presentationData)
|> map { _, presentationData -> (ItemListControllerState, (ItemListNodeState, PluginListArguments)) in
let plugins = loadInstalledPlugins()
let tweakFilenames = TweakLoader.installedTweakFilenames()
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(presentationData.strings.baseLanguageCode == "ru" ? "Плагины" : "Plugins"),
leftNavigationButton: ItemListNavigationButton(content: .text(presentationData.strings.Common_Back), style: .regular, enabled: true, action: { backAction?() }),
rightNavigationButton: ItemListNavigationButton(content: .text("+"), style: .bold, enabled: true, action: { presentDocumentPicker?() }),
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = pluginListEntries(presentationData: presentationData, plugins: plugins, tweakFilenames: tweakFilenames)
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: nil, initialScrollToItem: nil)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
backAction = { [weak controller] in controller?.dismiss() }
presentDocumentPicker = { [weak controller] in
guard let controller = controller else { return }
let picker: UIDocumentPickerViewController
if #available(iOS 14.0, *) {
let pluginType = UTType(filenameExtension: "plugin") ?? .plainText
picker = UIDocumentPickerViewController(forOpeningContentTypes: [pluginType], asCopy: true)
} else {
picker = UIDocumentPickerViewController(documentTypes: ["public.plain-text", "public.data"], in: .import)
}
let delegate = PluginDocumentPickerDelegate(
context: context,
onPick: { url in
_ = url.startAccessingSecurityScopedResource()
defer { url.stopAccessingSecurityScopedResource() }
guard let content = try? String(contentsOf: url, encoding: .utf8),
let metadata = currentPluginRuntime.parseMetadata(content: content) else { return }
let hasSettings = currentPluginRuntime.hasCreateSettings(content: content)
let fileManager = FileManager.default
guard let supportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
let pluginsDir = supportURL.appendingPathComponent("Plugins", isDirectory: true)
try? fileManager.createDirectory(at: pluginsDir, withIntermediateDirectories: true)
let destURL = pluginsDir.appendingPathComponent("\(metadata.id).plugin")
try? fileManager.removeItem(at: destURL)
try? fileManager.copyItem(at: url, to: destURL)
var plugins = loadInstalledPlugins()
plugins.append(PluginInfo(metadata: metadata, path: destURL.path, enabled: true, hasSettings: hasSettings))
saveInstalledPlugins(plugins)
reloadPromise.set(true)
onPluginsChanged()
}
)
picker.delegate = delegate
objc_setAssociatedObject(picker, &documentPickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
}
presentDebPicker = { [weak controller] in
guard let controller = controller else { return }
let picker: UIDocumentPickerViewController
if #available(iOS 14.0, *) {
let debType = UTType(filenameExtension: "deb") ?? .data
picker = UIDocumentPickerViewController(forOpeningContentTypes: [debType], asCopy: true)
} else {
picker = UIDocumentPickerViewController(documentTypes: ["public.data"], in: .import)
}
let delegate = PluginDocumentPickerDelegate(context: context, onPick: { url in
_ = url.startAccessingSecurityScopedResource()
defer { url.stopAccessingSecurityScopedResource() }
let lang = context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode
do {
let tweaksDir = TweakLoader.ensureTweaksDirectory()
let result = try DebExtractor.installDeb(from: url, tweaksDirectory: tweaksDir)
let names = result.installedDylibs.joined(separator: ", ")
let pkg = result.packageName ?? "Tweak"
let ver = result.packageVersion.map { " \($0)" } ?? ""
showDebResultAlertImpl?(lang == "ru" ? "Установлено" : "Installed", "\(pkg)\(ver): \(names)\n\n" + (lang == "ru" ? "Перезапустите приложение." : "Restart the app."))
reloadPromise.set(true)
onPluginsChanged()
} catch {
showDebResultAlertImpl?(lang == "ru" ? "Ошибка" : "Error", error.localizedDescription)
}
})
picker.delegate = delegate
objc_setAssociatedObject(picker, &documentPickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
}
pushControllerImpl = { [weak controller] vc in controller?.push(vc) }
let showNoAppAlert: () -> Void = { [weak controller] in
guard let ctrl = controller, let window = ctrl.view.window, let root = window.rootViewController else { return }
let lang = context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode
let msg = lang == "ru" ? "Нет нужного приложения" : "No required app"
let alert = UIAlertController(title: nil, message: msg, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
var top = root
while let presented = top.presentedViewController { top = presented }
top.present(alert, animated: true)
}
openLiveContainerImpl = {
guard let url = URL(string: "livecontainer://") else { return }
UIApplication.shared.open(url, options: [:]) { opened in if !opened { showNoAppAlert() } }
}
showDebResultAlertImpl = { [weak controller] title, message in
guard let controller = controller, let window = controller.view.window, let root = window.rootViewController else { return }
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
var top = root
while let presented = top.presentedViewController { top = presented }
top.present(alert, animated: true)
}
return controller
}
private final class PluginDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate {
let context: AccountContext
let onPick: (URL) -> Void
init(context: AccountContext, onPick: @escaping (URL) -> Void) {
self.context = context
self.onPick = onPick
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
onPick(url)
}
}
+98
View File
@@ -0,0 +1,98 @@
// MARK: Swiftgram Plugin metadata (exteraGram-compatible .plugin file format)
import Foundation
/// Metadata parsed from a .plugin file (exteraGram plugin format).
public struct PluginMetadata: Codable, Equatable {
public let id: String
public let name: String
public let description: String
public let version: String
public let author: String
/// Icon reference e.g. "ApplicationEmoji/141" or "glePlugins/1".
public let iconRef: String?
public let minVersion: String?
/// If true, plugin modifies profile display (Fake Profilestyle). App reads settings and applies via userDisplayRunner.
public let hasUserDisplay: Bool
public init(id: String, name: String, description: String, version: String, author: String, iconRef: String? = nil, minVersion: String? = nil, hasUserDisplay: Bool = false) {
self.id = id
self.name = name
self.description = description
self.version = version
self.author = author
self.iconRef = iconRef
self.minVersion = minVersion
self.hasUserDisplay = hasUserDisplay
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
description = try c.decode(String.self, forKey: .description)
version = try c.decode(String.self, forKey: .version)
author = try c.decode(String.self, forKey: .author)
iconRef = try c.decodeIfPresent(String.self, forKey: .iconRef)
minVersion = try c.decodeIfPresent(String.self, forKey: .minVersion)
hasUserDisplay = try c.decodeIfPresent(Bool.self, forKey: .hasUserDisplay) ?? false
}
}
/// Installed plugin info (stored in settings).
public struct PluginInfo: Codable, Equatable {
public var metadata: PluginMetadata
public var path: String
public var enabled: Bool
public var hasSettings: Bool
public init(metadata: PluginMetadata, path: String, enabled: Bool, hasSettings: Bool) {
self.metadata = metadata
self.path = path
self.enabled = enabled
self.hasSettings = hasSettings
}
}
/// Parses exteraGram-style metadata from .plugin file content (Python script with __name__, __description__, etc.).
public enum PluginMetadataParser {
private static let namePattern = #"__name__\s*=\s*["']([^"']+)["']"#
private static let descriptionPattern = #"__description__\s*=\s*["']([^"']+)["']"#
private static let versionPattern = #"__version__\s*=\s*["']([^"']+)["']"#
private static let authorPattern = #"__author__\s*=\s*["']([^"']+)["']"#
private static let idPattern = #"__id__\s*=\s*["']([^"']+)["']"#
private static let iconPattern = #"__icon__\s*=\s*["']([^"']+)["']"#
private static let minVersionPattern = #"__min_version__\s*=\s*["']([^"']+)["']"#
private static let createSettingsPattern = #"def\s+create_settings\s*\("#
/// Some plugins set __settings__ = True (e.g. panic_passcode_pro).
private static let settingsFlagPattern = #"__settings__\s*=\s*True"#
/// Plugins that modify profile display (Fake Profilestyle) set __user_display__ = True.
private static let userDisplayPattern = #"__user_display__\s*=\s*True"#
public static func parse(content: String) -> PluginMetadata? {
guard let name = firstMatch(in: content, pattern: namePattern),
let id = firstMatch(in: content, pattern: idPattern) else {
return nil
}
let description = firstMatch(in: content, pattern: descriptionPattern) ?? ""
let version = firstMatch(in: content, pattern: versionPattern) ?? "1.0"
let author = firstMatch(in: content, pattern: authorPattern) ?? ""
let iconRef = firstMatch(in: content, pattern: iconPattern)
let minVersion = firstMatch(in: content, pattern: minVersionPattern)
let hasUserDisplay = content.range(of: userDisplayPattern, options: .regularExpression) != nil
return PluginMetadata(id: id, name: name, description: description, version: version, author: author, iconRef: iconRef, minVersion: minVersion, hasUserDisplay: hasUserDisplay)
}
public static func hasCreateSettings(content: String) -> Bool {
content.range(of: createSettingsPattern, options: .regularExpression) != nil
|| content.range(of: settingsFlagPattern, options: .regularExpression) != nil
}
private static func firstMatch(in string: String, pattern: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: string, range: NSRange(string.startIndex..., in: string)),
let range = Range(match.range(at: 1), in: string) else {
return nil
}
return String(string[range])
}
}
+299
View File
@@ -0,0 +1,299 @@
// MARK: Swiftgram Plugin runner (PythonKit-based execution of .plugin files)
//
// Sets SGPluginHooks.messageHookRunner so that outgoing messages are passed to Python plugins.
// Requires PythonKit (https://github.com/pvieito/PythonKit). Add via SPM or embed for macOS/simulator.
// On iOS device, embed a Python framework (e.g. BeeWare) for full support.
import Foundation
import SGSimpleSettings
#if canImport(PythonKit)
import PythonKit
#endif
private let basePluginSource = """
from enum import Enum
from typing import Any, Optional
class HookStrategy(str, Enum):
PASS = "PASS"
MODIFY = "MODIFY"
CANCEL = "CANCEL"
class HookResult:
def __init__(self, strategy=None, params=None):
self.strategy = strategy if strategy is not None else HookStrategy.PASS
self.params = params
class BasePlugin:
def __init__(self):
self._hooks = set()
def on_plugin_load(self):
pass
def add_on_send_message_hook(self):
self._hooks.add("on_send_message_hook")
def add_hook(self, name):
self._hooks.add(name)
def on_update_hook(self, update_name, account, update):
pass
def get_setting(self, key, default=None):
try:
return _get_setting(key, default)
except NameError:
return default
def set_setting(self, key, value):
try:
_set_setting(key, value)
except NameError:
pass
def _has_hook(self, name):
return name in self._hooks
"""
/// Call once at app startup to install the Python-based message hook runner (when PythonKit is available).
public enum PluginRunner {
private static var incomingMessageObserver: NSObjectProtocol?
public static func install() {
#if canImport(PythonKit)
SGPluginHooks.messageHookRunner = { accountPeerId, peerId, text, replyToMessageId, replyMessageInfo in
runPluginsSendMessageHook(accountPeerId: accountPeerId, peerId: peerId, text: text, replyToMessageId: replyToMessageId, replyMessageInfo: replyMessageInfo)
}
SGPluginHooks.incomingMessageHookRunner = { accountId, peerId, messageId, text, outgoing in
runPluginsIncomingMessageHook(accountId: accountId, peerId: peerId, messageId: messageId, text: text, outgoing: outgoing)
}
#else
SGPluginHooks.messageHookRunner = nil
SGPluginHooks.incomingMessageHookRunner = nil
#endif
SGPluginHooks.userDisplayRunner = applyUserDisplayFromPlugins
PluginRunner.incomingMessageObserver = NotificationCenter.default.addObserver(forName: SGPluginIncomingMessageNotificationName, object: nil, queue: .main) { note in
guard let u = note.userInfo,
let accountId = u["accountId"] as? Int64,
let peerId = u["peerId"] as? Int64,
let messageId = u["messageId"] as? Int64,
let outgoing = u["outgoing"] as? Bool else { return }
let text = u["text"] as? String
SGPluginHooks.incomingMessageHookRunner?(accountId, peerId, messageId, text, outgoing)
}
}
}
// MARK: - User display plugins (__user_display__ = True) generic runner, no app code changes per plugin
private func applyUserDisplayFromPlugins(accountId: Int64, user: PluginDisplayUser) -> PluginDisplayUser? {
guard SGSimpleSettings.shared.pluginSystemEnabled,
let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let plugins = try? JSONDecoder().decode([PluginInfo].self, from: data) else {
return nil
}
let host = PluginHost.shared
for plugin in plugins where plugin.enabled && plugin.metadata.hasUserDisplay {
let pluginId = plugin.metadata.id
guard host.getPluginSettingBool(pluginId: pluginId, key: "enabled", default: false) else { continue }
let targetIdStr = host.getPluginSetting(pluginId: pluginId, key: "target_user_id")?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let targetUserId: Int64
if targetIdStr.isEmpty {
targetUserId = accountId
} else if let parsed = Int64(targetIdStr) {
targetUserId = parsed
} else {
continue
}
if user.id != targetUserId { continue }
func s(_ key: String) -> String? {
host.getPluginSetting(pluginId: pluginId, key: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
}
let firstName = s("fake_first_name").flatMap { $0.isEmpty ? nil : $0 } ?? user.firstName
let lastName = s("fake_last_name").flatMap { $0.isEmpty ? nil : $0 } ?? user.lastName
let username = s("fake_username").flatMap { $0.isEmpty ? nil : $0 } ?? user.username
let phone = s("fake_phone").flatMap { $0.isEmpty ? nil : $0 } ?? user.phone
let id: Int64 = s("fake_id").flatMap { $0.isEmpty ? nil : Int64($0) } ?? user.id
let isPremium = host.getPluginSettingBool(pluginId: pluginId, key: "fake_premium", default: user.isPremium)
let isVerified = host.getPluginSettingBool(pluginId: pluginId, key: "fake_verified", default: user.isVerified)
let isScam = host.getPluginSettingBool(pluginId: pluginId, key: "fake_scam", default: user.isScam)
let isFake = host.getPluginSettingBool(pluginId: pluginId, key: "fake_fake", default: user.isFake)
let isSupport = host.getPluginSettingBool(pluginId: pluginId, key: "fake_support", default: user.isSupport)
let isBot = host.getPluginSettingBool(pluginId: pluginId, key: "fake_bot", default: user.isBot)
return PluginDisplayUser(
firstName: firstName,
lastName: lastName,
username: username,
phone: phone,
id: id,
isPremium: isPremium,
isVerified: isVerified,
isScam: isScam,
isFake: isFake,
isSupport: isSupport,
isBot: isBot
)
}
return nil
}
#if canImport(PythonKit)
private func runPluginsSendMessageHook(accountPeerId: Int64, peerId: Int64, text: String, replyToMessageId: Int64?, replyMessageInfo: ReplyMessageInfo?) -> SGPluginHookResult? {
guard let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let plugins = try? JSONDecoder().decode([PluginInfo].self, from: data) else {
return nil
}
let enabled = plugins.filter { $0.enabled }
guard !enabled.isEmpty else { return nil }
let replyId = Int(replyToMessageId ?? 0)
let accountId = Int(accountPeerId)
let peerIdInt = Int(peerId)
for pluginInfo in enabled {
let pluginId = pluginInfo.metadata.id
guard let content = try? String(contentsOfFile: pluginInfo.path, encoding: .utf8) else { continue }
let builtins = Python.import("builtins")
let globals = Python.dict()
do {
try builtins.exec.thunk.call(PythonObject(basePluginSource), globals, globals)
try builtins.exec.thunk.call(PythonObject(content), globals, globals)
} catch {
continue
}
guard let bp = globals["BasePlugin"], bp.isNone == false else { continue }
let findClassCode = """
_plugin_cls = None
for _n, _o in list(globals().items()):
if _n != 'BasePlugin' and isinstance(_o, type) and issubclass(_o, BasePlugin):
_plugin_cls = _o
break
"""
try? builtins.exec.thunk.call(PythonObject(findClassCode), globals, globals)
guard let cls = globals["_plugin_cls"], cls.isNone == false else { continue }
let instance: PythonObject
do {
instance = try cls.call()
} catch {
continue
}
_ = try? instance.on_plugin_load.call()
let hasHook = instance._has_hook.call("on_send_message_hook")
guard hasHook.bool == true else { continue }
// Build params object in Python (message, peer, replyToMsg; reply message document info for FileViewer-style plugins)
globals["_msg_text"] = PythonObject(text)
globals["_msg_peer"] = PythonObject(peerIdInt)
globals["_msg_reply"] = PythonObject(replyId)
globals["_msg_reply_id"] = PythonObject(Int(replyMessageInfo?.messageId ?? 0))
globals["_msg_reply_is_doc"] = PythonObject(replyMessageInfo?.isDocument ?? false)
globals["_msg_reply_file_path"] = replyMessageInfo?.filePath.map { PythonObject($0) } ?? Python.None
globals["_msg_reply_file_name"] = replyMessageInfo?.fileName.map { PythonObject($0) } ?? Python.None
globals["_msg_reply_mime"] = replyMessageInfo?.mimeType.map { PythonObject($0) } ?? Python.None
let paramsCode = """
class _Params:
pass
class _ReplyMsg:
pass
_params_obj = _Params()
_params_obj.message = _msg_text
_params_obj.peer = _msg_peer
_params_obj.replyToMsgId = _msg_reply
_params_obj.replyToMsg = _ReplyMsg()
_params_obj.replyToMsg.id = _msg_reply_id
_params_obj.replyToMsg.messageOwner = _ReplyMsg()
_params_obj.replyToMsg.messageOwner.id = _msg_reply_id
_params_obj.replyToMsg.isDocument = _msg_reply_is_doc
_params_obj.replyToMsg.filePath = _msg_reply_file_path
_params_obj.replyToMsg.fileName = _msg_reply_file_name
_params_obj.replyToMsg.mimeType = _msg_reply_mime
"""
try? builtins.exec.thunk.call(PythonObject(paramsCode), globals, globals)
guard let paramsObj = globals["_params_obj"], paramsObj.isNone == false else { continue }
let result: PythonObject
do {
result = try instance.on_send_message_hook.call(accountId, paramsObj)
} catch {
continue
}
guard let strategyObj = result.strategy, strategyObj.isNone == false else { continue }
let strategyStr = String(strategyObj) ?? "PASS"
if strategyStr == "CANCEL" {
return SGPluginHookResult(strategy: .cancel, message: nil)
}
if strategyStr == "MODIFY" {
var newMessage = text
if let p = result.params, p.isNone == false, let msg = p.message, msg.isNone == false {
newMessage = String(msg) ?? text
}
return SGPluginHookResult(strategy: .modify, message: newMessage)
}
}
return nil
}
private func runPluginsIncomingMessageHook(accountId: Int64, peerId: Int64, messageId: Int64, text: String?, outgoing: Bool) {
guard SGSimpleSettings.shared.pluginSystemEnabled,
let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let plugins = try? JSONDecoder().decode([PluginInfo].self, from: data) else { return }
let enabled = plugins.filter { $0.enabled }
guard !enabled.isEmpty else { return }
let accountIdInt = Int(accountId)
let peerIdInt = Int(peerId)
let messageIdInt = Int(messageId)
let textPy = text.map { PythonObject($0) } ?? Python.None
let outgoingPy = PythonObject(outgoing)
for pluginInfo in enabled {
guard let content = try? String(contentsOfFile: pluginInfo.path, encoding: .utf8) else { continue }
let builtins = Python.import("builtins")
let globals = Python.dict()
do {
try builtins.exec.thunk.call(PythonObject(basePluginSource), globals, globals)
try builtins.exec.thunk.call(PythonObject(content), globals, globals)
} catch { continue }
guard let bp = globals["BasePlugin"], bp.isNone == false else { continue }
let findClassCode = """
_plugin_cls = None
for _n, _o in list(globals().items()):
if _n != 'BasePlugin' and isinstance(_o, type) and issubclass(_o, BasePlugin):
_plugin_cls = _o
break
"""
try? builtins.exec.thunk.call(PythonObject(findClassCode), globals, globals)
guard let cls = globals["_plugin_cls"], cls.isNone == false else { continue }
let instance: PythonObject
do { instance = try cls.call() } catch { continue }
_ = try? instance.on_plugin_load.call()
let hasNewMessage = instance._has_hook.call("updateNewMessage").bool == true
let hasChannelMessage = instance._has_hook.call("updateNewChannelMessage").bool == true
guard hasNewMessage || hasChannelMessage else { continue }
let updateName = hasChannelMessage ? "updateNewChannelMessage" : "updateNewMessage"
globals["_upd_message"] = textPy
globals["_upd_peer"] = PythonObject(peerIdInt)
globals["_upd_msg_id"] = PythonObject(messageIdInt)
globals["_upd_outgoing"] = outgoingPy
let paramsCode = """
class _UpdateObj:
pass
_update_obj = _UpdateObj()
_update_obj.message = _upd_message
_update_obj.peer = _upd_peer
_update_obj.message_id = _upd_msg_id
_update_obj.outgoing = _upd_outgoing
"""
try? builtins.exec.thunk.call(PythonObject(paramsCode), globals, globals)
guard let updateObj = globals["_update_obj"], updateObj.isNone == false else { continue }
_ = try? instance.on_update_hook.call(updateName, accountIdInt, updateObj)
}
}
#endif
@@ -0,0 +1,188 @@
// MARK: Swiftgram Plugin settings screen
import Foundation
import UIKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import SGSimpleSettings
import SGItemListUI
private func loadInstalledPlugins() -> [PluginInfo] {
guard let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let list = try? JSONDecoder().decode([PluginInfo].self, from: data) else {
return []
}
return list
}
private func saveInstalledPlugins(_ plugins: [PluginInfo]) {
if let data = try? JSONEncoder().encode(plugins),
let json = String(data: data, encoding: .utf8) {
SGSimpleSettings.shared.installedPluginsJson = json
SGSimpleSettings.shared.synchronizeShared()
}
}
private enum PluginSettingsSection: Int32, SGItemListSection {
case main
case pluginOptions
case info
}
private typealias PluginSettingsEntry = SGItemListUIEntry<PluginSettingsSection, SGBoolSetting, AnyHashable, AnyHashable, AnyHashable, AnyHashable>
private let userDisplayBoolKeys: [(key: String, titleRu: String, titleEn: String)] = [
("enabled", "Включить подмену профиля", "Enable profile override"),
("fake_premium", "Premium статус", "Premium status"),
("fake_verified", "Статус верификации", "Verified status"),
("fake_scam", "Scam статус", "Scam status"),
("fake_fake", "Fake статус", "Fake status"),
("fake_support", "Support статус", "Support status"),
("fake_bot", "Bot статус", "Bot status"),
]
private let userDisplayStringKeys: [(key: String, titleRu: String, titleEn: String)] = [
("target_user_id", "Telegram ID пользователя", "User Telegram ID"),
("fake_first_name", "Имя", "First name"),
("fake_last_name", "Фамилия", "Last name"),
("fake_username", "Юзернейм (без @)", "Username (no @)"),
("fake_phone", "Номер телефона", "Phone number"),
("fake_id", "Telegram ID (визуально)", "Telegram ID (display)"),
]
private func pluginSettingsEntries(presentationData: PresentationData, plugin: PluginInfo) -> [PluginSettingsEntry] {
let lang = presentationData.strings.baseLanguageCode
let isRu = lang == "ru"
var entries: [PluginSettingsEntry] = []
let id = SGItemListCounter()
let host = PluginHost.shared
let pluginId = plugin.metadata.id
entries.append(.header(id: id.count, section: .main, text: isRu ? "ПЛАГИН" : "PLUGIN", badge: nil))
let enableText = plugin.enabled ? (isRu ? "Выключить плагин" : "Disable plugin") : (isRu ? "Включить плагин" : "Enable plugin")
entries.append(.action(id: id.count, section: .main, actionType: "toggleEnabled" as AnyHashable, text: enableText, kind: .generic))
entries.append(.notice(id: id.count, section: .main, text: isRu ? "Включает функциональность плагина." : "Enables plugin functionality."))
if plugin.metadata.hasUserDisplay {
entries.append(.header(id: id.count, section: .pluginOptions, text: isRu ? "НАСТРОЙКИ ОТОБРАЖЕНИЯ" : "DISPLAY SETTINGS", badge: nil))
entries.append(.notice(id: id.count, section: .pluginOptions, text: isRu ? "Оставьте поля пустыми, чтобы использовать реальные данные. Пустой «Telegram ID пользователя» — свой профиль." : "Leave fields empty to use real data. Empty «User Telegram ID» means your own profile."))
for item in userDisplayBoolKeys {
let value = host.getPluginSettingBool(pluginId: pluginId, key: item.key, default: false)
let label = value ? (isRu ? "Вкл" : "On") : (isRu ? "Выкл" : "Off")
let text = "\(isRu ? item.titleRu : item.titleEn): \(label)"
entries.append(.action(id: id.count, section: .pluginOptions, actionType: "pluginBool:\(item.key)" as AnyHashable, text: text, kind: .generic))
}
for item in userDisplayStringKeys {
let value = host.getPluginSetting(pluginId: pluginId, key: item.key) ?? ""
let label = value.isEmpty ? (isRu ? "" : "") : value
let text = "\(isRu ? item.titleRu : item.titleEn): \(label)"
entries.append(.action(id: id.count, section: .pluginOptions, actionType: "pluginString:\(item.key)" as AnyHashable, text: text, kind: .generic))
}
} else if plugin.hasSettings {
entries.append(.header(id: id.count, section: .pluginOptions, text: isRu ? "НАСТРОЙКИ" : "SETTINGS", badge: nil))
entries.append(.notice(id: id.count, section: .pluginOptions, text: isRu ? "Настройки этого плагина задаются в файле .plugin (create_settings). Редактор для других типов плагинов в разработке." : "Settings for this plugin are defined in the .plugin file (create_settings). Editor for other plugin types coming later."))
}
entries.append(.header(id: id.count, section: .info, text: isRu ? "ИНФОРМАЦИЯ" : "INFORMATION", badge: nil))
entries.append(PluginSettingsEntry.notice(id: id.count, section: .info, text: "\(plugin.metadata.name)\n\(isRu ? "Версия" : "Version") \(plugin.metadata.version)\n\(plugin.metadata.author)\n\n\(plugin.metadata.description)"))
return entries
}
public func PluginSettingsController(context: AccountContext, plugin: PluginInfo, onSave: @escaping () -> Void) -> ViewController {
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
var backAction: (() -> Void)?
var presentAlertImpl: ((String, String, String, @escaping (String) -> Void) -> Void)?
let pluginId = plugin.metadata.id
let host = PluginHost.shared
let arguments = SGItemListArguments<SGBoolSetting, AnyHashable, AnyHashable, AnyHashable, AnyHashable>(
context: context,
setBoolValue: { _, _ in },
updateSliderValue: { _, _ in },
setOneFromManyValue: { _ in },
openDisclosureLink: { _ in },
action: { actionType in
guard let s = actionType as? String else { return }
if s == "toggleEnabled" {
var plugins = loadInstalledPlugins()
if let idx = plugins.firstIndex(where: { $0.metadata.id == pluginId }) {
plugins[idx].enabled.toggle()
saveInstalledPlugins(plugins)
reloadPromise.set(true)
onSave()
}
} else if s.hasPrefix("pluginBool:") {
let key = String(s.dropFirst("pluginBool:".count))
let current = host.getPluginSettingBool(pluginId: pluginId, key: key, default: false)
host.setPluginSettingBool(pluginId: pluginId, key: key, value: !current)
reloadPromise.set(true)
onSave()
} else if s.hasPrefix("pluginString:") {
let key = String(s.dropFirst("pluginString:".count))
let current = host.getPluginSetting(pluginId: pluginId, key: key) ?? ""
let titleRu = userDisplayStringKeys.first(where: { $0.key == key })?.titleRu ?? key
let titleEn = userDisplayStringKeys.first(where: { $0.key == key })?.titleEn ?? key
let lang = context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode
let title = lang == "ru" ? titleRu : titleEn
presentAlertImpl?(key, title, current) { newValue in
host.setPluginSetting(pluginId: pluginId, key: key, value: newValue)
reloadPromise.set(true)
onSave()
}
}
},
searchInput: { _ in }
)
let signal = combineLatest(
reloadPromise.get(),
context.sharedContext.presentationData
)
|> map { _, presentationData -> (ItemListControllerState, (ItemListNodeState, SGItemListArguments<SGBoolSetting, AnyHashable, AnyHashable, AnyHashable, AnyHashable>)) in
let plugins = loadInstalledPlugins()
let currentPlugin = plugins.first(where: { $0.metadata.id == plugin.metadata.id }) ?? plugin
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(currentPlugin.metadata.name),
leftNavigationButton: ItemListNavigationButton(content: .text(presentationData.strings.Common_Back), style: .regular, enabled: true, action: { backAction?() }),
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = pluginSettingsEntries(presentationData: presentationData, plugin: currentPlugin)
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
initialScrollToItem: nil
)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
backAction = { [weak controller] in controller?.dismiss() }
presentAlertImpl = { [weak controller] key, title, currentValue, completion in
guard let c = controller else { return }
let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
alert.addTextField { tf in
tf.text = currentValue
tf.placeholder = title
tf.autocapitalizationType = .none
tf.autocorrectionType = .no
}
let okTitle = context.sharedContext.currentPresentationData.with { $0 }.strings.Common_OK
let cancelTitle = context.sharedContext.currentPresentationData.with { $0 }.strings.Common_Cancel
alert.addAction(UIAlertAction(title: cancelTitle, style: .cancel))
alert.addAction(UIAlertAction(title: okTitle, style: .default) { _ in
let newValue = alert.textFields?.first?.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
completion(newValue)
})
c.present(alert, animated: true)
}
return controller
}
+439
View File
@@ -0,0 +1,439 @@
// MARK: Swiftgram Profile cover: photo/video as profile background (visible only to you)
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import SGSimpleSettings
import AVFoundation
import ObjectiveC
import UniformTypeIdentifiers
private var profileCoverImagePickerDelegateKey: UInt8 = 0
private var profileCoverVideoPickerDelegateKey: UInt8 = 0
private var profileCoverDocumentPickerDelegateKey: UInt8 = 0
private let profileCoverSubdirectory = "ProfileCover"
private let profileCoverPhotoName = "cover.jpg"
private let profileCoverVideoName = "cover.mov"
/// Post when profile cover is saved so the profile screen can refresh the cover.
public extension Notification.Name {
static let SGProfileCoverDidChange = Notification.Name("SGProfileCoverDidChange")
}
private func profileCoverDirectoryURL() -> URL {
let support: URL
if #available(iOS 16.0, *) {
support = URL.applicationSupportDirectory
} else {
guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
fatalError("Application Support not available")
}
support = dir
}
return support.appendingPathComponent(profileCoverSubdirectory, isDirectory: true)
}
private func saveProfileCoverPhoto(from image: UIImage) throws -> String {
let fm = FileManager.default
let dir = profileCoverDirectoryURL()
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent(profileCoverPhotoName)
try? fm.removeItem(at: url)
guard let data = image.jpegData(compressionQuality: 0.85) else { throw NSError(domain: "ProfileCover", code: 1, userInfo: nil) }
try data.write(to: url)
return url.path
}
private func saveProfileCoverVideo(from sourceURL: URL) throws -> String {
let fm = FileManager.default
let dir = profileCoverDirectoryURL()
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
let dest = dir.appendingPathComponent(profileCoverVideoName)
try? fm.removeItem(at: dest)
try fm.copyItem(at: sourceURL, to: dest)
return dest.path
}
private func removeProfileCoverMedia() {
let fm = FileManager.default
let dir = profileCoverDirectoryURL()
try? fm.removeItem(at: dir.appendingPathComponent(profileCoverPhotoName))
try? fm.removeItem(at: dir.appendingPathComponent(profileCoverVideoName))
}
// MARK: - Preview row (image/video or placeholder)
private final class ProfileCoverPreviewItem: ListViewItem, ItemListItem {
let presentationData: ItemListPresentationData
let sectionId: ItemListSectionId
let coverPath: String
let isVideo: Bool
init(presentationData: ItemListPresentationData, sectionId: ItemListSectionId, coverPath: String, isVideo: Bool) {
self.presentationData = presentationData
self.sectionId = sectionId
self.coverPath = coverPath
self.isVideo = isVideo
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = ProfileCoverPreviewItemNode()
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(.None) }) })
}
}
}
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 {
guard let nodeValue = node() as? ProfileCoverPreviewItemNode else { return completion(ListViewItemNodeLayout(contentSize: .zero, insets: .zero), { _ in }) }
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) })
}
}
}
}
var selectable: Bool { false }
static func == (lhs: ProfileCoverPreviewItem, rhs: ProfileCoverPreviewItem) -> Bool {
lhs.coverPath == rhs.coverPath && lhs.isVideo == rhs.isVideo && lhs.sectionId == rhs.sectionId
}
}
private final class ProfileCoverPreviewItemNode: ListViewItemNode {
private let backgroundNode: ASDisplayNode
private let imageNode: ASImageNode
private let placeholderLabel: ImmediateTextNode
private var item: ProfileCoverPreviewItem?
init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.imageNode = ASImageNode()
self.imageNode.contentMode = .scaleAspectFill
self.imageNode.clipsToBounds = true
self.placeholderLabel = ImmediateTextNode()
super.init(layerBacked: false)
addSubnode(backgroundNode)
addSubnode(imageNode)
addSubnode(placeholderLabel)
}
func asyncLayout() -> (ProfileCoverPreviewItem, ListViewItemLayoutParams, ItemListNeighbors) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) {
return { item, params, neighbors in
let height: CGFloat = 180.0
let contentSize = CGSize(width: params.width, height: height)
let insets = itemListNeighborsGroupedInsets(neighbors, params)
let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets)
return (layout, { [weak self] animation in
guard let self else { return }
self.item = item
self.backgroundNode.backgroundColor = item.presentationData.theme.list.itemBlocksBackgroundColor
self.backgroundNode.frame = CGRect(origin: .zero, size: contentSize)
self.imageNode.frame = CGRect(x: params.leftInset, y: 0, width: params.width - params.leftInset - params.rightInset, height: height)
self.imageNode.isHidden = item.coverPath.isEmpty
if !item.coverPath.isEmpty {
if item.isVideo {
self.loadVideoThumbnail(path: item.coverPath)
} else {
self.imageNode.image = UIImage(contentsOfFile: item.coverPath)
}
} else {
self.imageNode.image = nil
self.placeholderLabel.attributedText = NSAttributedString(string: item.presentationData.strings.baseLanguageCode == "ru" ? "Обложка не выбрана" : "No cover selected", font: Font.regular(15), textColor: item.presentationData.theme.list.itemSecondaryTextColor)
let labelSize = self.placeholderLabel.updateLayout(CGSize(width: params.width - 32, height: 60))
self.placeholderLabel.frame = CGRect(x: (params.width - labelSize.width) / 2, y: (height - labelSize.height) / 2, width: labelSize.width, height: labelSize.height)
self.placeholderLabel.isHidden = false
}
self.placeholderLabel.isHidden = !item.coverPath.isEmpty
})
}
}
private func loadVideoThumbnail(path: String) {
let url = URL(fileURLWithPath: path)
let asset = AVAsset(url: url)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 400, height: 400)
generator.generateCGImagesAsynchronously(forTimes: [NSValue(time: .zero)]) { [weak self] _, cgImage, _, _, _ in
Queue.mainQueue().async {
self?.imageNode.image = cgImage.flatMap { UIImage(cgImage: $0) }
}
}
}
}
// MARK: - Controller
private enum ProfileCoverEntry: ItemListNodeEntry {
case previewHeader(id: Int, text: String)
case preview(id: Int, path: String, isVideo: Bool)
case mediaHeader(id: Int, text: String)
case uploadPhoto(id: Int, text: String)
case setVideo(id: Int, text: String)
case uploadFromFiles(id: Int, text: String)
case deleteMedia(id: Int, text: String)
var id: Int { stableId }
var section: ItemListSectionId {
switch self {
case .previewHeader, .preview: return 0
default: return 1
}
}
var stableId: Int {
switch self {
case .previewHeader(let i, _), .preview(let i, _, _), .mediaHeader(let i, _), .uploadPhoto(let i, _), .setVideo(let i, _), .uploadFromFiles(let i, _), .deleteMedia(let i, _): return i
}
}
static func < (lhs: ProfileCoverEntry, rhs: ProfileCoverEntry) -> Bool { lhs.stableId < rhs.stableId }
static func == (lhs: ProfileCoverEntry, rhs: ProfileCoverEntry) -> Bool {
switch (lhs, rhs) {
case let (.previewHeader(a, t1), .previewHeader(b, t2)): return a == b && t1 == t2
case let (.preview(a, p1, v1), .preview(b, p2, v2)): return a == b && p1 == p2 && v1 == v2
case let (.mediaHeader(a, t1), .mediaHeader(b, t2)): return a == b && t1 == t2
case let (.uploadPhoto(a, t1), .uploadPhoto(b, t2)): return a == b && t1 == t2
case let (.setVideo(a, t1), .setVideo(b, t2)): return a == b && t1 == t2
case let (.uploadFromFiles(a, t1), .uploadFromFiles(b, t2)): return a == b && t1 == t2
case let (.deleteMedia(a, t1), .deleteMedia(b, t2)): return a == b && t1 == t2
default: return false
}
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! ProfileCoverArguments
switch self {
case .previewHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case .preview(_, let path, let isVideo):
return ProfileCoverPreviewItem(presentationData: presentationData, sectionId: self.section, coverPath: path, isVideo: isVideo)
case .mediaHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case .uploadPhoto(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.uploadPhoto() })
case .setVideo(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.setVideo() })
case .uploadFromFiles(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.uploadFromFiles() })
case .deleteMedia(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .destructive, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.deleteMedia() })
}
}
}
private final class ProfileCoverArguments {
let uploadPhoto: () -> Void
let setVideo: () -> Void
let uploadFromFiles: () -> Void
let deleteMedia: () -> Void
init(uploadPhoto: @escaping () -> Void, setVideo: @escaping () -> Void, uploadFromFiles: @escaping () -> Void, deleteMedia: @escaping () -> Void) {
self.uploadPhoto = uploadPhoto
self.setVideo = setVideo
self.uploadFromFiles = uploadFromFiles
self.deleteMedia = deleteMedia
}
}
private func profileCoverEntries(presentationData: PresentationData, path: String, isVideo: Bool) -> [ProfileCoverEntry] {
let lang = presentationData.strings.baseLanguageCode
var list: [ProfileCoverEntry] = []
var id = 0
list.append(.previewHeader(id: id, text: lang == "ru" ? "ПРЕДПРОСМОТР" : "PREVIEW"))
id += 1
list.append(.preview(id: id, path: path, isVideo: isVideo))
id += 1
list.append(.mediaHeader(id: id, text: lang == "ru" ? "МЕДИА" : "MEDIA"))
id += 1
list.append(.uploadPhoto(id: id, text: lang == "ru" ? "Загрузить фото" : "Upload photo"))
id += 1
list.append(.setVideo(id: id, text: lang == "ru" ? "Установить видео" : "Set video"))
id += 1
list.append(.uploadFromFiles(id: id, text: lang == "ru" ? "Загрузить из файлов" : "Load from files"))
id += 1
list.append(.deleteMedia(id: id, text: lang == "ru" ? "Удалить медиа" : "Delete media"))
return list
}
public func ProfileCoverController(context: AccountContext) -> ViewController {
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
var presentImagePicker: (() -> Void)?
var presentVideoPicker: (() -> Void)?
var presentDocumentPicker: (() -> Void)?
var backAction: (() -> Void)?
let arguments = ProfileCoverArguments(
uploadPhoto: { presentImagePicker?() },
setVideo: { presentVideoPicker?() },
uploadFromFiles: { presentDocumentPicker?() },
deleteMedia: {
removeProfileCoverMedia()
SGSimpleSettings.shared.profileCoverMediaPath = ""
SGSimpleSettings.shared.profileCoverIsVideo = false
SGSimpleSettings.shared.synchronizeShared()
reloadPromise.set(true)
}
)
let signal = combineLatest(reloadPromise.get(), context.sharedContext.presentationData)
|> map { _, presentationData -> (ItemListControllerState, (ItemListNodeState, ProfileCoverArguments)) in
let path = SGSimpleSettings.shared.profileCoverMediaPath
let isVideo = SGSimpleSettings.shared.profileCoverIsVideo
let lang = presentationData.strings.baseLanguageCode
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(lang == "ru" ? "Обложка профиля" : "Profile cover"),
leftNavigationButton: ItemListNavigationButton(content: .text(presentationData.strings.Common_Back), style: .regular, enabled: true, action: { backAction?() }),
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = profileCoverEntries(presentationData: presentationData, path: path, isVideo: isVideo)
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: nil, initialScrollToItem: nil)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
backAction = { [weak controller] in controller?.dismiss() }
presentImagePicker = { [weak controller] in
guard let controller = controller else { return }
// UIImagePickerController надёжнее PHPicker при выборе из галереи (iOS 16+)
let picker = UIImagePickerController()
picker.sourceType = .photoLibrary
picker.mediaTypes = ["public.image"]
let delegate = ProfileCoverImagePickerDelegate(
onPick: { image in
do {
let savedPath = try saveProfileCoverPhoto(from: image)
SGSimpleSettings.shared.profileCoverMediaPath = savedPath
SGSimpleSettings.shared.profileCoverIsVideo = false
SGSimpleSettings.shared.synchronizeShared()
reloadPromise.set(true)
NotificationCenter.default.post(name: .SGProfileCoverDidChange, object: nil)
} catch {}
}
)
picker.delegate = delegate
objc_setAssociatedObject(picker, &profileCoverImagePickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
}
presentVideoPicker = { [weak controller] in
guard let controller = controller else { return }
let picker = UIImagePickerController()
picker.sourceType = .photoLibrary
picker.mediaTypes = ["public.movie"]
picker.videoMaximumDuration = 30
let delegate = ProfileCoverVideoPickerDelegate(
onPick: { url in
let needsStop = url.startAccessingSecurityScopedResource()
defer { if needsStop { url.stopAccessingSecurityScopedResource() } }
do {
let savedPath = try saveProfileCoverVideo(from: url)
SGSimpleSettings.shared.profileCoverMediaPath = savedPath
SGSimpleSettings.shared.profileCoverIsVideo = true
SGSimpleSettings.shared.synchronizeShared()
reloadPromise.set(true)
NotificationCenter.default.post(name: .SGProfileCoverDidChange, object: nil)
} catch {}
}
)
picker.delegate = delegate
objc_setAssociatedObject(picker, &profileCoverVideoPickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
}
presentDocumentPicker = { [weak controller] in
guard let controller = controller else { return }
let onPick: (URL) -> Void = { url in
// With asCopy: true the file is in app sandbox; only some sources need security-scoped access
let needsStop = url.startAccessingSecurityScopedResource()
defer { if needsStop { url.stopAccessingSecurityScopedResource() } }
let ext = url.pathExtension.lowercased()
let isVideo = ["mov", "mp4", "m4v"].contains(ext)
if isVideo {
do {
let savedPath = try saveProfileCoverVideo(from: url)
SGSimpleSettings.shared.profileCoverMediaPath = savedPath
SGSimpleSettings.shared.profileCoverIsVideo = true
SGSimpleSettings.shared.synchronizeShared()
reloadPromise.set(true)
NotificationCenter.default.post(name: .SGProfileCoverDidChange, object: nil)
} catch {}
} else {
guard let data = try? Data(contentsOf: url), let image = UIImage(data: data) else { return }
do {
let savedPath = try saveProfileCoverPhoto(from: image)
SGSimpleSettings.shared.profileCoverMediaPath = savedPath
SGSimpleSettings.shared.profileCoverIsVideo = false
SGSimpleSettings.shared.synchronizeShared()
reloadPromise.set(true)
NotificationCenter.default.post(name: .SGProfileCoverDidChange, object: nil)
} catch {}
}
}
if #available(iOS 14.0, *) {
let types: [UTType] = [.image, .movie]
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
let delegate = ProfileCoverDocumentPickerDelegate(onPick: onPick)
picker.delegate = delegate
objc_setAssociatedObject(picker, &profileCoverDocumentPickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
} else {
let picker = UIDocumentPickerViewController(documentTypes: ["public.image", "public.movie"], in: .import)
let delegate = ProfileCoverDocumentPickerDelegate(onPick: onPick)
picker.delegate = delegate
objc_setAssociatedObject(picker, &profileCoverDocumentPickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
}
}
return controller
}
private final class ProfileCoverImagePickerDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onPick: (UIImage) -> Void
init(onPick: @escaping (UIImage) -> Void) { self.onPick = onPick }
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
picker.dismiss(animated: true)
guard let image = info[.originalImage] as? UIImage else { return }
onPick(image)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
}
private final class ProfileCoverVideoPickerDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onPick: (URL) -> Void
init(onPick: @escaping (URL) -> Void) { self.onPick = onPick }
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
picker.dismiss(animated: true)
guard let url = info[.mediaURL] as? URL else { return }
onPick(url)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
}
private final class ProfileCoverDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate {
let onPick: (URL) -> Void
init(onPick: @escaping (URL) -> Void) { self.onPick = onPick }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
onPick(url)
}
}
@@ -0,0 +1,990 @@
// MARK: Swiftgram
import SGLogging
import SGSimpleSettings
import SGStrings
import SGAPIToken
#if canImport(SGDeletedMessages)
import SGDeletedMessages
#endif
import SGItemListUI
import Foundation
import UIKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import MtProtoKit
import MessageUI
import TelegramPresentationData
import TelegramUIPreferences
import ItemListUI
import PresentationDataUtils
import OverlayStatusController
import AccountContext
import AppBundle
import WebKit
import PeerNameColorScreen
import UndoUI
private enum SGControllerSection: Int32, SGItemListSection {
case search
case trending
case content
case tabs
case folders
case chatList
case profiles
case stories
case translation
case voiceMessages
case calls
case photo
case stickers
case videoNotes
case contextMenu
case accountColors
case ghostMode
case other
}
enum SGBoolSetting: String {
case hidePhoneInSettings
case showTabNames
case showContactsTab
case showCallsTab
case wideTabBar
case foldersAtBottom
case startTelescopeWithRearCam
case hideStories
case uploadSpeedBoost
case showProfileId
case warnOnStoriesOpen
case sendWithReturnKey
case rememberLastFolder
case sendLargePhotos
case storyStealthMode
case disableSwipeToRecordStory
case disableDeleteChatSwipeOption
case quickTranslateButton
case showRepostToStory
case contextShowSelectFromUser
case contextShowSaveToCloud
case contextShowHideForwardName
case contextShowRestrict
case contextShowReport
case contextShowReply
case contextShowPin
case contextShowSaveMedia
case contextShowMessageReplies
case contextShowJson
case disableScrollToNextChannel
case disableScrollToNextTopic
case disableChatSwipeOptions
case disableGalleryCamera
case disableGalleryCameraPreview
case disableSendAsButton
case disableSnapDeletionEffect
case stickerTimestamp
case hideRecordingButton
case hideTabBar
case showDC
case showCreationDate
case showRegDate
case compactChatList
case compactFolderNames
case allChatsHidden
case defaultEmojisFirst
case messageDoubleTapActionOutgoingEdit
case wideChannelPosts
case forceEmojiTab
case forceBuiltInMic
case secondsInMessages
case hideChannelBottomButton
case confirmCalls
case swipeForVideoPIP
case enableVoipTcp
case nyStyleSnow
case nyStyleLightning
case tabBarSearchEnabled
case hideReactions
case compactMessagePreview
case showDeletedMessages
case saveDeletedMessagesMedia
case saveDeletedMessagesReactions
case saveDeletedMessagesForBots
case saveEditHistory
case enableLocalMessageEditing // used in GLEGramSettingsController
// Ghost Mode settings
case disableOnlineStatus
case disableTypingStatus
case disableRecordingVideoStatus
case disableUploadingVideoStatus
case disableVCMessageRecordingStatus
case disableVCMessageUploadingStatus
case disableUploadingPhotoStatus
case disableUploadingFileStatus
case disableChoosingLocationStatus
case disableChoosingContactStatus
case disablePlayingGameStatus
case disableRecordingRoundVideoStatus
case disableUploadingRoundVideoStatus
case disableSpeakingInGroupCallStatus
case disableChoosingStickerStatus
case disableEmojiInteractionStatus
case disableEmojiAcknowledgementStatus
case disableMessageReadReceipt
case disableStoryReadReceipt
case disableAllAds
case hideProxySponsor
case enableSavingProtectedContent
case sensitiveContentEnabled
case disableScreenshotDetection
case enableSavingSelfDestructingMessages
case disableSecretChatBlurOnScreenshot
case enableLocalPremium
case scrollToTopButtonEnabled
case fakeLocationEnabled
case keepRemovedChannels
case enableVideoToCircleOrVoice
case enableTelescope
case enableFontReplacement
case disableCompactNumbers
case disableZalgoText
case unlimitedFavoriteStickers
case enableOnlineStatusRecording
case addMusicFromDeviceToProfile
case pluginSystemEnabled
case chatExportEnabled
case emojiDownloaderEnabled
case feelRichEnabled
case giftIdEnabled
case fakeProfileEnabled
}
private enum SGOneFromManySetting: String {
case nyStyle
case bottomTabStyle
case downloadSpeedBoost
case allChatsTitleLengthOverride
// case allChatsFolderPositionOverride
case translationBackend
case transcriptionBackend
}
private enum SGSliderSetting: String {
case accountColorsSaturation
case outgoingPhotoQuality
case stickerSize
}
private enum SGDisclosureLink: String {
case contentSettings
case languageSettings
}
private struct PeerNameColorScreenState: Equatable {
var updatedNameColor: PeerNameColor?
var updatedBackgroundEmojiId: Int64?
}
private struct SGSettingsControllerState: Equatable {
var searchQuery: String?
}
private typealias SGControllerEntry = SGItemListUIEntry<SGControllerSection, SGBoolSetting, SGSliderSetting, SGOneFromManySetting, SGDisclosureLink, AnyHashable>
private func SGControllerEntries(presentationData: PresentationData, callListSettings: CallListSettings, experimentalUISettings: ExperimentalUISettings, SGSettings: SGUISettings, appConfiguration: AppConfiguration, nameColors: PeerNameColors, state: SGSettingsControllerState) -> [SGControllerEntry] {
let lang = presentationData.strings.baseLanguageCode
let strings = presentationData.strings
let newStr = strings.Settings_New
var entries: [SGControllerEntry] = []
let id = SGItemListCounter()
entries.append(.searchInput(id: id.count, section: .search, title: NSAttributedString(string: "🔍"), text: state.searchQuery ?? "", placeholder: strings.Common_Search))
if SGSimpleSettings.shared.canUseNY {
entries.append(.header(id: id.count, section: .trending, text: i18n("Settings.NY.Header", lang), badge: newStr))
entries.append(.toggle(id: id.count, section: .trending, settingName: .nyStyleSnow, value: SGSimpleSettings.shared.nyStyle == SGSimpleSettings.NYStyle.snow.rawValue, text: i18n("Settings.NY.Style.snow", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .trending, settingName: .nyStyleLightning, value: SGSimpleSettings.shared.nyStyle == SGSimpleSettings.NYStyle.lightning.rawValue, text: i18n("Settings.NY.Style.lightning", lang), enabled: true))
// entries.append(.oneFromManySelector(id: id.count, section: .trending, settingName: .nyStyle, text: i18n("Settings.NY.Style", lang), value: i18n("Settings.NY.Style.\(SGSimpleSettings.shared.nyStyle)", lang), enabled: true))
entries.append(.notice(id: id.count, section: .trending, text: i18n("Settings.NY.Notice", lang)))
} else {
id.increment(3)
}
if appConfiguration.sgWebSettings.global.canEditSettings {
entries.append(.disclosure(id: id.count, section: .content, link: .contentSettings, text: i18n("Settings.ContentSettings", lang)))
} else {
id.increment(1)
}
entries.append(.header(id: id.count, section: .tabs, text: i18n("Settings.Tabs.Header", lang), badge: nil))
entries.append(.toggle(id: id.count, section: .tabs, settingName: .hideTabBar, value: SGSimpleSettings.shared.hideTabBar, text: i18n("Settings.Tabs.HideTabBar", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .tabs, settingName: .showContactsTab, value: callListSettings.showContactsTab, text: i18n("Settings.Tabs.ShowContacts", lang), enabled: !SGSimpleSettings.shared.hideTabBar))
entries.append(.toggle(id: id.count, section: .tabs, settingName: .showCallsTab, value: callListSettings.showTab, text: strings.CallSettings_TabIcon, enabled: !SGSimpleSettings.shared.hideTabBar))
entries.append(.toggle(id: id.count, section: .tabs, settingName: .showTabNames, value: SGSimpleSettings.shared.showTabNames, text: i18n("Settings.Tabs.ShowNames", lang), enabled: !SGSimpleSettings.shared.hideTabBar))
entries.append(.toggle(id: id.count, section: .tabs, settingName: .tabBarSearchEnabled, value: SGSimpleSettings.shared.tabBarSearchEnabled, text: i18n("Settings.Tabs.SearchButton", lang), enabled: !SGSimpleSettings.shared.hideTabBar))
entries.append(.toggle(id: id.count, section: .tabs, settingName: .wideTabBar, value: SGSimpleSettings.shared.wideTabBar, text: i18n("Settings.Tabs.WideTabBar", lang), enabled: !SGSimpleSettings.shared.hideTabBar))
entries.append(.notice(id: id.count, section: .tabs, text: i18n("Settings.Tabs.WideTabBar.Notice", lang)))
entries.append(.header(id: id.count, section: .folders, text: strings.Settings_ChatFolders.uppercased(), badge: nil))
entries.append(.toggle(id: id.count, section: .folders, settingName: .foldersAtBottom, value: experimentalUISettings.foldersTabAtBottom, text: i18n("Settings.Folders.BottomTab", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .folders, settingName: .allChatsHidden, value: SGSimpleSettings.shared.allChatsHidden, text: i18n("Settings.Folders.AllChatsHidden", lang, strings.ChatList_Tabs_AllChats), enabled: true))
#if DEBUG
// entries.append(.oneFromManySelector(id: id.count, section: .folders, settingName: .allChatsFolderPositionOverride, text: i18n("Settings.Folders.AllChatsPlacement", lang), value: i18n("Settings.Folders.AllChatsPlacement.\(SGSimpleSettings.shared.allChatsFolderPositionOverride)", lang), enabled: true))
#endif
entries.append(.toggle(id: id.count, section: .folders, settingName: .compactFolderNames, value: SGSimpleSettings.shared.compactFolderNames, text: i18n("Settings.Folders.CompactNames", lang), enabled: true))
entries.append(.oneFromManySelector(id: id.count, section: .folders, settingName: .allChatsTitleLengthOverride, text: i18n("Settings.Folders.AllChatsTitle", lang), value: i18n("Settings.Folders.AllChatsTitle.\(SGSimpleSettings.shared.allChatsTitleLengthOverride)", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .folders, settingName: .rememberLastFolder, value: SGSimpleSettings.shared.rememberLastFolder, text: i18n("Settings.Folders.RememberLast", lang), enabled: true))
entries.append(.notice(id: id.count, section: .folders, text: i18n("Settings.Folders.RememberLast.Notice", lang)))
entries.append(.header(id: id.count, section: .chatList, text: i18n("Settings.ChatList.Header", lang), badge: nil))
entries.append(.toggle(id: id.count, section: .chatList, settingName: .compactChatList, value: SGSimpleSettings.shared.compactChatList, text: i18n("Settings.CompactChatList", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .chatList, settingName: .compactMessagePreview, value: SGSimpleSettings.shared.chatListLines != SGSimpleSettings.ChatListLines.three.rawValue, text: i18n("Settings.CompactMessagePreview", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .chatList, settingName: .disableChatSwipeOptions, value: !SGSimpleSettings.shared.disableChatSwipeOptions, text: i18n("Settings.ChatSwipeOptions", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .chatList, settingName: .disableDeleteChatSwipeOption, value: !SGSimpleSettings.shared.disableDeleteChatSwipeOption, text: i18n("Settings.DeleteChatSwipeOption", lang), enabled: !SGSimpleSettings.shared.disableChatSwipeOptions))
entries.append(.header(id: id.count, section: .profiles, text: i18n("Settings.Profiles.Header", lang), badge: nil))
entries.append(.toggle(id: id.count, section: .profiles, settingName: .showProfileId, value: SGSimpleSettings.shared.showProfileId, text: i18n("Settings.ShowProfileID", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .profiles, settingName: .showDC, value: SGSimpleSettings.shared.showDC, text: i18n("Settings.ShowDC", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .profiles, settingName: .showRegDate, value: SGSimpleSettings.shared.showRegDate, text: i18n("Settings.ShowRegDate", lang), enabled: true))
entries.append(.notice(id: id.count, section: .profiles, text: i18n("Settings.ShowRegDate.Notice", lang)))
entries.append(.toggle(id: id.count, section: .profiles, settingName: .showCreationDate, value: SGSimpleSettings.shared.showCreationDate, text: i18n("Settings.ShowCreationDate", lang), enabled: true))
entries.append(.notice(id: id.count, section: .profiles, text: i18n("Settings.ShowCreationDate.Notice", lang)))
entries.append(.toggle(id: id.count, section: .profiles, settingName: .confirmCalls, value: SGSimpleSettings.shared.confirmCalls, text: i18n("Settings.CallConfirmation", lang), enabled: true))
entries.append(.notice(id: id.count, section: .profiles, text: i18n("Settings.CallConfirmation.Notice", lang)))
entries.append(.header(id: id.count, section: .stories, text: strings.AutoDownloadSettings_Stories.uppercased(), badge: nil))
entries.append(.toggle(id: id.count, section: .stories, settingName: .hideStories, value: SGSimpleSettings.shared.hideStories, text: i18n("Settings.Stories.Hide", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .stories, settingName: .disableSwipeToRecordStory, value: SGSimpleSettings.shared.disableSwipeToRecordStory, text: i18n("Settings.Stories.DisableSwipeToRecord", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .stories, settingName: .warnOnStoriesOpen, value: SGSettings.warnOnStoriesOpen, text: i18n("Settings.Stories.WarnBeforeView", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .stories, settingName: .showRepostToStory, value: SGSimpleSettings.shared.showRepostToStoryV2, text: strings.Share_RepostToStory.replacingOccurrences(of: "\n", with: " "), enabled: true))
if SGSimpleSettings.shared.canUseStealthMode {
entries.append(.toggle(id: id.count, section: .stories, settingName: .storyStealthMode, value: SGSimpleSettings.shared.storyStealthMode, text: strings.Story_StealthMode_Title, enabled: true))
entries.append(.notice(id: id.count, section: .stories, text: strings.Story_StealthMode_ControlText))
} else {
id.increment(2)
}
entries.append(.header(id: id.count, section: .translation, text: strings.Localization_TranslateMessages.uppercased(), badge: nil))
entries.append(.oneFromManySelector(id: id.count, section: .translation, settingName: .translationBackend, text: i18n("Settings.Translation.Backend", lang), value: i18n("Settings.Translation.Backend.\(SGSimpleSettings.shared.translationBackend)", lang), enabled: true))
if SGSimpleSettings.shared.translationBackendEnum != .gtranslate {
entries.append(.notice(id: id.count, section: .translation, text: i18n("Settings.Translation.Backend.Notice", lang, "Settings.Translation.Backend.\(SGSimpleSettings.TranslationBackend.gtranslate.rawValue)".i18n(lang))))
} else {
id.increment(1)
}
entries.append(.toggle(id: id.count, section: .translation, settingName: .quickTranslateButton, value: SGSimpleSettings.shared.quickTranslateButton, text: i18n("Settings.Translation.QuickTranslateButton", lang), enabled: true))
entries.append(.disclosure(id: id.count, section: .translation, link: .languageSettings, text: strings.Localization_TranslateEntireChat))
entries.append(.notice(id: id.count, section: .translation, text: i18n("Common.NoTelegramPremiumNeeded", lang, strings.Settings_Premium)))
entries.append(.header(id: id.count, section: .voiceMessages, text: "Settings.Transcription.Header".i18n(lang), badge: nil))
entries.append(.oneFromManySelector(id: id.count, section: .voiceMessages, settingName: .transcriptionBackend, text: i18n("Settings.Transcription.Backend", lang), value: i18n("Settings.Transcription.Backend.\(SGSimpleSettings.shared.transcriptionBackend)", lang), enabled: true))
if SGSimpleSettings.shared.transcriptionBackendEnum != .apple {
entries.append(.notice(id: id.count, section: .voiceMessages, text: i18n("Settings.Transcription.Backend.Notice", lang, "Settings.Transcription.Backend.\(SGSimpleSettings.TranscriptionBackend.apple.rawValue)".i18n(lang))))
} else {
id.increment(1)
}
entries.append(.header(id: id.count, section: .voiceMessages, text: strings.Privacy_VoiceMessages.uppercased(), badge: nil))
entries.append(.toggle(id: id.count, section: .voiceMessages, settingName: .forceBuiltInMic, value: SGSimpleSettings.shared.forceBuiltInMic, text: i18n("Settings.forceBuiltInMic", lang), enabled: true))
entries.append(.notice(id: id.count, section: .voiceMessages, text: i18n("Settings.forceBuiltInMic.Notice", lang)))
entries.append(.header(id: id.count, section: .calls, text: strings.Calls_TabTitle.uppercased(), badge: nil))
entries.append(.toggle(id: id.count, section: .calls, settingName: .enableVoipTcp, value: experimentalUISettings.enableVoipTcp, text: "Force TCP", enabled: true))
entries.append(.notice(id: id.count, section: .calls, text: "Common.KnowWhatYouDo".i18n(lang)))
entries.append(.header(id: id.count, section: .photo, text: strings.NetworkUsageSettings_MediaImageDataSection, badge: nil))
entries.append(.header(id: id.count, section: .photo, text: strings.PhotoEditor_QualityTool.uppercased(), badge: nil))
entries.append(.percentageSlider(id: id.count, section: .photo, settingName: .outgoingPhotoQuality, value: SGSimpleSettings.shared.outgoingPhotoQuality))
entries.append(.notice(id: id.count, section: .photo, text: i18n("Settings.Photo.Quality.Notice", lang)))
entries.append(.toggle(id: id.count, section: .photo, settingName: .sendLargePhotos, value: SGSimpleSettings.shared.sendLargePhotos, text: i18n("Settings.Photo.SendLarge", lang), enabled: true))
entries.append(.notice(id: id.count, section: .photo, text: i18n("Settings.Photo.SendLarge.Notice", lang)))
entries.append(.header(id: id.count, section: .stickers, text: strings.StickerPacksSettings_Title.uppercased(), badge: nil))
entries.append(.header(id: id.count, section: .stickers, text: i18n("Settings.Stickers.Size", lang), badge: nil))
entries.append(.percentageSlider(id: id.count, section: .stickers, settingName: .stickerSize, value: SGSimpleSettings.shared.stickerSize))
entries.append(.toggle(id: id.count, section: .stickers, settingName: .stickerTimestamp, value: SGSimpleSettings.shared.stickerTimestamp, text: i18n("Settings.Stickers.Timestamp", lang), enabled: true))
entries.append(.header(id: id.count, section: .videoNotes, text: i18n("Settings.VideoNotes.Header", lang), badge: nil))
entries.append(.toggle(id: id.count, section: .videoNotes, settingName: .startTelescopeWithRearCam, value: SGSimpleSettings.shared.startTelescopeWithRearCam, text: i18n("Settings.VideoNotes.StartWithRearCam", lang), enabled: true))
entries.append(.header(id: id.count, section: .contextMenu, text: i18n("Settings.ContextMenu", lang), badge: nil))
entries.append(.notice(id: id.count, section: .contextMenu, text: i18n("Settings.ContextMenu.Notice", lang)))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowSaveToCloud, value: SGSimpleSettings.shared.contextShowSaveToCloud, text: i18n("ContextMenu.SaveToCloud", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowHideForwardName, value: SGSimpleSettings.shared.contextShowHideForwardName, text: strings.Conversation_ForwardOptions_HideSendersNames, enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowSelectFromUser, value: SGSimpleSettings.shared.contextShowSelectFromUser, text: i18n("ContextMenu.SelectFromUser", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowRestrict, value: SGSimpleSettings.shared.contextShowRestrict, text: strings.Conversation_ContextMenuBan, enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowReport, value: SGSimpleSettings.shared.contextShowReport, text: strings.Conversation_ContextMenuReport, enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowReply, value: SGSimpleSettings.shared.contextShowReply, text: strings.Conversation_ContextMenuReply, enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowPin, value: SGSimpleSettings.shared.contextShowPin, text: strings.Conversation_Pin, enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowSaveMedia, value: SGSimpleSettings.shared.contextShowSaveMedia, text: strings.Conversation_SaveToFiles, enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowMessageReplies, value: SGSimpleSettings.shared.contextShowMessageReplies, text: strings.Conversation_ContextViewThread, enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowJson, value: SGSimpleSettings.shared.contextShowJson, text: "JSON", enabled: true))
/* entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowRestrict, value: SGSimpleSettings.shared.contextShowRestrict, text: strings.Conversation_ContextMenuBan)) */
entries.append(.header(id: id.count, section: .accountColors, text: i18n("Settings.CustomColors.Header", lang), badge: nil))
entries.append(.header(id: id.count, section: .accountColors, text: i18n("Settings.CustomColors.Saturation", lang), badge: nil))
let accountColorSaturation = SGSimpleSettings.shared.accountColorsSaturation
entries.append(.percentageSlider(id: id.count, section: .accountColors, settingName: .accountColorsSaturation, value: accountColorSaturation))
// let nameColor: PeerNameColor
// if let updatedNameColor = state.updatedNameColor {
// nameColor = updatedNameColor
// } else {
// nameColor = .blue
// }
// let _ = nameColors.get(nameColor, dark: presentationData.theme.overallDarkAppearance)
// entries.append(.peerColorPicker(id: entries.count, section: .other,
// colors: nameColors,
// currentColor: nameColor, // TODO: PeerNameColor(rawValue: <#T##Int32#>)
// currentSaturation: accountColorSaturation
// ))
if accountColorSaturation == 0 {
id.increment(100)
entries.append(.peerColorDisclosurePreview(id: id.count, section: .accountColors, name: "\(strings.UserInfo_FirstNamePlaceholder) \(strings.UserInfo_LastNamePlaceholder)", color: presentationData.theme.chat.message.incoming.accentTextColor))
} else {
id.increment(200)
for index in nameColors.displayOrder.prefix(3) {
let color: PeerNameColor = PeerNameColor(rawValue: index)
let colors = nameColors.get(color, dark: presentationData.theme.overallDarkAppearance)
entries.append(.peerColorDisclosurePreview(id: id.count, section: .accountColors, name: "\(strings.UserInfo_FirstNamePlaceholder) \(strings.UserInfo_LastNamePlaceholder)", color: colors.main))
}
}
entries.append(.notice(id: id.count, section: .accountColors, text: i18n("Settings.CustomColors.Saturation.Notice", lang)))
id.increment(10000)
entries.append(.header(id: id.count, section: .other, text: strings.Appearance_Other.uppercased(), badge: nil))
entries.append(.toggle(id: id.count, section: .other, settingName: .swipeForVideoPIP, value: SGSimpleSettings.shared.videoPIPSwipeDirection == SGSimpleSettings.VideoPIPSwipeDirection.up.rawValue, text: i18n("Settings.swipeForVideoPIP", lang), enabled: true))
entries.append(.notice(id: id.count, section: .other, text: i18n("Settings.swipeForVideoPIP.Notice", lang)))
entries.append(.toggle(id: id.count, section: .other, settingName: .hideChannelBottomButton, value: !SGSimpleSettings.shared.hideChannelBottomButton, text: i18n("Settings.showChannelBottomButton", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .wideChannelPosts, value: SGSimpleSettings.shared.wideChannelPosts, text: i18n("Settings.wideChannelPosts", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .secondsInMessages, value: SGSimpleSettings.shared.secondsInMessages, text: i18n("Settings.secondsInMessages", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .messageDoubleTapActionOutgoingEdit, value: SGSimpleSettings.shared.messageDoubleTapActionOutgoing == SGSimpleSettings.MessageDoubleTapAction.edit.rawValue, text: i18n("Settings.messageDoubleTapActionOutgoingEdit", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .hideRecordingButton, value: !SGSimpleSettings.shared.hideRecordingButton, text: i18n("Settings.RecordingButton", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .disableSnapDeletionEffect, value: !SGSimpleSettings.shared.disableSnapDeletionEffect, text: i18n("Settings.SnapDeletionEffect", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .disableSendAsButton, value: !SGSimpleSettings.shared.disableSendAsButton, text: i18n("Settings.SendAsButton", lang, strings.Conversation_SendMesageAs), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .disableGalleryCamera, value: !SGSimpleSettings.shared.disableGalleryCamera, text: i18n("Settings.GalleryCamera", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .disableGalleryCameraPreview, value: !SGSimpleSettings.shared.disableGalleryCameraPreview, text: i18n("Settings.GalleryCameraPreview", lang), enabled: !SGSimpleSettings.shared.disableGalleryCamera))
entries.append(.toggle(id: id.count, section: .other, settingName: .disableScrollToNextChannel, value: !SGSimpleSettings.shared.disableScrollToNextChannel, text: i18n("Settings.PullToNextChannel", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .disableScrollToNextTopic, value: !SGSimpleSettings.shared.disableScrollToNextTopic, text: i18n("Settings.PullToNextTopic", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .hideReactions, value: SGSimpleSettings.shared.hideReactions, text: i18n("Settings.HideReactions", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .uploadSpeedBoost, value: SGSimpleSettings.shared.uploadSpeedBoost, text: i18n("Settings.UploadsBoost", lang), enabled: true))
entries.append(.oneFromManySelector(id: id.count, section: .other, settingName: .downloadSpeedBoost, text: i18n("Settings.DownloadsBoost", lang), value: i18n("Settings.DownloadsBoost.\(SGSimpleSettings.shared.downloadSpeedBoost)", lang), enabled: true))
entries.append(.notice(id: id.count, section: .other, text: i18n("Settings.DownloadsBoost.Notice", lang)))
entries.append(.toggle(id: id.count, section: .other, settingName: .sendWithReturnKey, value: SGSettings.sendWithReturnKey, text: i18n("Settings.SendWithReturnKey", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .forceEmojiTab, value: SGSimpleSettings.shared.forceEmojiTab, text: i18n("Settings.ForceEmojiTab", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .defaultEmojisFirst, value: SGSimpleSettings.shared.defaultEmojisFirst, text: i18n("Settings.DefaultEmojisFirst", lang), enabled: true))
entries.append(.notice(id: id.count, section: .other, text: i18n("Settings.DefaultEmojisFirst.Notice", lang)))
entries.append(.toggle(id: id.count, section: .other, settingName: .hidePhoneInSettings, value: SGSimpleSettings.shared.hidePhoneInSettings, text: i18n("Settings.HidePhoneInSettingsUI", lang), enabled: true))
entries.append(.notice(id: id.count, section: .other, text: i18n("Settings.HidePhoneInSettingsUI.Notice", lang)))
// NOTE: Swiftgram-specific privacy/content toggles were moved to GLEGram.
return filterSGItemListUIEntrires(entries: entries, by: state.searchQuery)
}
public func sgSettingsController(context: AccountContext/*, focusOnItemTag: Int? = nil*/) -> ViewController {
var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)?
var pushControllerImpl: ((ViewController) -> Void)?
// var getRootControllerImpl: (() -> UIViewController?)?
// var getNavigationControllerImpl: (() -> NavigationController?)?
var askForRestart: (() -> Void)?
let initialState = SGSettingsControllerState()
let statePromise = ValuePromise(initialState, ignoreRepeated: true)
let stateValue = Atomic(value: initialState)
let updateState: ((SGSettingsControllerState) -> SGSettingsControllerState) -> Void = { f in
statePromise.set(stateValue.modify { f($0) })
}
// let sliderPromise = ValuePromise(SGSimpleSettings.shared.accountColorsSaturation, ignoreRepeated: true)
// let sliderStateValue = Atomic(value: SGSimpleSettings.shared.accountColorsSaturation)
// let _: ((Int32) -> Int32) -> Void = { f in
// sliderPromise.set(sliderStateValue.modify( {f($0)}))
// }
let simplePromise = ValuePromise(true, ignoreRepeated: false)
let arguments = SGItemListArguments<SGBoolSetting, SGSliderSetting, SGOneFromManySetting, SGDisclosureLink, AnyHashable>(
context: context,
/*updatePeerColor: { color in
updateState { state in
var updatedState = state
updatedState.updatedNameColor = color
return updatedState
}
},*/ setBoolValue: { setting, value in
switch setting {
case .hidePhoneInSettings:
SGSimpleSettings.shared.hidePhoneInSettings = value
askForRestart?()
case .showTabNames:
SGSimpleSettings.shared.showTabNames = value
askForRestart?()
case .showContactsTab:
let _ = (
updateCallListSettingsInteractively(
accountManager: context.sharedContext.accountManager, { $0.withUpdatedShowContactsTab(value) }
)
).start()
case .showCallsTab:
let _ = (
updateCallListSettingsInteractively(
accountManager: context.sharedContext.accountManager, { $0.withUpdatedShowTab(value) }
)
).start()
case .tabBarSearchEnabled:
SGSimpleSettings.shared.tabBarSearchEnabled = value
case .wideTabBar:
SGSimpleSettings.shared.wideTabBar = value
askForRestart?()
case .foldersAtBottom:
let _ = (
updateExperimentalUISettingsInteractively(accountManager: context.sharedContext.accountManager, { settings in
var settings = settings
settings.foldersTabAtBottom = value
return settings
}
)
).start()
case .startTelescopeWithRearCam:
SGSimpleSettings.shared.startTelescopeWithRearCam = value
case .hideStories:
let _ = (
updateSGUISettings(engine: context.engine, { settings in
var settings = settings
settings.hideStories = value
return settings
})
).start()
case .showProfileId:
let _ = (
updateSGUISettings(engine: context.engine, { settings in
var settings = settings
settings.showProfileId = value
return settings
})
).start()
case .warnOnStoriesOpen:
let _ = (
updateSGUISettings(engine: context.engine, { settings in
var settings = settings
settings.warnOnStoriesOpen = value
return settings
})
).start()
case .sendWithReturnKey:
let _ = (
updateSGUISettings(engine: context.engine, { settings in
var settings = settings
settings.sendWithReturnKey = value
return settings
})
).start()
case .rememberLastFolder:
SGSimpleSettings.shared.rememberLastFolder = value
case .sendLargePhotos:
SGSimpleSettings.shared.sendLargePhotos = value
case .storyStealthMode:
SGSimpleSettings.shared.storyStealthMode = value
case .disableSwipeToRecordStory:
SGSimpleSettings.shared.disableSwipeToRecordStory = value
case .quickTranslateButton:
SGSimpleSettings.shared.quickTranslateButton = value
case .uploadSpeedBoost:
SGSimpleSettings.shared.uploadSpeedBoost = value
case .unlimitedFavoriteStickers:
SGSimpleSettings.shared.unlimitedFavoriteStickers = value
case .enableOnlineStatusRecording:
SGSimpleSettings.shared.enableOnlineStatusRecording = value
case .showRepostToStory:
SGSimpleSettings.shared.showRepostToStoryV2 = value
case .contextShowSelectFromUser:
SGSimpleSettings.shared.contextShowSelectFromUser = value
case .contextShowSaveToCloud:
SGSimpleSettings.shared.contextShowSaveToCloud = value
case .contextShowRestrict:
SGSimpleSettings.shared.contextShowRestrict = value
case .contextShowHideForwardName:
SGSimpleSettings.shared.contextShowHideForwardName = value
case .addMusicFromDeviceToProfile:
SGSimpleSettings.shared.addMusicFromDeviceToProfile = value
case .hideReactions:
SGSimpleSettings.shared.hideReactions = value
case .pluginSystemEnabled:
SGSimpleSettings.shared.pluginSystemEnabled = value
askForRestart?()
case .chatExportEnabled:
SGSimpleSettings.shared.chatExportEnabled = value
case .disableScrollToNextChannel:
SGSimpleSettings.shared.disableScrollToNextChannel = !value
case .disableScrollToNextTopic:
SGSimpleSettings.shared.disableScrollToNextTopic = !value
case .disableChatSwipeOptions:
SGSimpleSettings.shared.disableChatSwipeOptions = !value
simplePromise.set(true) // Trigger update for 'enabled' field of other toggles
askForRestart?()
case .disableDeleteChatSwipeOption:
SGSimpleSettings.shared.disableDeleteChatSwipeOption = !value
askForRestart?()
case .disableGalleryCamera:
SGSimpleSettings.shared.disableGalleryCamera = !value
simplePromise.set(true)
case .disableGalleryCameraPreview:
SGSimpleSettings.shared.disableGalleryCameraPreview = !value
case .disableSendAsButton:
SGSimpleSettings.shared.disableSendAsButton = !value
case .disableSnapDeletionEffect:
SGSimpleSettings.shared.disableSnapDeletionEffect = !value
case .contextShowReport:
SGSimpleSettings.shared.contextShowReport = value
case .contextShowReply:
SGSimpleSettings.shared.contextShowReply = value
case .contextShowPin:
SGSimpleSettings.shared.contextShowPin = value
case .contextShowSaveMedia:
SGSimpleSettings.shared.contextShowSaveMedia = value
case .contextShowMessageReplies:
SGSimpleSettings.shared.contextShowMessageReplies = value
case .stickerTimestamp:
SGSimpleSettings.shared.stickerTimestamp = value
case .contextShowJson:
SGSimpleSettings.shared.contextShowJson = value
case .hideRecordingButton:
SGSimpleSettings.shared.hideRecordingButton = !value
case .hideTabBar:
SGSimpleSettings.shared.hideTabBar = value
simplePromise.set(true) // Trigger update for 'enabled' field of other toggles
askForRestart?()
case .showDC:
SGSimpleSettings.shared.showDC = value
case .showCreationDate:
SGSimpleSettings.shared.showCreationDate = value
case .showRegDate:
SGSimpleSettings.shared.showRegDate = value
case .compactChatList:
SGSimpleSettings.shared.compactChatList = value
askForRestart?()
case .compactMessagePreview:
SGSimpleSettings.shared.chatListLines = value ? SGSimpleSettings.ChatListLines.one.rawValue : SGSimpleSettings.ChatListLines.three.rawValue
askForRestart?()
case .compactFolderNames:
SGSimpleSettings.shared.compactFolderNames = value
askForRestart?()
case .allChatsHidden:
SGSimpleSettings.shared.allChatsHidden = value
askForRestart?()
case .defaultEmojisFirst:
SGSimpleSettings.shared.defaultEmojisFirst = value
case .messageDoubleTapActionOutgoingEdit:
SGSimpleSettings.shared.messageDoubleTapActionOutgoing = value ? SGSimpleSettings.MessageDoubleTapAction.edit.rawValue : SGSimpleSettings.MessageDoubleTapAction.default.rawValue
case .wideChannelPosts:
SGSimpleSettings.shared.wideChannelPosts = value
case .forceEmojiTab:
SGSimpleSettings.shared.forceEmojiTab = value
case .forceBuiltInMic:
SGSimpleSettings.shared.forceBuiltInMic = value
case .hideChannelBottomButton:
SGSimpleSettings.shared.hideChannelBottomButton = !value
case .secondsInMessages:
SGSimpleSettings.shared.secondsInMessages = value
case .confirmCalls:
SGSimpleSettings.shared.confirmCalls = value
case .swipeForVideoPIP:
SGSimpleSettings.shared.videoPIPSwipeDirection = value ? SGSimpleSettings.VideoPIPSwipeDirection.up.rawValue : SGSimpleSettings.VideoPIPSwipeDirection.none.rawValue
case .enableVoipTcp:
let _ = (
updateExperimentalUISettingsInteractively(accountManager: context.sharedContext.accountManager, { settings in
var settings = settings
settings.enableVoipTcp = value
return settings
}
)
).start()
case .nyStyleSnow:
SGSimpleSettings.shared.nyStyle = value ? SGSimpleSettings.NYStyle.snow.rawValue : SGSimpleSettings.NYStyle.default.rawValue
simplePromise.set(true) // Trigger update for 'enabled' field of other toggles
case .nyStyleLightning:
SGSimpleSettings.shared.nyStyle = value ? SGSimpleSettings.NYStyle.lightning.rawValue : SGSimpleSettings.NYStyle.default.rawValue
simplePromise.set(true) // Trigger update for 'enabled' field of other toggles
case .showDeletedMessages:
SGSimpleSettings.shared.showDeletedMessages = value
case .saveDeletedMessagesMedia:
SGSimpleSettings.shared.saveDeletedMessagesMedia = value
case .saveDeletedMessagesReactions:
SGSimpleSettings.shared.saveDeletedMessagesReactions = value
case .saveDeletedMessagesForBots:
SGSimpleSettings.shared.saveDeletedMessagesForBots = value
case .saveEditHistory:
SGSimpleSettings.shared.saveEditHistory = value
case .enableLocalMessageEditing:
SGSimpleSettings.shared.enableLocalMessageEditing = value
case .enableFontReplacement:
SGSimpleSettings.shared.enableFontReplacement = value
case .disableCompactNumbers:
SGSimpleSettings.shared.disableCompactNumbers = value
case .disableZalgoText:
SGSimpleSettings.shared.disableZalgoText = value
// Ghost Mode settings
case .disableOnlineStatus:
SGSimpleSettings.shared.disableOnlineStatus = value
case .disableTypingStatus:
SGSimpleSettings.shared.disableTypingStatus = value
case .disableRecordingVideoStatus:
SGSimpleSettings.shared.disableRecordingVideoStatus = value
case .disableUploadingVideoStatus:
SGSimpleSettings.shared.disableUploadingVideoStatus = value
case .disableVCMessageRecordingStatus:
SGSimpleSettings.shared.disableVCMessageRecordingStatus = value
case .disableVCMessageUploadingStatus:
SGSimpleSettings.shared.disableVCMessageUploadingStatus = value
case .disableUploadingPhotoStatus:
SGSimpleSettings.shared.disableUploadingPhotoStatus = value
case .disableUploadingFileStatus:
SGSimpleSettings.shared.disableUploadingFileStatus = value
case .disableChoosingLocationStatus:
SGSimpleSettings.shared.disableChoosingLocationStatus = value
case .disableChoosingContactStatus:
SGSimpleSettings.shared.disableChoosingContactStatus = value
case .disablePlayingGameStatus:
SGSimpleSettings.shared.disablePlayingGameStatus = value
case .disableRecordingRoundVideoStatus:
SGSimpleSettings.shared.disableRecordingRoundVideoStatus = value
case .disableUploadingRoundVideoStatus:
SGSimpleSettings.shared.disableUploadingRoundVideoStatus = value
case .disableSpeakingInGroupCallStatus:
SGSimpleSettings.shared.disableSpeakingInGroupCallStatus = value
case .disableChoosingStickerStatus:
SGSimpleSettings.shared.disableChoosingStickerStatus = value
case .disableEmojiInteractionStatus:
SGSimpleSettings.shared.disableEmojiInteractionStatus = value
case .disableEmojiAcknowledgementStatus:
SGSimpleSettings.shared.disableEmojiAcknowledgementStatus = value
case .disableMessageReadReceipt:
SGSimpleSettings.shared.disableMessageReadReceipt = value
case .disableStoryReadReceipt:
SGSimpleSettings.shared.disableStoryReadReceipt = value
case .disableAllAds:
SGSimpleSettings.shared.disableAllAds = value
case .hideProxySponsor:
SGSimpleSettings.shared.hideProxySponsor = value
NotificationCenter.default.post(name: .sgHideProxySponsorDidChange, object: nil)
case .enableSavingProtectedContent:
SGSimpleSettings.shared.enableSavingProtectedContent = value
case .disableScreenshotDetection:
SGSimpleSettings.shared.disableScreenshotDetection = value
case .enableSavingSelfDestructingMessages:
SGSimpleSettings.shared.enableSavingSelfDestructingMessages = value
case .disableSecretChatBlurOnScreenshot:
SGSimpleSettings.shared.disableSecretChatBlurOnScreenshot = value
case .enableLocalPremium:
SGSimpleSettings.shared.enableLocalPremium = value
case .sensitiveContentEnabled:
// Intentionally not handled here.
// This setting lives in GLEGram and is applied via Telegram server-side content settings.
break
case .scrollToTopButtonEnabled:
SGSimpleSettings.shared.scrollToTopButtonEnabled = value
case .fakeLocationEnabled:
SGSimpleSettings.shared.fakeLocationEnabled = value
case .keepRemovedChannels:
SGSimpleSettings.shared.keepRemovedChannels = value
case .enableVideoToCircleOrVoice:
SGSimpleSettings.shared.enableVideoToCircleOrVoice = value
case .enableTelescope:
SGSimpleSettings.shared.enableTelescope = value
case .emojiDownloaderEnabled:
SGSimpleSettings.shared.emojiDownloaderEnabled = value
case .feelRichEnabled:
SGSimpleSettings.shared.feelRichEnabled = value
case .giftIdEnabled:
SGSimpleSettings.shared.giftIdEnabled = value
case .fakeProfileEnabled:
SGSimpleSettings.shared.fakeProfileEnabled = value
}
}, updateSliderValue: { setting, value in
switch (setting) {
case .accountColorsSaturation:
if SGSimpleSettings.shared.accountColorsSaturation != value {
SGSimpleSettings.shared.accountColorsSaturation = value
simplePromise.set(true)
}
case .outgoingPhotoQuality:
if SGSimpleSettings.shared.outgoingPhotoQuality != value {
SGSimpleSettings.shared.outgoingPhotoQuality = value
simplePromise.set(true)
}
case .stickerSize:
if SGSimpleSettings.shared.stickerSize != value {
SGSimpleSettings.shared.stickerSize = value
simplePromise.set(true)
}
}
}, setOneFromManyValue: { setting in
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let actionSheet = ActionSheetController(presentationData: presentationData)
var items: [ActionSheetItem] = []
switch (setting) {
case .downloadSpeedBoost:
let setAction: (String) -> Void = { value in
SGSimpleSettings.shared.downloadSpeedBoost = value
let enableDownloadX: Bool
switch (value) {
case SGSimpleSettings.DownloadSpeedBoostValues.none.rawValue:
enableDownloadX = false
default:
enableDownloadX = true
}
// Updating controller
simplePromise.set(true)
let _ = updateNetworkSettingsInteractively(postbox: context.account.postbox, network: context.account.network, { settings in
var settings = settings
settings.useExperimentalDownload = enableDownloadX
return settings
}).start(completed: {
Queue.mainQueue().async {
askForRestart?()
}
})
}
for value in SGSimpleSettings.DownloadSpeedBoostValues.allCases {
items.append(ActionSheetButtonItem(title: i18n("Settings.DownloadsBoost.\(value.rawValue)", presentationData.strings.baseLanguageCode), color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
setAction(value.rawValue)
}))
}
case .bottomTabStyle:
let setAction: (String) -> Void = { value in
SGSimpleSettings.shared.bottomTabStyle = value
simplePromise.set(true)
}
for value in SGSimpleSettings.BottomTabStyleValues.allCases {
items.append(ActionSheetButtonItem(title: i18n("Settings.Folders.BottomTabStyle.\(value.rawValue)", presentationData.strings.baseLanguageCode), color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
setAction(value.rawValue)
}))
}
case .allChatsTitleLengthOverride:
let setAction: (String) -> Void = { value in
SGSimpleSettings.shared.allChatsTitleLengthOverride = value
simplePromise.set(true)
}
for value in SGSimpleSettings.AllChatsTitleLengthOverride.allCases {
let title: String
switch (value) {
case SGSimpleSettings.AllChatsTitleLengthOverride.short:
title = "\"\(presentationData.strings.ChatList_Tabs_All)\""
case SGSimpleSettings.AllChatsTitleLengthOverride.long:
title = "\"\(presentationData.strings.ChatList_Tabs_AllChats)\""
default:
title = i18n("Settings.Folders.AllChatsTitle.none", presentationData.strings.baseLanguageCode)
}
items.append(ActionSheetButtonItem(title: title, color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
setAction(value.rawValue)
}))
}
// case .allChatsFolderPositionOverride:
// let setAction: (String) -> Void = { value in
// SGSimpleSettings.shared.allChatsFolderPositionOverride = value
// simplePromise.set(true)
// }
//
// for value in SGSimpleSettings.AllChatsFolderPositionOverride.allCases {
// items.append(ActionSheetButtonItem(title: i18n("Settings.Folders.AllChatsTitle.\(value)", presentationData.strings.baseLanguageCode), color: .accent, action: { [weak actionSheet] in
// actionSheet?.dismissAnimated()
// setAction(value.rawValue)
// }))
// }
case .translationBackend:
let setAction: (String) -> Void = { value in
SGSimpleSettings.shared.translationBackend = value
simplePromise.set(true)
}
for value in SGSimpleSettings.TranslationBackend.allCases {
if value == .system {
if #available(iOS 18.0, *) {
} else {
continue // System translation is not available on iOS 17 and below
}
}
items.append(ActionSheetButtonItem(title: i18n("Settings.Translation.Backend.\(value.rawValue)", presentationData.strings.baseLanguageCode), color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
setAction(value.rawValue)
}))
}
case .transcriptionBackend:
let setAction: (String) -> Void = { value in
SGSimpleSettings.shared.transcriptionBackend = value
simplePromise.set(true)
}
for value in SGSimpleSettings.TranscriptionBackend.allCases {
if #available(iOS 13.0, *) {
} else {
if value == .apple {
continue // Apple recognition is not available on iOS 12
}
}
items.append(ActionSheetButtonItem(title: i18n("Settings.Transcription.Backend.\(value.rawValue)", presentationData.strings.baseLanguageCode), color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
setAction(value.rawValue)
}))
}
case .nyStyle:
let setAction: (String) -> Void = { value in
SGSimpleSettings.shared.nyStyle = value
simplePromise.set(true)
}
for value in SGSimpleSettings.NYStyle.allCases {
items.append(ActionSheetButtonItem(title: i18n("Settings.NY.Style.\(value.rawValue)", presentationData.strings.baseLanguageCode), color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
setAction(value.rawValue)
}))
}
}
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
presentControllerImpl?(actionSheet, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
}, openDisclosureLink: { link in
switch (link) {
case .languageSettings:
pushControllerImpl?(context.sharedContext.makeLocalizationListController(context: context))
case .contentSettings:
let _ = (getSGSettingsURL(context: context) |> deliverOnMainQueue).start(next: { [weak context] url in
guard let strongContext = context else {
return
}
strongContext.sharedContext.applicationBindings.openUrl(url)
})
}
}, action: { actionType in
#if canImport(SGDeletedMessages)
if let actionString = actionType as? String, actionString == "clearDeletedMessages" {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let alertController = textAlertController(
context: context,
title: presentationData.strings.baseLanguageCode == "ru" ? "Очистить все сохраненные удаленные сообщения?" : "Clear All Saved Deleted Messages?",
text: presentationData.strings.baseLanguageCode == "ru" ? "Это действие удалит все сообщения, которые были помечены как удаленные. Это действие нельзя отменить." : "This action will permanently delete all messages that were marked as deleted. This action cannot be undone.",
actions: [
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
let statusController = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
presentControllerImpl?(statusController, nil)
let _ = (SGDeletedMessages.clearAllDeletedMessages(postbox: context.account.postbox)
|> deliverOnMainQueue).start(completed: {
statusController.dismiss()
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
presentControllerImpl?(OverlayStatusController(theme: presentationData.theme, type: .success), nil)
})
}),
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
]
)
presentControllerImpl?(alertController, nil)
}
#endif
}, searchInput: { searchQuery in
updateState { state in
var updatedState = state
updatedState.searchQuery = searchQuery
return updatedState
}
})
let sharedData = context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.callListSettings, ApplicationSpecificSharedDataKeys.experimentalUISettings])
let preferences = context.account.postbox.preferencesView(keys: [ApplicationSpecificPreferencesKeys.SGUISettings, PreferencesKeys.appConfiguration])
let updatedContentSettingsConfiguration = contentSettingsConfiguration(network: context.account.network)
|> map(Optional.init)
let contentSettingsConfiguration = Promise<ContentSettingsConfiguration?>()
contentSettingsConfiguration.set(.single(nil)
|> then(updatedContentSettingsConfiguration))
let signal = combineLatest(simplePromise.get(), /*sliderPromise.get(),*/ statePromise.get(), context.sharedContext.presentationData, sharedData, preferences, contentSettingsConfiguration.get(),
context.engine.accountData.observeAvailableColorOptions(scope: .replies),
context.engine.accountData.observeAvailableColorOptions(scope: .profile)
)
|> map { _, /*sliderValue,*/ state, presentationData, sharedData, view, contentSettingsConfiguration, availableReplyColors, availableProfileColors -> (ItemListControllerState, (ItemListNodeState, Any)) in
let sgUISettings: SGUISettings = view.values[ApplicationSpecificPreferencesKeys.SGUISettings]?.get(SGUISettings.self) ?? SGUISettings.default
let appConfiguration: AppConfiguration = view.values[PreferencesKeys.appConfiguration]?.get(AppConfiguration.self) ?? AppConfiguration.defaultValue
let callListSettings: CallListSettings = sharedData.entries[ApplicationSpecificSharedDataKeys.callListSettings]?.get(CallListSettings.self) ?? CallListSettings.defaultSettings
let experimentalUISettings: ExperimentalUISettings = sharedData.entries[ApplicationSpecificSharedDataKeys.experimentalUISettings]?.get(ExperimentalUISettings.self) ?? ExperimentalUISettings.defaultSettings
let entries = SGControllerEntries(presentationData: presentationData, callListSettings: callListSettings, experimentalUISettings: experimentalUISettings, SGSettings: sgUISettings, appConfiguration: appConfiguration, nameColors: PeerNameColors.with(availableReplyColors: availableReplyColors, availableProfileColors: availableProfileColors), state: state)
let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text("Swiftgram"), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back))
// TODO(swiftgram): focusOnItemTag support
/* var index = 0
var scrollToItem: ListViewScrollToItem?
if let focusOnItemTag = focusOnItemTag {
for entry in entries {
if entry.tag?.isEqual(to: focusOnItemTag) ?? false {
scrollToItem = ListViewScrollToItem(index: index, position: .top(0.0), animated: false, curve: .Default(duration: 0.0), directionHint: .Up)
}
index += 1
}
} */
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: /*focusOnItemTag*/ nil, initialScrollToItem: nil /* scrollToItem*/ )
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)
}
pushControllerImpl = { [weak controller] c in
(controller?.navigationController as? NavigationController)?.pushViewController(c)
}
// getRootControllerImpl = { [weak controller] in
// return controller?.view.window?.rootViewController
// }
// getNavigationControllerImpl = { [weak controller] in
// return controller?.navigationController as? NavigationController
// }
askForRestart = { [weak context] in
guard let context = context else {
return
}
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
presentControllerImpl?(
UndoOverlayController(
presentationData: presentationData,
content: .info(title: nil, // i18n("Common.RestartRequired", presentationData.strings.baseLanguageCode),
text: i18n("Common.RestartRequired", presentationData.strings.baseLanguageCode),
timeout: nil,
customUndoText: i18n("Common.RestartNow", presentationData.strings.baseLanguageCode) //presentationData.strings.Common_Yes
),
elevatedLayout: false,
action: { action in if action == .undo { exit(0) }; return true }
),
nil
)
}
return controller
}
@@ -0,0 +1,293 @@
// MARK: Swiftgram - Saved Deleted Messages List
import Foundation
import UIKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
#if canImport(SGDeletedMessages)
import SGDeletedMessages
#endif
// MARK: - GLEGram
// MARK: - Entry
private enum SavedDeletedListEntry: ItemListNodeEntry {
case search(id: Int, query: String)
case empty(id: Int, text: String)
case peerHeader(id: Int, sectionIndex: Int32, text: String)
case messageRow(id: Int, sectionIndex: Int32, text: String, dateText: String, peerId: PeerId, messageId: MessageId, searchableText: String)
case deleteAction(id: Int, sectionIndex: Int32, text: String, peerId: PeerId)
var stableId: Int {
switch self {
case .search(let id, _): return id
case .empty(let id, _): return id
case .peerHeader(let id, _, _): return id
case .messageRow(let id, _, _, _, _, _, _): return id
case .deleteAction(let id, _, _, _): return id
}
}
var section: ItemListSectionId {
switch self {
case .search(_, _): return 0
case .empty: return 0
case .peerHeader(_, let s, _): return s
case .messageRow(_, let s, _, _, _, _, _): return s
case .deleteAction(_, let s, _, _): return s
}
}
static func < (lhs: SavedDeletedListEntry, rhs: SavedDeletedListEntry) -> Bool {
lhs.stableId < rhs.stableId
}
static func == (lhs: SavedDeletedListEntry, rhs: SavedDeletedListEntry) -> Bool {
switch (lhs, rhs) {
case let (.search(a, q1), .search(b, q2)): return a == b && q1 == q2
case let (.empty(a, t1), .empty(b, t2)): return a == b && t1 == t2
case let (.peerHeader(a, s1, t1), .peerHeader(b, s2, t2)): return a == b && s1 == s2 && t1 == t2
case let (.messageRow(a, s1, t1, d1, p1, m1, _), .messageRow(b, s2, t2, d2, p2, m2, _)): return a == b && s1 == s2 && t1 == t2 && d1 == d2 && p1 == p2 && m1 == m2
case let (.deleteAction(a, s1, t1, p1), .deleteAction(b, s2, t2, p2)): return a == b && s1 == s2 && t1 == t2 && p1 == p2
default: return false
}
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! SavedDeletedListArguments
switch self {
case .search(_, let query):
return ItemListSingleLineInputItem(presentationData: presentationData, title: NSAttributedString(string: ""), text: query, placeholder: presentationData.strings.Common_Search, type: .regular(capitalization: false, autocorrection: false), spacing: 0.0, clearType: .always, tag: nil, sectionId: section, textUpdated: { args.searchUpdated($0) }, action: {})
case .empty(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: section)
case .peerHeader(_, _, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: section)
case .messageRow(_, _, let text, let dateText, let peerId, let messageId, _):
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: dateText, sectionId: section, style: .blocks, action: {
args.openMessage(peerId, messageId)
})
case .deleteAction(_, _, let text, let peerId):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .destructive, alignment: .natural, sectionId: section, style: .blocks, action: {
args.deleteMessagesForPeer(peerId)
})
}
}
}
// MARK: - Arguments
private final class SearchQueryRef {
var value: String = ""
}
private final class SavedDeletedListArguments {
let searchQueryRef: SearchQueryRef
var searchQuery: String { searchQueryRef.value }
let searchUpdated: (String) -> Void
let deleteMessagesForPeer: (PeerId) -> Void
let openMessage: (PeerId, MessageId) -> Void
init(searchQueryRef: SearchQueryRef, searchUpdated: @escaping (String) -> Void, deleteMessagesForPeer: @escaping (PeerId) -> Void, openMessage: @escaping (PeerId, MessageId) -> Void) {
self.searchQueryRef = searchQueryRef
self.searchUpdated = searchUpdated
self.deleteMessagesForPeer = deleteMessagesForPeer
self.openMessage = openMessage
}
}
// MARK: - Date formatting
private let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
// MARK: - Entries builder (full list, no filter)
#if canImport(SGDeletedMessages)
private func savedDeletedListEntries(
data: [(peer: Peer?, peerId: PeerId, messages: [Message])],
lang: String
) -> [SavedDeletedListEntry] {
var entries: [SavedDeletedListEntry] = []
var id = 0
entries.append(.search(id: id, query: ""))
id += 1
if data.isEmpty {
let text = (lang == "ru" ? "Нет сохранённых удалённых сообщений." : "No saved deleted messages.")
entries.append(.empty(id: id, text: text))
return entries
}
var sectionIndex: Int32 = 0
for group in data {
let peerName: String
if let peer = group.peer {
peerName = peer.debugDisplayTitle
} else {
peerName = "Peer \(group.peerId.id._internalGetInt64Value())"
}
sectionIndex += 1
let countStr = lang == "ru" ? "\(group.messages.count) сообщ." : "\(group.messages.count) msg"
entries.append(.peerHeader(id: id, sectionIndex: sectionIndex, text: "\(peerName.uppercased()) (\(countStr))"))
id += 1
for message in group.messages {
let text = message.text.isEmpty
? (lang == "ru" ? "[медиа]" : "[media]")
: String(message.text.prefix(120)).replacingOccurrences(of: "\n", with: " ")
let searchableText = (message.text + " " + (message.sgDeletedAttribute.originalText ?? "")).trimmingCharacters(in: .whitespacesAndNewlines)
let date = dateFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(message.timestamp)))
entries.append(.messageRow(id: id, sectionIndex: sectionIndex, text: text, dateText: date, peerId: group.peerId, messageId: message.id, searchableText: searchableText))
id += 1
}
let deleteText = lang == "ru" ? "Удалить все для этого чата" : "Delete all for this chat"
entries.append(.deleteAction(id: id, sectionIndex: sectionIndex, text: deleteText, peerId: group.peerId))
id += 1
}
return entries
}
/// Filter by search query - two-pass, keep search, keep sections that have matches.
private func filterSavedDeletedListEntries(_ entries: [SavedDeletedListEntry], by searchQuery: String?, lang: String) -> [SavedDeletedListEntry] {
guard let query = searchQuery?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !query.isEmpty else {
return entries
}
var sectionIdsWithMatches: Set<Int32> = []
for entry in entries {
switch entry {
case .search(_, _), .empty:
break
case .peerHeader(_, let s, let text):
if text.lowercased().contains(query) { sectionIdsWithMatches.insert(s) }
case .messageRow(_, let s, _, let dateText, _, _, let searchableText):
if searchableText.lowercased().contains(query) || dateText.lowercased().contains(query) { sectionIdsWithMatches.insert(s) }
case .deleteAction(_, let s, let text, _):
if text.lowercased().contains(query) { sectionIdsWithMatches.insert(s) }
}
}
var filtered: [SavedDeletedListEntry] = []
for entry in entries {
switch entry {
case .search(_, _):
filtered.append(entry)
case .empty:
continue
case .peerHeader(_, let s, _), .messageRow(_, let s, _, _, _, _, _), .deleteAction(_, let s, _, _):
if sectionIdsWithMatches.contains(s) {
filtered.append(entry)
}
}
}
if filtered.count == 1, case .search(_, _) = filtered[0] {
filtered.append(.empty(id: Int.max, text: lang == "ru" ? "Ничего не найдено." : "No results."))
}
return filtered
}
#endif
// MARK: - Controller
public func savedDeletedMessagesListController(context: AccountContext) -> ViewController {
#if canImport(SGDeletedMessages)
var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)?
var pushControllerImpl: ((ViewController) -> Void)?
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
let searchQueryPromise = ValuePromise("", ignoreRepeated: false)
let searchQueryRef = SearchQueryRef()
let arguments = SavedDeletedListArguments(
searchQueryRef: searchQueryRef,
searchUpdated: { value in
searchQueryRef.value = value
searchQueryPromise.set(value)
},
deleteMessagesForPeer: { peerId in
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let lang = presentationData.strings.baseLanguageCode
let title = lang == "ru" ? "Удалить" : "Delete"
let text = lang == "ru" ? "Удалить все сохранённые удалённые сообщения для этого чата?" : "Delete all saved deleted messages for this chat?"
let alert = textAlertController(
context: context,
title: title,
text: text,
actions: [
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
let _ = (SGDeletedMessages.getAllSavedDeletedMessages(postbox: context.account.postbox)
|> mapToSignal { groups -> Signal<Void, NoError> in
var idsToDelete: [MessageId] = []
for group in groups where group.peerId == peerId {
idsToDelete.append(contentsOf: group.messages.map { $0.id })
}
return SGDeletedMessages.deleteSavedDeletedMessages(ids: idsToDelete, postbox: context.account.postbox)
}
|> deliverOnMainQueue).start(completed: {
reloadPromise.set(true)
})
}),
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
]
)
presentControllerImpl?(alert, nil)
},
openMessage: { peerId, messageId in
let chatController = context.sharedContext.makeChatController(context: context, chatLocation: .peer(id: peerId), subject: .message(id: .id(messageId), highlight: nil, timecode: nil, setupReply: false), botStart: nil, mode: .standard(.default), params: nil)
pushControllerImpl?(chatController)
}
)
let dataSignal = reloadPromise.get()
|> mapToSignal { _ -> Signal<[(peer: Peer?, peerId: PeerId, messages: [Message])], NoError> in
return SGDeletedMessages.getAllSavedDeletedMessages(postbox: context.account.postbox)
}
let signal = combineLatest(dataSignal, searchQueryPromise.get(), context.sharedContext.presentationData)
|> map { data, searchQuery, presentationData -> (ItemListControllerState, (ItemListNodeState, SavedDeletedListArguments)) in
let lang = presentationData.strings.baseLanguageCode
let title = lang == "ru" ? "Сохранённые удалённые" : "Saved Deleted"
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(title),
leftNavigationButton: nil,
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let allEntries = savedDeletedListEntries(data: data, lang: lang)
let entriesWithQuery = allEntries.map { entry -> SavedDeletedListEntry in
if case .search(let id, _) = entry { return .search(id: id, query: searchQuery) }
return entry
}
let entries = filterSavedDeletedListEntries(entriesWithQuery, by: searchQuery, lang: lang)
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
initialScrollToItem: nil
)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
presentControllerImpl = { [weak controller] c, a in
controller?.present(c, in: PresentationContextType.window(PresentationSurfaceLevel.root), with: a)
}
pushControllerImpl = { [weak controller] c in
controller?.navigationController?.pushViewController(c, animated: true)
}
return controller
#else
return ViewController(navigationBarPresentationData: nil)
#endif
}
@@ -0,0 +1,178 @@
// MARK: Swiftgram
import SGSimpleSettings
import SGItemListUI
import Foundation
import UIKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import ItemListUI
import PresentationDataUtils
import AccountContext
private enum TabOrganizerSection: Int32, SGItemListSection {
case order
case visibility
}
private enum TabOrganizerBoolSetting: Hashable {
case showContactsTab
case showCallsTab
}
private typealias TabOrganizerEntry = SGItemListUIEntry<TabOrganizerSection, TabOrganizerBoolSetting, AnyHashable, AnyHashable, AnyHashable, AnyHashable>
private func tabTitle(_ tabId: String, _ lang: String) -> String {
switch tabId {
case "chats": return lang == "ru" ? "Чаты" : "Chats"
case "contacts": return lang == "ru" ? "Контакты" : "Contacts"
case "calls": return lang == "ru" ? "Звонки" : "Calls"
case "settings": return lang == "ru" ? "Настройки" : "Settings"
default: return tabId
}
}
private func tabIconName(_ tabId: String) -> String? {
switch tabId {
case "chats": return "bubble.left.and.bubble.right.fill"
case "contacts": return "person.2.fill"
case "calls": return "phone.fill"
case "settings": return "gearshape.fill"
default: return nil
}
}
private func tabOrganizerEntries(presentationData: PresentationData, settings: CallListSettings) -> [TabOrganizerEntry] {
let lang = presentationData.strings.baseLanguageCode
var entries: [TabOrganizerEntry] = []
let id = SGItemListCounter()
// — Order section —
entries.append(.header(id: id.count, section: .order,
text: lang == "ru" ? "ПОРЯДОК" : "ORDER", badge: nil))
entries.append(.notice(id: id.count, section: .order,
text: lang == "ru"
? "Удерживайте и перетащите строку, чтобы изменить порядок вкладок."
: "Hold and drag a row to reorder tabs."))
let order = settings.tabOrder.isEmpty ? CallListSettings.tabOrderDefault : settings.tabOrder
for tabId in order {
entries.append(.reorderableRow(
id: id.count,
section: .order,
text: tabTitle(tabId, lang),
reorderId: tabId as AnyHashable,
iconName: tabIconName(tabId)
))
}
// — Visibility section —
entries.append(.header(id: id.count, section: .visibility,
text: lang == "ru" ? "ВИДИМОСТЬ" : "VISIBILITY", badge: nil))
if order.contains("contacts") {
entries.append(.toggle(id: id.count, section: .visibility,
settingName: .showContactsTab,
value: settings.showContactsTab,
text: lang == "ru" ? "Показывать вкладку «Контакты»" : "Show Contacts tab",
enabled: true))
}
if order.contains("calls") {
entries.append(.toggle(id: id.count, section: .visibility,
settingName: .showCallsTab,
value: settings.showTab,
text: lang == "ru" ? "Показывать вкладку «Звонки»" : "Show Calls tab",
enabled: true))
}
return entries
}
public func TabOrganizerController(context: AccountContext, presentationData: PresentationData, onSave: @escaping () -> Void) -> ViewController {
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
var backAction: (() -> Void)?
let arguments = SGItemListArguments<TabOrganizerBoolSetting, AnyHashable, AnyHashable, AnyHashable, AnyHashable>(
context: context,
setBoolValue: { setting, value in
let _ = updateCallListSettingsInteractively(accountManager: context.sharedContext.accountManager) { current in
switch setting {
case .showContactsTab: return current.withUpdatedShowContactsTab(value)
case .showCallsTab: return current.withUpdatedShowTab(value)
}
}.start()
reloadPromise.set(true)
},
updateSliderValue: { _, _ in },
setOneFromManyValue: { _ in },
openDisclosureLink: { _ in },
action: { _ in },
searchInput: { _ in }
)
let signal = combineLatest(
reloadPromise.get(),
context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.callListSettings]),
context.sharedContext.presentationData
)
|> map { _, sharedData, presentationData -> (ItemListControllerState, (ItemListNodeState, SGItemListArguments<TabOrganizerBoolSetting, AnyHashable, AnyHashable, AnyHashable, AnyHashable>)) in
let settings = sharedData.entries[ApplicationSpecificSharedDataKeys.callListSettings]?.get(CallListSettings.self) ?? .defaultSettings
let title = presentationData.strings.baseLanguageCode == "ru" ? "Органайзер таббара" : "Tab Bar Organizer"
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(title),
leftNavigationButton: ItemListNavigationButton(content: .text(presentationData.strings.Common_Back), style: .regular, enabled: true, action: {
backAction?()
}),
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = tabOrganizerEntries(presentationData: presentationData, settings: settings)
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
initialScrollToItem: nil
)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
controller.setReorderEntry { (fromIndex: Int, toIndex: Int, entries: [TabOrganizerEntry]) -> Signal<Bool, NoError> in
let reorderableListIndices = entries.enumerated().compactMap { i, e -> Int? in
if case .reorderableRow = e { return i }; return nil
}
guard let fromPos = reorderableListIndices.firstIndex(of: fromIndex),
let toPos = reorderableListIndices.firstIndex(of: toIndex),
fromPos != toPos,
reorderableListIndices.count >= 2 else { return .single(false) }
var tabOrder: [String] = entries.compactMap { e in
if case .reorderableRow(_, _, _, let id, _) = e, let tabId = id as? String { return tabId }; return nil
}
guard tabOrder.count == reorderableListIndices.count else { return .single(false) }
let moved = tabOrder.remove(at: fromPos)
tabOrder.insert(moved, at: toPos)
let _ = updateCallListSettingsInteractively(accountManager: context.sharedContext.accountManager) { current in
current.withUpdatedTabOrder(tabOrder)
}.start()
reloadPromise.set(true)
return .single(true)
}
controller.setReorderCompleted { (_: [TabOrganizerEntry]) in
onSave()
}
backAction = { [weak controller] in
guard let controller else { return }
if let nav = controller.navigationController, nav.viewControllers.count > 1 {
nav.popViewController(animated: true)
} else {
(controller.navigationController ?? controller).dismiss(animated: true)
}
}
return controller
}
+101
View File
@@ -0,0 +1,101 @@
// MARK: Swiftgram Load .dylib tweaks at startup (no Python, no .plugin)
import Foundation
import SGSimpleSettings
#if canImport(Darwin)
import Darwin
#else
import Glibc
#endif
/// Directory where user-installed .dylib tweaks are stored. Tweaks are loaded on next app launch.
public enum TweakLoader {
private static let tweaksSubdirectory = "Tweaks"
/// URL to Application Support/Tweaks (call from main thread or after app container is available).
public static var tweaksDirectoryURL: URL {
let fileManager = FileManager.default
guard let support = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
fatalError("Application Support directory not available")
}
return support.appendingPathComponent(tweaksSubdirectory, isDirectory: true)
}
/// Ensure Tweaks directory exists; returns its URL.
@discardableResult
public static func ensureTweaksDirectory() -> URL {
let url = tweaksDirectoryURL
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
/// List installed tweak filenames (.dylib) in the Tweaks directory.
public static func installedTweakFilenames() -> [String] {
let url = tweaksDirectoryURL
guard let contents = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else {
return []
}
return contents
.filter { $0.pathExtension.lowercased() == "dylib" }
.map { $0.lastPathComponent }
.sorted()
}
/// Copy a .dylib file into the Tweaks directory. Returns destination URL on success.
public static func installTweak(from sourceURL: URL) throws -> URL {
let fileManager = FileManager.default
let dir = ensureTweaksDirectory()
let name = sourceURL.lastPathComponent
guard name.lowercased().hasSuffix(".dylib") else {
throw NSError(domain: "TweakLoader", code: 1, userInfo: [NSLocalizedDescriptionKey: "Not a .dylib file"])
}
let dest = dir.appendingPathComponent(name)
if fileManager.fileExists(atPath: dest.path) {
try fileManager.removeItem(at: dest)
}
try fileManager.copyItem(at: sourceURL, to: dest)
return dest
}
/// Remove a tweak by filename (e.g. "TGExtra.dylib").
public static func removeTweak(filename: String) throws {
let url = tweaksDirectoryURL.appendingPathComponent(filename)
if FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.removeItem(at: url)
}
}
/// Load all .dylib files from the Tweaks directory. Call once at app startup when pluginSystemEnabled.
/// On iOS, loading dylibs from a writable path may require jailbreak or special entitlements.
public static func loadTweaks() {
guard SGSimpleSettings.shared.pluginSystemEnabled else { return }
let dir = tweaksDirectoryURL
guard let contents = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else {
return
}
let dylibs = contents.filter { $0.pathExtension.lowercased() == "dylib" }
for url in dylibs {
loadTweak(at: url)
}
}
private static func loadTweak(at url: URL) {
let path = url.path
#if canImport(Darwin)
guard let handle = dlopen(path, RTLD_NOW | RTLD_LOCAL) else {
if let err = dlerror() {
NSLog("[TweakLoader] Failed to load %@: %s", path, err)
}
return
}
// Optional: call an init symbol if the tweak exports it (e.g. GLEGramTweakInit).
if let initSymbol = dlsym(handle, "GLEGramTweakInit") {
typealias InitFn = @convention(c) () -> Void
let fn = unsafeBitCast(initSymbol, to: InitFn.self)
fn()
}
// Keep handle alive (we don't dlclose; tweaks stay loaded for app lifetime).
_ = handle
#endif
}
}