mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 22:27:13 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
394 lines
13 KiB
Swift
394 lines
13 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import Display
|
|
import TelegramPresentationData
|
|
import GGCore
|
|
|
|
// Declarative menu model: category → tab → section → row.
|
|
// Features register rows into existing sections (and may add deeper tabs); the screens are
|
|
// generated from the registry. Order is always the declared sparse `order`, never array position.
|
|
|
|
public enum GGMenuCategory: String, CaseIterable {
|
|
case privacy
|
|
case messages
|
|
case media
|
|
case appearance
|
|
case security
|
|
case more
|
|
}
|
|
|
|
public struct GGMenuTabID: Hashable, RawRepresentable {
|
|
public let rawValue: String
|
|
|
|
public init(rawValue: String) {
|
|
self.rawValue = rawValue
|
|
}
|
|
|
|
public init(_ rawValue: String) {
|
|
self.rawValue = rawValue
|
|
}
|
|
}
|
|
|
|
public struct GGMenuSectionID: Hashable, RawRepresentable {
|
|
public let rawValue: String
|
|
|
|
public init(rawValue: String) {
|
|
self.rawValue = rawValue
|
|
}
|
|
|
|
public init(_ rawValue: String) {
|
|
self.rawValue = rawValue
|
|
}
|
|
}
|
|
|
|
/// A screen of the menu: the root, a category or a tab.
|
|
public enum GGMenuScreenID: Hashable {
|
|
case root
|
|
case category(GGMenuCategory)
|
|
case tab(GGMenuTabID)
|
|
}
|
|
|
|
/// Menu row icon. Always rendered as a 24 pt template glyph tinted with `ggMenuIconColor(theme:)`.
|
|
public enum GGMenuIcon: Equatable {
|
|
/// A glyph from the app asset catalog (Telegram's own glyphs, e.g. "Chat/Context Menu/Eye").
|
|
case bundle(String)
|
|
/// An SF Symbol available on iOS 15.
|
|
case system(String)
|
|
/// The Ghostgram ghost from this module's assets.
|
|
case ghost
|
|
}
|
|
|
|
public struct GGMenuChoiceOption {
|
|
public let value: String
|
|
public let title: () -> String
|
|
|
|
public init(value: String, title: @escaping () -> String) {
|
|
self.value = value
|
|
self.title = title
|
|
}
|
|
}
|
|
|
|
public struct GGMenuCardOption {
|
|
public let value: String
|
|
public let title: () -> String
|
|
/// Drawn inside the card; called with the current theme, keep it cheap or cache it.
|
|
public let image: (PresentationTheme) -> UIImage?
|
|
|
|
public init(value: String, title: @escaping () -> String, image: @escaping (PresentationTheme) -> UIImage?) {
|
|
self.value = value
|
|
self.title = title
|
|
self.image = image
|
|
}
|
|
}
|
|
|
|
/// Binds a row to a GGSetting or to a GGAccountSetting of the account the menu is open for.
|
|
public struct GGMenuBinding<Value: GGSettingValue> {
|
|
public let key: String
|
|
public let requiresRestart: Bool
|
|
let read: (GGAccountID) -> Value
|
|
let write: (Value, GGAccountID) -> Void
|
|
let isDefaultValue: (GGAccountID) -> Bool
|
|
let resetValue: (GGAccountID) -> Void
|
|
|
|
public static func global(_ setting: GGSetting<Value>) -> GGMenuBinding<Value> {
|
|
return GGMenuBinding<Value>(
|
|
key: setting.key,
|
|
requiresRestart: setting.flags.contains(.requiresRestart),
|
|
read: { _ in
|
|
return setting.value
|
|
},
|
|
write: { value, _ in
|
|
setting.set(value)
|
|
},
|
|
isDefaultValue: { _ in
|
|
return setting.isDefault
|
|
},
|
|
resetValue: { _ in
|
|
setting.reset()
|
|
}
|
|
)
|
|
}
|
|
|
|
public static func account(_ setting: GGAccountSetting<Value>) -> GGMenuBinding<Value> {
|
|
return GGMenuBinding<Value>(
|
|
key: setting.key,
|
|
requiresRestart: setting.flags.contains(.requiresRestart),
|
|
read: { account in
|
|
return setting.value(for: account)
|
|
},
|
|
write: { value, account in
|
|
setting.set(value, for: account)
|
|
},
|
|
isDefaultValue: { account in
|
|
return setting.isDefault(for: account)
|
|
},
|
|
resetValue: { account in
|
|
setting.reset(for: account)
|
|
}
|
|
)
|
|
}
|
|
|
|
public func value(in context: GGMenuContext) -> Value {
|
|
return self.read(context.accountID)
|
|
}
|
|
|
|
/// Writes through the registry; the menu rebuilds from `GGSettingsStore.shared.revision`.
|
|
public func set(_ value: Value, in context: GGMenuContext) {
|
|
if self.requiresRestart {
|
|
let current = self.read(context.accountID)
|
|
GGMenuRestartTracker.shared.noteBeforeChange(key: self.key, launchValue: current)
|
|
}
|
|
self.write(value, context.accountID)
|
|
}
|
|
|
|
public func isDefault(in context: GGMenuContext) -> Bool {
|
|
return self.isDefaultValue(context.accountID)
|
|
}
|
|
|
|
public func reset(in context: GGMenuContext) {
|
|
if self.requiresRestart {
|
|
let current = self.read(context.accountID)
|
|
GGMenuRestartTracker.shared.noteBeforeChange(key: self.key, launchValue: current)
|
|
}
|
|
self.resetValue(context.accountID)
|
|
}
|
|
|
|
func isPendingRestart(in context: GGMenuContext) -> Bool {
|
|
guard self.requiresRestart else {
|
|
return false
|
|
}
|
|
return GGMenuRestartTracker.shared.differsFromLaunch(key: self.key, current: self.read(context.accountID))
|
|
}
|
|
}
|
|
|
|
/// Type-erased view of a binding, used for the navbar reset button, «Нужен перезапуск» and duplicate checks.
|
|
struct GGMenuAnyBinding {
|
|
let key: String
|
|
let isDefault: (GGMenuContext) -> Bool
|
|
let reset: (GGMenuContext) -> Void
|
|
let isPendingRestart: (GGMenuContext) -> Bool
|
|
|
|
init<Value>(_ binding: GGMenuBinding<Value>) {
|
|
self.key = binding.key
|
|
self.isDefault = { context in
|
|
return binding.isDefault(in: context)
|
|
}
|
|
self.reset = { context in
|
|
binding.reset(in: context)
|
|
}
|
|
self.isPendingRestart = { context in
|
|
return binding.isPendingRestart(in: context)
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum GGMenuRowKind {
|
|
case toggle(GGMenuBinding<Bool>)
|
|
/// ≤ 6 options → action sheet, more → a list screen. The current value is shown in accent color.
|
|
case choice(GGMenuBinding<String>, options: [GGMenuChoiceOption])
|
|
/// Ticks update only the previews of the screen; the setting is written on release.
|
|
case slider(GGMenuBinding<Double>, range: ClosedRange<Double>, step: Double, format: (Double) -> String)
|
|
/// «Кнопка с подписью»: icon + title + grey subtitle, no chevron.
|
|
case tab(GGMenuTabID)
|
|
case action((GGMenuContext) -> Void)
|
|
/// Master switch with an «N/M» counter; children must be `.toggle` rows.
|
|
case expandableGroup(master: GGMenuBinding<Bool>, children: [GGMenuRow])
|
|
/// The same block without a master switch (2026-10-27): the title, a summary on the right made by
|
|
/// `summary` from the visible children (not called, and nothing shown, while none is on), a
|
|
/// chevron; a tap opens the children in place. Children must be `.toggle` rows.
|
|
case expandableList(children: [GGMenuRow], summary: (GGMenuGroupCount) -> String)
|
|
case preview(GGMenuPreview)
|
|
/// Escape hatch: build any list item. Use `context.sectionId` and `context.itemListPresentationData`.
|
|
case custom((GGMenuContext) -> ListViewItem)
|
|
/// Discrete slider with a label above every tick (2…6 options).
|
|
case discreteChoice(GGMenuBinding<String>, options: [GGMenuChoiceOption])
|
|
/// N cards with a picture, one selected.
|
|
case cards(GGMenuBinding<String>, options: [GGMenuCardOption])
|
|
}
|
|
|
|
public struct GGMenuRow {
|
|
/// Globally unique, "<feature>.<row>". Also the search target and the highlight tag.
|
|
public let id: String
|
|
public let section: GGMenuSectionID
|
|
/// Sparse, step 100. The position on screen is `order`, never array arithmetic.
|
|
public let order: Int
|
|
/// GGStrings accessor, evaluated at display time.
|
|
public let title: () -> String
|
|
/// Grey subtitle; text examples «привет → ПРИВЕТ» go here.
|
|
public let subtitle: (() -> String)?
|
|
public let icon: GGMenuIcon?
|
|
public let kind: GGMenuRowKind
|
|
public let isVisible: ((GGMenuContext) -> Bool)?
|
|
public let searchKeywords: () -> [String]
|
|
/// Accent label on the right of `.tab` / `.action` rows. Must reflect the feature's own state.
|
|
public let label: ((GGMenuContext) -> String?)?
|
|
public let isEnabled: ((GGMenuContext) -> Bool)?
|
|
/// Red action row (resets, deletions). Only meaningful for `.action`.
|
|
public let isDestructive: Bool
|
|
|
|
public init(
|
|
id: String,
|
|
section: GGMenuSectionID,
|
|
order: Int,
|
|
title: @escaping () -> String,
|
|
subtitle: (() -> String)? = nil,
|
|
icon: GGMenuIcon? = nil,
|
|
kind: GGMenuRowKind,
|
|
isVisible: ((GGMenuContext) -> Bool)? = nil,
|
|
searchKeywords: @escaping () -> [String] = { [] },
|
|
label: ((GGMenuContext) -> String?)? = nil,
|
|
isEnabled: ((GGMenuContext) -> Bool)? = nil,
|
|
isDestructive: Bool = false
|
|
) {
|
|
self.id = id
|
|
self.section = section
|
|
self.order = order
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.icon = icon
|
|
self.kind = kind
|
|
self.isVisible = isVisible
|
|
self.searchKeywords = searchKeywords
|
|
self.label = label
|
|
self.isEnabled = isEnabled
|
|
self.isDestructive = isDestructive
|
|
}
|
|
|
|
/// The children of an expandable row (with or without a master switch); nil for any other row.
|
|
var groupChildren: [GGMenuRow]? {
|
|
switch self.kind {
|
|
case let .expandableGroup(_, children):
|
|
return children
|
|
case let .expandableList(children, _):
|
|
return children
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var isToggle: Bool {
|
|
if case .toggle = self.kind {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
var anyBindings: [GGMenuAnyBinding] {
|
|
switch self.kind {
|
|
case let .toggle(binding):
|
|
return [GGMenuAnyBinding(binding)]
|
|
case let .choice(binding, _):
|
|
return [GGMenuAnyBinding(binding)]
|
|
case let .slider(binding, _, _, _):
|
|
return [GGMenuAnyBinding(binding)]
|
|
case let .discreteChoice(binding, _):
|
|
return [GGMenuAnyBinding(binding)]
|
|
case let .cards(binding, _):
|
|
return [GGMenuAnyBinding(binding)]
|
|
case let .expandableGroup(master, children):
|
|
var result = [GGMenuAnyBinding(master)]
|
|
for child in children {
|
|
result.append(contentsOf: child.anyBindings)
|
|
}
|
|
return result
|
|
case let .expandableList(children, _):
|
|
var result: [GGMenuAnyBinding] = []
|
|
for child in children {
|
|
result.append(contentsOf: child.anyBindings)
|
|
}
|
|
return result
|
|
case .tab, .action, .preview, .custom:
|
|
return []
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Segment of a tab screen with parallel views («Чаты / Папки / Типы чатов»), shown in the navbar.
|
|
public struct GGMenuSegment {
|
|
public let id: String
|
|
public let title: () -> String
|
|
|
|
public init(id: String, title: @escaping () -> String) {
|
|
self.id = id
|
|
self.title = title
|
|
}
|
|
}
|
|
|
|
/// Header of a sub-screen: an emoji or a bundled animated sticker over a short text.
|
|
public struct GGMenuTopView {
|
|
public enum Content: Equatable {
|
|
case emoji(String)
|
|
/// Name of a bundled `.tgs` (as `AnimatedStickerNodeLocalFileSource(name:)` expects).
|
|
case animation(String)
|
|
}
|
|
|
|
public let content: Content
|
|
public let text: () -> String
|
|
|
|
public init(content: Content, text: @escaping () -> String) {
|
|
self.content = content
|
|
self.text = text
|
|
}
|
|
}
|
|
|
|
public struct GGMenuTabDescriptor {
|
|
public let id: GGMenuTabID
|
|
/// The screen this tab is opened from (a category, or a tab for deeper feature tabs).
|
|
public let parent: GGMenuScreenID
|
|
public let title: () -> String
|
|
public let topView: GGMenuTopView?
|
|
public let segments: [GGMenuSegment]
|
|
/// Optional fully custom screen (history lists, editors). `nil` = generated from sections.
|
|
public let makeScreen: ((GGMenuContext) -> ViewController)?
|
|
public let searchKeywords: () -> [String]
|
|
|
|
public init(
|
|
id: GGMenuTabID,
|
|
parent: GGMenuScreenID,
|
|
title: @escaping () -> String,
|
|
topView: GGMenuTopView? = nil,
|
|
segments: [GGMenuSegment] = [],
|
|
makeScreen: ((GGMenuContext) -> ViewController)? = nil,
|
|
searchKeywords: @escaping () -> [String] = { [] }
|
|
) {
|
|
self.id = id
|
|
self.parent = parent
|
|
self.title = title
|
|
self.topView = topView
|
|
self.segments = segments
|
|
self.makeScreen = makeScreen
|
|
self.searchKeywords = searchKeywords
|
|
}
|
|
}
|
|
|
|
public struct GGMenuSectionDescriptor {
|
|
public let id: GGMenuSectionID
|
|
public let screen: GGMenuScreenID
|
|
/// Sparse, step 100.
|
|
public let order: Int
|
|
/// `nil` = shown in every segment of the screen.
|
|
public let segment: String?
|
|
public let header: (() -> String)?
|
|
public let footer: ((GGMenuContext) -> String?)?
|
|
public let isVisible: ((GGMenuContext) -> Bool)?
|
|
|
|
public init(
|
|
id: GGMenuSectionID,
|
|
screen: GGMenuScreenID,
|
|
order: Int,
|
|
segment: String? = nil,
|
|
header: (() -> String)? = nil,
|
|
footer: ((GGMenuContext) -> String?)? = nil,
|
|
isVisible: ((GGMenuContext) -> Bool)? = nil
|
|
) {
|
|
self.id = id
|
|
self.screen = screen
|
|
self.order = order
|
|
self.segment = segment
|
|
self.header = header
|
|
self.footer = footer
|
|
self.isVisible = isVisible
|
|
}
|
|
}
|