Files
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

289 lines
12 KiB
Swift

import Foundation
import UIKit
import TelegramPresentationData
import GGCore
// Live previews. One host per kind, constant height; the bubble and chat-list renderers are the
// real upstream ones (makeChatMessagePreviewItem / ChatListItem) — nothing is drawn by hand here.
public enum GGMenuPreview {
/// Real bubbles on the real wallpaper.
case miniChat(GGMiniChatConfiguration)
/// Real ChatListItem rows.
case miniChatList(GGMiniChatListConfiguration)
/// Any view inside the shared «block with glass corners». If the view adopts
/// `GGMenuPreviewView`, it is updated on every rebuild of the screen.
case custom(height: CGFloat, make: (GGMenuContext) -> UIView)
}
/// Adopt to get `update(context:)` on every rebuild (settings change, slider tick, theme change).
public protocol GGMenuPreviewView: UIView {
func update(context: GGMenuContext)
}
// MARK: - Mini chat
public struct GGPreviewReply: Equatable {
public var author: String
public var text: String
public init(author: String, text: String) {
self.author = author
self.text = text
}
}
/// A mark before the bubble's time (deleted / edited history marks).
///
/// `statusText` is put into the bubble's own status line, exactly as the chat does it (the status
/// prefix of `GGMessageDecorationHooks`), so the bubble makes room for it. `icon` is a glyph drawn
/// next to the time without making room — only for marks the chat itself draws as an icon.
public struct GGPreviewMark: Equatable {
public var icon: GGMenuIcon?
public var color: UIColor?
public var statusText: String?
public init(icon: GGMenuIcon, color: UIColor? = nil) {
self.icon = icon
self.color = color
self.statusText = nil
}
public init(statusText: String) {
self.icon = nil
self.color = nil
self.statusText = statusText
}
}
public struct GGPreviewMessage: Equatable {
public var id: Int32
public var isOutgoing: Bool
public var text: String
public var reply: GGPreviewReply?
/// A block quote on top of the text (a reply sent as a quote, e.g. to a deleted message).
public var quote: String?
/// Shows the native «edited» label.
public var isEdited: Bool
/// Clock instead of checkmarks (outgoing only).
public var isPending: Bool
/// One check instead of two (outgoing only): delivered, not read yet.
public var isUnread: Bool
/// Whole bubble alpha (deleted-message transparency).
public var alpha: CGFloat
public var mark: GGPreviewMark?
/// Seconds before now.
public var ageSeconds: Int32
public init(id: Int32, isOutgoing: Bool, text: String, reply: GGPreviewReply? = nil, quote: String? = nil, isEdited: Bool = false, isPending: Bool = false, isUnread: Bool = false, alpha: CGFloat = 1.0, mark: GGPreviewMark? = nil, ageSeconds: Int32 = 60) {
self.id = id
self.isOutgoing = isOutgoing
self.text = text
self.reply = reply
self.quote = quote
self.isEdited = isEdited
self.isPending = isPending
self.isUnread = isUnread
self.alpha = alpha
self.mark = mark
self.ageSeconds = ageSeconds
}
}
public struct GGMiniChatHeader: Equatable {
public enum Peer: Equatable {
/// The account the menu is open for (its own avatar and name).
case account
/// A demo peer with a letters avatar.
case demo(name: String)
}
public var peer: Peer
public var status: String
public var statusIsAccent: Bool
/// The chat's top bar without the avatar (the name moves to the left edge).
public var hidesAvatar: Bool
/// The chat's top bar without the name and the status.
public var hidesTitle: Bool
public init(peer: Peer, status: String, statusIsAccent: Bool = false, hidesAvatar: Bool = false, hidesTitle: Bool = false) {
self.peer = peer
self.status = status
self.statusIsAccent = statusIsAccent
self.hidesAvatar = hidesAvatar
self.hidesTitle = hidesTitle
}
}
public struct GGMiniChatContent: Equatable {
public var header: GGMiniChatHeader?
public var messages: [GGPreviewMessage]
public init(header: GGMiniChatHeader?, messages: [GGPreviewMessage]) {
self.header = header
self.messages = messages
}
}
extension GGMiniChatContent {
/// The standard conversation (an incoming message and an outgoing reply), localized. Features
/// usually start from it and change it in their own content closure or in a modifier.
public static func sample(header: GGMiniChatHeader? = nil) -> GGMiniChatContent {
let peerName = GGStrings.Menu.Preview.peerName
let incoming = GGStrings.Menu.Preview.incomingText
return GGMiniChatContent(header: header, messages: [
GGPreviewMessage(id: 1, isOutgoing: false, text: incoming, ageSeconds: 180),
GGPreviewMessage(id: 2, isOutgoing: true, text: GGStrings.Menu.Preview.outgoingText, reply: GGPreviewReply(author: peerName, text: incoming), ageSeconds: 120)
])
}
/// Header with the demo peer «online».
public static func sampleHeader() -> GGMiniChatHeader {
return GGMiniChatHeader(peer: .demo(name: GGStrings.Menu.Preview.peerName), status: GGStrings.Menu.Preview.statusOnline, statusIsAccent: true)
}
}
public struct GGMiniChatConfiguration {
/// Modifiers see it as `context.previewScope` and decide whether they apply.
public let scope: String
/// Constant height of the block; bubbles rest on its bottom edge.
public let height: CGFloat
/// Base content, localized at display time. Modifiers run on top of it.
public let content: (GGMenuContext) -> GGMiniChatContent
/// The theme the bubbles are drawn with, instead of the app's: e.g. one built from the values
/// a slider of this screen is being dragged to (`context.draftValue(forKey:)`), so the bubbles
/// follow the finger while the setting itself is written only on release. Called inside
/// `draftScope`, on the main thread, on every rebuild of the screen.
public let presentationData: ((GGMenuContext) -> PresentationData)?
/// Wraps everything the preview computes for the bubbles (the theme above, the message items
/// and their layout — main thread, synchronously): a feature sets its own draft state for the
/// duration of `body` (layout hooks reading the feature's settings then see the dragged value)
/// and must restore it before returning. Never write settings here.
public let draftScope: ((GGMenuContext, () -> Void) -> Void)?
/// Layers of the chat screen drawn by features (animated background, snow, edge bands): made
/// once per preview node, updated on every rebuild of the screen and every layout.
public let overlays: ((GGMenuContext) -> [GGMiniChatOverlay])?
public init(scope: String, height: CGFloat = 196.0, content: @escaping (GGMenuContext) -> GGMiniChatContent, presentationData: ((GGMenuContext) -> PresentationData)? = nil, draftScope: ((GGMenuContext, () -> Void) -> Void)? = nil, overlays: ((GGMenuContext) -> [GGMiniChatOverlay])? = nil) {
self.scope = scope
self.height = height
self.content = content
self.presentationData = presentationData
self.draftScope = draftScope
self.overlays = overlays
}
/// A preview that follows drafts and overlays is rebuilt on every rebuild of its screen.
var isLive: Bool {
return self.presentationData != nil || self.draftScope != nil || self.overlays != nil
}
}
/// Where an overlay of the mini chat sits.
public enum GGMiniChatOverlayPlacement {
/// Above the wallpaper, under the bubbles (animated background, snow).
case belowMessages
/// Above the bubbles, under the chat's top bar (edge blur and gradient).
case aboveMessages
}
/// A layer of the chat screen shown in the mini chat (`GGMiniChatConfiguration.overlays`).
public protocol GGMiniChatOverlay: AnyObject {
var view: UIView { get }
var placement: GGMiniChatOverlayPlacement { get }
/// Main thread. `size` — the visible block; `insets.top` — the height of the top bar of the
/// preview (0 without a header). Read drafts through `context.draftValue(forKey:)`.
func update(context: GGMenuContext, size: CGSize, insets: UIEdgeInsets)
}
// MARK: - Mini chat list
public struct GGPreviewChatListRow: Equatable {
public enum Kind: Equatable {
case user
case group
case channel
}
public var id: Int64
public var kind: Kind
public var title: String
public var text: String
/// Author shown before the text in groups.
public var authorName: String?
public var isOutgoing: Bool
public var ageSeconds: Int32
public var isPinned: Bool
public var isOnline: Bool
public var isTyping: Bool
public var unreadCount: Int32
public init(id: Int64, kind: Kind, title: String, text: String, authorName: String? = nil, isOutgoing: Bool = false, ageSeconds: Int32 = 600, isPinned: Bool = false, isOnline: Bool = false, isTyping: Bool = false, unreadCount: Int32 = 0) {
self.id = id
self.kind = kind
self.title = title
self.text = text
self.authorName = authorName
self.isOutgoing = isOutgoing
self.ageSeconds = ageSeconds
self.isPinned = isPinned
self.isOnline = isOnline
self.isTyping = isTyping
self.unreadCount = unreadCount
}
}
extension GGPreviewChatListRow {
/// Three rows: a pinned person online, a group with unread messages, a channel. Localized.
public static func samples() -> [GGPreviewChatListRow] {
return [
GGPreviewChatListRow(id: 1, kind: .user, title: GGStrings.Menu.Preview.peerName, text: GGStrings.Menu.Preview.chatListText1, ageSeconds: 300, isPinned: true, isOnline: true),
GGPreviewChatListRow(id: 2, kind: .group, title: GGStrings.Menu.Preview.chatListName2, text: GGStrings.Menu.Preview.chatListText2, authorName: GGStrings.Menu.Preview.chatListAuthor2, ageSeconds: 900, unreadCount: 3),
GGPreviewChatListRow(id: 3, kind: .channel, title: GGStrings.Menu.Preview.chatListName3, text: GGStrings.Menu.Preview.chatListText3, ageSeconds: 3600)
]
}
}
public struct GGMiniChatListConfiguration {
public let scope: String
/// Constant height of the block; rows are laid out from the top and clipped.
public let height: CGFloat
public let rows: (GGMenuContext) -> [GGPreviewChatListRow]
public init(scope: String, height: CGFloat = 228.0, rows: @escaping (GGMenuContext) -> [GGPreviewChatListRow]) {
self.scope = scope
self.height = height
self.rows = rows
}
}
// MARK: - Modifiers
public enum GGPreviewModifiers {
/// A feature changes the demo messages of every mini chat (deleted transparency, «edited»,
/// translation…). Check `context.previewScope` to limit it to some previews.
public static func registerChatModifier(id: String, order: Int, _ modify: @escaping (inout [GGPreviewMessage], GGMenuContext) -> Void) {
GGMenuStorage.shared.addChatModifier(GGMenuStorage.ChatModifier(id: id, order: order, modify: modify))
}
public static func registerChatListModifier(id: String, order: Int, _ modify: @escaping (inout [GGPreviewChatListRow], GGMenuContext) -> Void) {
GGMenuStorage.shared.addChatListModifier(GGMenuStorage.ChatListModifier(id: id, order: order, modify: modify))
}
static func applyChat(_ messages: [GGPreviewMessage], context: GGMenuContext) -> [GGPreviewMessage] {
var result = messages
for modifier in GGMenuStorage.shared.chatModifiers {
modifier.modify(&result, context)
}
return result
}
static func applyChatList(_ rows: [GGPreviewChatListRow], context: GGMenuContext) -> [GGPreviewChatListRow] {
var result = rows
for modifier in GGMenuStorage.shared.chatListModifiers {
modifier.modify(&result, context)
}
return result
}
}