mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 14:17:19 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
289 lines
12 KiB
Swift
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
|
|
}
|
|
}
|